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 _registrations; readonly IInstanceSettings _settings; readonly MastodonMapper _mapper; public InstanceController(ILocalActorService localActors, IOptionsMonitor 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 ContactAccount(ServerSettings settings, CancellationToken token) { var id = settings.ContactAccountId; if (string.IsNullOrEmpty(id)) return default; var avatar = await DB.Default.Find().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 LanguageCodes(IServiceProvider services) { var configured = services.GetRequiredService>().CurrentValue.SupportedLanguages; var codes = (configured ?? new List()).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>().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 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 KnownServers(CancellationToken token) => await DB.Default.CountAsync(i => i.Seen == "touched", token); [HttpGet("/api/v2/instance"), AllowAnonymous] public async Task 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()); // 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 Activity(CancellationToken token) { if (!Settings.PublishActivity) return Json(Array.Empty()); 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 DomainBlocks(CancellationToken token) => Json((await DB.Default.Find().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); } } }