diff --git a/CLAUDE.md b/CLAUDE.md index a919774..dfc7aef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,9 @@ Its defining idea: **one private login owns several public personas.** becoming two kinds: a public **community** (FEP-1b12, Lemmy-compatible) and a private, invitation-only **circle**. - **`DmGroup` is a direct-message conversation.** - **`privapub` is the instance actor** (type Application). It signs fetches no persona should be tied to. +- **`privapub_reports` is the reporter** (type Service, `LocalActorKind.Reporter`). It carries reports to Lemmy, which + takes none from an Application, and names nobody. Both are server actors (`LocalActor.IsServerActor`): never followed, + mentioned or shown as accounts. Privacy features in the model: - location-ranged posts (`Post.Location`, `RangeKm`), to become local-only and never federated; @@ -245,10 +248,12 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too. 16. **An `Accept` or `Reject` is routed by what it answers:** our Follow (`FollowService`, any `-again-N` resend), an interaction request (`InteractionApprovals`, with the author's authorization read back from its origin) or a persona's `Join` (`Participations`, by its `/grunts/join-` id), each only from the origin of what it answers. -17. **A server's software is for display, with one exception** (owner decision 2026-10-05): a direct message to one - account on a server whose NodeInfo names Lemmy before 1.0 or Mbin goes as a `ChatMessage` - (`StatusService.TakesOnlyChatMessages`), as one to an account that writes to us that way does. Nothing else may - branch on `RemoteInstance.Software`. +17. **A server's software is for display, with two exceptions** (owner decisions 2026-10-05 and 2026-10-06): a direct + message to one account on a server whose NodeInfo names Lemmy before 1.0 or Mbin goes as a `ChatMessage` + (`StatusService.TakesOnlyChatMessages`), as one to an account that writes to us that way does; and a report of a + post in a community on a server whose NodeInfo is in `ReportService.ServiceReportTakers` leaves in Lemmy's shape, + from the reporter, one `Flag` per post. A server joins that set only once the pasture shows it keeps such a report + with its reason. Nothing else may branch on `RemoteInstance.Software`. ## Mastodon client API invariants @@ -327,7 +332,8 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too. see ids. Never emit `CreatedAt`. - **Per-avatar state stays per avatar:** blocks, mutes, notifications, follows. Nothing may relate sibling avatars. - **Blocks federate** (owner decision, 2026-10-01): a block is sent as `Block` from the blocking avatar, an unblock as - `Undo{Block}`. Reports still leave as `Flag` from the instance actor, never from the reporting avatar. + `Undo{Block}`. Reports still leave as `Flag` from the server's own actors (the instance actor, or the reporter for + Lemmy's communities), never from the reporting avatar. - **What PrivaPub reveals is the owner's call.** Previews, blocks, website authorship, views, bridging and reactions were decided in `docs/ROADMAP.md` ("Owner decisions on what PrivaPub reveals"). Anything new that tells another server something about an avatar gets the same treatment: ask, then record it there. diff --git a/FEDERATION.md b/FEDERATION.md index ad4c01b..002db62 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -133,6 +133,9 @@ The names are the project's own and are stable; resolve actors through WebFinger is used to read another server's content. It also answers at the server's root (`/`) for a request that asks for ActivityPub, as Lemmy's site actor does: PieFed sends a community's announces to the inbox of the Application at a peer's root, and to `/inbox` when there is none. +- The reporter is `/peasants/privapub_reports` (type `Service`, "Reports from "), one for the whole server with its + own key. It sends the reports Lemmy takes only from a person or a service (see **Reports** below) and does nothing + else: nobody follows or mentions it, the Mastodon API has no account for it, and it has no page. ## Groups @@ -225,10 +228,18 @@ persona's posts passed on to its followers. A deleted post answers 410 with a `T is told to the post's audience as `Add` or `Remove` on `featured`, as Mastodon tells it. - **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it followed); an unblock sends `Undo{Block}`. -- **Reports** are sent as `Flag` by the instance actor, never by the reporting account. +- **Reports** are sent as `Flag`, never by the reporting account and never naming it. + - To the reported account's server: from the instance actor, with the account and the posts as `object` and the + persona's words in `content`. + - To a community on a server whose NodeInfo names Lemmy (owner decision 2026-10-06, the second place PrivaPub decides + by a server's software): one `Flag` per reported post or comment that was made in that community (its own + `audience`, else its thread's), from the reporter, `to` the community, the post alone as `object`, the words (or, + with none, the category) in `summary` and `content`, sent to the community's inbox. Lemmy refuses a Flag from an + `Application`, with no `to` or with no reason. Such a server gets no instance actor's Flag: an account alone, or a + post outside its communities, is not reported there, since Lemmy takes neither. - **Direct messages** go out as a `Note` addressed to their recipients, except a message to one account elsewhere that writes to us as `ChatMessage`s (Pleroma's type), or whose server takes nothing else: Lemmy before 1.0 and Mbin, as - their NodeInfo names them (owner decision 2026-10-05, the one place PrivaPub decides by a server's software). That + their NodeInfo names them (owner decision 2026-10-05, the first place PrivaPub decides by a server's software). That message goes out as a `ChatMessage`, to the account alone, without a mention in its text. - **Replies are passed on** (inbox forwarding; owner decision 2026-10-05). A public or unlisted reply from another server to a persona's public, unlisted or followers-only post goes on to the persona's followers as its author's server sent @@ -382,7 +393,8 @@ Posts with a location (shown to nearby users of this server) never leave the ser one, else its `replies` (PeerTube's `comments`) and theirs, two levels down; 5 pages and 100 posts at most. Only public and unlisted replies are kept, each fetched from its own origin. - **Reading our documents (SecureMode).** privapub.thepra.dev answers ActivityPub GETs only when they are signed, like - Mastodon's authorized fetch; the instance actor `/peasants/privapub` is the exception, since its key is needed first. + Mastodon's authorized fetch; the server's own actors (the instance actor `/peasants/privapub` and the reporter + `/peasants/privapub_reports`) are the exception, since their keys are needed first. A browser asking for HTML is redirected to the public page instead. - **Posts that are not public** (followers-only, direct, circle) are served to a signed request from someone they were for, or from the instance actor of a server where someone they were for lives, and to nobody else (404). Once deleted diff --git a/PrivaPub.Tests/Domain/ContentRendererTests.cs b/PrivaPub.Tests/Domain/ContentRendererTests.cs index 4955e4c..8357b58 100644 --- a/PrivaPub.Tests/Domain/ContentRendererTests.cs +++ b/PrivaPub.Tests/Domain/ContentRendererTests.cs @@ -70,6 +70,7 @@ namespace PrivaPub.Tests.Domain public Task FindByUri(string actorUri, CancellationToken token) => throw new NotSupportedException(); public Task FindByAddress(string address, CancellationToken token) => throw new NotSupportedException(); public Task GetInstanceActor(CancellationToken token) => throw new NotSupportedException(); + public Task GetReporterActor(CancellationToken token) => throw new NotSupportedException(); public Task IsUserNameTaken(string userName, CancellationToken token) => throw new NotSupportedException(); public Task TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token) => throw new NotSupportedException(); public LocalActor FromAvatar(Avatar avatar) => throw new NotSupportedException(); diff --git a/PrivaPub.Tests/Federation/ServiceReportTests.cs b/PrivaPub.Tests/Federation/ServiceReportTests.cs new file mode 100644 index 0000000..20838be --- /dev/null +++ b/PrivaPub.Tests/Federation/ServiceReportTests.cs @@ -0,0 +1,245 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; + +using MongoDB.Entities; + +using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Outbox; +using PrivaPub.Federation.Signing; +using PrivaPub.Infrastructure.Http; +using PrivaPub.Infrastructure.Jobs; +using PrivaPub.Models.Federation; +using PrivaPub.Models.Jobs; +using PrivaPub.Models.Post; +using PrivaPub.Models.User; +using PrivaPub.Tests.Support; + +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace PrivaPub.Tests.Federation +{ + // Lemmy takes a report only from a person or a service, about one post or comment, addressed to its community (owner + // decision 2026-10-06): a report about a post in a community on a Lemmy leaves from the server's reporter, one per post. + // Alone in its collection: the server rows it writes for the peer's hosts decide for whoever reports there. + [Trait("Category", "Integration")] + [Xunit.Collection(nameof(Exclusive))] + public sealed class ServiceReportTests : IAsyncLifetime + { + static readonly string[] PeerHosts = { "localhost", "127.0.0.1" }; + + Harness _harness; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _harness = await Harness.Start(); + await DB.Default.DeleteAsync(i => PeerHosts.Contains(i.Host)); + } + + public async ValueTask DisposeAsync() + { + if (_harness == default) + return; + await DB.Default.DeleteAsync(i => PeerHosts.Contains(i.Host)); + await _harness.DisposeAsync(); + } + + static CancellationToken Token => TestContext.Current.CancellationToken; + + static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); + + // what NodeInfo said the server at that host runs + static async Task Runs(string host, string software) + { + await DB.Default.DeleteAsync(i => i.Host == host); + await DB.Default.SaveAsync(new RemoteInstance { Host = host, Software = software, SoftwareVersion = "1.0.0" }, Token); + } + + async Task Known(RemoteActor actor) => await _harness.Remote.GetActor(actor.Id, refresh: false, Token); + + static async Task Held(RemoteActor author, string community = default, Post parent = default) + { + var post = new Post + { + ObjectURI = $"{Origin(author)}/post/{Guid.NewGuid():N}", + ActorURI = author.Id, + AudienceURI = community, + AnsweringToPostId = parent?.ID, + ContentHtml = "

reported

" + }; + await DB.Default.SaveAsync(post, Token); + return post; + } + + async Task> Queued() => + (await DB.Default.Find().Match(j => j.Kind == JobKind.Deliver && j.CreatedAt >= DateTime.UtcNow.AddMinutes(-5)).ExecuteAsync(Token)) + .Select(j => JsonSerializer.Deserialize(j.Payload)) + .Select(p => (p, JsonNode.Parse(p.Body)!.AsObject())) + .ToList(); + + // a community on a Lemmy (the peer as localhost), and one of its posters there + async Task<(RemoteActor Community, RemoteActor Poster)> OnLemmy() + { + var community = new RemoteActor(_harness.Peer, "cats", _harness.Peer.B, type: "Group", sharedInbox: true); + var poster = new RemoteActor(_harness.Peer, "poster", _harness.Peer.B); + await Known(community); + await Known(poster); + await Runs("localhost", "lemmy"); + return (community, poster); + } + + [Fact] + public async Task A_post_in_a_lemmy_community_is_reported_to_its_community_by_the_reporter_and_nowhere_else() + { + var (_, alice) = await _harness.Persona("alice"); + var (community, poster) = await OnLemmy(); + var post = await Held(poster, community.Id); + var reporter = await _harness.Local.GetReporterActor(Token); + + var report = await _harness.Reports.File(alice, (await Known(poster)).ID, new[] { post.ID }, " a slur in the title ", "violation", forward: true, Token); + + Assert.True(report.Forwarded); + var flag = Assert.Single(await _harness.Outgoing(community.SharedInbox)); + Assert.Equal("Flag", flag["type"]!.GetValue()); + Assert.Equal(reporter.Uri, flag["actor"]!.GetValue()); + Assert.EndsWith("/peasants/privapub_reports", reporter.Uri); + Assert.StartsWith(reporter.Uri + "/", flag["id"]!.GetValue()); + Assert.Equal(new[] { community.Id }, flag["to"]!.AsArray().Select(t => t!.GetValue())); + Assert.Equal(community.Id, flag["audience"]!.GetValue()); + Assert.Equal(post.ObjectURI, flag["object"]!.GetValue()); + Assert.Equal("a slur in the title", flag["summary"]!.GetValue()); + Assert.Equal("a slur in the title", flag["content"]!.GetValue()); + Assert.Empty(await _harness.Outgoing(poster.Id + "/inbox")); + Assert.DoesNotContain(await Queued(), q => q.Payload.Inbox != community.SharedInbox && q.Body.ToJsonString().Contains(post.ObjectURI)); + Assert.DoesNotContain(alice.UserName, flag.ToJsonString()); + Assert.DoesNotContain(alice.Id, flag.ToJsonString()); + var queued = Assert.Single(await Queued(), q => q.Body["actor"]!.GetValue() == reporter.Uri && q.Body["object"]!.GetValue() == post.ObjectURI); + Assert.Equal(LocalActorKind.Reporter, queued.Payload.SignerKind); + } + + [Fact] + public async Task The_reporter_signs_its_flag_with_its_own_key() + { + var (_, alice) = await _harness.Persona("alice"); + var (community, poster) = await OnLemmy(); + var post = await Held(poster, community.Id); + var reporter = await _harness.Local.GetReporterActor(Token); + var instance = await _harness.Local.GetInstanceActor(Token); + _harness.Peer.Answer("/inbox", 202); + await _harness.Reports.File(alice, (await Known(poster)).ID, new[] { post.ID }, default, "spam", forward: true, Token); + var job = await DB.Default.Find() + .Match(j => j.Kind == JobKind.Deliver && j.DedupeKey.StartsWith(reporter.ActivityUri($"flag-"))) + .Sort(j => j.CreatedAt, Order.Descending).ExecuteFirstAsync(Token); + + var handler = new DeliveryJobHandler(_harness.Local, Peer.Http(), new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions())), + NullLogger.Instance); + var outcome = await handler.Handle(job, Token); + + Assert.Equal(JobResult.Done, outcome.Result); + var received = Assert.Single(_harness.Peer.Requests, r => r.Path == "/inbox" && r.Method == "POST"); + // no words: Lemmy takes no report without a reason, so the category is it + Assert.Equal("spam", JsonNode.Parse(received.Body)!["summary"]!.GetValue()); + var signature = HttpSignatures.Parse(received.Signature); + Assert.Equal(reporter.KeyId, signature.KeyId); + Assert.NotEqual(instance.PublicKeyPem, reporter.PublicKeyPem); + var signingString = $"(request-target): post /inbox\nhost: {received.Headers["Host"]}\ndate: {received.Headers["Date"]}\ndigest: {received.Headers["Digest"]}"; + using var key = RSA.Create(); + key.ImportFromPem(reporter.PublicKeyPem); + Assert.True(key.VerifyData(Encoding.UTF8.GetBytes(signingString), signature.Signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); + } + + [Fact] + public async Task A_comment_finds_its_community_through_its_thread_and_two_posts_are_two_flags() + { + var (_, alice) = await _harness.Persona("alice"); + var (community, poster) = await OnLemmy(); + var thread = await Held(poster, community.Id); + var comment = await Held(poster, parent: thread); + var answer = await Held(poster, parent: comment); + + var report = await _harness.Reports.File(alice, (await Known(poster)).ID, new[] { comment.ID, answer.ID }, "harassment", "other", forward: true, Token); + + Assert.True(report.Forwarded); + var flags = await _harness.Outgoing(community.SharedInbox); + Assert.Equal(2, flags.Count); + Assert.Equal(new[] { comment.ObjectURI, answer.ObjectURI }.Order(), flags.Select(f => f["object"]!.GetValue()).Order()); + Assert.All(flags, f => Assert.Equal(community.Id, f["to"]![0]!.GetValue())); + Assert.Equal(2, flags.Select(f => f["id"]!.GetValue()).Distinct().Count()); + } + + [Fact] + public async Task An_account_alone_on_a_lemmy_is_reported_to_nobody() + { + var (_, alice) = await _harness.Persona("alice"); + var (community, poster) = await OnLemmy(); + + var report = await _harness.Reports.File(alice, (await Known(poster)).ID, Array.Empty(), "a spammer", "spam", forward: true, Token); + + Assert.False(report.Forwarded); + Assert.False((await DB.Default.Find().OneAsync(report.ID, Token)).Forwarded); + Assert.Empty(await _harness.Outgoing(community.SharedInbox)); + Assert.Empty(await _harness.Outgoing(poster.Id + "/inbox")); + } + + [Fact] + public async Task Any_other_server_still_gets_the_instance_flag_and_a_lemmy_community_its_own() + { + var (_, alice) = await _harness.Persona("alice"); + var (community, _) = await OnLemmy(); + // an account on a Mastodon (the peer as 127.0.0.1) that wrote in the community on the Lemmy, and elsewhere + var mastodonian = new RemoteActor(_harness.Peer, "masto"); + await Runs("127.0.0.1", "mastodon"); + var inCommunity = await Held(mastodonian, community.Id); + var elsewhere = await Held(mastodonian); + var instance = await _harness.Local.GetInstanceActor(Token); + + var report = await _harness.Reports.File(alice, (await Known(mastodonian)).ID, new[] { inCommunity.ID, elsewhere.ID }, "spam", "spam", forward: true, Token); + + Assert.True(report.Forwarded); + var toCommunity = Assert.Single(await _harness.Outgoing(community.SharedInbox)); + Assert.Equal(inCommunity.ObjectURI, toCommunity["object"]!.GetValue()); + var toAuthor = Assert.Single(await _harness.Outgoing(mastodonian.Id + "/inbox")); + Assert.Equal(instance.Uri, toAuthor["actor"]!.GetValue()); + Assert.Equal(new[] { mastodonian.Id, inCommunity.ObjectURI, elsewhere.ObjectURI }.Order(), + toAuthor["object"]!.AsArray().Select(o => o!.GetValue()).Order()); + Assert.Null(toAuthor["to"]); + } + + [Fact] + public async Task A_community_on_a_server_that_takes_no_service_reports_gets_nothing_new() + { + var (_, alice) = await _harness.Persona("alice"); + var community = new RemoteActor(_harness.Peer, "forum", _harness.Peer.B, type: "Group", sharedInbox: true); + var poster = new RemoteActor(_harness.Peer, "poster", _harness.Peer.B); + await Known(community); + var foreign = await Known(poster); + await Runs("localhost", "friendica"); + var post = await Held(poster, community.Id); + + var report = await _harness.Reports.File(alice, foreign.ID, new[] { post.ID }, "spam", "spam", forward: true, Token); + + Assert.True(report.Forwarded); + Assert.Empty(await _harness.Outgoing(community.SharedInbox)); + var flag = Assert.Single(await _harness.Outgoing(poster.Id + "/inbox")); + Assert.EndsWith("/peasants/privapub", flag["actor"]!.GetValue()); + } + + [Fact] + public async Task Nobody_follows_the_reporter() + { + var follower = new RemoteActor(_harness.Peer, "follower"); + var reporter = await _harness.Local.GetReporterActor(Token); + + var result = await _harness.Deliver(follower, "/human-centipede", new JsonObject + { + ["id"] = $"{Origin(follower)}/follow/{Guid.NewGuid():N}", ["type"] = "Follow", ["actor"] = follower.Id, ["object"] = reporter.Uri + }); + + Assert.Equal(404, result.StatusCode); + Assert.False(await DB.Default.Find().Match(f => f.ActorURI == follower.Id).ExecuteAnyAsync(Token)); + } + } +} diff --git a/PrivaPub.Tests/Http/FederationGetTests.cs b/PrivaPub.Tests/Http/FederationGetTests.cs index 91d6ff0..1b373d5 100644 --- a/PrivaPub.Tests/Http/FederationGetTests.cs +++ b/PrivaPub.Tests/Http/FederationGetTests.cs @@ -115,6 +115,37 @@ namespace PrivaPub.Tests.Http Assert.Equal(HttpStatusCode.NotFound, browser.Status); } + // Lemmy takes a report only from a Person, a Service or an Organization, whose document names its preferredUsername, + // inbox, outbox and key; a browser gets the document too, as from the instance actor: the reporter has no page + [Fact] + public async Task The_reporter_is_a_service_with_an_inbox_an_outbox_and_its_own_key() + { + var fetched = await _client.Fetch("/peasants/privapub_reports"); + var browser = await _client.Fetch("/peasants/privapub_reports", Browser); + var outbox = await _client.Fetch("/peasants/privapub_reports/anus"); + var instance = await _client.Fetch("/peasants/privapub"); + + Assert.Equal(HttpStatusCode.OK, fetched.Status); + var actor = fetched.Json; + var id = $"{Base}/peasants/privapub_reports"; + Assert.Equal(id, actor["id"]!.GetValue()); + Assert.Equal("Service", actor["type"]!.GetValue()); + Assert.Equal("privapub_reports", actor["preferredUsername"]!.GetValue()); + Assert.Equal(id + "/mouth", actor["inbox"]!.GetValue()); + Assert.Equal(id + "/anus", actor["outbox"]!.GetValue()); + Assert.Equal(id, actor["url"]!.GetValue()); + Assert.Equal(id + "#main-key", actor["publicKey"]!["id"]!.GetValue()); + Assert.Equal(id, actor["publicKey"]!["owner"]!.GetValue()); + Assert.NotEqual(instance.Json["publicKey"]!["publicKeyPem"]!.GetValue(), actor["publicKey"]!["publicKeyPem"]!.GetValue()); + Assert.False(actor["discoverable"]!.GetValue()); + Assert.Null(actor["wall"]); + Assert.Null(actor["implements"]); + Assert.Equal(HttpStatusCode.OK, browser.Status); + Assert.Equal("Service", browser.Json["type"]!.GetValue()); + Assert.Equal(HttpStatusCode.OK, outbox.Status); + Assert.Equal(0, outbox.Json["totalItems"]!.GetValue()); + } + [Fact] public async Task Users_moves_permanently_to_peasants_and_unknown_actors_are_404() { @@ -507,7 +538,7 @@ namespace PrivaPub.Tests.Http } [Fact] - public async Task Secure_mode_refuses_unsigned_gets_except_for_the_instance_actor() + public async Task Secure_mode_refuses_unsigned_gets_except_for_the_servers_own_actors() { var secure = await SecureModeHost.Shared(); using var client = secure.Client(); @@ -533,6 +564,9 @@ namespace PrivaPub.Tests.Http var instance = await client.Fetch("/peasants/privapub"); Assert.Equal(HttpStatusCode.OK, instance.Status); Assert.Equal("Application", instance.Json["type"]!.GetValue()); + var reporter = await client.Fetch("/peasants/privapub_reports"); + Assert.Equal(HttpStatusCode.OK, reporter.Status); + Assert.Equal("Service", reporter.Json["type"]!.GetValue()); Assert.Equal(HttpStatusCode.OK, (await client.Fetch("/")).Status); foreach (var path in paths[..^1]) Assert.True((await client.Fetch(reader.SignedGet(path))).Status == HttpStatusCode.OK, $"{path} refused a signed GET"); diff --git a/PrivaPub.Tests/Http/MastodonAccountsTests.cs b/PrivaPub.Tests/Http/MastodonAccountsTests.cs index 832ed6f..f13b447 100644 --- a/PrivaPub.Tests/Http/MastodonAccountsTests.cs +++ b/PrivaPub.Tests/Http/MastodonAccountsTests.cs @@ -127,7 +127,7 @@ namespace PrivaPub.Tests.Http } [Fact] - public async Task Lookup_finds_local_and_remote_accounts_but_never_a_circle_or_the_instance_actor() + public async Task Lookup_finds_local_and_remote_accounts_but_never_a_circle_or_the_servers_own_actors() { var alice = await _host.Mastodon("alice"); var (bob, bobId) = await Remote(); @@ -149,6 +149,7 @@ namespace PrivaPub.Tests.Http Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get($"/api/v1/accounts/lookup?acct={circleName}")).Status); Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/accounts/lookup?acct={circleName}")).Status); Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get("/api/v1/accounts/lookup?acct=privapub")).Status); + Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get("/api/v1/accounts/lookup?acct=privapub_reports")).Status); Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get($"/api/v1/accounts/lookup?acct=nobody{Guid.NewGuid():N}")).Status); Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get("/api/v1/accounts/lookup?acct=a@b@c")).Status); diff --git a/PrivaPub.Tests/Http/WellKnownTests.cs b/PrivaPub.Tests/Http/WellKnownTests.cs index 2584b68..3c95ccd 100644 --- a/PrivaPub.Tests/Http/WellKnownTests.cs +++ b/PrivaPub.Tests/Http/WellKnownTests.cs @@ -108,7 +108,7 @@ namespace PrivaPub.Tests.Http } [Fact] - public async Task WebFinger_finds_the_instance_actor_and_a_community() + public async Task WebFinger_finds_the_servers_own_actors_and_a_community() { var owner = await _host.Persona(await _host.SignUp(), "fingerowner"); var community = await _host.FederatedGroup(owner, community: true); @@ -120,6 +120,10 @@ namespace PrivaPub.Tests.Http Assert.Equal($"acct:privapub@{Domain}", instance.Json["subject"]!.GetValue()); Assert.Equal($"{Base}/peasants/privapub", Link(instance.Json, "self")); Assert.Single(instance.Json["links"]!.AsArray()); + var reporter = await WebFinger($"acct:privapub_reports@{Domain}"); + Assert.Equal(HttpStatusCode.OK, reporter.Status); + Assert.Equal($"{Base}/peasants/privapub_reports", Link(reporter.Json, "self")); + Assert.Single(reporter.Json["links"]!.AsArray()); Assert.Equal(HttpStatusCode.OK, group.Status); Assert.Equal($"acct:{community.UserName}@{Domain}", group.Json["subject"]!.GetValue()); Assert.Equal(community.Uri, Link(group.Json, "self")); diff --git a/PrivaPub.Tests/Infrastructure/IndexTests.cs b/PrivaPub.Tests/Infrastructure/IndexTests.cs index 685204d..c955f48 100644 --- a/PrivaPub.Tests/Infrastructure/IndexTests.cs +++ b/PrivaPub.Tests/Infrastructure/IndexTests.cs @@ -59,6 +59,8 @@ namespace PrivaPub.Tests.Infrastructure Assert.True(await local.IsUserNameTaken(name, token)); Assert.False(await local.TryReserveUserName("admin", LocalActorKind.Person, "c", token)); Assert.False(await local.TryReserveUserName(LocalActorService.InstanceUserName, LocalActorKind.Person, "c", token)); + Assert.False(await local.TryReserveUserName(LocalActorService.ReporterUserName, LocalActorKind.Group, "c", token)); + Assert.True(await local.IsUserNameTaken("PrivaPub_Reports", token)); } } } diff --git a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs index 0e47668..3347e42 100644 --- a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs @@ -115,7 +115,7 @@ namespace PrivaPub.Api.Mastodon.Controllers if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase)) { var local = await _localActors.FindByUserName(parts[0], token); - return local is { IsFederated: true, IsCircle: false, Kind: not LocalActorKind.Application } ? Json(await _mapper.Local(local, false, token)) : NotFoundError(); + return local is { IsFederated: true, IsCircle: false, IsServerActor: false } ? Json(await _mapper.Local(local, false, token)) : NotFoundError(); } var userName = parts[0]; var domain = parts[1].ToLowerInvariant(); diff --git a/PrivaPub/Domain/Content/ContentRenderer.cs b/PrivaPub/Domain/Content/ContentRenderer.cs index 66b65e7..ed5cd21 100644 --- a/PrivaPub/Domain/Content/ContentRenderer.cs +++ b/PrivaPub/Domain/Content/ContentRenderer.cs @@ -122,7 +122,7 @@ namespace PrivaPub.Domain.Content if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase)) { var local = await _localActors.FindByUserName(parts[0], token); - return local is { IsFederated: true, IsCircle: false, Kind: not LocalActorKind.Application } + return local is { IsFederated: true, IsCircle: false, IsServerActor: false } ? new ResolvedMention(local.Uri, local.UserName, local.Domain, local.Uri, true, local.Id, local.Inbox) : default; } diff --git a/PrivaPub/Domain/Relationships/ReportService.cs b/PrivaPub/Domain/Relationships/ReportService.cs index 4bd349d..621bf9c 100644 --- a/PrivaPub/Domain/Relationships/ReportService.cs +++ b/PrivaPub/Domain/Relationships/ReportService.cs @@ -1,6 +1,7 @@ using MongoDB.Entities; using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Inbox; using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Rendering; using PrivaPub.Models.Social; @@ -50,12 +51,41 @@ namespace PrivaPub.Domain.Relationships PostIds = posts.Select(p => p.ID).ToList(), ObjectURIs = posts.Select(p => p.ObjectURI).ToList(), Comment = comment?.Trim(), - Category = category is "spam" or "legal" or "violation" ? category : "other", - Forwarded = forward && remoteTarget != default + Category = category is "spam" or "legal" or "violation" ? category : "other" }; await DB.Default.SaveAsync(report, token); + if (!forward || remoteTarget == default) + return report; - if (report.Forwarded) + // to the community of each post made in one on a server that takes reports only in Lemmy's shape + var flagged = 0; + var reason = string.IsNullOrEmpty(report.Comment) ? report.Category : report.Comment; + foreach (var post in posts) + { + var community = await Communities.Of(post, _dbEntities, token); + if (community == default || !await TakesServiceReports(community, token)) + continue; + var group = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == community).ExecuteFirstAsync(token); + var inbox = string.IsNullOrEmpty(group?.SharedInboxURL) ? group?.InboxURL : group.SharedInboxURL; + if (string.IsNullOrEmpty(inbox)) + continue; + var serviceReporter = await _localActors.GetReporterActor(token); + await _delivery.Enqueue(serviceReporter, new[] { inbox }, new JsonObject + { + ["@context"] = ActivityPubRenderer.ActivityStreams, + ["id"] = serviceReporter.ActivityUri($"flag-{report.ID}-{++flagged}"), + ["type"] = "Flag", + ["actor"] = serviceReporter.Uri, + ["to"] = new JsonArray(community), + ["audience"] = community, + ["object"] = post.ObjectURI, + ["summary"] = reason, + ["content"] = reason + }, token); + } + // every other server, the author's, as before: an account alone, or posts outside such communities, go nowhere when + // the author's server takes reports only in Lemmy's shape + if (!await TakesServiceReports(targetUri, token)) { var instance = await _localActors.GetInstanceActor(token); var flag = new JsonObject @@ -68,8 +98,29 @@ namespace PrivaPub.Domain.Relationships ["object"] = new JsonArray(report.ObjectURIs.Prepend(targetUri).Select(u => (JsonNode)u).ToArray()) }; await _delivery.Enqueue(instance, new[] { remoteTarget.SharedInboxURL ?? remoteTarget.InboxURL }, flag, token); + flagged++; + } + if (flagged > 0) + { + report.Forwarded = true; + await DB.Default.Update().MatchID(report.ID).Modify(r => r.Forwarded, true).ExecuteAsync(token); } return report; } + + // The second place PrivaPub decides by a server's software (owner decision 2026-10-06): Lemmy takes a report only from a + // person or a service, about one post or comment, addressed to its community, so a report for it leaves in that shape + // from the server's anonymous reporter; a server joins only once the pasture shows it stores such a report with its + // reason. NodeInfo names it: PieFed and Mbin speak Lemmy's shapes, and nothing else tells them apart. + static readonly HashSet ServiceReportTakers = new(StringComparer.Ordinal) { "lemmy" }; + + static async Task TakesServiceReports(string actorUri, CancellationToken token) + { + if (!Uri.TryCreate(actorUri, UriKind.Absolute, out var uri)) + return false; + var host = uri.Host; + var instance = await DB.Default.Find().Match(i => i.Host == host).ExecuteFirstAsync(token); + return instance?.Software is { } software && ServiceReportTakers.Contains(software.ToLowerInvariant()); + } } } diff --git a/PrivaPub/Domain/Social/FollowService.cs b/PrivaPub/Domain/Social/FollowService.cs index d5cf1ad..edf308e 100644 --- a/PrivaPub/Domain/Social/FollowService.cs +++ b/PrivaPub/Domain/Social/FollowService.cs @@ -96,7 +96,7 @@ namespace PrivaPub.Domain.Social var (local, remote) = await ResolveTarget(target, token); if (local == default && remote == default) return default; - if (local != default && (local.Id == follower.Id || !local.IsFederated || local.IsCircle || local.Kind == LocalActorKind.Application)) + if (local != default && (local.Id == follower.Id || !local.IsFederated || local.IsCircle || local.IsServerActor)) return default; var targetUri = local?.Uri ?? remote.ActorURI; diff --git a/PrivaPub/Federation/Actors/LocalActorService.cs b/PrivaPub/Federation/Actors/LocalActorService.cs index 05e41e3..8b5dc63 100644 --- a/PrivaPub/Federation/Actors/LocalActorService.cs +++ b/PrivaPub/Federation/Actors/LocalActorService.cs @@ -50,6 +50,8 @@ namespace PrivaPub.Federation.Actors public string FeaturedTags => $"{Uri}/tattoos"; public string Wall => $"{Uri}/graffiti"; public bool HasWall => Kind == LocalActorKind.Person && !IsCircle && IsFederated; + // the server's own actors (the instance actor, the reporter): nobody follows, mentions or looks them up as accounts + public bool IsServerActor => Kind is LocalActorKind.Application or LocalActorKind.Reporter; public string Flock => $"{Uri}/flock"; public string Wardens => $"{Uri}/wardens"; public string SharedInbox => $"{BaseAddress}/human-centipede"; @@ -71,6 +73,7 @@ namespace PrivaPub.Federation.Actors Task FindByUri(string actorUri, CancellationToken token); Task FindByAddress(string address, CancellationToken token); Task GetInstanceActor(CancellationToken token); + Task GetReporterActor(CancellationToken token); Task IsUserNameTaken(string userName, CancellationToken token); Task TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token); LocalActor FromAvatar(Avatar avatar); @@ -83,16 +86,23 @@ namespace PrivaPub.Federation.Actors public class LocalActorService : ILocalActorService { public const string InstanceUserName = "privapub"; + // the anonymous Service that carries this server's reports to Lemmy, which takes no report from an Application + // (owner decision 2026-10-06): one actor for the server, never one per persona + public const string ReporterUserName = "privapub_reports"; + + public static bool IsServerActorName(string userName) => + string.Equals(userName, InstanceUserName, StringComparison.OrdinalIgnoreCase) || string.Equals(userName, ReporterUserName, StringComparison.OrdinalIgnoreCase); static readonly HashSet ReservedByInstance = new(StringComparer.Ordinal) { - InstanceUserName, "admin", "administrator", "root", "system", "support", "help", "moderator", "mod", + InstanceUserName, ReporterUserName, "admin", "administrator", "root", "system", "support", "help", "moderator", "mod", "abuse", "postmaster", "webmaster", "hostmaster", "security", "noreply", "no_reply", "null", "undefined" }; readonly DbEntities _dbEntities; readonly IOptionsMonitor _appConfiguration; InstanceActor _instanceActor; + ReporterActor _reporterActor; public LocalActorService(DbEntities dbEntities, IOptionsMonitor appConfiguration) { @@ -109,6 +119,8 @@ namespace PrivaPub.Federation.Actors userName = userName.ToLowerInvariant(); if (userName == InstanceUserName) return await GetInstanceActor(token); + if (userName == ReporterUserName) + return await GetReporterActor(token); var avatar = await _dbEntities.Avatars .Match(a => a.UserName == userName && !a.DeletionAt.HasValue) @@ -144,6 +156,8 @@ namespace PrivaPub.Federation.Actors case LocalActorKind.Group: var group = await _dbEntities.Groups.MatchID(id).ExecuteFirstAsync(token); return group == default ? default : FromGroup(group); + case LocalActorKind.Reporter: + return await GetReporterActor(token); default: return await GetInstanceActor(token); } @@ -187,6 +201,37 @@ namespace PrivaPub.Federation.Actors }; } + public async Task GetReporterActor(CancellationToken token) + { + var reporter = _reporterActor ??= await LoadReporterActor(token); + + return new LocalActor + { + Id = reporter.ID, + Kind = LocalActorKind.Reporter, + UserName = ReporterUserName, + Name = $"Reports from {new Uri(BaseAddress).Host}", + Summary = "It carries this PrivaPub server's reports to the servers that take them only from a person or a service; " + + "it never names who made them.", + PrivateKeyPem = reporter.PrivateKey, + PublicKeyPem = reporter.PublicKey, + Discoverable = false, + Published = reporter.CreationDate, + BaseAddress = BaseAddress + }; + } + + async Task LoadReporterActor(CancellationToken token) + { + var reporter = await _dbEntities.ReporterActors.Sort(r => r.CreationDate, Order.Ascending).ExecuteFirstAsync(token); + if (reporter != default) + return reporter; + var (privateKey, publicKey) = Keys.NewKeyPair(); + reporter = new ReporterActor { PrivateKey = privateKey, PublicKey = publicKey }; + await DB.Default.SaveAsync(reporter, token); + return reporter; + } + async Task LoadInstanceActor(CancellationToken token) { var instance = await _dbEntities.InstanceActors.Sort(i => i.CreationDate, Order.Ascending).ExecuteFirstAsync(token); diff --git a/PrivaPub/Federation/Controllers/PeasantsController.cs b/PrivaPub/Federation/Controllers/PeasantsController.cs index 7df51d3..2e48cbf 100644 --- a/PrivaPub/Federation/Controllers/PeasantsController.cs +++ b/PrivaPub/Federation/Controllers/PeasantsController.cs @@ -69,7 +69,7 @@ namespace PrivaPub.Federation.Controllers }; if (local is not { IsFederated: true }) return NotFound(); - if (WantsHtml() && local.Kind != LocalActorKind.Application) + if (WantsHtml() && !local.IsServerActor) return Redirect(local.HtmlUrl); return Activity(ActivityPubRenderer.Actor(local)); } @@ -531,10 +531,11 @@ namespace PrivaPub.Federation.Controllers { if (HttpMethods.IsGet(Request.Method)) Response.Headers.Vary = "Accept"; - // SecureMode asks every reader of ActivityPub documents for a signature, except for the instance actor, whose key - // peers need first, and except for browsers, which only get redirected to the public pages + // SecureMode asks every reader of ActivityPub documents for a signature, except for the server's own actors (the + // instance actor, the reporter), whose keys peers need first, and except for browsers, which only get redirected to + // the public pages if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method) && !WantsHtml() && Request.Path.Value != "/" - && !string.Equals(context.RouteData.Values["actor"] as string, LocalActorService.InstanceUserName, StringComparison.OrdinalIgnoreCase) + && !LocalActorService.IsServerActorName(context.RouteData.Values["actor"] as string) && await _fetches.Requester(Request, HttpContext.RequestAborted) == default) { context.Result = StatusCode(StatusCodes.Status401Unauthorized); diff --git a/PrivaPub/Federation/Controllers/WellKnownController.cs b/PrivaPub/Federation/Controllers/WellKnownController.cs index a3b9657..8aba452 100644 --- a/PrivaPub/Federation/Controllers/WellKnownController.cs +++ b/PrivaPub/Federation/Controllers/WellKnownController.cs @@ -74,8 +74,8 @@ namespace PrivaPub.Federation.Controllers var document = new JsonObject { ["subject"] = $"acct:{actor.Handle}", - ["aliases"] = actor.Kind == LocalActorKind.Application ? new JsonArray(actor.Uri) : new JsonArray(actor.HtmlUrl, actor.Uri), - ["links"] = actor.Kind == LocalActorKind.Application + ["aliases"] = actor.IsServerActor ? new JsonArray(actor.Uri) : new JsonArray(actor.HtmlUrl, actor.Uri), + ["links"] = actor.IsServerActor ? new JsonArray(new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = actor.Uri }) : new JsonArray( new JsonObject { ["rel"] = "http://webfinger.net/rel/profile-page", ["type"] = "text/html", ["href"] = actor.HtmlUrl }, diff --git a/PrivaPub/Federation/Inbox/Arrival.cs b/PrivaPub/Federation/Inbox/Arrival.cs index c1ddbe9..30d18c4 100644 --- a/PrivaPub/Federation/Inbox/Arrival.cs +++ b/PrivaPub/Federation/Inbox/Arrival.cs @@ -66,6 +66,7 @@ namespace PrivaPub.Federation.Inbox LocalActorKind.Person => "person", LocalActorKind.Group when !local.IsCircle => "group", LocalActorKind.Application => "application", + LocalActorKind.Reporter => "reporter", _ => default }; } diff --git a/PrivaPub/Federation/Inbox/Communities.cs b/PrivaPub/Federation/Inbox/Communities.cs new file mode 100644 index 0000000..e04fb27 --- /dev/null +++ b/PrivaPub/Federation/Inbox/Communities.cs @@ -0,0 +1,24 @@ +using MongoDB.Entities; + +using PrivaPub.StaticServices; + +using PostEntity = PrivaPub.Models.Post.Post; + +namespace PrivaPub.Federation.Inbox +{ + public static class Communities + { + // the remote community a post was made in: its own audience, or the first one its thread names on the way up (a + // comment fetched for its thread, or delivered to a persona, may name none) + public static async Task Of(PostEntity post, DbEntities dbEntities, CancellationToken token) + { + for (var depth = 0; post != default && depth < RemotePosts.MaxDepth; depth++) + { + if (!string.IsNullOrEmpty(post.AudienceURI)) + return post.AudienceURI; + post = string.IsNullOrEmpty(post.AnsweringToPostId) ? default : await dbEntities.Posts.MatchID(post.AnsweringToPostId).ExecuteFirstAsync(token); + } + return default; + } + } +} diff --git a/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs b/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs index da64043..224308d 100644 --- a/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs @@ -285,13 +285,7 @@ namespace PrivaPub.Federation.Inbox.Handlers var post = target == default ? default : await _dbEntities.Posts.Match(p => p.ObjectURI == target && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); - for (var depth = 0; post != default && depth < RemotePosts.MaxDepth; depth++) - { - if (post.AudienceURI == group.ActorURI) - return true; - post = string.IsNullOrEmpty(post.AnsweringToPostId) ? default : await _dbEntities.Posts.MatchID(post.AnsweringToPostId).ExecuteFirstAsync(token); - } - return false; + return post != default && await Communities.Of(post, _dbEntities, token) == group.ActorURI; } // A community's moderators lock one of its posts, or unlock it: no more replies, ours included. The community vouches diff --git a/PrivaPub/Federation/Inbox/Handlers/FollowHandler.cs b/PrivaPub/Federation/Inbox/Handlers/FollowHandler.cs index 4b560c1..1efc74c 100644 --- a/PrivaPub/Federation/Inbox/Handlers/FollowHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/FollowHandler.cs @@ -44,7 +44,7 @@ namespace PrivaPub.Federation.Inbox.Handlers var follower = actor; var target = await _localActors.FindByAddress(Id(follow["object"]), token); - if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application) + if (target is not { IsFederated: true } || target.IsServerActor) { Arrival.Drop("unknown-recipient"); return; diff --git a/PrivaPub/Federation/Inbox/InboxReceiver.cs b/PrivaPub/Federation/Inbox/InboxReceiver.cs index 5f6b558..db9626b 100644 --- a/PrivaPub/Federation/Inbox/InboxReceiver.cs +++ b/PrivaPub/Federation/Inbox/InboxReceiver.cs @@ -254,7 +254,7 @@ namespace PrivaPub.Federation.Inbox case "Follow": // (by its actor's id, or the profile page Forte names instead) var target = await _localActors.FindByAddress(Id(inner), token); - if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application) + if (target is not { IsFederated: true } || target.IsServerActor) return new(StatusCodes.Status404NotFound, "no such local actor", Reason: "unknown-recipient"); break; case "Undo" when inner is JsonObject && Id(inner["actor"]) != actorUri: diff --git a/PrivaPub/Federation/Outbox/DeliveryService.cs b/PrivaPub/Federation/Outbox/DeliveryService.cs index 83cd450..84ff300 100644 --- a/PrivaPub/Federation/Outbox/DeliveryService.cs +++ b/PrivaPub/Federation/Outbox/DeliveryService.cs @@ -181,6 +181,7 @@ namespace PrivaPub.Federation.Outbox { IsCircle: true } => default, { Kind: LocalActorKind.Person } => "person", { Kind: LocalActorKind.Group } => "group", + { Kind: LocalActorKind.Reporter } => "reporter", _ => "application" }, Signature = "cavage:rsa-sha256" diff --git a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs index 702ec71..16872b6 100644 --- a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs +++ b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs @@ -125,13 +125,14 @@ namespace PrivaPub.Federation.Rendering { LocalActorKind.Group => "Group", LocalActorKind.Application => "Application", + LocalActorKind.Reporter => "Service", _ when actor.IsBot => "Service", _ => "Person" }, ["preferredUsername"] = actor.UserName, ["name"] = actor.Name, ["summary"] = Html(actor.Summary), - ["url"] = actor.Kind == LocalActorKind.Application ? actor.Uri : actor.HtmlUrl, + ["url"] = actor.IsServerActor ? actor.Uri : actor.HtmlUrl, ["inbox"] = actor.Inbox, ["outbox"] = actor.Outbox, ["followers"] = actor.Followers, diff --git a/PrivaPub/Infrastructure/Data/Migrations/_015_the_reporter_has_its_name.cs b/PrivaPub/Infrastructure/Data/Migrations/_015_the_reporter_has_its_name.cs new file mode 100644 index 0000000..b840326 --- /dev/null +++ b/PrivaPub/Infrastructure/Data/Migrations/_015_the_reporter_has_its_name.cs @@ -0,0 +1,24 @@ +using MongoDB.Entities; + +using PrivaPub.Federation.Actors; +using PrivaPub.Models.Federation; +using PrivaPub.Models.User; + +namespace PrivaPub.Infrastructure.Data.Migrations +{ + // the anonymous reporter's name (LocalActorService.ReporterUserName) is the server's, as the instance actor's is: a + // persona or group already holding it would be hidden behind the reporter, so the upgrade stops and says so + public class _015_the_reporter_has_its_name : IMigration + { + public async Task UpgradeAsync() + { + var held = await DB.Default.Find().Match(r => r.Name == LocalActorService.ReporterUserName).ExecuteFirstAsync(); + if (held is { OwnerKind: LocalActorKind.Reporter }) + return; + if (held != default) + throw new InvalidOperationException( + $"The name {LocalActorService.ReporterUserName} belongs to a {held.OwnerKind} ({held.OwnerId}); rename it before upgrading"); + await DB.Default.SaveAsync(new ReservedName { Name = LocalActorService.ReporterUserName, OwnerKind = LocalActorKind.Reporter }); + } + } +} diff --git a/PrivaPub/Models/Federation/Follower.cs b/PrivaPub/Models/Federation/Follower.cs index e238c90..d8c3f82 100644 --- a/PrivaPub/Models/Federation/Follower.cs +++ b/PrivaPub/Models/Federation/Follower.cs @@ -18,6 +18,7 @@ namespace PrivaPub.Models.Federation { Person, Group, - Application + Application, + Reporter//the anonymous Service that carries reports to Lemmy (LocalActorService.ReporterUserName) } } diff --git a/PrivaPub/Models/Federation/ReporterActor.cs b/PrivaPub/Models/Federation/ReporterActor.cs new file mode 100644 index 0000000..a110451 --- /dev/null +++ b/PrivaPub/Models/Federation/ReporterActor.cs @@ -0,0 +1,12 @@ +using MongoDB.Entities; + +namespace PrivaPub.Models.Federation +{ + // the server's anonymous reporter (LocalActorService.ReporterUserName), its keys + public class ReporterActor : Entity + { + public string PrivateKey { get; set; } + public string PublicKey { get; set; } + public DateTime CreationDate { get; set; } = DateTime.UtcNow; + } +} diff --git a/PrivaPub/Models/Jobs/RemoteInstance.cs b/PrivaPub/Models/Jobs/RemoteInstance.cs index 3c78148..6e87e13 100644 --- a/PrivaPub/Models/Jobs/RemoteInstance.cs +++ b/PrivaPub/Models/Jobs/RemoteInstance.cs @@ -11,8 +11,9 @@ namespace PrivaPub.Models.Jobs public DateTime? LastFailureAt { get; set; } public string LastError { get; set; } - public string Software { get; set; }//NodeInfo software.name: for display, never for deciding behaviour, but for the one - //owner decision (2026-10-05) that a first private message to Lemmy before 1.0 or Mbin is a ChatMessage + public string Software { get; set; }//NodeInfo software.name: for display, never for deciding behaviour, but for two + //owner decisions: a first private message to Lemmy before 1.0 or Mbin is a ChatMessage (2026-10-05), and a report + //to a Lemmy community leaves from the reporter in Lemmy's shape (2026-10-06, ReportService.ServiceReportTakers) public string SoftwareVersion { get; set; } public string NodeName { get; set; } public List Protocols { get; set; } = new(); diff --git a/PrivaPub/StaticServices/DbEntities.cs b/PrivaPub/StaticServices/DbEntities.cs index 9bc9fe0..71c7418 100644 --- a/PrivaPub/StaticServices/DbEntities.cs +++ b/PrivaPub/StaticServices/DbEntities.cs @@ -33,6 +33,7 @@ namespace PrivaPub.StaticServices public Find Followers { get { return DB.Default.Find(); } } public Find Deliveries { get { return DB.Default.Find(); } } public Find InstanceActors { get { return DB.Default.Find(); } } + public Find ReporterActors { get { return DB.Default.Find(); } } public Find Followings { get { return DB.Default.Find(); } } public Find TimelineEntries { get { return DB.Default.Find(); } } diff --git a/PrivaPub/Web/Pages/Pages.cs b/PrivaPub/Web/Pages/Pages.cs index 576b8fd..e70c69a 100644 --- a/PrivaPub/Web/Pages/Pages.cs +++ b/PrivaPub/Web/Pages/Pages.cs @@ -61,7 +61,7 @@ namespace PrivaPub.Web.Pages public async Task OnGetAsync(string user, CancellationToken token) { Actor = await _localActors.FindByUserName(user, token); - if (Actor is not { IsFederated: true, IsCircle: false } || Actor.Kind == LocalActorKind.Application) + if (Actor is not { IsFederated: true, IsCircle: false } || Actor.IsServerActor) return NotFound(); if (WantsActivityJson()) return Redirect(Actor.Uri); diff --git a/docs/INTEROP.md b/docs/INTEROP.md index 14ea824..efa3d38 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -502,10 +502,18 @@ on a Page: pins live in `featured`, locks in `Lock`. - **Accepted types:** Page, Article, Note, Video and Event become posts. **`Question` is dropped.** - **Anti-spam:** activities for a community are accepted only if a local user follows it. - **Actors:** a Create, vote, moderation action or Flag must come from a Person, Service or Organization. **A Group or - Application actor fails to parse**, so moderation comes from the moderator Person. Our Flags, sent by an - Application instance actor with no `to` and an array `object` (the account and the post), are refused: confirmed live - 2026-10-06, Lemmy 1.0.0-beta.2 answers 400 and keeps no report. Reaching its moderators needs an anonymous - `Service`-typed reporter, `to: [the post's community]` and the post alone as `object`. + Application actor fails to parse**, so moderation comes from the moderator Person. Our Flags, sent by the + Application instance actor with no `to` and an array `object` (the account and the post), were refused: confirmed + live 2026-10-06, Lemmy 1.0.0-beta.2 answered 400 and kept no report. +- **Reports reach its moderators since 2026-10-06** (owner decision, `ReportService.ServiceReportTakers`): a report of a + post or comment in a community on a server whose NodeInfo names Lemmy leaves from the anonymous `Service` + `privapub_reports`, one `Flag` per post, `to: [the community]`, the post alone as `object`, the persona's words in + `summary` and `content`, sent to the community's inbox. Lemmy takes no report of an account alone, so none goes there. + Lemmy 1.0 reads the reporter's site then (our Application at `/`, seen live), which it needs to pass the report on to + the moderators of a community on another server. + **Pasture evidence (2026-10-06, `lemmy.sh` and `lemmy19.sh`):** on 1.0 and 0.19 alice's report of lemmyuser's thread + and of a comment in it is kept as a `post_report` and a `comment_report` with her words, whose creator is "Reports + from privapub.test", and no report names her. - **Flags:** exactly one `to` (community or site); `object` a URL or an array; the reason in `summary` or `content`. - **Moderation trust:** an action is trusted when it is on the community's or the object's host (FEP-fe34), or when its actor is in the moderators list Lemmy fetched. @@ -524,7 +532,7 @@ on a Page: pins live in `featured`, locks in `Lock`. | `ChatMessage` in and out (out to someone who writes to us that way, and to Lemmy 0.19 and Mbin by NodeInfo, done 2026-10-05) | P1 | `visibility: direct` | | Outbound shape for Lemmy: both `to` and `cc`; the community in `to`; Public in the object, Create and Announce; votes and comments sent to the community inbox | P1 | — | | Communities we host: pick `Announce(object)` per peer by NodeInfo (as PieFed does). Announcing to every follower instance, the author's included, is done and needed (pasture evidence below) | P1 | — | -| Flags from a `Service`-typed reporter actor with `to: [community]`; the reporter stays anonymous | P2 | — | +| Flags from a `Service`-typed reporter actor with `to: [community]`; the reporter stays anonymous: **done 2026-10-06** (`privapub_reports`, checked live on 1.0 and 0.19) | P2 | — | | `Warn` → `moderation_warning` notification; `Resolve{Flag}` | P2 | AccountWarning | | Remote communities: `description`, `language[]`, private (`locked`), `discoverable`; post tags | P2 | `Account.locked`, own `privapub.flairs[]` | | Serve our communities' collections the way Lemmy reads them: inline outbox of `Announce{Create{Page}}`, inline featured Pages, inline moderators. Lemmy does not page. | P2 | — | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ae35c3d..5e91f21 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -275,6 +275,7 @@ and circles (see Owner decisions). |---|---| | May a persona join a remote event? | **Yes.** A persona joins and leaves another server's event (`Join`/`Leave`, sent to the event's organiser only). Its participation is public on that server, as on every platform with events. Events that take participants only by invitation or on another site are refused before anything is sent. | | A first private message to Lemmy 0.19 or Mbin (G-0008) | **Decide by the server's software.** Lemmy before 1.0 and Mbin take a private message only as a `ChatMessage` and their actors do not say so, so a direct message to one account on a server whose NodeInfo names one of them goes as a `ChatMessage`: the one exception to "a server's software is for display only". | +| Reports to Lemmy (2026-10-06) | **In Lemmy's shape, for Lemmy only.** Lemmy takes a report only from a person or a service, about one post or comment, addressed to its community, so a report of a post in a community on a server whose NodeInfo names Lemmy leaves from an anonymous `Service`, `privapub_reports`, one `Flag` per post: the second exception to "a server's software is for display only". PieFed and Mbin, which speak Lemmy's shapes, join only once the pasture shows each keeps such a report with its reason; every other server keeps the instance actor's report. | | An account a persona follows moves | **Move the follow, as Mastodon does.** After a verified `Move` the persona follows the new account (a Follow to its server) and unfollows the old, in the same lists; a mute or a block of the old account carries over. | | Should PrivaPub pass on replies to a persona's posts? | **Yes, "the fediverse is broken without".** A public or unlisted reply that someone on another server writes to a persona's post goes on to the persona's followers, as Mastodon forwards it: the activity as its author signed it, never to the replier's own server, never for a post that is local-only, in a group or not shown to followers. Its edits and deletion follow the same way. | @@ -540,8 +541,9 @@ The first refactor commit is a pure move with namespaces only. Logic changes fol tags at `/tattoos`. - **Blocks and mutes:** per avatar. **Block is not federated**: it sends Reject or Undo Follow instead and drops the blocked actor's traffic. Domain blocks per account as well. -- **Reports:** inbound Flag becomes a `Report`. Outbound Flag is **sent by the instance actor**, so the reporting - persona isn't revealed. Moderator endpoints on `/clientapi`. +- **Reports:** inbound Flag becomes a `Report`. Outbound Flag is **sent by the instance actor**, or for a post in a + Lemmy community by the reporter `privapub_reports` (2026-10-06), so the reporting persona isn't revealed. Moderator + endpoints on `/clientapi`. - **Locked accounts:** the follow-request flow. ### P4 Groups and privacy features @@ -663,7 +665,8 @@ it, raw where it doesn't. - the outbound shape Lemmy requires; - communities we host announce to the author's own instance too: **done 2026-10-06** (its server's shared inbox, whether anyone there follows the community or not: Lemmy keeps its user's post pending until it is announced back); - - Flags from a `Service`-typed reporter actor. + - Flags from a `Service`-typed reporter actor: **done 2026-10-06** for Lemmy (owner decision below; `privapub_reports`, + one Flag per post `to` its community, checked live on 1.0 and 0.19). - **GoToSocial interaction policies** (**done 2026-10-05**, `InteractionApprovals`): stored and shown as `interaction_policy`; `ReplyRequest`/`LikeRequest`/`AnnounceRequest` where approval is needed; `Accept{result}` verified and carried; replies a policy does not let in kept only with an authorization. diff --git a/tools/pasture/lib/interop.sh b/tools/pasture/lib/interop.sh index da2d0ee..9a6e72f 100644 --- a/tools/pasture/lib/interop.sh +++ b/tools/pasture/lib/interop.sh @@ -52,6 +52,18 @@ privapub_token() { "$here/../smoke/oauth.sh" "$P" "$ROOT_USER" "$ROOT_PASS" "$persona" "read write follow" | cut -d' ' -f1 } +# p_report : a persona reports an account's posts and asks for +# the report to be forwarded; prints whether PrivaPub says it was (Mastodon's "forwarded") +p_report() { + local auth=$1 account=$2 reason=$3; shift 3 + local ids=() id + for id in "$@"; do ids+=(-d "status_ids[]=$id"); done + curl -s -X POST -H "$auth" "$P/api/v1/reports" -d "account_id=$account" "${ids[@]}" --data-urlencode "comment=$reason" \ + -d 'category=other' -d 'forward=true' | j "print(d['forwarded'])" +} +# the anonymous Service that carries PrivaPub's reports to the servers that take them only from a person or a service +P_REPORTER=https://privapub.test/peasants/privapub_reports + # stats_check : the admin statistics name the peer's software and count traffic both ways. stats_check() { local host=$1 software=$2 admin found diff --git a/tools/pasture/scenarios/lemmy.sh b/tools/pasture/scenarios/lemmy.sh index 389c818..824a59d 100644 --- a/tools/pasture/scenarios/lemmy.sh +++ b/tools/pasture/scenarios/lemmy.sh @@ -112,6 +112,22 @@ until_true 45 '[ "$(p_banned)" = "True" ]' && ok "the community's ban of alice_l [ "$(p_reply "while banned")" = "422" ] && ok "a banned persona's reply in the community is refused" || ko "a banned persona's reply was taken" lm POST community/ban_user "{\"community_id\":$cats_on_lm,\"person_id\":$alice_on_lm,\"ban\":false,\"reason\":\"pasture\"}" >/dev/null until_true 45 '[ "$(p_banned)" = "False" ]' && ok "the community's unban reaches PrivaPub" || ko "the community's unban did not reach PrivaPub" +echo " reports" +# Lemmy takes a report only from a person or a service, about one post or comment, addressed to its community (owner +# decision 2026-10-06): alice's report of lemmyuser's thread and comment leaves from PrivaPub's reporter, one Flag each, +# and never names her. Lemmy sends nothing back; what it keeps is read from its database. +lm POST comment "{\"post_id\":$lm_cats_id,\"content\":\"a Lemmy comment to report\"}" >/dev/null +lm_comment_on_p() { curl -s -H "$LAH" "$P/api/v1/statuses/$lm_cats_on_p/context" | j "print(next((s['id'] for s in d['descendants'] if 'a Lemmy comment to report' in s['content']), ''))"; } +until_true 45 '[ -n "$(lm_comment_on_p)" ]' && ok "lemmyuser's comment to report reaches PrivaPub" || ko "lemmyuser's comment never reached PrivaPub" +lm_author_on_p=$(curl -s -H "$LAH" "$P/api/v1/statuses/$lm_cats_on_p" | j "print(d['account']['id'])") +reason="a pasture report $(date +%s)" +[ "$(p_report "$LAH" "$lm_author_on_p" "$reason" "$lm_cats_on_p" "$(lm_comment_on_p)")" = "True" ] \ + && ok "PrivaPub says alice's report was forwarded" || ko "PrivaPub did not forward alice's report" +lm_reports() { podman exec pasture-postgres psql -U pasture -d lemmy -tAc "select count(*) from $1 r join person p on p.id = r.creator_id where r.reason = '$reason' and p.ap_id = '$P_REPORTER'"; } +until_true 45 '[ "$(lm_reports post_report)" = "1" ]' && ok "Lemmy keeps the thread's report from PrivaPub's reporter, with alice's words" || ko "Lemmy kept no report of the thread" +until_true 45 '[ "$(lm_reports comment_report)" = "1" ]' && ok "Lemmy keeps the comment's report from PrivaPub's reporter, with alice's words" || ko "Lemmy kept no report of the comment" +[ "$(podman exec pasture-postgres psql -U pasture -d lemmy -tAc "select count(*) from post_report r join person p on p.id = r.creator_id where p.ap_id like '%alice_lemmy%'")" = "0" ] \ + && ok "no report on Lemmy names alice" || ko "a report on Lemmy names alice" # the community relays it as Announce{Delete}, believed once Lemmy answers 410 for the post; Lemmy sends what it queued # every 30 seconds lm POST post/remove "{\"post_id\":$lm_cats_id,\"removed\":true,\"reason\":\"pasture\"}" >/dev/null diff --git a/tools/pasture/scenarios/lemmy19.sh b/tools/pasture/scenarios/lemmy19.sh index f0d5a51..0d455f4 100644 --- a/tools/pasture/scenarios/lemmy19.sh +++ b/tools/pasture/scenarios/lemmy19.sh @@ -117,6 +117,20 @@ until_true 45 '[ "$(p_banned)" = "True" ]' && ok "the community's ban of alice_l [ "$(p_reply "while banned")" = "422" ] && ok "a banned persona's reply in the community is refused" || ko "a banned persona's reply was taken" lm POST community/ban_user "{\"community_id\":$cats_on_lm,\"person_id\":$alice_on_lm,\"ban\":false,\"reason\":\"pasture\"}" >/dev/null until_true 45 '[ "$(p_banned)" = "False" ]' && ok "the community's unban reaches PrivaPub" || ko "the community's unban did not reach PrivaPub" +echo " reports" +# as on Lemmy 1.0: the report leaves from PrivaPub's reporter, one Flag for the thread and one for the comment +lm POST comment "{\"post_id\":$lm_cats_id,\"content\":\"a Lemmy 0.19 comment to report\"}" >/dev/null +lm_comment_on_p() { curl -s -H "$LAH" "$P/api/v1/statuses/$lm_cats_on_p/context" | j "print(next((s['id'] for s in d['descendants'] if 'a Lemmy 0.19 comment to report' in s['content']), ''))"; } +until_true 45 '[ -n "$(lm_comment_on_p)" ]' && ok "lemmyuser's comment to report reaches PrivaPub" || ko "lemmyuser's comment never reached PrivaPub" +lm_author_on_p=$(curl -s -H "$LAH" "$P/api/v1/statuses/$lm_cats_on_p" | j "print(d['account']['id'])") +reason="a pasture report $(date +%s)" +[ "$(p_report "$LAH" "$lm_author_on_p" "$reason" "$lm_cats_on_p" "$(lm_comment_on_p)")" = "True" ] \ + && ok "PrivaPub says alice's report was forwarded" || ko "PrivaPub did not forward alice's report" +lm_reports() { podman exec pasture-postgres psql -U pasture -d lemmy19 -tAc "select count(*) from $1 r join person p on p.id = r.creator_id where r.reason = '$reason' and p.actor_id = '$P_REPORTER'"; } +until_true 45 '[ "$(lm_reports post_report)" = "1" ]' && ok "Lemmy 0.19 keeps the thread's report from PrivaPub's reporter, with alice's words" || ko "Lemmy 0.19 kept no report of the thread" +until_true 45 '[ "$(lm_reports comment_report)" = "1" ]' && ok "Lemmy 0.19 keeps the comment's report from PrivaPub's reporter, with alice's words" || ko "Lemmy 0.19 kept no report of the comment" +[ "$(podman exec pasture-postgres psql -U pasture -d lemmy19 -tAc "select count(*) from post_report r join person p on p.id = r.creator_id where p.actor_id like '%alice_lemmy19%'")" = "0" ] \ + && ok "no report on Lemmy 0.19 names alice" || ko "a report on Lemmy 0.19 names alice" lm POST post/remove "{\"post_id\":$lm_cats_id,\"removed\":true,\"reason\":\"pasture\"}" >/dev/null until_true 45 '[ -z "$(p_home_has "$lm_cats_thread")" ]' && ok "a moderator's removal reaches PrivaPub" || ko "a moderator's removal did not reach PrivaPub"