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);