Files
SocialPub/PrivaPub/Domain/Statuses/ScheduledStatuses.cs
T
thepraandClaude Opus 5.5 9f68ca3ed0 R6 moderation: roles, silence and suspension, bans that write nothing on the personas, deletions, report actions
Roles (user, moderator, administrator) from the CLI and /clientapi/admin/roots/{id}/role, never one's own nor the last
administrator. Silence keeps an account's posts out of public timelines, hashtags, trends, search and relays while its
followers still get them; suspension refuses and revokes a local persona's tokens, answers 404 for its posts and
collections, holds back every delivery and tells other servers nothing new, its actor unchanged; a suspended remote
account's activities are dropped and nothing goes to its own inbox (a shared one still serves its server). A banned
login's personas are held back in memory alone (Restrictions), so nothing pairs them in stored data. Personas and groups
are deleted one at a time (PersonaRemoval, GroupRemoval), by their login or an administrator with the password. Reports
resolve with an action and a note; posts are taken down, a remote copy hidden. Warnings reach the persona as Mastodon's
moderation_warning. Every staff act goes to the staff's log. Migration _020 limits posts from servers already silenced;
a restore keeps the moderation made since the backup.

Verified: interop.sh moderation gts mastodon with SecureMode, 194 passed; restore.sh, 25 passed, now with a suspension,
a silence and a takedown made after the backup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 05:23:45 +02:00

133 lines
6.1 KiB
C#

