From 9ec1f9930b9b718a80441f393f5a090931275ce4 Mon Sep 17 00:00:00 2001 From: thepra Date: Thu, 1 Oct 2026 11:23:03 +0200 Subject: [PATCH] Profile and post pages, NodeInfo 2.1, and FEDERATION.md /@user and /@user/{id} are Razor pages showing an avatar's or community's public and unlisted posts: no scripts, a strict CSP, no-referrer, noindex, and an alternate link to the ActivityPub document. A client asking them for activity+json is redirected to the actor or note, and the actor and note redirect browsers here. NodeInfo answers 2.1 as well as 2.0 (repository, homepage, a link to FEDERATION.md) and counts only public local posts. FEDERATION.md (FEP-67ff) lists the protocols, FEPs, route names, activities and the security rules a peer will notice. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB --- FEDERATION.md | 95 +++++++++++++ .../Controllers/WellKnownController.cs | 44 ++++-- .../Middleware/SocialPubConfigurations.cs | 2 + PrivaPub/Program.cs | 1 + PrivaPub/Web/Pages/Pages.cs | 128 ++++++++++++++++++ PrivaPub/Web/Pages/Profile.cshtml | 36 +++++ PrivaPub/Web/Pages/Shared/_Layout.cshtml | 38 ++++++ PrivaPub/Web/Pages/Shared/_Post.cshtml | 19 +++ PrivaPub/Web/Pages/Status.cshtml | 11 ++ PrivaPub/Web/Pages/_ViewImports.cshtml | 3 + PrivaPub/Web/Pages/_ViewStart.cshtml | 3 + 11 files changed, 368 insertions(+), 12 deletions(-) create mode 100644 FEDERATION.md create mode 100644 PrivaPub/Web/Pages/Pages.cs create mode 100644 PrivaPub/Web/Pages/Profile.cshtml create mode 100644 PrivaPub/Web/Pages/Shared/_Layout.cshtml create mode 100644 PrivaPub/Web/Pages/Shared/_Post.cshtml create mode 100644 PrivaPub/Web/Pages/Status.cshtml create mode 100644 PrivaPub/Web/Pages/_ViewImports.cshtml create mode 100644 PrivaPub/Web/Pages/_ViewStart.cshtml diff --git a/FEDERATION.md b/FEDERATION.md new file mode 100644 index 0000000..bbb5bdf --- /dev/null +++ b/FEDERATION.md @@ -0,0 +1,95 @@ +# Federation + +PrivaPub is an ActivityPub server written in C#. This document follows +[FEP-67ff](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md) and describes how it federates. + +## Supported federation protocols and standards + +- [ActivityPub](https://www.w3.org/TR/activitypub/) (server-to-server) +- [WebFinger](https://webfinger.net/) +- [HTTP Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures), `rsa-sha256` / `hs2019` with RSA keys +- [NodeInfo](https://nodeinfo.diaspora.software/) 2.0 and 2.1 + +## Supported FEPs + +- [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md) +- [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md) +- [FEP-2c59: Discovery of a WebFinger address from an ActivityPub actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2c59/fep-2c59.md) + +Planned: FEP-1b12 (communities), FEP-8fcf (followers synchronisation), FEP-5feb (`indexable`), FEP-7628 (Move), +FEP-044f (quotes). + +## Actors + +Every account is an *avatar*: one private login can own several, and they are deliberately unlinkable. Nothing in an +actor document, a collection, NodeInfo or a delivery relates two avatars of the same login. + +| Thing | Address | +|---|---| +| Actor (Person, Group, Application) | `/peasants/{name}` (`/users/{name}` redirects) | +| Inbox | `/peasants/{name}/mouth` | +| Outbox | `/peasants/{name}/anus` | +| Shared inbox | `/human-centipede` | +| Followers / following | `/peasants/{name}/groupies`, `/peasants/{name}/stalking` | +| Objects | `/peasants/{name}/scribbles/{id}` | +| Activities | `/peasants/{name}/grunts/{id}` | +| Direct-message context | `/peasants/{name}/whispers/{id}` | +| Profile and post pages | `/@{name}`, `/@{name}/{id}` | + +The names are the project's own and are stable; resolve actors through WebFinger, not by guessing a path. + +- The key is `{actor}#main-key`, RSA 2048, served as SPKI PEM with `owner` set to the actor. +- `published` on an actor is truncated to the day. `indexable` is `false`. +- The instance actor is `/peasants/privapub` (type `Application`). It signs every fetch PrivaPub makes, so no avatar's key + is used to read another server's content. + +## Groups + +A group is either a **community** or a **circle**. + +- A community is a `Group` actor. It accepts `Follow` and re-shares (`Announce`) posts from its followers that address + it. Full FEP-1b12 behaviour (announcing activities, `audience`, moderation) is planned. +- A circle is private and does not federate yet: its actor, collections and WebFinger answer 404, and its posts are never + delivered. + +## Activities + +Received: + +| Activity | Effect | +|---|---| +| `Follow` | follows an avatar or community; `Accept` is sent unless the community approves members by hand | +| `Undo{Follow}` | unfollows | +| `Create{Note, Article, Page, Question, …}` | stored when it addresses or mentions a local avatar, replies to a local post, or is addressed to a community the author follows | +| `Update{Note}` | replaces the content; the previous version is kept | +| `Update{Person}` | refetches the actor | +| `Delete` | deletes the object, or the actor and its follows | + +Sent: `Create{Note}`, `Delete{Tombstone}`, `Accept{Follow}`, `Announce` (communities). + +A `Create`'s `Note` carries Mastodon's `content`, `contentMap`, `summary` and `sensitive`, plus `Mention` and `Hashtag` +tags. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show +`name`. A content warning without its own text uses the title, or "Content warning". + +Visibility is expressed in `to`/`cc` the way Mastodon does it: public, unlisted, followers-only and direct. Inbound +followers-only posts are recognised by the author's own `followers` collection. + +## Security rules a peer will notice + +- **Signatures.** Inbox POSTs must be signed over `(request-target)`, `host`, `digest` and `date` (or `(created)`). The + date may be at most one hour old and fifteen minutes ahead. A bad signature gets 401, malformed input 400, an accepted + activity 202, too many requests 429. Activities are processed after the 202. +- **Origins.** An actor document is accepted only from the address it names as its `id`. A key only if its actor lists + it with `owner` set to the actor and on the actor's origin. An activity's `id`, and any object it creates, updates or + deletes, must be on its actor's origin. An embedded object from another origin is fetched from that origin. +- **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three + redirects and read at most 1 MB. +- **HTML.** Received HTML is sanitised to Mastodon's allowlist. +- **Delivery.** Failed deliveries are retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused, + starting at an hour and growing to a week. + +## Known limitations + +- Likes, boosts, follow requests to remote accounts, media uploads and polls are not implemented yet. +- Collections expose counts, not members. +- Only `rsa-sha256`-style keys are verified. RFC 9421 signatures are planned. diff --git a/PrivaPub/Federation/Controllers/WellKnownController.cs b/PrivaPub/Federation/Controllers/WellKnownController.cs index 862181a..22c6fb2 100644 --- a/PrivaPub/Federation/Controllers/WellKnownController.cs +++ b/PrivaPub/Federation/Controllers/WellKnownController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using MongoDB.Entities; using PrivaPub.Models.Federation; +using PrivaPub.Models.Post; using PrivaPub.Models.User; using PrivaPub.StaticServices; @@ -64,24 +65,37 @@ namespace PrivaPub.Federation.Controllers { var document = new JsonObject { - ["links"] = new JsonArray(new JsonObject - { - ["rel"] = "http://nodeinfo.diaspora.software/ns/schema/2.0", - ["href"] = $"{_localActors.BaseAddress}/nodeinfo/2.0" - }) + ["links"] = new JsonArray( + new JsonObject + { + ["rel"] = "http://nodeinfo.diaspora.software/ns/schema/2.1", + ["href"] = $"{_localActors.BaseAddress}/nodeinfo/2.1" + }, + new JsonObject + { + ["rel"] = "http://nodeinfo.diaspora.software/ns/schema/2.0", + ["href"] = $"{_localActors.BaseAddress}/nodeinfo/2.0" + }) }; return Content(document.ToJsonString(), "application/json; charset=utf-8"); } - [HttpGet, Route("/nodeinfo/2.0")] - public async Task NodeInfo(CancellationToken token) + [HttpGet, Route("/nodeinfo/{version:regex(^2\\.[[01]]$)}")] + public async Task NodeInfo(string version, CancellationToken token) { var users = await DB.Default.CountAsync(a => !a.DeletionAt.HasValue, token); - var posts = await DB.Default.CountAsync(p => !p.IsFederatedCopy, token); + var posts = await DB.Default.CountAsync(p => !p.IsFederatedCopy && !p.DeletedAt.HasValue + && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted), token); + var software = new JsonObject { ["name"] = "privapub", ["version"] = BuildInfo.Ref }; + if (version == "2.1") + { + software["repository"] = "https://git.thepra.dev/thepra/SocialPub"; + software["homepage"] = "https://git.thepra.dev/thepra/SocialPub"; + } var document = new JsonObject { - ["version"] = "2.0", - ["software"] = new JsonObject { ["name"] = "privapub", ["version"] = BuildInfo.Ref }, + ["version"] = version, + ["software"] = software, ["protocols"] = new JsonArray("activitypub"), ["services"] = new JsonObject { ["inbound"] = new JsonArray(), ["outbound"] = new JsonArray() }, ["openRegistrations"] = true, @@ -90,9 +104,15 @@ namespace PrivaPub.Federation.Controllers ["users"] = new JsonObject { ["total"] = users }, ["localPosts"] = posts }, - ["metadata"] = new JsonObject() + ["metadata"] = new JsonObject + { + ["nodeName"] = "PrivaPub", + ["nodeDescription"] = "A small ActivityPub server where one private login keeps several unlinkable public personas.", + ["federation"] = new JsonObject { ["document"] = "https://git.thepra.dev/thepra/SocialPub/src/branch/master/FEDERATION.md" } + } }; - return Content(document.ToJsonString(), "application/json; profile=\"http://nodeinfo.diaspora.software/ns/schema/2.0#\"; charset=utf-8"); + return Content(document.ToJsonString(), + $"application/json; profile=\"http://nodeinfo.diaspora.software/ns/schema/{version}#\"; charset=utf-8"); } } } diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index 05bbc38..b7ae4d8 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -142,6 +142,8 @@ namespace PrivaPub.Middleware [new OpenApiSecuritySchemeReference("Bearer", document)] = [] }); }) + .AddRazorPages(options => options.RootDirectory = "/Web/Pages") + .Services .AddControllers(options => { options.Filters.Add(); }) .AddJsonOptions(options => { diff --git a/PrivaPub/Program.cs b/PrivaPub/Program.cs index 3d8e646..a8dfcee 100644 --- a/PrivaPub/Program.cs +++ b/PrivaPub/Program.cs @@ -139,6 +139,7 @@ try builtAt = BuildInfo.BuiltAt, })); app.MapControllers(); + app.MapRazorPages(); //app.MapFallbackToFile("index.html"); } catch (Exception ex) diff --git a/PrivaPub/Web/Pages/Pages.cs b/PrivaPub/Web/Pages/Pages.cs new file mode 100644 index 0000000..6af8da8 --- /dev/null +++ b/PrivaPub/Web/Pages/Pages.cs @@ -0,0 +1,128 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +using MongoDB.Entities; + +using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Rendering; +using PrivaPub.Models.Federation; +using PrivaPub.Models.Post; +using PrivaPub.StaticServices; + +using PostEntity = PrivaPub.Models.Post.Post; + +namespace PrivaPub.Web.Pages +{ + public sealed record PostView(string Title, string Warning, string ContentHtml, string Url, DateTime Published, bool Edited) + { + public static PostView From(PostEntity post, LocalActor author) => new( + post.Title, + post.SpoilerText ?? (post.HasContentWarning ? ActivityPubRenderer.ContentWarning : default), + post.ContentHtml ?? ActivityPubRenderer.Html(post.Text), + author.PostHtmlUrl(post.ID), + DateTime.SpecifyKind(post.CreationDate, DateTimeKind.Utc), + post.EditedAt.HasValue); + } + + public abstract class PublicPageModel : PageModel + { + protected bool WantsActivityJson() + { + var accept = Request.Headers.Accept.ToString(); + return accept.Contains("activity+json", StringComparison.OrdinalIgnoreCase) || accept.Contains("ld+json", StringComparison.OrdinalIgnoreCase); + } + + protected void Harden() + { + Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; + Response.Headers["Referrer-Policy"] = "no-referrer"; + Response.Headers["X-Content-Type-Options"] = "nosniff"; + } + } + + public class ProfileModel : PublicPageModel + { + const int PageSize = 20; + + readonly ILocalActorService _localActors; + readonly DbEntities _dbEntities; + + public ProfileModel(ILocalActorService localActors, DbEntities dbEntities) + { + _localActors = localActors; + _dbEntities = dbEntities; + } + + public LocalActor Actor { get; private set; } + public string BiographyHtml { get; private set; } + public IReadOnlyList Posts { get; private set; } = Array.Empty(); + + public async Task OnGetAsync(string user, CancellationToken token) + { + Actor = await _localActors.FindByUserName(user, token); + if (Actor is not { IsFederated: true } || Actor.Kind == LocalActorKind.Application) + return NotFound(); + if (WantsActivityJson()) + return Redirect(Actor.Uri); + + Harden(); + BiographyHtml = ActivityPubRenderer.Html(Actor.Summary); + var posts = await (Actor.Kind == LocalActorKind.Group + ? _dbEntities.Posts.Match(p => p.GroupId == Actor.Id) + : _dbEntities.Posts.Match(p => p.GroupUserId == Actor.Id && !p.IsFederatedCopy)) + .Match(p => !p.DeletedAt.HasValue && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted)) + .Sort(p => p.ID, Order.Descending) + .Limit(PageSize) + .ExecuteAsync(token); + var authors = new Dictionary { [Actor.Id] = Actor }; + var views = new List(); + foreach (var post in posts) + { + if (post.IsFederatedCopy) + continue; + if (!authors.TryGetValue(post.GroupUserId, out var author)) + authors[post.GroupUserId] = author = await _localActors.FindById(LocalActorKind.Person, post.GroupUserId, token); + if (author != default) + views.Add(PostView.From(post, author)); + } + Posts = views; + return Page(); + } + } + + public class StatusModel : PublicPageModel + { + readonly ILocalActorService _localActors; + readonly DbEntities _dbEntities; + + public StatusModel(ILocalActorService localActors, DbEntities dbEntities) + { + _localActors = localActors; + _dbEntities = dbEntities; + } + + public LocalActor Actor { get; private set; } + public PostView Post { get; private set; } + public string ObjectUri { get; private set; } + + public async Task OnGetAsync(string user, string id, CancellationToken token) + { + Actor = await _localActors.FindByUserName(user, token); + if (Actor is not { IsFederated: true, Kind: LocalActorKind.Person }) + return NotFound(); + var post = await _dbEntities.Posts + .Match(p => p.ID == id && p.GroupUserId == Actor.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue + && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted)) + .ExecuteFirstAsync(token); + if (post == default) + return NotFound(); + ObjectUri = Actor.PostUri(post.ID); + if (WantsActivityJson()) + return Redirect(ObjectUri); + + Harden(); + Post = PostView.From(post, Actor); + return Page(); + } + } +} diff --git a/PrivaPub/Web/Pages/Profile.cshtml b/PrivaPub/Web/Pages/Profile.cshtml new file mode 100644 index 0000000..6119deb --- /dev/null +++ b/PrivaPub/Web/Pages/Profile.cshtml @@ -0,0 +1,36 @@ +@page "/@{user}" +@model PrivaPub.Web.Pages.ProfileModel +@{ + ViewData["Title"] = $"{Model.Actor.Name} (@{Model.Actor.Handle})"; + ViewData["Alternate"] = Model.Actor.Uri; +} +
+

