R5 the server's own settings: instance, rules, announcements, relays, media limits, the staff's log
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:
thepraandClaude Opus 5.5 committed 2026-10-17 04:02:47 +02:00
1 parent f67e2ae35e
commit 70d1ffe9a2
46 files changed
+2223 -107

No files matched your search

@@ -0,0 +1,98 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Domain.Social;
using PrivaPub.Models.Social;
namespace PrivaPub.Api.Mastodon.Controllers
{
// Mastodon's announcements: the administrators' (Domain/Social/Announcements.cs), each persona's dismissals and
// reactions its own. Anonymous readers get the list without `read` or `me`.
public class AnnouncementsController : MastodonController
{
readonly IAnnouncements _announcements;
public AnnouncementsController(IAnnouncements announcements)
{
_announcements = announcements;
}
[HttpGet("/api/v1/announcements"), Scope("read", requiresUser: false), AllowAnonymous]
public async Task<IActionResult> List(CancellationToken token) =>
Json(await Map(await _announcements.Shown(token), MyId, token));
[HttpPost("/api/v1/announcements/{id}/dismiss"), Scope("write:accounts")]
public async Task<IActionResult> Dismiss(string id, CancellationToken token) =>
await _announcements.Dismiss(id, MyId, token) ? Json(new { }) : NotFoundError();
[HttpPut("/api/v1/announcements/{id}/reactions/{name}"), Scope("write:favourites")]
public Task<IActionResult> React(string id, string name, CancellationToken token) => Reaction(id, name, add: true, token);
[HttpDelete("/api/v1/announcements/{id}/reactions/{name}"), Scope("write:favourites")]
public Task<IActionResult> Unreact(string id, string name, CancellationToken token) => Reaction(id, name, add: false, token);
async Task<IActionResult> Reaction(string id, string name, bool add, CancellationToken token)
{
var (status, error) = await _announcements.React(id, MyId, name, add, token);
return error == default ? Json(new { }) : Error(status, error);
}
/// <summary>Mastodon's Announcement entities, as the viewer (a persona, or nobody) sees them.</summary>
public static async Task<List<Dictionary<string, object>>> Map(IReadOnlyList<Announcement> announcements, string viewerId, CancellationToken token)
{
var ids = announcements.Select(a => a.ID).ToList();
if (ids.Count == 0)
return new List<Dictionary<string, object>>();
var reactions = await DB.Default.Find<AnnouncementReaction>().Match(r => ids.Contains(r.AnnouncementId)).ExecuteAsync(token);
var read = viewerId == default
? new HashSet<string>()
: (await DB.Default.Find<AnnouncementRead>().Match(r => r.AvatarId == viewerId && ids.Contains(r.AnnouncementId)).ExecuteAsync(token))
.Select(r => r.AnnouncementId).ToHashSet();
return announcements.Select(a =>
{
var entity = new Dictionary<string, object>
{
["id"] = a.ID,
["content"] = a.Html ?? string.Empty,
["starts_at"] = a.StartsAt == default ? default : MastodonJson.Time(a.StartsAt.Value),
["ends_at"] = a.EndsAt == default ? default : MastodonJson.Time(a.EndsAt.Value),
["all_day"] = a.AllDay,
["published_at"] = MastodonJson.Time(a.PublishedAt ?? a.CreatedAt),
["updated_at"] = MastodonJson.Time(a.UpdatedAt),
["mentions"] = Array.Empty<object>(),
["statuses"] = Array.Empty<object>(),
["tags"] = Array.Empty<object>(),
["emojis"] = Array.Empty<object>(),
["reactions"] = reactions.Where(r => r.AnnouncementId == a.ID)
.GroupBy(r => r.Name)
.OrderBy(g => g.Min(r => r.CreatedAt))
.Select(g => Reaction(g.Key, g.Count(), viewerId == default ? default : g.Any(r => r.AvatarId == viewerId)))
.ToList()
};
if (viewerId != default)
entity["read"] = read.Contains(a.ID);
return entity;
}).ToList();
}
// a unicode emoji's reaction: no url (custom emoji come later); `me` only for a persona
static Dictionary<string, object> Reaction(string name, int count, bool? me)
{
var reaction = new Dictionary<string, object> { ["name"] = name, ["count"] = count };
if (me is { } mine)
reaction["me"] = mine;
return reaction;
}
/// <summary>The `announcement.reaction` event's payload: the emoji, how many chose it now, and on which.</summary>
public static async Task<Dictionary<string, object>> ReactionEvent(string announcementId, string name, CancellationToken token) => new()
{
["name"] = name,
["count"] = await DB.Default.CountAsync<AnnouncementReaction>(r => r.AnnouncementId == announcementId && r.Name == name, token),
["announcement_id"] = announcementId
};
}
}
@@ -5,31 +5,71 @@ using Microsoft.Extensions.Options;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Content;
using PrivaPub.Domain.Instance;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Actors;
using PrivaPub.Infrastructure;
using PrivaPub.Models;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Api.Mastodon.Controllers
{
// The instance API: what the server says of itself comes from its settings (IInstanceSettings, edited on the
// administrator's page; until then exactly what it said before), its numbers from Counted, its limits from where they
// are enforced.
public class InstanceController : MastodonController
{
public const string Version = "4.2.0 (compatible; PrivaPub)";
const string Description = "A small ActivityPub server where one private login keeps several unlinkable public personas.";
const int ActivityWeeks = 12;
static readonly TimeSpan ActivityLifetime = TimeSpan.FromHours(1);
static volatile CachedActivity _activity;
sealed record CachedActivity(DateTime At, object Weeks);
readonly ILocalActorService _localActors;
readonly IOptionsMonitor<RegistrationOptions> _registrations;
readonly IInstanceSettings _settings;
readonly MastodonMapper _mapper;
public InstanceController(ILocalActorService localActors, IOptionsMonitor<RegistrationOptions> registrations)
public InstanceController(ILocalActorService localActors, IOptionsMonitor<RegistrationOptions> registrations, IInstanceSettings settings,
MastodonMapper mapper)
{
_localActors = localActors;
_registrations = registrations;
_settings = settings;
_mapper = mapper;
}
bool Open => _registrations.CurrentValue.IsOpen;
ServerSettings Settings => _settings.Current;
string Thumbnail => Settings.ThumbnailUrl ?? $"{_localActors.BaseAddress}{ServerSettings.DefaultThumbnailPath}";
static object Rules(ServerSettings settings) =>
settings.ListedRules().Select(r => new { id = r.Id, text = r.Text ?? string.Empty, hint = r.Hint ?? string.Empty }).ToList();
// the persona the administrator named as the server's contact, while it exists and its login may act
async Task<object> ContactAccount(ServerSettings settings, CancellationToken token)
{
var id = settings.ContactAccountId;
if (string.IsNullOrEmpty(id))
return default;
var avatar = await DB.Default.Find<Avatar>().Match(a => a.ID == id && !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
if (avatar == default || (await Counted.BarredPersonas(token)).Contains(id))
return default;
return await _mapper.Local(_localActors.FromAvatar(avatar), false, token);
}
// the languages this server is set up for (AppConfiguration:SupportedLanguages), "en" when none are named
public static IReadOnlyList<string> LanguageCodes(IServiceProvider services)
{
@@ -75,27 +115,28 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/instance"), AllowAnonymous]
public async Task<IActionResult> V1(CancellationToken token)
{
var settings = Settings;
var users = await Counted.Users(token);
var statuses = await Counted.LocalPosts(token);
var domains = await KnownServers(token);
return Json(new
{
uri = Domain,
title = "PrivaPub",
short_description = Description,
description = Description,
email = string.Empty,
title = settings.Title,
short_description = settings.ShortDescription ?? string.Empty,
description = settings.ShortDescription ?? string.Empty,
email = settings.ContactEmail ?? string.Empty,
version = Version,
urls = new { streaming_api = Streaming },
stats = new { user_count = users, status_count = statuses, domain_count = domains },
thumbnail = $"{_localActors.BaseAddress}/media/missing-header.png",
thumbnail = Thumbnail,
languages = LanguageCodes(HttpContext.RequestServices),
registrations = Open,
approval_required = false,
invites_enabled = true,//a group invitation always creates an account
configuration = new { accounts = new { max_featured_tags = 0 }, statuses = Statuses, media_attachments = MediaAttachments, polls = Polls },
contact_account = default(object),
rules = Array.Empty<object>()
contact_account = await ContactAccount(settings, token),
rules = Rules(settings)
});
}
@@ -109,13 +150,13 @@ namespace PrivaPub.Api.Mastodon.Controllers
public async Task<IActionResult> V2(CancellationToken token) => Json(new
{
domain = Domain,
title = "PrivaPub",
title = Settings.Title,
version = Version,
api_versions = new { mastodon = 7 },
source_url = "https://git.thepra.dev/thepra/SocialPub",
description = Description,
description = Settings.ShortDescription ?? string.Empty,
usage = new { users = new { active_month = await Counted.ActiveUsers(30, token) } },
thumbnail = new { url = $"{_localActors.BaseAddress}/media/missing-header.png" },
thumbnail = new { url = Thumbnail },
languages = LanguageCodes(HttpContext.RequestServices),
configuration = new
{
@@ -127,20 +168,110 @@ namespace PrivaPub.Api.Mastodon.Controllers
translation = new { enabled = false }
},
registrations = new { enabled = Open, approval_required = false, message = Open ? default : RegistrationOptions.ClosedMessage },
contact = new { email = string.Empty, account = default(object) },
rules = Array.Empty<object>()
contact = new { email = Settings.ContactEmail ?? string.Empty, account = await ContactAccount(Settings, token) },
rules = Rules(Settings)
});
[HttpGet("/api/v1/instance/peers"), AllowAnonymous]
public IActionResult Peers() => Json(Array.Empty<string>());
// Mastodon's weekly activity, only when the administrator publishes it (off by default): twelve weeks, this one first,
// counted as Counted counts (local posts, logins made); sign-ins are not kept, so `logins` is 0. Worked out at most
// once an hour.
[HttpGet("/api/v1/instance/activity"), AllowAnonymous]
public IActionResult Activity() => Json(Array.Empty<object>());
public async Task<IActionResult> Activity(CancellationToken token)
{
if (!Settings.PublishActivity)
return Json(Array.Empty<object>());
if (_activity is { } cached && DateTime.UtcNow - cached.At < ActivityLifetime)
return Json(cached.Weeks);
var weeks = (await Counted.Weekly(ActivityWeeks, token)).Select(w => new
{
week = new DateTimeOffset(w.Week, TimeSpan.Zero).ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture),
statuses = w.Posts.ToString(CultureInfo.InvariantCulture),
logins = "0",
registrations = w.Registrations.ToString(CultureInfo.InvariantCulture)
}).ToList();
_activity = new CachedActivity(DateTime.UtcNow, weeks);
return Json(weeks);
}
[HttpGet("/api/v1/instance/rules"), AllowAnonymous]
public IActionResult Rules() => Json(Array.Empty<object>());
public IActionResult InstanceRules() => Json(Rules(Settings));
[HttpGet("/api/v1/instance/extended_description"), AllowAnonymous]
public IActionResult ExtendedDescription() => Json(new { updated_at = MastodonJson.Day(DateTime.UtcNow), content = $"<p>{Description}</p>" });
public IActionResult ExtendedDescription()
{
var settings = Settings;
return Json(new
{
updated_at = settings.DescriptionUpdatedAt is { } updated ? MastodonJson.Time(updated) : MastodonJson.Day(DateTime.UtcNow),
content = ContentRenderer.Document(settings.Description)
});
}
[HttpGet("/api/v1/instance/privacy_policy"), AllowAnonymous]
public IActionResult PrivacyPolicy()
{
var settings = Settings;
return Json(new
{
updated_at = MastodonJson.Time(settings.PrivacyPolicyUpdatedAt ?? ServerSettings.DefaultPrivacyPolicyUpdatedAt),
content = ContentRenderer.Document(settings.PrivacyPolicy)
});
}
// Mastodon 4.4's terms of service: the one version the server keeps, 404 while it has none
[HttpGet("/api/v1/instance/terms_of_service"), AllowAnonymous]
public IActionResult TermsOfService() => Terms(default);
[HttpGet("/api/v1/instance/terms_of_service/{date}"), AllowAnonymous]
public IActionResult TermsOfServiceOn(string date) =>
DateTime.TryParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var on)
? Terms(on.Date)
: NotFoundError();
IActionResult Terms(DateTime? on)
{
var settings = Settings;
if (string.IsNullOrWhiteSpace(settings.TermsOfService))
return NotFoundError();
var effective = (settings.TermsEffectiveAt ?? settings.UpdatedAt ?? DateTime.UtcNow).Date;
if (on != default && on.Value != effective)
return NotFoundError();
return Json(new
{
effective_date = effective.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
effective = effective <= DateTime.UtcNow.Date,
content = ContentRenderer.Document(settings.TermsOfService),
succeeded_by = default(string)
});
}
// the server's blocks the administrator chose to publish (off for each by default: publishing one tells the blocked
// server, and everyone else, that it is blocked), its name partly hidden when asked, as Mastodon hides it
[HttpGet("/api/v1/instance/domain_blocks"), AllowAnonymous]
public async Task<IActionResult> DomainBlocks(CancellationToken token) =>
Json((await DB.Default.Find<DomainBlock>().Match(b => b.Published).Sort(b => b.Domain, Order.Ascending).ExecuteAsync(token))
.Select(b => new
{
domain = b.Obfuscate ? Obfuscated(b.Domain) : b.Domain,
digest = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(b.Domain ?? string.Empty))),
severity = b.Severity switch { DomainBlockSeverity.Suspend => "suspend", DomainBlockSeverity.Silence => "silence", _ => "noop" },
comment = b.PublicComment
}));
// Mastodon's DomainBlock#public_domain: a quarter of the name kept at each end, every other character but the dots a *
public static string Obfuscated(string domain)
{
if (string.IsNullOrEmpty(domain))
return domain;
var visible = domain.Length / 4;
var hidden = domain.ToCharArray();
for (var i = 0; i < hidden.Length; i++)
if (i > visible && i < domain.Length - visible && hidden[i] != '.')
hidden[i] = '*';
return new string(hidden);
}
}
}
@@ -127,7 +127,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
Host = host,
Software = "privapub",
Version = BuildInfo.Ref,
NodeName = "PrivaPub",
NodeName = HttpContext.RequestServices.GetRequiredService<Domain.Instance.IInstanceSettings>().Current.Title,
Protocols = new() { "activitypub" },
OpenRegistrations = _registrations.CurrentValue.IsOpen,
DescribedAt = MastodonJson.Time(DateTime.UtcNow),
@@ -21,7 +21,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
public async Task<IActionResult> Create(CancellationToken token)
{
var report = await _reports.File(Me, Params.Get("account_id"), Params.List("status_ids"), Params.Get("comment"), Params.Get("category"),
Params.Bool("forward") == true, token);
Params.List("rule_ids"), Params.Bool("forward") == true, token);
if (report == default)
return NotFoundError();
return Json(new
@@ -34,7 +34,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
forwarded = report.Forwarded,
created_at = MastodonJson.Time(report.CreatedAt),
status_ids = report.PostIds,
rule_ids = Array.Empty<string>(),
rule_ids = report.RuleIds,
target_account = await _mapper.Account(report.TargetAccountId, token)
});
}
@@ -143,9 +143,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/custom_emojis"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult CustomEmojis() => Json(Array.Empty<object>());
[HttpGet("/api/v1/announcements"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult Announcements() => Json(Array.Empty<object>());
[HttpGet("/api/v1/suggestions"), Scope("read")]
public IActionResult SuggestionsV1() => Json(Array.Empty<object>());
@@ -185,17 +182,5 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/instance/translation_languages"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult TranslationLanguages() => Json(new { });
[HttpGet("/api/v1/instance/domain_blocks"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult InstanceDomainBlocks() => Json(Array.Empty<object>());
[HttpGet("/api/v1/instance/privacy_policy"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult PrivacyPolicy() => Json(new
{
updated_at = "2026-10-04T00:00:00.000Z",
content = "<p>One private login owns several public personas, and nobody but this server's admin can tell they belong "
+ "together. What a persona posts is shared as its visibility says; a located post never leaves this server. "
+ "Uploads lose their metadata. Statistics name servers, never people: see /stargazing.</p>"
});
}
}
@@ -59,8 +59,16 @@ namespace PrivaPub.Api.Mastodon.Controllers
// is not the persona's to see
async Task<string> Payload(Streams.Event e, CancellationToken token)
{
if (e.Name == "delete")
if (e.Name is "delete" or "announcement.delete")
return e.Id;
if (e.Name == "announcement")
{
var shown = await HttpContext.RequestServices.GetRequiredService<Domain.Social.IAnnouncements>().FindShown(e.Id, token);
return shown == default ? default
: JsonSerializer.Serialize((await AnnouncementsController.Map(new[] { shown }, MyId, token))[0], MastodonJson.Options);
}
if (e.Name == "announcement.reaction")
return JsonSerializer.Serialize(await AnnouncementsController.ReactionEvent(e.Id, e.Data, token), MastodonJson.Options);
if (e.Name == "notification")
{
var notification = await _dbEntities.Notifications.Match(n => n.ID == e.Id && n.AvatarId == MyId).ExecuteFirstAsync(token);
@@ -8,6 +8,7 @@ using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Admin;
using PrivaPub.Domain.Moderation;
using PrivaPub.Extensions;
using PrivaPub.Infrastructure.Backup;
using PrivaPub.Models.User;
@@ -24,7 +25,7 @@ namespace PrivaPub.Controllers.ClientToServer
Route("clientapi/admin/backups"),
Authorize(Policy = Policies.IsAdmin)]
public class BackupController(Backups backups, TransferStore transfers, IPasswordHasher hasher, IStringLocalizer<GenericRes> localizer,
ILogger<BackupController> logger) : ControllerBase
ILogger<BackupController> logger, IModerationLog log) : ControllerBase
{
[HttpGet, Route("")]
public async Task<IActionResult> List(CancellationToken token)
@@ -64,6 +65,7 @@ namespace PrivaPub.Controllers.ClientToServer
return Forbid();
if (await MaintenanceLock.Current(token) is { } held)
return Conflict(new WebResult().Invalidate(localizer["A {0} is running.", held.What]));
await log.Write(admin.ID, Models.AdminActions.BackupCreate, Models.AdminTargets.Backup, default, "manual", token: token);
_ = Task.Run(async () =>
{
try
@@ -85,11 +87,15 @@ namespace PrivaPub.Controllers.ClientToServer
[HttpDelete, Route("{id}")]
public async Task<IActionResult> Delete(string id, CancellationToken token)
{
if (await Administrator(token) == default)
var admin = await Administrator(token);
if (admin == default)
return Forbid();
if (RestoreMarker.Read(backups.Root) is { } waiting && (waiting.Backup == id || waiting.PreRestore == id))
return Conflict(new WebResult().Invalidate(localizer["A restore waits for this backup."]));
return backups.Delete(id) ? Ok() : NotFound();
if (!backups.Delete(id))
return NotFound();
await log.Write(admin.ID, Models.AdminActions.BackupDelete, Models.AdminTargets.Backup, id, id, token: token);
return Ok();
}
// a link to download it with, for the password
@@ -211,6 +217,7 @@ namespace PrivaPub.Controllers.ClientToServer
if (refused != default)
return UnprocessableEntity(new WebResult().Invalidate(refused));
logger.LogWarning("{Admin} asked to restore {Backup}", admin.UserName, id);
await log.Write(admin.ID, Models.AdminActions.BackupRestore, Models.AdminTargets.Backup, id, id, token: token);
return Accepted();
}
@@ -6,7 +6,10 @@ using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Admin;
using PrivaPub.Domain.Moderation;
using PrivaPub.Extensions;
using PrivaPub.Federation.Moderation;
using PrivaPub.Models;
using PrivaPub.Models.Federation;
using PrivaPub.Resources;
@@ -20,12 +23,14 @@ namespace PrivaPub.Controllers.ClientToServer
readonly IDomainBlocks _domainBlocks;
readonly IStringLocalizer _localizer;
readonly Domain.Media.IMediaProxy _proxy;
readonly IModerationLog _log;
public DomainBlockController(IDomainBlocks domainBlocks, IStringLocalizer<GenericRes> localizer, Domain.Media.IMediaProxy proxy)
public DomainBlockController(IDomainBlocks domainBlocks, IStringLocalizer<GenericRes> localizer, Domain.Media.IMediaProxy proxy, IModerationLog log)
{
_domainBlocks = domainBlocks;
_localizer = localizer;
_proxy = proxy;
_log = log;
}
[HttpGet, Route("/clientapi/admin/domainblocks/list")]
@@ -46,6 +51,8 @@ namespace PrivaPub.Controllers.ClientToServer
.Modify(b => b.RejectMedia, form.RejectMedia)
.Modify(b => b.PublicComment, form.PublicComment)
.Modify(b => b.PrivateComment, form.PrivateComment)
.Modify(b => b.Published, form.Published)
.Modify(b => b.Obfuscate, form.Obfuscate)
.Modify(b => b.SetOnInsert(x => x.CreatedAt, DateTime.UtcNow))
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
@@ -53,6 +60,9 @@ namespace PrivaPub.Controllers.ClientToServer
// what the media proxy holds of it goes with the block
if (block.Severity == DomainBlockSeverity.Suspend || block.RejectMedia)
_proxy.Purge(domain);
await _log.Write(User.GetUserId(), AdminActions.DomainBlockInsert, AdminTargets.Domain, domain, domain,
note: string.Join(", ", new[] { block.Severity.ToString().ToLowerInvariant(), block.RejectMedia ? "media rejected" : default,
block.Published ? (block.Obfuscate ? "published, obfuscated" : "published") : default }.Where(n => n != default)), token: token);
return Ok(ToView(block));
}
@@ -60,8 +70,10 @@ namespace PrivaPub.Controllers.ClientToServer
public async Task<IActionResult> Delete([FromQuery] string domain, CancellationToken token)
{
domain = DomainBlocks.Normalise(domain);
await DB.Default.DeleteAsync<DomainBlock>(b => b.Domain == domain);
var deleted = await DB.Default.DeleteAsync<DomainBlock>(b => b.Domain == domain);
await _domainBlocks.Reload(token);
if (deleted.DeletedCount > 0)
await _log.Write(User.GetUserId(), AdminActions.DomainBlockDelete, AdminTargets.Domain, domain, domain, token: token);
return Ok();
}
@@ -73,6 +85,8 @@ namespace PrivaPub.Controllers.ClientToServer
RejectMedia = block.RejectMedia,
PublicComment = block.PublicComment,
PrivateComment = block.PrivateComment,
Published = block.Published,
Obfuscate = block.Obfuscate,
CreatedAt = block.CreatedAt
};
}
@@ -0,0 +1,481 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Admin;
using PrivaPub.Domain.Instance;
using PrivaPub.Domain.Media;
using PrivaPub.Domain.Moderation;
using PrivaPub.Domain.Privacy;
using PrivaPub.Domain.Social;
using PrivaPub.Extensions;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Relays;
using PrivaPub.Infrastructure;
using PrivaPub.Models;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.Resources;
namespace PrivaPub.Controllers.ClientToServer
{
// The server's own settings from the administrator's page (completion program R5): what it says of itself, its rules,
// its thumbnail, its media limits, whether its activity is published, its announcements and the relays it subscribes to.
// Only an administrator, checked again against the database. Every change is kept in the staff's log.
[ApiController,
Route("clientapi/admin"),
Authorize(Policy = Policies.IsAdmin)]
public class InstanceAdminController : ControllerBase
{
const int MaxSettingsRelays = 20;
const string Thumbnail = "thumbnail";
readonly IInstanceSettings _settings;
readonly IModerationLog _log;
readonly IStringLocalizer _localizer;
readonly ILocalActorService _localActors;
readonly ILogger<InstanceAdminController> _logger;
public InstanceAdminController(IInstanceSettings settings, IModerationLog log, IStringLocalizer<GenericRes> localizer, ILocalActorService localActors,
ILogger<InstanceAdminController> logger)
{
_settings = settings;
_log = log;
_localizer = localizer;
_localActors = localActors;
_logger = logger;
}
// the token says administrator; the database has the last word
async Task<RootUser> Administrator(CancellationToken token)
{
var root = await DB.Default.Find<RootUser>().MatchID(User.GetUserId()).ExecuteFirstAsync(token);
return root is { IsBanned: false, DeletedAt: null } && root.Policies.Contains(Policies.IsAdmin) ? root : default;
}
IActionResult Refused(int status, string message) => StatusCode(status, new WebResult().Invalidate(_localizer[message], status));
#region settings
[HttpGet, Route("/clientapi/admin/settings")]
public async Task<IActionResult> Settings(CancellationToken token)
{
if (await Administrator(token) == default)
return Forbid();
return Ok(await View(_settings.Current, token));
}
[HttpPost, Route("/clientapi/admin/settings")]
public async Task<IActionResult> SaveSettings(ServerSettingsForm form, CancellationToken token)
{
var admin = await Administrator(token);
if (admin == default)
return Forbid();
var contact = Clean(form.ContactAccountId);
if (contact != default && !await IsContactable(contact, token))
return Refused(StatusCodes.Status422UnprocessableEntity, "The contact account must be a persona of this server.");
var rules = form.Rules ?? new();
if (rules.Count > ServerSettingsForm.MaxRules || rules.Any(r => string.IsNullOrWhiteSpace(r?.Text)))
return Refused(StatusCodes.Status422UnprocessableEntity, "Every rule needs its text, and there can be 50 at most.");
var changed = new List<string>();
var ruleActs = new List<(string Action, ServerRule Rule)>();
var now = DateTime.UtcNow;
var saved = await _settings.Update(settings =>
{
void Set(string name, string value, Func<string> get, Action<string> set)
{
if (get() == value)
return;
set(value);
changed.Add(name);
}
Set("title", form.Title.Trim(), () => settings.Title, v => settings.Title = v);
Set("short description", Clean(form.ShortDescription), () => settings.ShortDescription, v => settings.ShortDescription = v);
Set("description", Clean(form.Description), () => settings.Description, v =>
{
settings.Description = v;
settings.DescriptionUpdatedAt = now;
});
Set("contact email", Clean(form.ContactEmail), () => settings.ContactEmail, v => settings.ContactEmail = v);
Set("contact account", contact, () => settings.ContactAccountId, v => settings.ContactAccountId = v);
Set("privacy policy", Clean(form.PrivacyPolicy), () => settings.PrivacyPolicy, v =>
{
settings.PrivacyPolicy = v;
settings.PrivacyPolicyUpdatedAt = now;
});
var terms = Clean(form.TermsOfService);
Set("terms of service", terms, () => settings.TermsOfService, v => settings.TermsOfService = v);
var effective = terms == default ? (DateTime?)null
: form.TermsEffectiveAt is { } on ? DateTime.SpecifyKind(on.Date, DateTimeKind.Utc)
: changed.Contains("terms of service") || settings.TermsEffectiveAt == default ? now.Date
: settings.TermsEffectiveAt;
if (effective != settings.TermsEffectiveAt)
{
settings.TermsEffectiveAt = effective;
if (!changed.Contains("terms of service"))
changed.Add("terms' date");
}
// rules: the form's order; a named rule keeps its id, a new one gets one, one left out is deleted but kept
var existing = settings.Rules.GroupBy(r => r.Id).ToDictionary(g => g.Key, g => g.First());
var listed = new HashSet<string>();
var position = 0;
foreach (var asked in rules)
{
var text = asked.Text.Trim();
var hint = Clean(asked.Hint);
if (asked.Id != default && existing.TryGetValue(asked.Id, out var rule) && listed.Add(rule.Id))
{
if (rule.Text != text || rule.Hint != hint || rule.DeletedAt != default)
ruleActs.Add((AdminActions.RuleUpdate, rule));
else if (rule.Position != position)
changed.Add("rules' order");
rule.Text = text;
rule.Hint = hint;
rule.DeletedAt = default;
rule.Position = position++;
continue;
}
var added = new ServerRule { Text = text, Hint = hint, Position = position++ };
settings.Rules.Add(added);
listed.Add(added.Id);
ruleActs.Add((AdminActions.RuleInsert, added));
}
foreach (var gone in settings.Rules.Where(r => r.DeletedAt == default && !listed.Contains(r.Id)))
{
gone.DeletedAt = now;
ruleActs.Add((AdminActions.RuleDelete, gone));
}
var media = form.Media ?? new();
var limits = settings.Media ?? new();
if (limits.QuotaBytesPerRoot != media.QuotaBytesPerRoot || limits.MaxImageBytes != media.MaxImageBytes
|| limits.MaxVideoBytes != media.MaxVideoBytes || limits.MaxSeconds != media.MaxSeconds)
{
settings.Media = new ServerMediaLimits
{
QuotaBytesPerRoot = media.QuotaBytesPerRoot is { } quota ? Math.Max(0, quota) : (long?)null,
MaxImageBytes = media.MaxImageBytes is { } image ? Math.Clamp(image, 1, MediaLimitsFromSettings.MaxImageBytes) : (long?)null,
MaxVideoBytes = media.MaxVideoBytes is { } video ? Math.Clamp(video, 1, MediaLimitsFromSettings.MaxVideoBytes) : (long?)null,
MaxSeconds = media.MaxSeconds is { } seconds ? Math.Clamp(seconds, 1, MediaLimitsFromSettings.MaxSeconds) : (int?)null
};
changed.Add("media limits");
}
if (settings.PublishActivity != form.PublishActivity)
{
settings.PublishActivity = form.PublishActivity;
changed.Add(form.PublishActivity ? "activity published" : "activity unpublished");
}
}, admin.ID, token);
if (changed.Count > 0)
await _log.Write(admin.ID, AdminActions.SettingsUpdate, AdminTargets.Settings, ServerSettings.Name, saved.Title, note: string.Join(", ", changed), token: token);
foreach (var (action, rule) in ruleActs)
await _log.Write(admin.ID, action, AdminTargets.Rule, rule.Id, rule.Text, token: token);
return Ok(await View(saved, token));
}
// the server's thumbnail (the instance API's), from a picture checked and re-encoded like a profile picture
[HttpPost, Route("/clientapi/admin/settings/thumbnail"), RequestSizeLimit(MediaLimitsFromSettings.MaxImageBytes + 1024 * 1024),
RequestFormLimits(MultipartBodyLengthLimit = MediaLimitsFromSettings.MaxImageBytes + 1024 * 1024)]
public async Task<IActionResult> SaveThumbnail([FromServices] IMediaService media, CancellationToken token)
{
var admin = await Administrator(token);
if (admin == default)
return Forbid();
var file = Request.HasFormContentType ? (await Request.ReadFormAsync(token)).Files["file"] : default;
var outcome = await media.ServerImage(Thumbnail, file, token);
if (!outcome.Ok)
return StatusCode(outcome.Status, new WebResult().Invalidate(outcome.Error, outcome.Status));
var previous = default(string);
var saved = await _settings.Update(settings =>
{
previous = settings.ThumbnailMediaId;
settings.ThumbnailMediaId = outcome.Attachment.ID;
settings.ThumbnailUrl = media.Url(outcome.Attachment.FilePath);
}, admin.ID, token);
if (previous != default)
await media.Trash(m => m.ID == previous && m.ServerUse == Thumbnail, "thumbnail replaced", token);
await _log.Write(admin.ID, AdminActions.SettingsThumbnail, AdminTargets.Settings, ServerSettings.Name, saved.Title, note: "replaced", token: token);
return Ok(await View(saved, token));
}
[HttpDelete, Route("/clientapi/admin/settings/thumbnail")]
public async Task<IActionResult> DeleteThumbnail([FromServices] IMediaService media, CancellationToken token)
{
var admin = await Administrator(token);
if (admin == default)
return Forbid();
var previous = default(string);
var saved = await _settings.Update(settings =>
{
previous = settings.ThumbnailMediaId;
settings.ThumbnailMediaId = default;
settings.ThumbnailUrl = default;
}, admin.ID, token);
if (previous != default)
{
await media.Trash(m => m.ID == previous && m.ServerUse == Thumbnail, "thumbnail removed", token);
await _log.Write(admin.ID, AdminActions.SettingsThumbnail, AdminTargets.Settings, ServerSettings.Name, saved.Title, note: "removed", token: token);
}
return Ok(await View(saved, token));
}
// a local persona whose login may act: the contact is shown to everyone as the one who runs the server
static async Task<bool> IsContactable(string avatarId, CancellationToken token) =>
await DB.Default.Find<Avatar>().Match(a => a.ID == avatarId && !a.DeletionAt.HasValue).ExecuteAnyAsync(token)
&& !(await Counted.BarredPersonas(token)).Contains(avatarId);
static string Clean(string text) => string.IsNullOrWhiteSpace(text) ? default : text.Trim();
async Task<ViewServerSettings> View(ServerSettings settings, CancellationToken token)
{
var services = HttpContext.RequestServices;
var configured = services.GetRequiredService<IConfiguration>().GetSection("Media").Get<MediaOptions>() ?? new MediaOptions();
var effective = services.GetRequiredService<IOptionsMonitor<MediaOptions>>().CurrentValue;
var contact = settings.ContactAccountId == default ? default
: await DB.Default.Find<Avatar>().Match(a => a.ID == settings.ContactAccountId && !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
var updatedBy = settings.UpdatedBy == default || settings.UpdatedBy == AdminAction.Cli ? settings.UpdatedBy
: (await DB.Default.Find<RootUser>().MatchID(settings.UpdatedBy).ExecuteFirstAsync(token))?.UserName;
var limits = settings.Media ?? new();
return new ViewServerSettings
{
Title = settings.Title,
ShortDescription = settings.ShortDescription,
Description = settings.Description,
DescriptionUpdatedAt = settings.DescriptionUpdatedAt,
ContactEmail = settings.ContactEmail,
ContactAccountId = settings.ContactAccountId,
ContactAccount = contact == default ? default : new ViewContactAccount
{
Id = contact.ID,
UserName = contact.UserName,
Name = contact.Name,
Handle = _localActors.FromAvatar(contact).Handle
},
ThumbnailUrl = settings.ThumbnailUrl ?? $"{_localActors.BaseAddress}{ServerSettings.DefaultThumbnailPath}",
ThumbnailIsDefault = settings.ThumbnailUrl == default,
PrivacyPolicy = settings.PrivacyPolicy,
PrivacyPolicyUpdatedAt = settings.PrivacyPolicyUpdatedAt,
TermsOfService = settings.TermsOfService,
TermsEffectiveAt = settings.TermsEffectiveAt,
Rules = settings.Rules.OrderBy(r => r.DeletedAt != default).ThenBy(r => r.Position).Select(r => new ViewServerRule
{
Id = r.Id,
Text = r.Text,
Hint = r.Hint,
Position = r.Position,
IsDeleted = r.DeletedAt != default,
DeletedAt = r.DeletedAt
}).ToList(),
Relays = await RelayViews(settings, token),
Media = new ViewMediaLimits
{
QuotaBytesPerRoot = limits.QuotaBytesPerRoot,
MaxImageBytes = limits.MaxImageBytes,
MaxVideoBytes = limits.MaxVideoBytes,
MaxSeconds = limits.MaxSeconds,
ConfiguredQuotaBytesPerRoot = configured.QuotaBytesPerRoot,
ConfiguredMaxImageBytes = configured.MaxImageBytes,
ConfiguredMaxVideoBytes = configured.MaxVideoBytes,
ConfiguredMaxSeconds = configured.MaxSeconds,
EffectiveQuotaBytesPerRoot = effective.QuotaBytesPerRoot,
EffectiveMaxImageBytes = effective.MaxImageBytes,
EffectiveMaxVideoBytes = effective.MaxVideoBytes,
EffectiveMaxSeconds = effective.MaxSeconds
},
RegistrationMode = services.GetRequiredService<IOptionsMonitor<RegistrationOptions>>().CurrentValue.Mode.ToString(),
PublishActivity = settings.PublishActivity,
UpdatedAt = settings.UpdatedAt,
UpdatedBy = updatedBy
};
}
#endregion
#region announcements
[HttpGet, Route("/clientapi/admin/announcements/list")]
public async Task<IActionResult> Announcements([FromServices] IAnnouncements announcements, CancellationToken token)
{
if (await Administrator(token) == default)
return Forbid();
var views = new List<ViewAnnouncement>();
foreach (var announcement in await announcements.All(token))
views.Add(await View(announcement, token));
return Ok(views);
}
[HttpPost, Route("/clientapi/admin/announcements/insert")]
public async Task<IActionResult> InsertAnnouncement([FromServices] IAnnouncements announcements, AnnouncementForm form, CancellationToken token)
{
var admin = await Administrator(token);
if (admin == default)
return Forbid();
if (Invalid(form) is { } refused)
return refused;
var made = await announcements.Insert(Draft(form), token);
await _log.Write(admin.ID, AdminActions.AnnouncementInsert, AdminTargets.Announcement, made.ID, made.Text,
note: made.IsPublished ? "published" : "draft", token: token);
return Ok(await View(made, token));
}
[HttpPost, Route("/clientapi/admin/announcements/update")]
public async Task<IActionResult> UpdateAnnouncement([FromServices] IAnnouncements announcements, AnnouncementForm form, CancellationToken token)
{
var admin = await Administrator(token);
if (admin == default)
return Forbid();
if (Invalid(form) is { } refused)
return refused;
var updated = await announcements.Update(form.Id, Draft(form), token);
if (updated == default)
return NotFound();
await _log.Write(admin.ID, AdminActions.AnnouncementUpdate, AdminTargets.Announcement, updated.ID, updated.Text,
note: updated.IsPublished ? "published" : "draft", token: token);
return Ok(await View(updated, token));
}
[HttpPost, Route("/clientapi/admin/announcements/delete")]
public async Task<IActionResult> DeleteAnnouncement([FromServices] IAnnouncements announcements, [FromQuery] string id, CancellationToken token)
{
var admin = await Administrator(token);
if (admin == default)
return Forbid();
var deleted = await announcements.Delete(id, token);
if (deleted == default)
return NotFound();
await _log.Write(admin.ID, AdminActions.AnnouncementDelete, AdminTargets.Announcement, deleted.ID, deleted.Text, token: token);
return Ok();
}
IActionResult Invalid(AnnouncementForm form) =>
string.IsNullOrWhiteSpace(form.Text)
? Refused(StatusCodes.Status422UnprocessableEntity, "An announcement needs its text.")
: form.StartsAt != default && form.EndsAt != default && form.EndsAt < form.StartsAt
? Refused(StatusCodes.Status422UnprocessableEntity, "An announcement can't end before it starts.")
: default;
static AnnouncementDraft Draft(AnnouncementForm form) => new(form.Text, form.StartsAt, form.EndsAt, form.AllDay, form.IsPublished);
static async Task<ViewAnnouncement> View(Announcement announcement, CancellationToken token)
{
var reactions = await DB.Default.Find<AnnouncementReaction>().Match(r => r.AnnouncementId == announcement.ID).ExecuteAsync(token);
return new ViewAnnouncement
{
Id = announcement.ID,
Text = announcement.Text,
Html = announcement.Html,
StartsAt = announcement.StartsAt,
EndsAt = announcement.EndsAt,
AllDay = announcement.AllDay,
IsPublished = announcement.IsPublished,
IsShown = announcement.IsPublished && (announcement.EndsAt == default || announcement.EndsAt > DateTime.UtcNow),
PublishedAt = announcement.PublishedAt,
CreatedAt = announcement.CreatedAt,
UpdatedAt = announcement.UpdatedAt,
Reads = await DB.Default.CountAsync<AnnouncementRead>(r => r.AnnouncementId == announcement.ID, token),
Reactions = reactions.GroupBy(r => r.Name).Select(g => new ViewAnnouncementReaction { Name = g.Key, Count = g.Count() }).ToList()
};
}
#endregion
#region relays
[HttpGet, Route("/clientapi/admin/relays")]
public async Task<IActionResult> Relays(CancellationToken token)
{
if (await Administrator(token) == default)
return Forbid();
return Ok(await RelayViews(_settings.Current, token));
}
// a relay named by the settings: subscribed to at once (in the background: resolving it is a fetch), the page polls
[HttpPost, Route("/clientapi/admin/relays/insert")]
public async Task<IActionResult> InsertRelay([FromServices] IRelays relays, RelayForm form, CancellationToken token)
{
var admin = await Administrator(token);
if (admin == default)
return Forbid();
var address = form.Address?.Trim();
if (!Uri.TryCreate(address, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps || !string.IsNullOrEmpty(uri.UserInfo)
|| !string.IsNullOrEmpty(uri.Fragment))
return Refused(StatusCodes.Status422UnprocessableEntity, "A relay is named by the https address of its actor or its inbox.");
if (relays.Configured.Contains(address))
return Refused(StatusCodes.Status409Conflict, "The configuration names this relay already.");
if (_settings.Current.Relays.Contains(address))
return Ok(await RelayViews(_settings.Current, token));
if (_settings.Current.Relays.Count >= MaxSettingsRelays)
return Refused(StatusCodes.Status422UnprocessableEntity, "The settings name 20 relays at most.");
var saved = await _settings.Update(settings =>
{
if (!settings.Relays.Contains(address))
settings.Relays.Add(address);
}, admin.ID, token);
await _log.Write(admin.ID, AdminActions.RelayInsert, AdminTargets.Relay, address, address, token: token);
Reconcile(relays);
return Accepted(await RelayViews(saved, token));
}
// a relay the settings named: unsubscribed at once (its Undo is queued); the configuration's are removed only there
[HttpPost, Route("/clientapi/admin/relays/delete")]
public async Task<IActionResult> DeleteRelay([FromServices] IRelays relays, [FromQuery] string address, CancellationToken token)
{
var admin = await Administrator(token);
if (admin == default)
return Forbid();
address = address?.Trim();
if (relays.Configured.Contains(address))
return Refused(StatusCodes.Status409Conflict, "This relay is named by the configuration (Federation:Relays): remove it there.");
if (string.IsNullOrEmpty(address) || !_settings.Current.Relays.Contains(address))
return NotFound();
var saved = await _settings.Update(settings => settings.Relays.Remove(address), admin.ID, token);
await _log.Write(admin.ID, AdminActions.RelayDelete, AdminTargets.Relay, address, address, token: token);
Reconcile(relays);
return Accepted(await RelayViews(saved, token));
}
void Reconcile(IRelays relays) => _ = Task.Run(async () =>
{
try
{
await relays.Reconcile(CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Relay subscriptions could not be brought up to date");
}
}, CancellationToken.None);
async Task<List<ViewRelay>> RelayViews(ServerSettings settings, CancellationToken token)
{
var configured = HttpContext.RequestServices.GetRequiredService<IRelays>().Configured;
var subscriptions = (await DB.Default.Find<RelaySubscription>().ExecuteAsync(token)).GroupBy(s => s.Configured).ToDictionary(g => g.Key, g => g.First());
ViewRelay View(string address, string source)
{
var subscription = subscriptions.GetValueOrDefault(address);
return new ViewRelay
{
Address = address,
Source = source,
State = subscription == default ? "none" : subscription.State.ToString().ToLowerInvariant(),
ActorURI = subscription?.ActorURI,
RequestedAt = subscription?.RequestedAt,
AnsweredAt = subscription?.AnsweredAt
};
}
return configured.Select(a => View(a, "configuration"))
.Concat((settings.Relays ?? new()).Where(a => !configured.Contains(a)).Select(a => View(a, "settings")))
.ToList();
}
#endregion
}
}
@@ -5,8 +5,12 @@ using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Admin;
using PrivaPub.Domain.Instance;
using PrivaPub.Domain.Moderation;
using PrivaPub.Extensions;
using PrivaPub.Models;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
namespace PrivaPub.Controllers.ClientToServer
{
@@ -15,9 +19,22 @@ namespace PrivaPub.Controllers.ClientToServer
Authorize(Policy = Policies.IsModerator)]
public class ModeratorController : ControllerBase
{
const int MaxLogPage = 100;
readonly IInstanceSettings _settings;
readonly IModerationLog _log;
public ModeratorController(IInstanceSettings settings, IModerationLog log)
{
_settings = settings;
_log = log;
}
[HttpGet, Route("/clientapi/moderator/reports")]
public async Task<IActionResult> Reports([FromQuery] bool resolved, CancellationToken token) =>
Ok((await DB.Default.Find<Report>().Match(r => r.IsResolved == resolved).Sort(r => r.ID, Order.Descending).Limit(100).ExecuteAsync(token))
public async Task<IActionResult> Reports([FromQuery] bool resolved, CancellationToken token)
{
var rules = _settings.Current.Rules.ToDictionary(r => r.Id, r => r.Text ?? string.Empty);
return Ok((await DB.Default.Find<Report>().Match(r => r.IsResolved == resolved).Sort(r => r.ID, Order.Descending).Limit(100).ExecuteAsync(token))
.Select(r => new ViewReport
{
ID = r.ID,
@@ -27,6 +44,8 @@ namespace PrivaPub.Controllers.ClientToServer
PostIds = r.PostIds,
ObjectURIs = r.ObjectURIs,
Category = r.Category,
RuleIds = r.RuleIds ?? new(),
Rules = (r.RuleIds ?? new()).Select(id => rules.GetValueOrDefault(id, string.Empty)).ToList(),
Comment = r.Comment,
Forwarded = r.Forwarded,
IsResolved = r.IsResolved,
@@ -35,16 +54,71 @@ namespace PrivaPub.Controllers.ClientToServer
RemoteResolvedAt = r.RemoteResolvedAt,
RemoteResolvedBy = r.RemoteResolvedBy
}));
}
[HttpPost, Route("/clientapi/moderator/reports/{id}/resolve")]
public async Task<IActionResult> Resolve(string id, CancellationToken token)
{
var by = User.GetUserId();
var result = await DB.Default.Update<Report>().MatchID(id)
.Modify(r => r.IsResolved, true)
.Modify(r => r.ResolvedAt, DateTime.UtcNow)
.Modify(r => r.ResolvedBy, User.GetUserId())
.Modify(r => r.ResolvedBy, by)
.ExecuteAsync(token);
return result.MatchedCount == 0 ? NotFound() : Ok();
if (result.MatchedCount == 0)
return NotFound();
var report = await DB.Default.Find<Report>().MatchID(id).ExecuteFirstAsync(token);
await _log.Write(by, AdminActions.ReportResolve, AdminTargets.Report, id, report?.Category, reportId: id, token: token);
return Ok();
}
// The staff's log, newest first, a page at a time (before: the last id of the page before). Administrators read all
// of it; a moderator who isn't one reads what isn't about a login, and who acted only when it was the command line or
// themselves.
[HttpGet, Route("/clientapi/moderator/log")]
public async Task<IActionResult> Log([FromQuery] string before, [FromQuery] int limit = 50, CancellationToken token = default)
{
var me = await DB.Default.Find<RootUser>().MatchID(User.GetUserId()).ExecuteFirstAsync(token);
if (me is not { IsBanned: false, DeletedAt: null } || !me.Policies.Contains(Policies.IsModerator))
return Forbid();
var admin = me.Policies.Contains(Policies.IsAdmin);
limit = Math.Clamp(limit, 1, MaxLogPage);
var query = DB.Default.Find<AdminAction>();
if (!string.IsNullOrEmpty(before))
{
if (!MongoDB.Bson.ObjectId.TryParse(before, out _))
return BadRequest();
query.Match(f => f.Lt(a => a.ID, before));
}
if (!admin)
query.Match(a => a.TargetKind != AdminTargets.Root);
var actions = await query.Sort(a => a.ID, Order.Descending).Limit(limit).ExecuteAsync(token);
var actorIds = actions.Select(a => a.ByRootId).Where(id => id != default && id != AdminAction.Cli).Distinct().ToList();
var names = admin && actorIds.Count > 0
? (await DB.Default.Find<RootUser>().Match(u => actorIds.Contains(u.ID)).Project(u => u.Include(x => x.UserName)).ExecuteAsync(token))
.ToDictionary(u => u.ID, u => u.UserName)
: new Dictionary<string, string>();
return Ok(new ViewAdminActionsPage
{
Items = actions.Select(a => new ViewAdminAction
{
Id = a.ID,
At = a.At,
By = a.ByRootId == AdminAction.Cli ? AdminAction.Cli
: a.ByRootId == default ? default
: admin ? names.GetValueOrDefault(a.ByRootId, a.ByRootId)
: a.ByRootId == me.ID ? me.UserName
: default,
ByYou = a.ByRootId == me.ID,
Action = a.Action,
TargetKind = a.TargetKind,
TargetId = a.TargetId,
TargetLabel = a.TargetLabel,
ReportId = a.ReportId,
Note = a.Note
}).ToList(),
Next = actions.Count == limit ? actions[^1].ID : default
});
}
}
}
@@ -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();
}
}
+7 -2
View File
@@ -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))
+4 -3
View File
@@ -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;
+54
View File
@@ -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)] + "…";
}
}
+25
View File
@@ -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";
}
}
+18 -5
View File
@@ -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)
+162
View File
@@ -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);
}
}
+13 -2
View File
@@ -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;
}
}
@@ -104,16 +104,21 @@ namespace PrivaPub.Federation.Actors
"abuse", "postmaster", "webmaster", "hostmaster", "security", "noreply", "no_reply", "null", "undefined"
};
// what the instance actor says of itself until the administrator describes the server (ServerSettings)
const string InstanceSummary = "The instance actor of this PrivaPub server; it signs the requests no other actor speaks for.";
readonly DbEntities _dbEntities;
readonly IOptionsMonitor<AppConfiguration> _appConfiguration;
readonly Domain.Instance.IInstanceSettings _settings;
InstanceActor _instanceActor;
ReporterActor _reporterActor;
ReaderActor _readerActor;
public LocalActorService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration)
public LocalActorService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration, Domain.Instance.IInstanceSettings settings)
{
_dbEntities = dbEntities;
_appConfiguration = appConfiguration;
_settings = settings;
}
public string BaseAddress => _appConfiguration.CurrentValue.BackendBaseAddress?.TrimEnd('/');
@@ -195,14 +200,18 @@ namespace PrivaPub.Federation.Actors
public async Task<LocalActor> GetInstanceActor(CancellationToken token)
{
var instance = _instanceActor ??= await LoadInstanceActor(token);
// the server's name, and its short description once the administrator wrote one of their own
var settings = _settings.Current;
return new LocalActor
{
Id = instance.ID,
Kind = LocalActorKind.Application,
UserName = InstanceUserName,
Name = "PrivaPub",
Summary = "The instance actor of this PrivaPub server; it signs the requests no other actor speaks for.",
Name = settings.Title,
Summary = string.IsNullOrWhiteSpace(settings.ShortDescription) || settings.ShortDescription == ServerSettings.DefaultDescription
? InstanceSummary
: settings.ShortDescription,
PrivateKeyPem = instance.PrivateKey,
PublicKeyPem = instance.PublicKey,
Discoverable = false,
@@ -23,10 +23,13 @@ namespace PrivaPub.Federation.Controllers
readonly ILocalActorService _localActors;
readonly DbEntities _dbEntities;
readonly IOptionsMonitor<RegistrationOptions> _registrations;
readonly Domain.Instance.IInstanceSettings _settings;
public WellKnownController(ILocalActorService localActors, DbEntities dbEntities, IOptionsMonitor<RegistrationOptions> registrations)
public WellKnownController(ILocalActorService localActors, DbEntities dbEntities, IOptionsMonitor<RegistrationOptions> registrations,
Domain.Instance.IInstanceSettings settings)
{
_registrations = registrations;
_settings = settings;
_localActors = localActors;
_dbEntities = dbEntities;
}
@@ -140,8 +143,9 @@ namespace PrivaPub.Federation.Controllers
},
["metadata"] = new JsonObject
{
["nodeName"] = "PrivaPub",
["nodeDescription"] = "A small ActivityPub server where one private login keeps several unlinkable public personas.",
// the server's name and short description, as its administrator set them (IInstanceSettings)
["nodeName"] = _settings.Current.Title,
["nodeDescription"] = _settings.Current.ShortDescription ?? string.Empty,
["federation"] = new JsonObject { ["document"] = "https://git.thepra.dev/thepra/SocialPub/src/branch/master/FEDERATION.md" }
}
};
+41 -9
View File
@@ -18,6 +18,10 @@ namespace PrivaPub.Federation.Relays
{
public interface IRelays
{
/// <summary>The relays Federation:Relays names, as it names them.</summary>
IReadOnlyList<string> Configured { get; }
/// <summary>The relays named by the configuration or by the server's settings, each once.</summary>
IReadOnlyList<string> Named { get; }
Task Reconcile(CancellationToken token);
Task<bool> Answered(JsonNode activity, ForeignAvatar actor, bool accepted, CancellationToken token);
Task<bool> Passes(string actorUri, CancellationToken token);
@@ -25,9 +29,10 @@ namespace PrivaPub.Federation.Relays
}
// Relays (Activity-Relay, aode-relay, pub-relay): the instance actor follows Public at each relay the configuration
// names, as Mastodon subscribes, and takes what they pass on: public posts, forwarded as their authors sent them (read
// again from their origin, as any forwarded post) or announced by the relay. A persona's public posts go to the relays
// that accepted us (owner decision 2026-10-06), as Mastodon sends them; nothing else of a persona's does.
// (Federation:Relays) or the server's settings (ServerSettings.Relays, from the administrator's page) name, as Mastodon
// subscribes, and takes what they pass on: public posts, forwarded as their authors sent them (read again from their
// origin, as any forwarded post) or announced by the relay. A persona's public posts go to the relays that accepted us
// (owner decision 2026-10-06), as Mastodon sends them; nothing else of a persona's does.
public class Relays : IRelays
{
static readonly TimeSpan AskAgain = TimeSpan.FromDays(1);
@@ -36,31 +41,58 @@ namespace PrivaPub.Federation.Relays
readonly IRemoteActorService _remoteActors;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
readonly Domain.Instance.IInstanceSettings _settings;
readonly SemaphoreSlim _reconciling = new(1, 1);//the hourly round and the administrator's page, one at a time
volatile HashSet<string> _accepted;
public Relays(IOptions<FederationOptions> options, IRemoteActorService remoteActors, ILocalActorService localActors, IDeliveryService delivery)
public Relays(IOptions<FederationOptions> options, IRemoteActorService remoteActors, ILocalActorService localActors, IDeliveryService delivery,
Domain.Instance.IInstanceSettings settings)
{
_options = options;
_remoteActors = remoteActors;
_localActors = localActors;
_delivery = delivery;
_settings = settings;
}
// subscribes to the relays the configuration names (again a day after an unanswered or refused request), and
// unsubscribes from those it no longer names
public IReadOnlyList<string> Configured => Clean(_options.Value.Relays);
public IReadOnlyList<string> Named => Clean(Configured.Concat(_settings.Current.Relays ?? new()));
static List<string> Clean(IEnumerable<string> addresses) =>
(addresses ?? Array.Empty<string>()).Where(r => !string.IsNullOrWhiteSpace(r)).Select(r => r.Trim()).Distinct(StringComparer.Ordinal).ToList();
// subscribes to the relays the configuration and the settings name (again a day after an unanswered or refused
// request), and unsubscribes from those they no longer name: with an Undo, unless the same relay is still named
// under another address (an Undo would end that subscription too)
public async Task Reconcile(CancellationToken token)
{
var configured = (_options.Value.Relays ?? new()).Where(r => !string.IsNullOrWhiteSpace(r)).Select(r => r.Trim()).Distinct().ToList();
await _reconciling.WaitAsync(token);
try
{
await ReconcileNow(token);
}
finally
{
_reconciling.Release();
}
}
async Task ReconcileNow(CancellationToken token)
{
var configured = Named;
var known = await DB.Default.Find<RelaySubscription>().ExecuteAsync(token);
if (configured.Count == 0 && known.Count == 0)
return;
var instance = await _localActors.GetInstanceActor(token);
var kept = known.Where(k => configured.Contains(k.Configured)).Select(k => k.ActorURI).Where(a => a != default).ToHashSet(StringComparer.Ordinal);
foreach (var gone in known.Where(k => !configured.Contains(k.Configured)))
{
if (gone.State != RelayState.Rejected && !string.IsNullOrEmpty(gone.InboxURL))
if (gone.State != RelayState.Rejected && !string.IsNullOrEmpty(gone.InboxURL) && !kept.Contains(gone.ActorURI))
await _delivery.Enqueue(instance, new[] { gone.InboxURL }, Undo(instance, gone.FollowActivityURI), token);
await DB.Default.DeleteAsync<RelaySubscription>(gone.ID);
}
_accepted = default;
foreach (var address in configured)
{
var subscription = known.FirstOrDefault(k => k.Configured == address);
@@ -143,7 +175,7 @@ namespace PrivaPub.Federation.Relays
};
}
// subscribes to the configured relays a minute after start, then every six hours
// subscribes to the relays named (configuration and settings) a minute after start, then every six hours
public sealed class RelaySubscriber(IServiceScopeFactory scopes, ILogger<RelaySubscriber> logger) : BackgroundService
{
static readonly TimeSpan Every = TimeSpan.FromHours(6);
@@ -13,7 +13,10 @@ namespace PrivaPub.Infrastructure.Backup
// deletion left it), with what a deletion takes away; P's moderation of a persona (silenced, suspended, banned) stays;
// - a root keeps P's password, e-mail, ban and policies;
// - roots, personas and groups made since become tombstones: deleted, their names kept; local posts made since answer
// 410; media made since go to the trash, as do media trashed since (and they leave the posts restored with them);
// 410; media made since go to the trash, as do media trashed since (and they leave the posts restored with them),
// but the server's own pictures the live settings hold;
// - the staff's log is the union (a restore never loses an entry, its own request included), and the server's
// settings are P's: the live settings outlive a restore;
// - every session ends, and every server's circuit is closed again.
// Run on the imported database, it reads P's files only, so it can run again after a failed attempt.
public static class ProtectiveMerge
@@ -29,7 +32,8 @@ namespace PrivaPub.Infrastructure.Backup
("DeletedObject", ["ObjectURI"]),
("Report", ["_id"]),
("PersonaFilter", ["_id"]),
("openiddict.applications", ["client_id"])
("openiddict.applications", ["client_id"]),
("AdminAction", ["_id"])
];
const string Since = "restore: made after the backup";
@@ -39,8 +43,8 @@ namespace PrivaPub.Infrastructure.Backup
IEnumerable<BsonDocument> P(string collection) => ServerRestore.Read(Path.Combine(previous, collection + ".jsonl.gz"));
var now = DateTime.UtcNow;
// who follows whom: P's
foreach (var collection in new[] { "Follower", "Following" })
// who follows whom: P's; and the server's settings as they were live
foreach (var collection in new[] { "Follower", "Following", "ServerSettings" })
{
await database.DropCollectionAsync(collection, token);
await ServerRestore.Insert(database.GetCollection<BsonDocument>(collection), P(collection), token);
@@ -206,7 +210,9 @@ namespace PrivaPub.Infrastructure.Backup
{
if (!restored.TryGetValue(row["_id"], out var mine))
{
if (!IsSet(row, "TrashedAt"))
// made since, and to the trash, but a picture of the server's own (its thumbnail): the live settings,
// which a restore keeps, still show it
if (!IsSet(row, "TrashedAt") && !IsSet(row, "ServerUse"))
{
row["TrashedAt"] = now;
row["TrashReason"] = Since;
@@ -223,7 +229,7 @@ namespace PrivaPub.Infrastructure.Backup
report.MediaTrashed++;
}
await ServerRestore.Insert(media, made, token);
report.MediaTrashed += made.Count(m => m["TrashReason"] == Since);
report.MediaTrashed += made.Count(m => m.GetValue("TrashReason", BsonNull.Value) == Since);
}
var deleted = await (await posts.FindAsync(new BsonDocument("DeletedAt", new BsonDocument("$ne", BsonNull.Value)),
+22 -8
View File
@@ -4,6 +4,7 @@ using OpenIddict.Abstractions;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.User;
using PrivaPub.Domain.Moderation;
using PrivaPub.Infrastructure.Backup;
using PrivaPub.Models;
using PrivaPub.Models.User;
@@ -46,7 +47,7 @@ namespace PrivaPub.Infrastructure.Cli
switch (args)
{
case [("promote" or "demote") and var verb, var userName]:
return await Promote(verb == "promote", userName, output);
return await Promote(services, verb == "promote", userName, output);
case ["create-root", var login, .. var flags] when flags.All(f => f == "--admin"):
return await CreateRoot(services, login, input.ReadLine(), flags.Contains("--admin"), output);
case ["move-persona", var persona, var root]:
@@ -54,7 +55,7 @@ namespace PrivaPub.Infrastructure.Cli
case ["delete-root", var root]:
return await DeleteRoot(services, root, output);
case ["reset-totp", var root]:
return await ResetTotp(root, output);
return await ResetTotp(services, root, output);
case ["media", "audit", .. var flags] when flags.All(f => f == "--fix"):
return await AuditMedia(services, flags.Contains("--fix"), output);
case ["backup", "verify", var id]:
@@ -139,6 +140,7 @@ namespace PrivaPub.Infrastructure.Cli
Console.Error.WriteLine(refused);
return 1;
}
await Log(services, AdminActions.BackupRestore, AdminTargets.Backup, id, id);
output.WriteLine($"{id} will be restored: the running service stops within seconds and restores it when it starts again");
output.WriteLine("(systemd starts it again; follow it with journalctl -u privapub, then PrivaPub admin restore --status)");
return 0;
@@ -187,7 +189,12 @@ namespace PrivaPub.Infrastructure.Cli
return 0;
}
static async Task<int> Promote(bool promote, string userName, TextWriter output)
// every act below is kept in the staff's log as the command line's ("cli"): a login's by its name alone, a persona's
// without the login it went to
static Task Log(IServiceProvider services, string action, string kind, string id, string label, string note = default) =>
services.GetRequiredService<IModerationLog>().Write(AdminAction.Cli, action, kind, id, label, note: note);
static async Task<int> Promote(IServiceProvider services, bool promote, string userName, TextWriter output, bool created = false)
{
userName = userName.ToLowerInvariant();
var user = await DB.Default.Find<RootUser>().Match(u => u.UserName == userName).ExecuteFirstAsync();
@@ -201,6 +208,8 @@ namespace PrivaPub.Infrastructure.Cli
await DB.Default.SaveAsync(user);
if (promote)
await ServerSetup.Seal(user.ID, default);//an administrator exists: the client's setup is closed
if (!created)
await Log(services, promote ? AdminActions.RootPromote : AdminActions.RootDemote, AdminTargets.Root, user.ID, userName);
output.WriteLine($"{userName}: {string.Join(", ", user.Policies)}");
return 0;
@@ -223,7 +232,9 @@ namespace PrivaPub.Infrastructure.Cli
Console.Error.WriteLine(created.ErrorMessage);
return 1;
}
return admin ? await Promote(true, login, output) : await Report(login, output);
var made = await DB.Default.Find<RootUser>().Match(u => u.UserName == login.ToLowerInvariant()).ExecuteFirstAsync();
await Log(services, AdminActions.RootCreate, AdminTargets.Root, made?.ID, login.ToLowerInvariant(), admin ? "administrator" : default);
return admin ? await Promote(services, true, login, output, created: true) : await Report(login, output);
}
static Task<int> Report(string login, TextWriter output)
@@ -266,22 +277,24 @@ namespace PrivaPub.Infrastructure.Cli
await DB.Default.Update<RootToAvatar>().MatchID(link.ID).Modify(ra => ra.RootId, root.ID).ExecuteAsync();
await services.GetRequiredService<IOpenIddictTokenManager>().RevokeBySubjectAsync(persona.ID);
await services.GetRequiredService<IOpenIddictAuthorizationManager>().RevokeBySubjectAsync(persona.ID);
await Log(services, AdminActions.PersonaMove, AdminTargets.Persona, persona.ID, "@" + personaName, "moved to another login");
output.WriteLine($"@{personaName}: moved to {login}");
return 0;
}
// The way back for a login that lost its authenticator and its recovery codes: its two-factor sign-in goes, its
// password stays.
static async Task<int> ResetTotp(string login, TextWriter output)
static async Task<int> ResetTotp(IServiceProvider services, string login, TextWriter output)
{
login = login.ToLowerInvariant();
var updated = await DB.Default.Update<RootUser>().Match(u => u.UserName == login && u.DeletedAt == null)
.Modify(u => u.Totp, null).ExecuteAsync();
if (updated.MatchedCount == 0)
var root = await DB.Default.Find<RootUser>().Match(u => u.UserName == login && u.DeletedAt == null).ExecuteFirstAsync();
var updated = root == default ? default : await DB.Default.Update<RootUser>().MatchID(root.ID).Modify(u => u.Totp, null).ExecuteAsync();
if (updated == default || updated.MatchedCount == 0)
{
Console.Error.WriteLine($"no root user '{login}'");
return 1;
}
await Log(services, AdminActions.RootResetTotp, AdminTargets.Root, root.ID, login);
output.WriteLine($"{login}: two-factor sign-in is off");
return 0;
}
@@ -305,6 +318,7 @@ namespace PrivaPub.Infrastructure.Cli
Console.Error.WriteLine($"'{login}' could not be deleted");
return 1;
}
await Log(services, AdminActions.RootDelete, AdminTargets.Root, root.ID, login);
output.WriteLine($"{login}: deleted{(personas.Count > 0 ? ", with " + string.Join(", ", personas) : string.Empty)}");
return 0;
}
+11
View File
@@ -48,6 +48,17 @@ namespace PrivaPub.Infrastructure.Data
await Plain<Models.Media.MediaAttachment>(token, m => m.ScheduledStatusId);
await Plain<Models.Media.MediaAttachment>(token, m => m.ProfileOfAvatarId);
await Plain<Models.Media.MediaAttachment>(token, m => m.TrashedAt);
await Plain<Models.Media.MediaAttachment>(token, m => m.ServerUse);//the server's own pictures (its thumbnail)
// the administrators' announcements: each persona's dismissal and reactions, once each (new collections, R5)
await DB.Default.Index<AnnouncementRead>().Key(r => r.AnnouncementId, KeyType.Ascending).Key(r => r.AvatarId, KeyType.Ascending)
.Option(o => o.Unique = true).CreateAsync(token);
await Plain<AnnouncementRead>(token, r => r.AvatarId);
await DB.Default.Index<AnnouncementReaction>().Key(r => r.AnnouncementId, KeyType.Ascending).Key(r => r.AvatarId, KeyType.Ascending)
.Key(r => r.Name, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token);
await Plain<AnnouncementReaction>(token, r => r.AvatarId);
// the staff's log, read newest first
await DB.Default.Index<Models.AdminAction>().Key(a => a.At, KeyType.Descending).CreateAsync(token);
await Unique<ForeignAvatar>(a => a.ActorURI, Builders<ForeignAvatar>.Filter.Gt(a => a.ActorURI, ""), token);
await Plain<ForeignAvatar>(token, a => a.PublicKeyId);
+64
View File
@@ -0,0 +1,64 @@
using MongoDB.Entities;
namespace PrivaPub.Models
{
// One act of the server's staff, kept for the moderators' log (Domain/Moderation/ModerationLog.cs, completion program
// R5): who (an administrator's or moderator's root id, or "cli" for the command line), what, and on what. Never an IP
// address, and never a root next to its own personas: an entry about a persona drops the acting root when the persona
// is that root's, and an entry about a root names none of its personas. A backup holds the log and a restore keeps
// every entry made since (ProtectiveMerge).
public class AdminAction : Entity
{
public const string Cli = "cli";
public DateTime At { get; set; } = DateTime.UtcNow;
public string ByRootId { get; set; }
public string Action { get; set; }//AdminActions.*
public string TargetKind { get; set; }//AdminTargets.*
public string TargetId { get; set; }
public string TargetLabel { get; set; }
public string ReportId { get; set; }
public string Note { get; set; }
}
public static class AdminActions
{
public const string DomainBlockInsert = "domain_block.insert";
public const string DomainBlockDelete = "domain_block.delete";
public const string BackupCreate = "backup.create";
public const string BackupDelete = "backup.delete";
public const string BackupRestore = "backup.restore";
public const string SettingsUpdate = "settings.update";
public const string SettingsThumbnail = "settings.thumbnail";
public const string RuleInsert = "rule.insert";
public const string RuleUpdate = "rule.update";
public const string RuleDelete = "rule.delete";
public const string AnnouncementInsert = "announcement.insert";
public const string AnnouncementUpdate = "announcement.update";
public const string AnnouncementDelete = "announcement.delete";
public const string RelayInsert = "relay.insert";
public const string RelayDelete = "relay.delete";
public const string ReportResolve = "report.resolve";
public const string RootPromote = "root.promote";
public const string RootDemote = "root.demote";
public const string RootCreate = "root.create";
public const string RootDelete = "root.delete";
public const string RootResetTotp = "root.reset_totp";
public const string PersonaMove = "persona.move";
}
public static class AdminTargets
{
public const string Root = "root";
public const string Persona = "persona";
public const string Account = "account";
public const string Domain = "domain";
public const string Report = "report";
public const string Settings = "settings";
public const string Rule = "rule";
public const string Announcement = "announcement";
public const string Emoji = "emoji";
public const string Relay = "relay";
public const string Backup = "backup";
}
}
@@ -9,6 +9,10 @@ namespace PrivaPub.Models.Federation
public bool RejectMedia { get; set; }
public string PublicComment { get; set; }
public string PrivateComment { get; set; }
// listed at /api/v1/instance/domain_blocks, which tells the blocked server too (off by default); Obfuscate hides part
// of its name there, as Mastodon does
public bool Published { get; set; }
public bool Obfuscate { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
+1
View File
@@ -24,6 +24,7 @@ namespace PrivaPub.Models.Media
public DateTime? AttachedAt { get; set; }
public string ScheduledStatusId { get; set; }//kept for a scheduled post until it is published or dropped
public string ProfileOfAvatarId { get; set; }//a persona's avatar or header (Kind "avatar" or "header"), never a post's
public string ServerUse { get; set; }//the server's own picture ("thumbnail", ServerSettings.ThumbnailMediaId): held while it is set
// set once nothing holds it any more (its post deleted, edited out, replaced, its root removed, never posted): its
// files move out of what /media/files serves at once, and the janitor deletes them after a grace
public DateTime? TrashedAt { get; set; }
+65
View File
@@ -0,0 +1,65 @@
using MongoDB.Entities;
namespace PrivaPub.Models
{
// The server's own settings (completion program R5), edited from the administrator's page: one document with the fixed
// id `settings`, read through IInstanceSettings (Domain/Instance/InstanceSettings.cs). While none exists, Defaults() is
// what PrivaPub served and sent before the settings existed, word for word, so nothing changes until an administrator
// saves. A backup holds it, and a restore keeps the live one (ProtectiveMerge).
public class ServerSettings : Entity
{
public const string Name = "settings";
public const string DefaultTitle = "PrivaPub";
public const string DefaultDescription = "A small ActivityPub server where one private login keeps several unlinkable public personas.";
public const string DefaultThumbnailPath = "/media/missing-header.png";
public static readonly DateTime DefaultPrivacyPolicyUpdatedAt = new(2026, 10, 4, 0, 0, 0, DateTimeKind.Utc);
// the privacy policy PrivaPub served before it could be edited, as Markdown: it renders to the same paragraph
public const string DefaultPrivacyPolicy = "One private login owns several public personas, and nobody but this server's admin can tell they belong "
+ "together. What a persona posts is shared as its visibility says; a located post never leaves this server. "
+ "Uploads lose their metadata. Statistics name servers, never people: see /stargazing.";
public string Title { get; set; } = DefaultTitle;
public string ShortDescription { get; set; } = DefaultDescription;
public string Description { get; set; } = DefaultDescription;//Markdown
public DateTime? DescriptionUpdatedAt { get; set; }//null: the description was never edited (it then answers today)
public string ContactEmail { get; set; }
public string ContactAccountId { get; set; }//a local persona the administrator chose: publicly tied to running the server
public string ThumbnailMediaId { get; set; }//its MediaAttachment (ServerUse "thumbnail")
public string ThumbnailUrl { get; set; }//null: /media/missing-header.png
public string PrivacyPolicy { get; set; } = DefaultPrivacyPolicy;//Markdown
public DateTime? PrivacyPolicyUpdatedAt { get; set; } = DefaultPrivacyPolicyUpdatedAt;
public string TermsOfService { get; set; }//Markdown; null: the server has none (the API answers 404)
public DateTime? TermsEffectiveAt { get; set; }
public List<ServerRule> Rules { get; set; } = new();//soft-deleted ones stay, so old reports keep their meaning
public List<string> Relays { get; set; } = new();//besides Federation:Relays
public ServerMediaLimits Media { get; set; } = new();
public List<string> StaffPicks { get; set; } = new();//account ids, for later
public bool PublishActivity { get; set; }//GET /api/v1/instance/activity; off by default
public DateTime? UpdatedAt { get; set; }//null: never saved
public string UpdatedBy { get; set; }//the administrator's root id, or "cli"
public static ServerSettings Defaults() => new() { ID = Name };
/// <summary>The rules shown to everyone: those not deleted, in their order.</summary>
public IEnumerable<ServerRule> ListedRules() => Rules.Where(r => r.DeletedAt == null).OrderBy(r => r.Position);
}
public class ServerRule
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string Text { get; set; }
public string Hint { get; set; }
public int Position { get; set; }
public DateTime? DeletedAt { get; set; }
}
// what the administrator changed of Media:* (MediaOptions); null keeps the configured value. Applied by
// Domain/Instance/MediaLimitsFromSettings within hard caps.
public class ServerMediaLimits
{
public long? QuotaBytesPerRoot { get; set; }
public long? MaxImageBytes { get; set; }
public long? MaxVideoBytes { get; set; }
public int? MaxSeconds { get; set; }
}
}
+36
View File
@@ -0,0 +1,36 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Social
{
// An announcement of the server's administrators (Mastodon's /api/v1/announcements, completion program R5): written in
// Markdown on the administrator's page, shown while published and not ended. Never federated.
public class Announcement : Entity
{
public string Text { get; set; }//Markdown
public string Html { get; set; }//rendered and sanitized (ContentRenderer.Document)
public DateTime? StartsAt { get; set; }//what it is about, if it has a time (as Mastodon's)
public DateTime? EndsAt { get; set; }//once past, it is no longer shown
public bool AllDay { get; set; }
public bool IsPublished { get; set; }
public DateTime? PublishedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
// a persona dismissed it (Mastodon's read), kept per persona and never told anyone
public class AnnouncementRead : Entity
{
public string AnnouncementId { get; set; }
public string AvatarId { get; set; }
public DateTime ReadAt { get; set; } = DateTime.UtcNow;
}
// a persona's reaction to it (one unicode emoji), counted for everyone, the persona's own known only to it
public class AnnouncementReaction : Entity
{
public string AnnouncementId { get; set; }
public string AvatarId { get; set; }
public string Name { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
}
+1
View File
@@ -72,6 +72,7 @@ namespace PrivaPub.Models.Social
public List<string> PostIds { get; set; } = new();
public List<string> ObjectURIs { get; set; } = new();
public string Category { get; set; } = "other";
public List<string> RuleIds { get; set; } = new();//the server's rules it says were broken (ServerSettings.Rules, deleted ones too)
public string Comment { get; set; }
public bool Forwarded { get; set; }
public bool IsResolved { get; set; }
+9
View File
@@ -59,6 +59,14 @@ try
.AddScoped<PrivaPub.Api.Mastodon.Mappers.AccountSearch>()
.AddSingleton<PrivaPub.Domain.Social.Trends>()
.Configure<PrivaPub.Domain.Media.MediaOptions>(builder.Configuration.GetSection("Media"))
// the server's own settings (R5): read at start, saved from the administrator's page, read again every five
// minutes; the media limits it changes reach every IOptionsMonitor<MediaOptions> reader
.AddSingleton<PrivaPub.Domain.Instance.IInstanceSettings, PrivaPub.Domain.Instance.InstanceSettings>()
.AddHostedService<PrivaPub.Domain.Instance.InstanceSettingsRefresher>()
.AddSingleton<IPostConfigureOptions<PrivaPub.Domain.Media.MediaOptions>, PrivaPub.Domain.Instance.MediaLimitsFromSettings>()
.AddSingleton<IOptionsChangeTokenSource<PrivaPub.Domain.Media.MediaOptions>, PrivaPub.Domain.Instance.MediaLimitsChangeSource>()
.AddSingleton<PrivaPub.Domain.Moderation.IModerationLog, PrivaPub.Domain.Moderation.ModerationLog>()
.AddSingleton<PrivaPub.Domain.Social.IAnnouncements, PrivaPub.Domain.Social.Announcements>()
.AddSingleton<PrivaPub.Domain.Media.IMediaService, PrivaPub.Domain.Media.MediaService>()
.AddSingleton<PrivaPub.Domain.Media.IMediaProxy, PrivaPub.Domain.Media.MediaProxy>()
.AddHostedService<PrivaPub.Domain.Media.MediaJanitor>()
@@ -125,6 +133,7 @@ try
{
await DB.Default.MigrateAsync<Program>();
await Indexes.Create();
await app.Services.GetRequiredService<PrivaPub.Domain.Instance.IInstanceSettings>().Reload(CancellationToken.None);
}
catch (Exception ex)
{
+7 -2
View File
@@ -19,14 +19,19 @@ namespace PrivaPub.Services
public class Mailer : IMailer
{
readonly IOptionsMonitor<AppConfiguration> _app;
readonly Domain.Instance.IInstanceSettings _settings;
public Mailer(IOptionsMonitor<AppConfiguration> app) => _app = app;
public Mailer(IOptionsMonitor<AppConfiguration> app, Domain.Instance.IInstanceSettings settings)
{
_app = app;
_settings = settings;
}
public async Task Send(string to, string subject, string body, CancellationToken token)
{
var email = _app.CurrentValue.EmailConfiguration;
var message = new MimeMessage();
message.From.Add(new MailboxAddress("PrivaPub", email.SmtpUsername));
message.From.Add(new MailboxAddress(_settings.Current.Title, email.SmtpUsername));//the server's name, as its administrator set it
message.To.Add(MailboxAddress.Parse(to));
message.Subject = subject;
message.Body = new TextPart("plain") { Text = body };
+3
View File
@@ -69,6 +69,9 @@ namespace PrivaPub.Services
await DB.Default.DeleteAsync<Models.Social.PersonaList>(l => l.AvatarId == avatar.ID);
await DB.Default.DeleteAsync<Models.Social.PersonaFilter>(f => f.AvatarId == avatar.ID);
await DB.Default.DeleteAsync<Models.Social.FollowedTag>(t => t.AvatarId == avatar.ID);
// what it did with the server's announcements, kept for it alone
await DB.Default.DeleteAsync<Models.Social.AnnouncementRead>(r => r.AvatarId == avatar.ID);
await DB.Default.DeleteAsync<Models.Social.AnnouncementReaction>(r => r.AvatarId == avatar.ID);
// (the reader of feeds stops following what nobody reads any more at its next round)
await DB.Default.DeleteAsync<Models.Social.FeedSubscription>(s => s.AvatarId == avatar.ID);
// nothing of it publishes later, and none of its files stays served: its posts' media, its pictures and
+2 -1
View File
@@ -1,9 +1,10 @@
@page "/oauth/login"
@model PrivaPub.Web.Pages.OAuth.LoginModel
@inject PrivaPub.Domain.Instance.IInstanceSettings InstanceSettings
@{
ViewData["Title"] = "Sign in";
}
<header><h1>Sign in to PrivaPub</h1></header>
<header><h1>Sign in to @InstanceSettings.Current.Title</h1></header>
<p class="meta">Sign in with your private login. You will then choose which persona the application acts as; the
application never sees the login.</p>
<form method="post">
+2 -1
View File
@@ -1,3 +1,4 @@
@inject PrivaPub.Domain.Instance.IInstanceSettings InstanceSettings
<!DOCTYPE html>
<html lang="en">
<head>
@@ -32,7 +33,7 @@
<body>
<main>
@RenderBody()
<footer>PrivaPub</footer>
<footer>@InstanceSettings.Current.Title</footer>
</main>
</body>
</html>