using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Media;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.Domain.Media;
namespace PrivaPub.Domain.Statuses
{
// Scheduled posts (Mastodon's scheduled_statuses): kept as asked, their media kept from the janitor, and published at
// their time by a PublishScheduled job as if the persona posted then. Mastodon's limits hold: at least five minutes
// ahead, 300 waiting, 25 on one day.
public static class ScheduledStatuses
{
public static readonly TimeSpan Lead = TimeSpan.FromMinutes(5);
public const int MaxWaiting = 300;
public const int MaxPerDay = 25;
public static StatusDraft Draft(ScheduledParams p, string scheduledId = default) => new()
{
ScheduledStatusId = scheduledId,
Text = p.Text,
PlainText = true,
SpoilerText = p.SpoilerText,
Sensitive = p.Sensitive,
Visibility = Visibility(p.Visibility),
InReplyTo = p.InReplyToId,
Language = p.Language,
MediaIds = p.MediaIds,
QuotedStatusId = p.QuotedStatusId,
QuotePolicy = p.QuotePolicy,
Poll = p.Poll == default ? default : new PollDraft(p.Poll.Options, p.Poll.ExpiresIn, p.Poll.Multiple, p.Poll.HideTotals)
};
public static PostVisibility Visibility(string value) => value?.ToLowerInvariant() switch
{
"unlisted" => PostVisibility.Unlisted,
"private" => PostVisibility.FollowersOnly,
"direct" => PostVisibility.Direct,
_ => PostVisibility.Public
};
// why the persona cannot schedule at this time, or default
public static async Task<string> Refusal(string avatarId, DateTime at, string except, CancellationToken token)
{
if (at < DateTime.UtcNow + Lead)
return "Scheduled at The scheduled date must be at least 5 minutes in the future";
if (await DB.Default.CountAsync<ScheduledStatus>(s => s.AvatarId == avatarId && s.ID != except, token) >= MaxWaiting)
return $"Scheduled at You have exceeded the limit of {MaxWaiting} scheduled statuses";
var day = at.Date;
var next = day.AddDays(1);
if (await DB.Default.CountAsync<ScheduledStatus>(s => s.AvatarId == avatarId && s.ID != except && s.ScheduledAt >= day && s.ScheduledAt < next, token) >= MaxPerDay)
return $"Scheduled at You have exceeded the limit of {MaxPerDay} scheduled statuses on that day";
return default;
}
// a job per scheduled time: a rescheduled post has a job for its new time, and the old one finds it not due
public static Job JobFor(ScheduledStatus scheduled, string host) => new()
{
Kind = JobKind.PublishScheduled,
Payload = scheduled.ID,
Host = host,
DedupeKey = $"scheduled|{scheduled.ID}|{scheduled.ScheduledAt.Ticks}",
RunAt = scheduled.ScheduledAt
};
// whether a persona may still publish: neither deleted nor its root deleted or banned
public static async Task<bool> MayPublish(string avatarId, CancellationToken token)
{
if (!await DB.Default.Find<Avatar>().Match(a => a.ID == avatarId && !a.DeletionAt.HasValue).ExecuteAnyAsync(token))
return false;
var rootIds = (await DB.Default.Find<RootToAvatar>().Match(r => r.AvatarId == avatarId).ExecuteAsync(token)).Select(r => r.RootId).ToList();
return await DB.Default.Find<RootUser>().Match(u => rootIds.Contains(u.ID) && u.DeletedAt == null && !u.IsBanned).ExecuteAnyAsync(token);
}
// the post's media go back to being plain uploads (the janitor's after a day), or are attached to the post by now
public static Task Release(string scheduledId, CancellationToken token) =>
DB.Default.Update<MediaAttachment>().Match(m => m.ScheduledStatusId == scheduledId).Modify(m => m.ScheduledStatusId, null).ExecuteAsync(token);
}
public class PublishScheduledJob : IJobHandler
{
readonly IServiceScopeFactory _scopes;
readonly ILogger<PublishScheduledJob> _logger;
public PublishScheduledJob(IServiceScopeFactory scopes, ILogger<PublishScheduledJob> logger)
{
_scopes = scopes;
_logger = logger;
}
public JobKind Kind => JobKind.PublishScheduled;
public int Concurrency => 2;
public int MaxAttempts => 3;
public int PerHostLimit => 2;
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
{
var scheduled = await DB.Default.Find<ScheduledStatus>().MatchID(job.Payload).ExecuteFirstAsync(token);
if (scheduled == default || scheduled.ScheduledAt > DateTime.UtcNow.AddSeconds(30))
return JobOutcome.Done;//dropped, or moved to a later time that has its own job
using var scope = _scopes.CreateScope();
// a persona suspended since, or whose login was banned, waits: lifting it publishes what came due meanwhile
// (R6), unless that was a month ago
if (Moderation.Restrictions.IsSuspended(scheduled.AvatarId) && scheduled.ScheduledAt > DateTime.UtcNow.AddDays(-30)
&& await DB.Default.Find<Avatar>().Match(a => a.ID == scheduled.AvatarId && !a.DeletionAt.HasValue).ExecuteAnyAsync(token))
return JobOutcome.Defer(DateTime.UtcNow.AddHours(1), "its author is suspended");
// a persona deleted or banned since it was scheduled publishes nothing, and what it held is trashed
if (!await ScheduledStatuses.MayPublish(scheduled.AvatarId, token))
{
await scope.ServiceProvider.GetRequiredService<IMediaService>().Trash(m => m.ScheduledStatusId == scheduled.ID, "its author is gone", token);
await DB.Default.DeleteAsync<ScheduledStatus>(scheduled.ID);
return JobOutcome.Done;
}
var author = await scope.ServiceProvider.GetRequiredService<ILocalActorService>().FindById(LocalActorKind.Person, scheduled.AvatarId, token);
if (author != default)
{
var outcome = await scope.ServiceProvider.GetRequiredService<IStatusService>().Publish(author, ScheduledStatuses.Draft(scheduled.Params, scheduled.ID), token);
if (!outcome.Ok)
_logger.LogWarning("Scheduled status {Id} of {Avatar} could not be published: {Error}", scheduled.ID, scheduled.AvatarId, outcome.Error);
}
await ScheduledStatuses.Release(scheduled.ID, token);
await DB.Default.DeleteAsync<ScheduledStatus>(scheduled.ID);
return JobOutcome.Done;
}
}
}