@Model.Actor.Name

+
@@@Model.Actor.Handle
+
+
@Html.Raw(Model.BiographyHtml)
+@if (Model.Actor.Fields.Count > 0) +{ +
+ @foreach (var field in Model.Actor.Fields) + { +
@field.Key
+
@if (Uri.TryCreate(field.Value, UriKind.Absolute, out var link) && link.Scheme is "https" or "http") + { + @field.Value + } + else + { + @field.Value + }
+ } +
+} +@foreach (var post in Model.Posts) +{ + +} +@if (Model.Posts.Count == 0) +{ +

Nothing public yet.

+} diff --git a/PrivaPub/Web/Pages/Shared/_Layout.cshtml b/PrivaPub/Web/Pages/Shared/_Layout.cshtml new file mode 100644 index 0000000..9560efe --- /dev/null +++ b/PrivaPub/Web/Pages/Shared/_Layout.cshtml @@ -0,0 +1,38 @@ + + + + + + + @ViewData["Title"] + @if (ViewData["Alternate"] is string alternate) + { + + } + + + +
+ @RenderBody() +
PrivaPub
+
+ + diff --git a/PrivaPub/Web/Pages/Shared/_Post.cshtml b/PrivaPub/Web/Pages/Shared/_Post.cshtml new file mode 100644 index 0000000..7ac90bd --- /dev/null +++ b/PrivaPub/Web/Pages/Shared/_Post.cshtml @@ -0,0 +1,19 @@ +@model PrivaPub.Web.Pages.PostView +
+ @if (!string.IsNullOrEmpty(Model.Title)) + { +

@Model.Title

+ } + @if (!string.IsNullOrEmpty(Model.Warning)) + { +
+ @Model.Warning + @Html.Raw(Model.ContentHtml) +
+ } + else + { + @Html.Raw(Model.ContentHtml) + } +
@(Model.Edited ? " · edited" : "")
+
diff --git a/PrivaPub/Web/Pages/Status.cshtml b/PrivaPub/Web/Pages/Status.cshtml new file mode 100644 index 0000000..ef1ac5e --- /dev/null +++ b/PrivaPub/Web/Pages/Status.cshtml @@ -0,0 +1,11 @@ +@page "/@{user}/{id}" +@model PrivaPub.Web.Pages.StatusModel +@{ + ViewData["Title"] = $"{Model.Actor.Name}: {Model.Post.Title ?? "post"}"; + ViewData["Alternate"] = Model.ObjectUri; +} +
+

@Model.Actor.Name

+
@@@Model.Actor.Handle
+
+ diff --git a/PrivaPub/Web/Pages/_ViewImports.cshtml b/PrivaPub/Web/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..33dfb4c --- /dev/null +++ b/PrivaPub/Web/Pages/_ViewImports.cshtml @@ -0,0 +1,3 @@ +@using PrivaPub.Web.Pages +@namespace PrivaPub.Web.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/PrivaPub/Web/Pages/_ViewStart.cshtml b/PrivaPub/Web/Pages/_ViewStart.cshtml new file mode 100644 index 0000000..e1b65b4 --- /dev/null +++ b/PrivaPub/Web/Pages/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +}