2026-10-01 11:52:31 +02:00
|
|
|
using Microsoft.AspNetCore.Authorization;
|
|
|
|
|
using Microsoft.AspNetCore.Mvc;
|
2026-10-04 02:37:38 +02:00
|
|
|
using Microsoft.Extensions.Options;
|
2026-10-01 11:52:31 +02:00
|
|
|
|
|
|
|
|
using MongoDB.Entities;
|
|
|
|
|
|
|
|
|
|
using PrivaPub.Api.Mastodon.Infrastructure;
|
2026-10-17 04:02:47 +02:00
|
|
|
using PrivaPub.Api.Mastodon.Mappers;
|
|
|
|
|
using PrivaPub.Domain.Content;
|
|
|
|
|
using PrivaPub.Domain.Instance;
|
2026-10-01 11:52:31 +02:00
|
|
|
using PrivaPub.Domain.Privacy;
|
|
|
|
|
using PrivaPub.Federation.Actors;
|
2026-10-04 02:37:38 +02:00
|
|
|
using PrivaPub.Infrastructure;
|
2026-10-17 04:02:47 +02:00
|
|
|
using PrivaPub.Models;
|
|
|
|
|
using PrivaPub.Models.Federation;
|
2026-10-01 11:52:31 +02:00
|
|
|
using PrivaPub.Models.User;
|
|
|
|
|
|
2026-10-17 04:02:47 +02:00
|
|
|
using System.Globalization;
|
|
|
|
|
using System.Security.Cryptography;
|
|
|
|
|
using System.Text;
|
|
|
|
|
|
2026-10-01 11:52:31 +02:00
|
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
|
|
|
|
|
|
|
|
|
namespace PrivaPub.Api.Mastodon.Controllers
|
|
|
|
|
{
|
2026-10-17 04:02:47 +02:00
|
|
|
// 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.
|
2026-10-01 11:52:31 +02:00
|
|
|
public class InstanceController : MastodonController
|
|
|
|
|
{
|
|
|
|
|
public const string Version = "4.2.0 (compatible; PrivaPub)";
|
2026-10-17 04:02:47 +02:00
|
|
|
const int ActivityWeeks = 12;
|
|
|
|
|
static readonly TimeSpan ActivityLifetime = TimeSpan.FromHours(1);
|
|
|
|
|
static volatile CachedActivity _activity;
|
|
|
|
|
|
|
|
|
|
sealed record CachedActivity(DateTime At, object Weeks);
|
2026-10-01 11:52:31 +02:00
|
|
|
|
|
|
|
|
readonly ILocalActorService _localActors;
|
2026-10-04 02:37:38 +02:00
|
|
|
readonly IOptionsMonitor<RegistrationOptions> _registrations;
|
2026-10-17 04:02:47 +02:00
|
|
|
readonly IInstanceSettings _settings;
|
|
|
|
|
readonly MastodonMapper _mapper;
|
2026-10-01 11:52:31 +02:00
|
|
|
|
2026-10-17 04:02:47 +02:00
|
|
|
public InstanceController(ILocalActorService localActors, IOptionsMonitor<RegistrationOptions> registrations, IInstanceSettings settings,
|
|
|
|
|
MastodonMapper mapper)
|
2026-10-01 11:52:31 +02:00
|
|
|
{
|
|
|
|
|
_localActors = localActors;
|
2026-10-04 02:37:38 +02:00
|
|
|
_registrations = registrations;
|
2026-10-17 04:02:47 +02:00
|
|
|
_settings = settings;
|
|
|
|
|
_mapper = mapper;
|
2026-10-01 11:52:31 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-04 02:37:38 +02:00
|
|
|
bool Open => _registrations.CurrentValue.IsOpen;
|
|
|
|
|
|
2026-10-17 04:02:47 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 03:48:32 +02:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 11:52:31 +02:00
|
|
|
string Domain => new Uri(_localActors.BaseAddress).Authority;
|
|
|
|
|
|
2026-10-05 04:04:08 +02:00
|
|
|
// 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);
|
|
|
|
|
|
2026-10-04 03:48:32 +02:00
|
|
|
// 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 };
|
2026-10-01 11:52:31 +02:00
|
|
|
|
2026-10-04 03:48:32 +02:00
|
|
|
object MediaAttachments
|
2026-10-01 11:52:31 +02:00
|
|
|
{
|
2026-10-04 03:48:32 +02:00
|
|
|
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,
|
2026-10-07 10:48:56 +02:00
|
|
|
image_matrix_limit = media.MaxPixels,
|
2026-10-04 03:48:32 +02:00
|
|
|
video_size_limit = media.MaxVideoBytes,
|
2026-10-07 10:48:56 +02:00
|
|
|
video_frame_rate_limit = (int)media.MaxFrameRate,
|
|
|
|
|
video_matrix_limit = media.MaxVideoPixels
|
2026-10-04 03:48:32 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-01 11:52:31 +02:00
|
|
|
|
2026-10-04 03:48:32 +02:00
|
|
|
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
|
|
|
|
|
};
|
2026-10-01 11:52:31 +02:00
|
|
|
|
|
|
|
|
[HttpGet("/api/v1/instance"), AllowAnonymous]
|
|
|
|
|
public async Task<IActionResult> V1(CancellationToken token)
|
|
|
|
|
{
|
2026-10-17 04:02:47 +02:00
|
|
|
var settings = Settings;
|
2026-10-04 03:48:32 +02:00
|
|
|
var users = await Counted.Users(token);
|
|
|
|
|
var statuses = await Counted.LocalPosts(token);
|
|
|
|
|
var domains = await KnownServers(token);
|
2026-10-01 11:52:31 +02:00
|
|
|
return Json(new
|
|
|
|
|
{
|
|
|
|
|
uri = Domain,
|
2026-10-17 04:02:47 +02:00
|
|
|
title = settings.Title,
|
|
|
|
|
short_description = settings.ShortDescription ?? string.Empty,
|
|
|
|
|
description = settings.ShortDescription ?? string.Empty,
|
|
|
|
|
email = settings.ContactEmail ?? string.Empty,
|
2026-10-01 11:52:31 +02:00
|
|
|
version = Version,
|
2026-10-05 04:04:08 +02:00
|
|
|
urls = new { streaming_api = Streaming },
|
2026-10-01 11:52:31 +02:00
|
|
|
stats = new { user_count = users, status_count = statuses, domain_count = domains },
|
2026-10-17 04:02:47 +02:00
|
|
|
thumbnail = Thumbnail,
|
2026-10-04 03:48:32 +02:00
|
|
|
languages = LanguageCodes(HttpContext.RequestServices),
|
2026-10-04 02:37:38 +02:00
|
|
|
registrations = Open,
|
2026-10-01 11:52:31 +02:00
|
|
|
approval_required = false,
|
2026-10-04 02:37:38 +02:00
|
|
|
invites_enabled = true,//a group invitation always creates an account
|
2026-10-01 11:52:31 +02:00
|
|
|
configuration = new { accounts = new { max_featured_tags = 0 }, statuses = Statuses, media_attachments = MediaAttachments, polls = Polls },
|
2026-10-17 04:02:47 +02:00
|
|
|
contact_account = await ContactAccount(settings, token),
|
|
|
|
|
rules = Rules(settings)
|
2026-10-01 11:52:31 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 03:48:32 +02:00
|
|
|
// 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);
|
|
|
|
|
|
2026-10-01 11:52:31 +02:00
|
|
|
[HttpGet("/api/v2/instance"), AllowAnonymous]
|
2026-10-04 03:48:32 +02:00
|
|
|
public async Task<IActionResult> V2(CancellationToken token) => Json(new
|
2026-10-01 11:52:31 +02:00
|
|
|
{
|
|
|
|
|
domain = Domain,
|
2026-10-17 04:02:47 +02:00
|
|
|
title = Settings.Title,
|
2026-10-01 11:52:31 +02:00
|
|
|
version = Version,
|
2026-10-01 18:53:28 +02:00
|
|
|
api_versions = new { mastodon = 7 },
|
2026-10-01 11:52:31 +02:00
|
|
|
source_url = "https://git.thepra.dev/thepra/SocialPub",
|
2026-10-17 04:02:47 +02:00
|
|
|
description = Settings.ShortDescription ?? string.Empty,
|
2026-10-04 03:48:32 +02:00
|
|
|
usage = new { users = new { active_month = await Counted.ActiveUsers(30, token) } },
|
2026-10-17 04:02:47 +02:00
|
|
|
thumbnail = new { url = Thumbnail },
|
2026-10-04 03:48:32 +02:00
|
|
|
languages = LanguageCodes(HttpContext.RequestServices),
|
2026-10-01 11:52:31 +02:00
|
|
|
configuration = new
|
|
|
|
|
{
|
2026-10-05 04:04:08 +02:00
|
|
|
urls = new { streaming = Streaming },
|
2026-10-04 03:48:32 +02:00
|
|
|
accounts = new { max_featured_tags = 0, max_pinned_statuses = StatusesController.MaxPins },
|
2026-10-01 11:52:31 +02:00
|
|
|
statuses = Statuses,
|
|
|
|
|
media_attachments = MediaAttachments,
|
|
|
|
|
polls = Polls,
|
|
|
|
|
translation = new { enabled = false }
|
|
|
|
|
},
|
2026-10-04 02:37:38 +02:00
|
|
|
registrations = new { enabled = Open, approval_required = false, message = Open ? default : RegistrationOptions.ClosedMessage },
|
2026-10-17 04:02:47 +02:00
|
|
|
contact = new { email = Settings.ContactEmail ?? string.Empty, account = await ContactAccount(Settings, token) },
|
|
|
|
|
rules = Rules(Settings)
|
2026-10-01 11:52:31 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
[HttpGet("/api/v1/instance/peers"), AllowAnonymous]
|
|
|
|
|
public IActionResult Peers() => Json(Array.Empty<string>());
|
|
|
|
|
|
2026-10-17 04:02:47 +02:00
|
|
|
// 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.
|
2026-10-01 11:52:31 +02:00
|
|
|
[HttpGet("/api/v1/instance/activity"), AllowAnonymous]
|
2026-10-17 04:02:47 +02:00
|
|
|
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);
|
|
|
|
|
}
|
2026-10-01 11:52:31 +02:00
|
|
|
|
|
|
|
|
[HttpGet("/api/v1/instance/rules"), AllowAnonymous]
|
2026-10-17 04:02:47 +02:00
|
|
|
public IActionResult InstanceRules() => Json(Rules(Settings));
|
2026-10-01 11:52:31 +02:00
|
|
|
|
|
|
|
|
[HttpGet("/api/v1/instance/extended_description"), AllowAnonymous]
|
2026-10-17 04:02:47 +02:00
|
|
|
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);
|
|
|
|
|
}
|
2026-10-01 11:52:31 +02:00
|
|
|
}
|
|
|
|
|
}
|