Files
SocialPub/PrivaPub/Api/Mastodon/Controllers/InstanceController.cs
T
thepraandClaude Opus 5.5 70d1ffe9a2
Build / Build (push) Successful in 1m3s
R5 the server's own settings: instance, rules, announcements, relays, media limits, the staff's log
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
2026-10-17 04:02:47 +02:00

278 lines
12 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
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 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, 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)
{
var configured = services.GetRequiredService<Microsoft.Extensions.Options.IOptionsMonitor<Models.AppConfiguration>>().CurrentValue.SupportedLanguages;
var codes = (configured ?? new List<string>()).Where(c => !string.IsNullOrWhiteSpace(c)).Select(c => c.Split('-')[0].ToLowerInvariant()).Distinct().ToList();
return codes.Count == 0 ? new[] { "en" } : codes;
}
string Domain => new Uri(_localActors.BaseAddress).Authority;
// where clients open their streams (StreamingController): this server, as ws or wss
string Streaming => new UriBuilder(_localActors.BaseAddress) { Scheme = _localActors.BaseAddress.StartsWith("https", StringComparison.OrdinalIgnoreCase) ? "wss" : "ws" }
.Uri.GetLeftPart(UriPartial.Authority);
// every limit is what the server enforces, read from where it is enforced
static object Statuses => new { max_characters = PrivaPub.Domain.Statuses.StatusService.MaxCharacters, max_media_attachments = 4, characters_reserved_per_url = 23 };
object MediaAttachments
{
get
{
var media = HttpContext.RequestServices.GetRequiredService<IOptionsMonitor<PrivaPub.Domain.Media.MediaOptions>>().CurrentValue;
return new
{
supported_mime_types = PrivaPub.Domain.Media.MediaService.SupportedTypes,
image_size_limit = media.MaxImageBytes,
image_matrix_limit = media.MaxPixels,
video_size_limit = media.MaxVideoBytes,
video_frame_rate_limit = (int)media.MaxFrameRate,
video_matrix_limit = media.MaxVideoPixels
};
}
}
static object Polls => new
{
max_options = PrivaPub.Domain.Statuses.PollService.MaxOptions,
max_characters_per_option = PrivaPub.Domain.Statuses.PollService.MaxOptionLength,
min_expiration = PrivaPub.Domain.Statuses.PollService.MinExpiration,
max_expiration = PrivaPub.Domain.Statuses.PollService.MaxExpiration
};
[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 = 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 = 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 = await ContactAccount(settings, token),
rules = Rules(settings)
});
}
// the servers we have exchanged activities with, as the statistics count them. Which ones is not published
// (/instance/peers stays empty, as on a Mastodon with its peers API off): on a small server it would say who its
// personas talk to.
static async Task<long> KnownServers(CancellationToken token) =>
await DB.Default.CountAsync<Models.Jobs.RemoteInstance>(i => i.Seen == "touched", token);
[HttpGet("/api/v2/instance"), AllowAnonymous]
public async Task<IActionResult> V2(CancellationToken token) => Json(new
{
domain = Domain,
title = Settings.Title,
version = Version,
api_versions = new { mastodon = 7 },
source_url = "https://git.thepra.dev/thepra/SocialPub",
description = Settings.ShortDescription ?? string.Empty,
usage = new { users = new { active_month = await Counted.ActiveUsers(30, token) } },
thumbnail = new { url = Thumbnail },
languages = LanguageCodes(HttpContext.RequestServices),
configuration = new
{
urls = new { streaming = Streaming },
accounts = new { max_featured_tags = 0, max_pinned_statuses = StatusesController.MaxPins },
statuses = Statuses,
media_attachments = MediaAttachments,
polls = Polls,
translation = new { enabled = false }
},
registrations = new { enabled = Open, approval_required = false, message = Open ? default : RegistrationOptions.ClosedMessage },
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 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 InstanceRules() => Json(Rules(Settings));
[HttpGet("/api/v1/instance/extended_description"), AllowAnonymous]
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);
}
}
}