R5 the server's own settings: instance, rules, announcements, relays, media limits, the staff's log
Build / Build (push) Successful in 1m3s
Build / Build (push) Successful in 1m3s
A singleton ServerSettings (title, descriptions, contact, thumbnail, privacy policy, terms, rules, relays, media overrides, PublishActivity), cached by InstanceSettings and read by the instance API, NodeInfo, the instance actor, the web pages and the mailer; its defaults are the old literals, so nothing changes until an administrator edits. /clientapi/admin/settings (and /thumbnail), /announcements, /relays for administrators; /clientapi/moderator/log, the staff's log (AdminAction, written by domain blocks, backups, settings and the CLI; no IP, never a root paired with its personas). The Mastodon API's rules, extended description, privacy policy, terms of service, domain blocks (Published, Obfuscate), announcements (dismiss, reactions, streaming) and activity (only when published). Reports carry rule ids. Media overrides clamped to hard caps. Relays named by the settings reconcile next to the configured ones. Verified: interop.sh instance accounts relay with mastodon, relay, aoderelay and mailpit, 119 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
1 parent
f67e2ae35e
commit
70d1ffe9a2
46 files changed
+2223
-107
No files matched your search
@@ -43,6 +43,11 @@ namespace PrivaPub.Domain.Content
|
||||
|
||||
public string TagUrl(string tag) => $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag)}";
|
||||
|
||||
/// <summary>A text of the server's own (its description, privacy policy, terms, an announcement) from Markdown to
|
||||
/// sanitized HTML: the posts' pipeline (raw HTML off), without mentions or hashtags, so nothing is looked up.</summary>
|
||||
public static string Document(string markdown) =>
|
||||
string.IsNullOrWhiteSpace(markdown) ? string.Empty : ContentSanitizer.Html(Markdig.Markdown.ToHtml(markdown, Pipeline));
|
||||
|
||||
public Task<RenderedContent> Markdown(string text, CancellationToken token) =>
|
||||
Decorate(string.IsNullOrWhiteSpace(text) ? string.Empty : Markdig.Markdown.ToHtml(text, Pipeline), token);
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
using MongoDB.Bson.Serialization;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Domain.Media;
|
||||
using PrivaPub.Models;
|
||||
|
||||
namespace PrivaPub.Domain.Instance
|
||||
{
|
||||
public interface IInstanceSettings
|
||||
{
|
||||
/// <summary>The settings as they are now, never null: the defaults (today's literals) until an administrator saves.
|
||||
/// Never change it: Update does.</summary>
|
||||
ServerSettings Current { get; }
|
||||
/// <summary>Reads the document again; tells whoever listens (ChangeToken) when it changed.</summary>
|
||||
Task Reload(CancellationToken token);
|
||||
/// <summary>Changes the settings as stored now (or the defaults when none are), saves them and makes them current.</summary>
|
||||
Task<ServerSettings> Update(Action<ServerSettings> change, string byRootId, CancellationToken token);
|
||||
/// <summary>Fires once the settings change (a save here, or a reload that found another version).</summary>
|
||||
IChangeToken ChangeToken();
|
||||
}
|
||||
|
||||
// The server's own settings (Models/ServerSettings.cs) in memory: read at start, saved from the administrator's page, and
|
||||
// read again every five minutes (InstanceSettingsRefresher), so an edit made elsewhere takes too. What reads them reads
|
||||
// Current, which is never null and never changed in place.
|
||||
public class InstanceSettings : IInstanceSettings
|
||||
{
|
||||
readonly ILogger<InstanceSettings> _logger;
|
||||
readonly SemaphoreSlim _saving = new(1, 1);
|
||||
volatile ServerSettings _current = ServerSettings.Defaults();
|
||||
volatile bool _stored;//whether Current came from the document (or is the defaults)
|
||||
CancellationTokenSource _changed = new();
|
||||
|
||||
public InstanceSettings(ILogger<InstanceSettings> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public ServerSettings Current => _current;
|
||||
|
||||
public IChangeToken ChangeToken() => new CancellationChangeToken(Volatile.Read(ref _changed).Token);
|
||||
|
||||
public async Task Reload(CancellationToken token)
|
||||
{
|
||||
var stored = await Find(token);
|
||||
if ((stored != default) == _stored && stored?.UpdatedAt == _current.UpdatedAt)
|
||||
return;
|
||||
Swap(stored ?? ServerSettings.Defaults(), stored != default);
|
||||
}
|
||||
|
||||
public async Task<ServerSettings> Update(Action<ServerSettings> change, string byRootId, CancellationToken token)
|
||||
{
|
||||
await _saving.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
var settings = await Find(token) ?? ServerSettings.Defaults();
|
||||
change(settings);
|
||||
settings.ID = ServerSettings.Name;
|
||||
settings.UpdatedAt = DateTime.UtcNow;
|
||||
settings.UpdatedBy = byRootId;
|
||||
await DB.Default.SaveAsync(settings, token);
|
||||
// what is kept in memory is a copy nobody else holds
|
||||
Swap(BsonSerializer.Deserialize<ServerSettings>(MongoDB.Bson.BsonExtensionMethods.ToBson(settings)), stored: true);
|
||||
return settings;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_saving.Release();
|
||||
}
|
||||
}
|
||||
|
||||
static Task<ServerSettings> Find(CancellationToken token) =>
|
||||
DB.Default.Find<ServerSettings>().Match(s => s.ID == ServerSettings.Name).ExecuteFirstAsync(token);
|
||||
|
||||
void Swap(ServerSettings settings, bool stored)
|
||||
{
|
||||
_current = settings;
|
||||
_stored = stored;
|
||||
var previous = Interlocked.Exchange(ref _changed, new CancellationTokenSource());
|
||||
try
|
||||
{
|
||||
previous.Cancel();
|
||||
}
|
||||
catch (AggregateException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "A listener to the server's settings failed");
|
||||
}
|
||||
//(not disposed: a reader may still be registering on its token)
|
||||
}
|
||||
}
|
||||
|
||||
// reads the settings again every five minutes, so an edit made elsewhere (another process, the database) takes
|
||||
public sealed class InstanceSettingsRefresher(IInstanceSettings settings, ILogger<InstanceSettingsRefresher> logger) : BackgroundService
|
||||
{
|
||||
static readonly TimeSpan Every = TimeSpan.FromMinutes(5);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken token)
|
||||
{
|
||||
using var timer = new PeriodicTimer(Every);
|
||||
while (await timer.WaitForNextTickAsync(token))
|
||||
{
|
||||
try
|
||||
{
|
||||
await settings.Reload(token);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "The server's settings could not be read again");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Media:* as the administrator changed it (ServerSettings.Media), within hard caps: images up to 40 MB, video up to
|
||||
// 99 MB (nginx takes 100 MB, as does MediaController's request limit), a quota of 0 or more, audio and video up to four
|
||||
// hours. Every IOptionsMonitor<MediaOptions> reader sees it, since the settings' change token is one of its sources.
|
||||
public sealed class MediaLimitsFromSettings(IInstanceSettings settings) : IPostConfigureOptions<MediaOptions>
|
||||
{
|
||||
public const long MaxImageBytes = 40L * 1024 * 1024;
|
||||
public const long MaxVideoBytes = 99L * 1024 * 1024;
|
||||
public const int MaxSeconds = 4 * 3600;
|
||||
|
||||
public void PostConfigure(string name, MediaOptions options)
|
||||
{
|
||||
if (name != Options.DefaultName || settings.Current.Media is not { } media)
|
||||
return;
|
||||
if (media.QuotaBytesPerRoot is { } quota)
|
||||
options.QuotaBytesPerRoot = Math.Max(0, quota);
|
||||
if (media.MaxImageBytes is { } image)
|
||||
options.MaxImageBytes = Math.Clamp(image, 1, MaxImageBytes);
|
||||
if (media.MaxVideoBytes is { } video)
|
||||
options.MaxVideoBytes = Math.Clamp(video, 1, MaxVideoBytes);
|
||||
if (media.MaxSeconds is { } seconds)
|
||||
options.MaxSeconds = Math.Clamp(seconds, 1, MaxSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MediaLimitsChangeSource(IInstanceSettings settings) : IOptionsChangeTokenSource<MediaOptions>
|
||||
{
|
||||
public string Name => Options.DefaultName;
|
||||
|
||||
public IChangeToken GetChangeToken() => settings.ChangeToken();
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,8 @@ namespace PrivaPub.Domain.Media
|
||||
{
|
||||
// What the media directory holds against what the database says holds it, and, with fix, the two made to agree:
|
||||
// - a persona's current avatar or header with no row (they had none before) becomes a row of its own;
|
||||
// - media of a deleted post or of a deleted persona go to the trash;
|
||||
// - media of a deleted post or of a deleted persona go to the trash, and a picture of the server's own (ServerUse) that
|
||||
// its settings no longer name;
|
||||
// - rows whose files are gone go to the trash;
|
||||
// - files nothing holds (deleted posts' media and replaced pictures, from before media were trashed) get a trashed row
|
||||
// each.
|
||||
@@ -59,7 +60,11 @@ namespace PrivaPub.Domain.Media
|
||||
var ownerIds = rows.Select(r => r.OwnerAvatarId).Where(id => id != null).Distinct().ToList();
|
||||
var liveOwners = (await DB.Default.Find<Avatar>().Match(a => ownerIds.Contains(a.ID) && !a.DeletionAt.HasValue).Project(a => a.Include(x => x.ID)).ExecuteAsync(token))
|
||||
.Select(a => a.ID).ToHashSet();
|
||||
var ofDeleted = rows.Where(r => (r.PostId != null && !livePosts.Contains(r.PostId)) || (r.OwnerAvatarId != null && !liveOwners.Contains(r.OwnerAvatarId))).ToList();
|
||||
// (the server's own pictures belong to nobody: they are held while the settings name them)
|
||||
var settings = await DB.Default.Find<Models.ServerSettings>().Match(x => x.ID == Models.ServerSettings.Name).ExecuteFirstAsync(token);
|
||||
var serverHeld = new HashSet<string> { settings?.ThumbnailMediaId };
|
||||
var ofDeleted = rows.Where(r => r.ServerUse != null ? !serverHeld.Contains(r.ID)
|
||||
: (r.PostId != null && !livePosts.Contains(r.PostId)) || (r.OwnerAvatarId != null && !liveOwners.Contains(r.OwnerAvatarId))).ToList();
|
||||
var missing = rows.Except(ofDeleted).Where(r => !string.IsNullOrEmpty(r.FilePath) && !files.Contains(r.FilePath)).ToList();
|
||||
if (fix)
|
||||
foreach (var row in ofDeleted.Concat(missing))
|
||||
|
||||
@@ -359,7 +359,8 @@ namespace PrivaPub.Domain.Media
|
||||
}
|
||||
|
||||
// one pass:
|
||||
// - uploads never posted for a day (and not waiting for a scheduled post, nor a profile picture) go to the trash;
|
||||
// - uploads never posted for a day (and not waiting for a scheduled post, nor a profile picture, nor the server's own
|
||||
// picture) go to the trash;
|
||||
// - trashed files still served (a crash between the mark and the move) are moved out;
|
||||
// - trashed files past their grace are deleted, with their rows, unless a backup or a restore is running (one may be
|
||||
// reading the trash);
|
||||
@@ -367,8 +368,8 @@ namespace PrivaPub.Domain.Media
|
||||
public async Task Sweep(CancellationToken token)
|
||||
{
|
||||
var cutoff = DateTime.UtcNow - UnattachedLifetime;
|
||||
var unattached = await _media.Trash(m => m.PostId == null && m.ScheduledStatusId == null && m.ProfileOfAvatarId == null && m.CreatedAt < cutoff,
|
||||
"never posted", token);
|
||||
var unattached = await _media.Trash(m => m.PostId == null && m.ScheduledStatusId == null && m.ProfileOfAvatarId == null && m.ServerUse == null
|
||||
&& m.CreatedAt < cutoff, "never posted", token);
|
||||
|
||||
var trashed = await DB.Default.Find<MediaAttachment>().Match(m => m.TrashedAt != null).Limit(2000).ExecuteAsync(token);
|
||||
var purgeBefore = DateTime.UtcNow - TrashGrace;
|
||||
|
||||
@@ -46,6 +46,9 @@ namespace PrivaPub.Domain.Media
|
||||
Task<bool> ProcessPending(MediaAttachment pending, CancellationToken token);
|
||||
/// <summary>A persona's new avatar or header ("avatar" or "header"), cropped to its size, as a row of its own.</summary>
|
||||
Task<MediaOutcome> ProfileImage(string avatarId, string kind, IFormFile file, int width, int height, CancellationToken token);
|
||||
/// <summary>A picture of the server's own (its thumbnail, "thumbnail"), fitted within 1200×630, as a row nobody owns,
|
||||
/// held by its <see cref="MediaAttachment.ServerUse"/>.</summary>
|
||||
Task<MediaOutcome> ServerImage(string use, IFormFile file, CancellationToken token);
|
||||
/// <summary>Trashes the media <paramref name="which"/> matches (and that isn't trashed yet): each row is marked in one
|
||||
/// conditional update, so a row attached meanwhile is left alone, and its files stop being served at once.</summary>
|
||||
Task<long> Trash(System.Linq.Expressions.Expression<Func<MediaAttachment, bool>> which, string reason, CancellationToken token);
|
||||
@@ -245,6 +248,57 @@ namespace PrivaPub.Domain.Media
|
||||
return new MediaOutcome(attachment);
|
||||
}
|
||||
|
||||
public const int ServerImageWidth = 1200;
|
||||
public const int ServerImageHeight = 630;
|
||||
|
||||
// as safe as a profile picture: checked before it is decoded, decoded by the loaders allowed alone, written without its
|
||||
// metadata; fitted (not cropped) within the size link previews use
|
||||
public async Task<MediaOutcome> ServerImage(string use, IFormFile file, CancellationToken token)
|
||||
{
|
||||
var contentType = file?.ContentType?.Split(';')[0].Trim().ToLowerInvariant();
|
||||
if (file == default || file.Length == 0)
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File can't be blank");
|
||||
if (!ImageTypes.Contains(contentType))
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File type is not supported");
|
||||
if (file.Length > _options.CurrentValue.MaxImageBytes)
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File is too big");
|
||||
var input = await Read(file, token);
|
||||
if (Refusal(input, _options.CurrentValue) is { } refused)
|
||||
return refused;
|
||||
byte[] bytes;
|
||||
int outWidth, outHeight;
|
||||
await _processing.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
using var image = Image.ThumbnailBuffer(input, ServerImageWidth, height: ServerImageHeight, size: Enums.Size.Down);
|
||||
using var flat = Flatten(image);
|
||||
bytes = flat.WriteToBuffer(".jpg[Q=85,keep=none]");
|
||||
(outWidth, outHeight) = (flat.Width, flat.Height);
|
||||
}
|
||||
catch (VipsException ex)
|
||||
{
|
||||
_logger.LogInformation("Refused a server picture: {Error}", ex.Message);
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file is not a readable image");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_processing.Release();
|
||||
}
|
||||
var attachment = new MediaAttachment
|
||||
{
|
||||
Kind = "image",
|
||||
ServerUse = use,
|
||||
ContentType = "image/jpeg",
|
||||
FilePath = await Save(bytes, "jpg", token),
|
||||
Size = bytes.Length,
|
||||
Width = outWidth,
|
||||
Height = outHeight,
|
||||
AttachedAt = DateTime.UtcNow
|
||||
};
|
||||
await DB.Default.SaveAsync(attachment, token);
|
||||
return new MediaOutcome(attachment);
|
||||
}
|
||||
|
||||
// a login over its quota (Media:QuotaBytesPerRoot; none when 0) uploads nothing more
|
||||
async Task<MediaOutcome> OverQuota(string avatarId, long incoming, CancellationToken token)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
namespace PrivaPub.Domain.Moderation
|
||||
{
|
||||
public interface IModerationLog
|
||||
{
|
||||
/// <summary>Keeps one act of the staff (Models/AdminAction.cs). Never throws: an act that was done stays done when
|
||||
/// its entry can't be written, and the failure is logged.</summary>
|
||||
Task Write(string byRootId, string action, string targetKind, string targetId, string targetLabel, string reportId = default, string note = default,
|
||||
CancellationToken token = default);
|
||||
}
|
||||
|
||||
// The staff's log (completion program R5): administrators read all of it, moderators what isn't about a login
|
||||
// (ModeratorController's /clientapi/moderator/log). An entry never holds an IP address, and never pairs a root with its
|
||||
// own personas: an act on a persona that the acting root owns is kept without who acted.
|
||||
public class ModerationLog : IModerationLog
|
||||
{
|
||||
const int MaxLabel = 200;
|
||||
const int MaxNote = 1000;
|
||||
|
||||
readonly ILogger<ModerationLog> _logger;
|
||||
|
||||
public ModerationLog(ILogger<ModerationLog> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Write(string byRootId, string action, string targetKind, string targetId, string targetLabel, string reportId = default,
|
||||
string note = default, CancellationToken token = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (targetKind == AdminTargets.Persona && byRootId != default && byRootId != AdminAction.Cli && targetId != default
|
||||
&& await DB.Default.Find<RootToAvatar>().Match(r => r.RootId == byRootId && r.AvatarId == targetId).ExecuteAnyAsync(token))
|
||||
byRootId = default;
|
||||
await DB.Default.SaveAsync(new AdminAction
|
||||
{
|
||||
ByRootId = byRootId,
|
||||
Action = action,
|
||||
TargetKind = targetKind,
|
||||
TargetId = targetId,
|
||||
TargetLabel = Cut(targetLabel, MaxLabel),
|
||||
ReportId = reportId,
|
||||
Note = Cut(note, MaxNote)
|
||||
}, CancellationToken.None);//an act that was done is logged even when its request went away
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "The staff's log could not keep a {Action}", action);
|
||||
}
|
||||
}
|
||||
|
||||
static string Cut(string text, int max) =>
|
||||
string.IsNullOrEmpty(text) ? text : text.Length <= max ? text : text[..(max - 1)] + "…";
|
||||
}
|
||||
}
|
||||
@@ -88,5 +88,30 @@ namespace PrivaPub.Domain.Privacy
|
||||
var barred = await BarredPersonas(token);
|
||||
return await DB.Default.CountAsync<PostEntity>(f => f.Where(p => !p.IsFederatedCopy && !barred.Contains(p.GroupUserId)) & f.Where(Post), token);
|
||||
}
|
||||
|
||||
// Mastodon's instance activity, week by week (from Monday 00:00 UTC), this week first: the local posts made in it,
|
||||
// counted as LocalPosts counts them (by their ids, which carry when they were made, so the _id index serves it), and
|
||||
// the logins made in it (RootUser.CreatedAt). Never personas by their creation: their dates are spread on purpose.
|
||||
public static async Task<List<(DateTime Week, long Posts, long Registrations)>> Weekly(int weeks, CancellationToken token)
|
||||
{
|
||||
var barred = await BarredPersonas(token);
|
||||
var today = DateTime.UtcNow.Date;
|
||||
var monday = DateTime.SpecifyKind(today.AddDays(-(((int)today.DayOfWeek + 6) % 7)), DateTimeKind.Utc);
|
||||
var counted = new List<(DateTime, long, long)>();
|
||||
for (var i = 0; i < weeks; i++)
|
||||
{
|
||||
var start = monday.AddDays(-7 * i);
|
||||
var end = start.AddDays(7);
|
||||
var (low, high) = (IdAt(start), IdAt(end));
|
||||
var posts = await DB.Default.CountAsync<PostEntity>(f => f.Gte(p => p.ID, low) & f.Lt(p => p.ID, high)
|
||||
& f.Where(p => !p.IsFederatedCopy && !barred.Contains(p.GroupUserId)) & f.Where(Post), token);
|
||||
var registrations = await DB.Default.CountAsync<RootUser>(u => u.CreatedAt >= start && u.CreatedAt < end, token);
|
||||
counted.Add((start, posts, registrations));
|
||||
}
|
||||
return counted;
|
||||
}
|
||||
|
||||
// the smallest ObjectId of a moment
|
||||
static string IdAt(DateTime at) => ((uint)new DateTimeOffset(at, TimeSpan.Zero).ToUnixTimeSeconds()).ToString("x8") + "0000000000000000";
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,8 @@ namespace PrivaPub.Domain.Relationships
|
||||
{
|
||||
public interface IReportService
|
||||
{
|
||||
Task<Report> File(LocalActor reporter, string targetAccountId, IReadOnlyList<string> postIds, string comment, string category, bool forward,
|
||||
CancellationToken token);
|
||||
Task<Report> File(LocalActor reporter, string targetAccountId, IReadOnlyList<string> postIds, string comment, string category,
|
||||
IReadOnlyList<string> ruleIds, bool forward, CancellationToken token);
|
||||
}
|
||||
|
||||
public class ReportService : IReportService
|
||||
@@ -23,17 +23,29 @@ namespace PrivaPub.Domain.Relationships
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IDeliveryService _delivery;
|
||||
readonly Instance.IInstanceSettings _settings;
|
||||
|
||||
public ReportService(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery)
|
||||
public ReportService(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery, Instance.IInstanceSettings settings)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
_delivery = delivery;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
public async Task<Report> File(LocalActor reporter, string targetAccountId, IReadOnlyList<string> postIds, string comment, string category,
|
||||
bool forward, CancellationToken token)
|
||||
IReadOnlyList<string> ruleIds, bool forward, CancellationToken token)
|
||||
{
|
||||
// the server's rules it names, deleted ones too (they may have been listed when the reader opened the dialog); a
|
||||
// report naming rules is a violation unless it says what else it is, and only a violation names rules, as on Mastodon
|
||||
var known = _settings.Current.Rules.Select(r => r.Id).ToHashSet(StringComparer.Ordinal);
|
||||
var rules = (ruleIds ?? Array.Empty<string>()).Where(known.Contains).Distinct().ToList();
|
||||
if (rules.Count > 0 && string.IsNullOrEmpty(category))
|
||||
category = "violation";
|
||||
category = category is "spam" or "legal" or "violation" ? category : "other";
|
||||
if (category != "violation")
|
||||
rules.Clear();
|
||||
|
||||
var localTarget = await _dbEntities.Avatars.MatchID(targetAccountId).ExecuteFirstAsync(token);
|
||||
var remoteTarget = localTarget == default ? await _dbEntities.ForeignAvatars.MatchID(targetAccountId).ExecuteFirstAsync(token) : default;
|
||||
if (localTarget == default && remoteTarget == default)
|
||||
@@ -51,7 +63,8 @@ namespace PrivaPub.Domain.Relationships
|
||||
PostIds = posts.Select(p => p.ID).ToList(),
|
||||
ObjectURIs = posts.Select(p => p.ObjectURI).ToList(),
|
||||
Comment = comment?.Trim(),
|
||||
Category = category is "spam" or "legal" or "violation" ? category : "other"
|
||||
Category = category,
|
||||
RuleIds = rules
|
||||
};
|
||||
await DB.Default.SaveAsync(report, token);
|
||||
if (!forward || remoteTarget == default)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Domain.Content;
|
||||
using PrivaPub.Domain.Timelines;
|
||||
using PrivaPub.Models.Social;
|
||||
|
||||
using System.Linq.Expressions;
|
||||
|
||||
namespace PrivaPub.Domain.Social
|
||||
{
|
||||
public sealed record AnnouncementDraft(string Text, DateTime? StartsAt, DateTime? EndsAt, bool AllDay, bool IsPublished);
|
||||
|
||||
public interface IAnnouncements
|
||||
{
|
||||
Task<List<Announcement>> Shown(CancellationToken token);
|
||||
Task<Announcement> FindShown(string id, CancellationToken token);
|
||||
Task<bool> Dismiss(string id, string avatarId, CancellationToken token);
|
||||
/// <summary>200 when it is done, 404 for an announcement not shown, 422 (with why) for a name that isn't one emoji.</summary>
|
||||
Task<(int Status, string Error)> React(string id, string avatarId, string name, bool add, CancellationToken token);
|
||||
Task<List<Announcement>> All(CancellationToken token);
|
||||
Task<Announcement> Insert(AnnouncementDraft draft, CancellationToken token);
|
||||
Task<Announcement> Update(string id, AnnouncementDraft draft, CancellationToken token);
|
||||
Task<Announcement> Delete(string id, CancellationToken token);
|
||||
}
|
||||
|
||||
// The administrators' announcements (completion program R5): written on the administrator's page, shown to every persona
|
||||
// while published and not ended, each persona's dismissal and reactions kept apart and never federated. Every change a
|
||||
// client shows is told to every persona's home stream, as Mastodon tells it: `announcement` (made, edited or published),
|
||||
// `announcement.reaction` and `announcement.delete` (deleted or no longer shown).
|
||||
public class Announcements : IAnnouncements
|
||||
{
|
||||
public const int MaxReactionNames = 8;//as Mastodon: eight different emoji on one announcement
|
||||
const int MaxNameLength = 32;
|
||||
|
||||
public static Expression<Func<Announcement, bool>> IsShown()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return a => a.IsPublished && (a.EndsAt == null || a.EndsAt > now);
|
||||
}
|
||||
|
||||
static bool Showing(Announcement a) => a != default && a.IsPublished && (a.EndsAt == null || a.EndsAt > DateTime.UtcNow);
|
||||
|
||||
// as Mastodon orders them: by when they are about, else when they were published
|
||||
public async Task<List<Announcement>> Shown(CancellationToken token) =>
|
||||
(await DB.Default.Find<Announcement>().Match(IsShown()).ExecuteAsync(token))
|
||||
.OrderBy(a => a.StartsAt ?? a.PublishedAt ?? a.CreatedAt).ThenBy(a => a.ID).ToList();
|
||||
|
||||
public Task<Announcement> FindShown(string id, CancellationToken token) =>
|
||||
string.IsNullOrEmpty(id) ? Task.FromResult<Announcement>(default)
|
||||
: DB.Default.Find<Announcement>().Match(a => a.ID == id).Match(IsShown()).ExecuteFirstAsync(token);
|
||||
|
||||
public async Task<bool> Dismiss(string id, string avatarId, CancellationToken token)
|
||||
{
|
||||
if (await FindShown(id, token) == default)
|
||||
return false;
|
||||
try
|
||||
{
|
||||
await DB.Default.Update<AnnouncementRead>()
|
||||
.Match(r => r.AnnouncementId == id && r.AvatarId == avatarId)
|
||||
.Modify(b => b.SetOnInsert(r => r.ReadAt, DateTime.UtcNow))
|
||||
.Option(o => o.IsUpsert = true)
|
||||
.ExecuteAsync(token);
|
||||
}
|
||||
catch (MongoException ex) when (ex is MongoCommandException { Code: 11000 } or MongoWriteException { WriteError.Category: ServerErrorCategory.DuplicateKey })
|
||||
{
|
||||
//dismissed at the same moment by another request
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<(int Status, string Error)> React(string id, string avatarId, string name, bool add, CancellationToken token)
|
||||
{
|
||||
if (await FindShown(id, token) == default)
|
||||
return (StatusCodes.Status404NotFound, "Record not found");
|
||||
// one unicode emoji (custom emoji come with R7): the same normalisation as a post's reactions
|
||||
var picked = Reactions.Normalise(name, new List<Models.Post.CustomEmoji>(), requireKnownCustom: true);
|
||||
if (picked == default || picked.Url != default || picked.Name.Length > MaxNameLength)
|
||||
return (StatusCodes.Status422UnprocessableEntity, "Validation failed: Name is not a recognized emoji");
|
||||
name = picked.Name;
|
||||
if (!add)
|
||||
{
|
||||
var removed = await DB.Default.DeleteAsync<AnnouncementReaction>(r => r.AnnouncementId == id && r.AvatarId == avatarId && r.Name == name);
|
||||
if (removed.DeletedCount > 0)
|
||||
Streams.ToUsers(new Streams.Event("announcement.reaction", id, name));
|
||||
return (StatusCodes.Status200OK, default);
|
||||
}
|
||||
if (await DB.Default.Find<AnnouncementReaction>().Match(r => r.AnnouncementId == id && r.AvatarId == avatarId && r.Name == name).ExecuteAnyAsync(token))
|
||||
return (StatusCodes.Status200OK, default);
|
||||
var names = await DB.Default.Fluent<AnnouncementReaction>().Match(r => r.AnnouncementId == id).Group(r => r.Name, g => g.Key).ToListAsync(token);
|
||||
if (!names.Contains(name) && names.Count >= MaxReactionNames)
|
||||
return (StatusCodes.Status422UnprocessableEntity, "Validation failed: Maximum number of reactions reached");
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(new AnnouncementReaction { AnnouncementId = id, AvatarId = avatarId, Name = name }, token);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return (StatusCodes.Status200OK, default);//the same reaction at the same moment
|
||||
}
|
||||
Streams.ToUsers(new Streams.Event("announcement.reaction", id, name));
|
||||
return (StatusCodes.Status200OK, default);
|
||||
}
|
||||
|
||||
public async Task<List<Announcement>> All(CancellationToken token) =>
|
||||
await DB.Default.Find<Announcement>().Sort(a => a.ID, Order.Descending).Limit(200).ExecuteAsync(token);
|
||||
|
||||
public async Task<Announcement> Insert(AnnouncementDraft draft, CancellationToken token)
|
||||
{
|
||||
var announcement = new Announcement();
|
||||
Apply(announcement, draft);
|
||||
await DB.Default.SaveAsync(announcement, token);
|
||||
if (Showing(announcement))
|
||||
Streams.ToUsers(new Streams.Event("announcement", announcement.ID));
|
||||
return announcement;
|
||||
}
|
||||
|
||||
public async Task<Announcement> Update(string id, AnnouncementDraft draft, CancellationToken token)
|
||||
{
|
||||
var announcement = string.IsNullOrEmpty(id) ? default : await DB.Default.Find<Announcement>().Match(a => a.ID == id).ExecuteFirstAsync(token);
|
||||
if (announcement == default)
|
||||
return default;
|
||||
var wasShown = Showing(announcement);
|
||||
Apply(announcement, draft);
|
||||
announcement.UpdatedAt = DateTime.UtcNow;
|
||||
await DB.Default.SaveAsync(announcement, token);
|
||||
if (Showing(announcement))
|
||||
Streams.ToUsers(new Streams.Event("announcement", announcement.ID));
|
||||
else if (wasShown)
|
||||
Streams.ToUsers(new Streams.Event("announcement.delete", announcement.ID));
|
||||
return announcement;
|
||||
}
|
||||
|
||||
public async Task<Announcement> Delete(string id, CancellationToken token)
|
||||
{
|
||||
var announcement = string.IsNullOrEmpty(id) ? default : await DB.Default.Find<Announcement>().Match(a => a.ID == id).ExecuteFirstAsync(token);
|
||||
if (announcement == default)
|
||||
return default;
|
||||
await DB.Default.DeleteAsync<Announcement>(a => a.ID == id);
|
||||
await DB.Default.DeleteAsync<AnnouncementRead>(r => r.AnnouncementId == id);
|
||||
await DB.Default.DeleteAsync<AnnouncementReaction>(r => r.AnnouncementId == id);
|
||||
if (Showing(announcement))
|
||||
Streams.ToUsers(new Streams.Event("announcement.delete", id));
|
||||
return announcement;
|
||||
}
|
||||
|
||||
static void Apply(Announcement announcement, AnnouncementDraft draft)
|
||||
{
|
||||
announcement.Text = draft.Text?.Trim();
|
||||
announcement.Html = ContentRenderer.Document(announcement.Text);
|
||||
announcement.StartsAt = Utc(draft.StartsAt);
|
||||
announcement.EndsAt = Utc(draft.EndsAt);
|
||||
announcement.AllDay = draft.AllDay;
|
||||
if (draft.IsPublished && !announcement.IsPublished)
|
||||
announcement.PublishedAt = DateTime.UtcNow;
|
||||
announcement.IsPublished = draft.IsPublished;
|
||||
}
|
||||
|
||||
static DateTime? Utc(DateTime? value) => value == default ? (DateTime?)null : value.Value.Kind == DateTimeKind.Local ? value.Value.ToUniversalTime()
|
||||
: DateTime.SpecifyKind(value.Value, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,9 @@ namespace PrivaPub.Domain.Timelines
|
||||
|
||||
public static readonly string[] Names = { User, UserNotification, Public, PublicLocal, PublicRemote, Hashtag, HashtagLocal, List, Direct };
|
||||
|
||||
// what happened: Mastodon's event name, and the id of what it is about
|
||||
public sealed record Event(string Name, string Id);
|
||||
// what happened: Mastodon's event name, the id of what it is about and, for some, what else the event says (an
|
||||
// announcement reaction's emoji)
|
||||
public sealed record Event(string Name, string Id, string Data = default);
|
||||
|
||||
// one stream a connection listens to: its name and, for a hashtag or a list, which one
|
||||
public sealed record Key(string Name, string Parameter = default)
|
||||
@@ -102,6 +103,16 @@ namespace PrivaPub.Domain.Timelines
|
||||
listener.Tell(key, e);
|
||||
}
|
||||
|
||||
// to every persona's home stream (user), as Mastodon tells its announcements: each connection maps it for its own
|
||||
// persona when it sends it
|
||||
public static void ToUsers(Event e)
|
||||
{
|
||||
var user = new Key(User);
|
||||
foreach (var listener in Listeners.Keys)
|
||||
if (listener.Hears(user))
|
||||
listener.Tell(user, e);
|
||||
}
|
||||
|
||||
public static bool Anyone => !Listeners.IsEmpty;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user