Mastodon's streaming API: /api/v1/streaming as a WebSocket (streams
subscribed in the URL or by message) and /api/v1/streaming/{stream} as
server-sent events, with health and the URL advertised. The user stream
tells posts reaching the persona's home (not those an exclusive list keeps
apart, which its list stream tells), notifications, edits and deletions;
public, hashtag and list streams tell what belongs in them. An in-process
hub carries ids only; each connection maps a post or a notification for its
own persona as it sends it, so nothing it may not see, or whose author it
blocked or muted, goes out. A deletion reaches only the streams that showed
the post. The token comes as access_token, header or WebSocket protocol.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
147 lines
6.0 KiB
C#
147 lines
6.0 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Api.Mastodon.Infrastructure;
|
|
using PrivaPub.Domain.Privacy;
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Infrastructure;
|
|
using PrivaPub.Models.User;
|
|
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
|
|
|
namespace PrivaPub.Api.Mastodon.Controllers
|
|
{
|
|
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.";
|
|
|
|
readonly ILocalActorService _localActors;
|
|
readonly IOptionsMonitor<RegistrationOptions> _registrations;
|
|
|
|
public InstanceController(ILocalActorService localActors, IOptionsMonitor<RegistrationOptions> registrations)
|
|
{
|
|
_localActors = localActors;
|
|
_registrations = registrations;
|
|
}
|
|
|
|
bool Open => _registrations.CurrentValue.IsOpen;
|
|
|
|
// 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.MaxImageSide * media.MaxImageSide,
|
|
video_size_limit = media.MaxVideoBytes,
|
|
video_frame_rate_limit = 60,
|
|
video_matrix_limit = 2_304_000
|
|
};
|
|
}
|
|
}
|
|
|
|
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 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,
|
|
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",
|
|
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>()
|
|
});
|
|
}
|
|
|
|
// 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 = "PrivaPub",
|
|
version = Version,
|
|
api_versions = new { mastodon = 7 },
|
|
source_url = "https://git.thepra.dev/thepra/SocialPub",
|
|
description = Description,
|
|
usage = new { users = new { active_month = await Counted.ActiveUsers(30, token) } },
|
|
thumbnail = new { url = $"{_localActors.BaseAddress}/media/missing-header.png" },
|
|
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 = string.Empty, account = default(object) },
|
|
rules = Array.Empty<object>()
|
|
});
|
|
|
|
[HttpGet("/api/v1/instance/peers"), AllowAnonymous]
|
|
public IActionResult Peers() => Json(Array.Empty<string>());
|
|
|
|
[HttpGet("/api/v1/instance/activity"), AllowAnonymous]
|
|
public IActionResult Activity() => Json(Array.Empty<object>());
|
|
|
|
[HttpGet("/api/v1/instance/rules"), AllowAnonymous]
|
|
public IActionResult Rules() => Json(Array.Empty<object>());
|
|
|
|
[HttpGet("/api/v1/instance/extended_description"), AllowAnonymous]
|
|
public IActionResult ExtendedDescription() => Json(new { updated_at = MastodonJson.Day(DateTime.UtcNow), content = $"<p>{Description}</p>" });
|
|
}
|
|
}
|