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
163 lines
7.6 KiB
C#
163 lines
7.6 KiB
C#
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);
|
|
}
|
|
}
|