Compare commits

...
110 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 6f240828cf A located post reaches no home, its author's included
Build / Build (push) Successful in 7m38s
Deploy / privapub.thepra.dev (push) Successful in 7m48s
The fan-out put every post in its author's home, located ones too, while the Mastodon API looks past located posts
everywhere else. In the author's home a located post showed as public (the mapper has no word for it) and every action
on it answered 404: decePub's end-to-end runs, which write one from Milano for the globe, found it there. The fan-out
now gives a located post no home, no stream and no notification, and the home timeline passes over the entries left
from before. A located post is read through /clientapi/post/nearby and deleted through /clientapi/post/delete.
NearbyTests checks it has no TimelineEntry; the suite passes (881).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 02:14:16 +02:00
thepraandClaude Opus 5.5 5ecf2d9629 The report tests read only the deliveries they queued
Build / Build (push) Successful in 7m8s
ServiceReportTests looked at every delivery job of the last five minutes and read each body's actor. On the CI runner,
slower than the workstation, other tests' jobs were still in that window, one of them without an actor, and four cases
failed with a NullReferenceException. A test now sees only the jobs queued since it began, and checks the signer from
the job itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 19:41:22 +02:00
thepraandClaude Opus 5.5 f105d1f7ea PieFed and Mbin take reports from the reporter too
Build / Build (push) Failing after 7m51s
Deploy / privapub.thepra.dev (push) Successful in 7m58s
Both speak Lemmy's shapes and joined the servers that get reports in that shape only once the pasture showed each keeps
one with its reason. By their source, PieFed dropped the instance actor's report (it makes a user only of a Person or a
Service) and Mbin kept it without the persona's words (it reads the reason from `summary` alone). With them in
`ServiceReportTakers`, each keeps the reports of a thread and of a comment from "Reports from privapub.test" with
alice's words, and none names her (piefed.sh and mbin.sh, 65 checks with the full sweep's 876). A report of a PieFed
account alone, which PieFed would take from the reporter, is not sent yet; INTEROP says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 19:24:09 +02:00
thepraandClaude Opus 5.5 f66c280b0b Reports reach Lemmy's moderators, from an anonymous reporter
Lemmy takes a report only from a person or a service, about one post or comment, addressed to its community, and it
answered PrivaPub's Flag (the instance actor's, an Application, with no `to` and the account and posts as its object)
400. A report of a post or comment in a community on a server whose NodeInfo names Lemmy now leaves from
`privapub_reports`, a Service with its own key that names nobody: one Flag per post, `to` the community (its own
audience, else its thread's), with the persona's words, or the category, in `summary` and `content`, sent to the
community's inbox. This is the second exception to "a server's software is for display" (owner decision 2026-10-06,
`ReportService.ServiceReportTakers`). Every other server keeps the instance actor's report. An account alone is not
reported to Lemmy, which takes no such report, and `forwarded` now says whether anything left.

The reporter is read unsigned in SecureMode and answers WebFinger like the instance actor. Nobody follows or mentions
it, the Mastodon API has no account for it, and a migration reserves its name. Checked live: Lemmy 1.0 and 0.19 keep the
reports of a thread and of a comment, with alice's words, from "Reports from privapub.test", and none names her (69
checks). A sweep of every scenario with this and the next commit: 876 checks pass; Ghost's Network feed listed alice's
post too late once, and Ghost passes alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 19:23:17 +02:00
thepraandClaude Opus 5.5 10ff4b3d2a The crawler scenario brings mastodon.test's visit forward; the backlog after a full sweep
The crawler visits one server a minute, every one PrivaPub knows, and as the pasture grew mastodon.test's turn came
after the scenario stopped waiting; the scenario now makes it due at once. The backlog is regenerated after a sweep of
every peer: 2581 checks pass, none fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 16:57:50 +02:00
thepraandClaude Opus 5.5 bebb4d6370 Pasture: WordPress pinned with its updates off, Mbin's worker restarting, threads checking our own deliveries
A full sweep found three peers silent. WordPress had updated itself to 7.1.2 from WP-Cron, and the new CA bundle dropped
Caddy's root: it now runs the 7.1.2 image with automatic updates off. Mbin's messenger worker had died when the shared
Postgres restarted under it: it now consumes again whenever it stops. Friendica sat in a quarantine left by the old
breaker. The threads scenario checks that PrivaPub never sends carol's reply to Mastodon, since a relay Mastodon has just
left (the relay scenario's) may still pass it on. With these, every scenario passes but the crawler's, which needs it
switched on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 16:29:21 +02:00
thepraandClaude Opus 5.5 cb34ab88f7 A deleted post's id given to a new post is that new post
PrivaPub remembers a deleted remote post's id for 90 days so that a late Create cannot bring it back. Gancio numbers a
new event after the last one it keeps, so deleting its last event gives the next the same id, and that event was
dropped as deleted. A Create published after the deletion is now kept as the new post; the deleted one's own Create,
however late, still is not. Checked live: Gancio's scenario passes again (17 checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 16:29:21 +02:00
thepraandClaude Opus 5.5 af2c61292b A community announces back to its author's own server
Lemmy keeps its user's post in a community pending until the community announces it back. A community hosted here
announced only to its followers, so a post from a server where nobody follows the community stayed pending there; the
author's own server (its shared inbox) now gets the announces too. INTEROP records, confirmed live, why Lemmy refuses
our Flags (an Application reporter, no `to`, an array object). FEDERATION.md's custom emoji line is put back before the
replies paragraph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 16:29:04 +02:00
thepraandClaude Opus 5.5 a3a66b6db2 Replies a post's author approves (FEP-5624), checked live with PeerTube
PeerTube puts canReply on a video whose comments wait for approval, and answers each comment with ApproveReply. A
persona's reply to such a post now waits (privapub.approval: pending), its Create going to the author alone; the
author's ApproveReply, signed by the author and naming the post answered, lets it out to its audience with
replyApproval, and RejectReply leaves it ours. A null canReply (PeerTube's open comments) says nothing; an empty one
refuses. The PeerTube scenario holds a comment for review and approves it through PeerTube's API (28 checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 16:16:34 +02:00
thepraandClaude Opus 5.5 5d5f1d6909 A persona's notification policy and its requests
Mastodon 4.3's policy was a stub that accepted everything. It is now kept per persona: notifications from accounts it
does not follow, accounts that do not follow it (or only for three days), accounts newer than 30 days, private mentions
it did not ask for and silenced accounts are accepted, filtered or dropped. Filtered ones stay out of every list and
count unless asked for, gathered in a request per account; letting a request in lets that account in for good,
dismissing it deletes them. Everything is accepted until the persona chooses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 15:48:33 +02:00
thepraandClaude Opus 5.5 9872b78677 Ktistec in the pasture
Ktistec 3.13.0 (peers/ktistec.sh): built from its tag (images/ktistec, its own Dockerfile pinned), set up through its
API. scenarios/ktistec.sh, 27 checks, twice in a row: follows, posts, replies, likes and boosts with their undos both
ways, its poll and alice's vote, FEP-044f quotes both ways, edits and deletions both ways, the unfollow, statistics. It
is driven through the part of the Mastodon API it speaks and its own outbox API, and read from a copy of its SQLite
database, since its API counts no likes or boosts. CLAUDE.md also asks that a change to what PrivaPub sends be run
against every peer before it is committed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 14:09:29 +02:00
thepraandClaude Opus 5.5 b478049303 Our @context stays JSON-LD 1.0, and a 500 no longer takes a host out
The proof term added with integrity proofs had a @graph container, which only JSON-LD 1.1 knows: Smithereen's reader
refused every document carrying our context with a 500. Those 500s, retried, then put Smithereen's host in quarantine
for an hour, and every delivery to it waited. Proofs are canonicalised as JSON (JCS), so the term loses nothing as a
plain id; a test keeps every term within 1.0. Only an unreachable host, a timeout or a gateway's 502, 503 or 504 now
counts against a host: a 500 is the server failing on that one activity, retried on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 13:02:02 +02:00
thepraandClaude Opus 5.5 b66f0acf4e The Mastodon scenario waits on Mastodon's database, and tells the quoted post by its id
Mastodon's relationships API keeps each pair a day, and a read racing the change awaited writes the old answer back
after the change cleared it: an unfollow that had landed showed as a follow until the block. Changes from PrivaPub are
now awaited in Mastodon's database. The post to quote was found by its words, which alice's quotes from earlier runs
hold too; it is found by its id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 12:07:49 +02:00
thepraandClaude Opus 5.5 679463eb3f Edit history from formerRepresentations
Pleroma and Akkoma send a post's earlier versions with it. A post PrivaPub first meets after its edits now has its
history, and an edit that carries them brings the versions missed in between; each is read as the post itself is. The
Akkoma scenario checks a post fetched after two edits by an account no persona follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 12:07:49 +02:00
thepraandClaude Opus 5.5 02928caac1 A remote handle on its own domain, once that domain's WebFinger agrees (FEP-2c59)
An account on a server whose handles are not its host's (Mastodon's LOCAL_DOMAIN apart from WEB_DOMAIN) showed as
user@host. The actor's `webfinger` names the handle; its domain is kept once WebFinger there points back to the actor,
and asked again when the name changes. A persona's blocked servers match a handle's domain as well as the actor's host,
as the lists Mastodon exports name handles' domains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 11:49:32 +02:00
thepraandClaude Opus 5.5 e708f1ad2e hs2019 signatures hashed with SHA-512, and SHA-512 digests
A draft-cavage hs2019 signature leaves the hash to the key: with an RSA key it is tried with SHA-256, as nearly everyone
signs, then with SHA-512, as some do; rsa-sha256 stays SHA-256 only. A Digest of SHA-512= is checked as SHA-256= is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 11:13:59 +02:00
thepraandClaude Opus 5.5 0310bbe0f9 Castopod in the pasture
Castopod 1.15.5 (peers/castopod.sh): the official image on the shared MySQL, its podcast @pod made and published through
its admin pages, episodes through its REST API, its fediverse queue sent by spark. scenarios/castopod.sh, 15 checks: the
follow, an episode reaching alice with its sound, her like, boost and comment landing there, the unfollow, statistics
(from NodeInfo2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 11:12:06 +02:00
thepraandClaude Opus 5.5 69ab8e50eb Podcast episodes play; servers with NodeInfo2 only are described
Castopod announces an episode with a Note that only links to the episode's page, which serves a PodcastEpisode. PrivaPub
reads PodcastEpisode (its words in description, its sound in audio, its cover in image), and a Note that is only a link
to its author's own episode takes the episode's sound, title, cover and words, so the post plays. A server that
publishes no NodeInfo is described from NodeInfo2 (/.well-known/x-nodeinfo2), as Castopod publishes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 11:12:05 +02:00
thepraandClaude Opus 5.5 3df3c333bf Forte in the pasture
Forte 26.9.10, the ActivityPub-only fork of Streams, built from its tag (images/forte: composer on Hubzilla's PHP image)
with portable identities (did:key actors behind /.well-known/apgateway). scenarios/forte.sh, 21 checks: follows, posts,
comments, likes, edits and deletions both ways, a third channel's comment passed on by the thread's owner, the
unfollow, statistics. INTEROP.md records what it does its own way: follows of profile pages, edits only as
Add{Update} under the Create's id, collections that misname themselves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 10:58:45 +02:00
thepraandClaude Opus 5.5 99b5e2dbf3 A follow of a persona's profile page; an Update under its Create's id
Forte follows a persona by its profile page (/@name, WebFinger's alias) rather than its actor's id: Follow and
Undo{Follow} now find the persona by either (it was a 404). Forte also sends an edit only added to the thread's context
(FEP-171b), under the same activity id as the post's Create: the unwrapping now tells two activities that share an id
apart by what they carry, as the inbox does, so the Update is not taken for a copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 10:58:45 +02:00
thepraandClaude Opus 5.5 d06f684f0d A thread owner's Add of a reply to its context is the reply passed on
FEP-171b, received (Hubzilla, Streams, Forte): an Add whose target is a collection of the actor's own server, and whose
object is someone's Create, Update or Delete of their own object, is queued as that activity forwarded by the owner, so
it is believed on its FEP-8b32 proof or as its origin has it, and shares its job with the plain forward Hubzilla also
sends. Checked live against Hubzilla (unchanged, 20 checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 10:03:44 +02:00
thepraandClaude Opus 5.5 b39eadffeb Hubzilla in the pasture
Hubzilla 11.4.1 with its pubcrawl addon (peers/hubzilla.sh): the hlhd image on the shared MySQL, a cron sidecar, hzuser
and hzfriend made through Hubzilla's own PHP as public channels that speak ActivityPub. scenarios/hubzilla.sh, 20
checks: follows, posts, comments, likes, edits and deletions both ways, and a third channel's comment that the thread's
owner passes on, which PrivaPub takes on its FEP-8b32 proof. Known gap G-0010 (upstream): Hubzilla 11 keeps a follower
after its Undo{Follow}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 09:59:49 +02:00
thepraandClaude Opus 5.5 9d0b5fac3e Backlog from a fresh village on today's PrivaPub: 2577 pass, one peer gap
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 09:17:36 +02:00
thepraandClaude Opus 5.5 9ab87b2779 Personas prove what goes to relays; the server says what it reads
FEP-521a and FEP-8b32. Every persona has an Ed25519 key of its own (Avatar.SigningKey; migration 014 gives the earlier
ones theirs), named in its actor's assertionMethod as a Multikey, the terms defined in the actor's own context. A
persona's activity going to a relay carries an eddsa-jcs-2022 proof (JSON canonicalised by RFC 8785, Jcs), so what
Activity-Relay forwards reaches Mastodon, which verifies it with its own code. Nothing else carries one: Mitra takes a
proof over the HTTP signature and refuses one by a key it has not read, without reading the actor again. Received: an
actor's own Multikeys are kept, and a forwarded activity whose proof one of them verifies is taken as it came instead of
being read again from its origin.

Discovery: WebFinger for the server's origin links its instance actor (FEP-d556), NodeInfo links it as the application
actor (FEP-2677), and actors name RFC 9421 under implements (FEP-844e).

Checked live: relay 16 (Activity-Relay's forward of alice's post reaches Mastodon), Mitra, GoToSocial and Mastodon
unchanged (165 in all).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 09:01:21 +02:00
thepraandClaude Opus 5.5 c47b6e5533 Personas have walls their followers write on
Owner decision of 2026-10-06 (G-0009, FEP-400e). A persona's actor names its wall (…/graffiti, sm:wall) and, in
Smithereen's privacySettings, that its followers may write on it. A public post that is not a reply, sent with the wall
as its target by an account following the persona, is hosted: the persona is notified, it reaches the persona's and its
followers' homes, and the followers' servers and the author's are told with Add{Note}. The persona deletes it with
DELETE /api/v1/statuses/:id, which sends Remove{Note}; Smithereen deletes the post then. The wall lists the persona's
public posts that start a thread and what was written on it.

Elsewhere: an account's Add{Note} on its own wall shows the post to its followers here as on that wall (privapub.wall on
the status), and its Remove takes it away. An Add or Remove naming a collection of the account's own server PrivaPub
does not know has its document read again first, at most hourly: accounts kept before walls were read had none.

Checked live: Smithereen 40 checks (G-0009 closed); GoToSocial and Mastodon unchanged (121).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 08:17:10 +02:00
thepraandClaude Opus 5.5 e0682fa868 A sender's digest of its followers here is compared and mended
FEP-8fcf, received. When a delivery's Collection-Synchronization header digests the sender's followers on PrivaPub
otherwise than the personas following it, a job reads the list the header names (on the sender's origin, signed by the
instance actor): a follow the list leaves out ends, only when the list is the one the digest describes; a request it
lists is taken as accepted; a persona it lists that follows nothing there sends Undo{Follow}, as Mastodon does. Each
claiming delivery is compared once.

Checked live (scenarios/followsync.sh, now 15 checks): PrivaPub ends a follow Mastodon lost and undoes one only
Mastodon remembered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 07:46:46 +02:00
thepraandClaude Opus 5.5 bd18de35a6 Deliveries to followers carry a digest of them, per server
Owner decision of 2026-10-06 (FEP-8fcf). A persona's delivery addressed to its followers carries a signed
Collection-Synchronization header naming its followers, its roll-call (…/groupies/roll-call) and the digest of its
accepted followers on the receiving server only. The roll-call answers a signed request with the persona's followers
on the signer's server and nobody else's.

Mastodon gives every Undo{Follow} it sends after reading a roll-call the same id (…#follows//undo), so a second one
looked like a copy: an Undo of a Follow that comes again while the follow it ends exists again is now kept once per
follow.

Checked live (scenarios/followsync.sh): Mastodon drops a follow PrivaPub lost, and undoes one it lost itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 07:37:03 +02:00
thepraandClaude Opus 5.5 bf37223849 Public threads name their replies and context
Owner decision of 2026-10-06: a persona's public or unlisted post outside any group names its replies
(…/scribbles/{id}/replies) and its conversation's context (FEP-7888), the root's …/context that a reply inherits from
its parent, ours or another server's. Both list only the public and unlisted posts PrivaPub holds; followers-only,
circle, direct and local-only posts name neither and their collections answer 404. Checked live: opening alice's
thread on Mastodon finds carol's reply, which nobody there follows (scenarios/threads.sh).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 07:13:54 +02:00
thepraandClaude Opus 5.5 93e13e5563 Owner decisions of 2026-10-06; personas' public posts go to relays
The owner decided four gated questions, now in ROADMAP: personas publish a wall (G-0009), their public posts go to the
relays PrivaPub subscribes to, public threads publish their replies and context, and FEP-8fcf follower digests are sent.

The first is in: a persona's own public post outside any group, its edit and its deletion also go to the relays that
accepted us, as Mastodon sends them; nothing less public, and no boost. Checked live (scenarios/relay.sh, 15 checks):
the post reaches Activity-Relay, and Mastodon through aode-relay's announce. Mastodon drops what Activity-Relay forwards
without an LD signature or FEP-8b32 proof, which PrivaPub does not add yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 07:08:37 +02:00
thepraandClaude Opus 5.5 b45321f28d Following an account brings its earlier posts to its profile
Once a follow holds, OutboxBackfill reads the account's latest public posts from its outbox's first page (twenty at
most, once a day) and keeps them as any fetched post: its profile shows them at once instead of only what it posts from
then on. Homes still get only what arrives afterwards, as on Mastodon. Announces and other servers' objects in the
outbox are left out. Checked live against Mastodon (scenarios/pins.sh, now 9 checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 02:15:49 +02:00
thepraandClaude Opus 5.5 b218768904 Pasture: Vernissage 1.43.0
Vernissage joins the pasture on SQLite with its queues on the shared Redis; scenarios/vernissage.sh: follows both ways,
a photo with its alt text, likes, boosts and comments both ways, a deletion, the unfollow and statistics: 19 checks,
with no change to PrivaPub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 02:11:53 +02:00
thepraandClaude Opus 5.5 53158b4cf4 One delivery a server; BookWyrm 0.9.3 in the pasture
An activity now goes once to each server, to its shared inbox when it has one, for the accounts a post names, answers
or quotes as for its followers, as Mastodon delivers (a circle post excepted: each member's copy names that member).
BookWyrm took the same post twice when one copy reached its shared inbox and another the named account's inbox at once.
SharedInboxTests checks a reply to a follower's post goes once.

BookWyrm joins the pasture (peers/bookwyrm.sh: its image on the shared Postgres and Redis, gunicorn and a Celery worker,
its user, book and statuses made in its Django shell). scenarios/bookwyrm.sh: follows both ways, a review, a comment and
a quotation reaching alice as BookWyrm's pure posts, her like, boost and reply landing there, her post naming bwuser and
bwuser's like and reply, a deletion, the unfollow and statistics: 20 checks. GoToSocial (64), Mastodon (57) and Misskey
(35) still pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 01:56:06 +02:00
thepraandClaude Opus 5.5 12b51a211a Pasture: Ghost 6.67 and its ActivityPub service
Ghost joins the pasture with its ActivityPub service (Fedify) on the shared MySQL, routed by Caddy as Ghost's own proxy
does. scenarios/ghost.sh: follows both ways, the publication's titled Articles with their edit and deletion, likes and
boosts both ways, Ghost's reply and note, alice's post in its Network feed, both unfollows and statistics: 22 checks,
with no change to PrivaPub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 00:49:18 +02:00
thepraandClaude Opus 5.5 a584134858 Pasture: Owncast 0.3.0
Owncast joins the pasture with federation turned on through its admin API; scenarios/owncast.sh has alice follow the
stream, receive its admin's message, like and boost it (both show in Owncast's admin), and unfollow: 13 checks, with
no change to PrivaPub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 00:32:46 +02:00
thepraandClaude Opus 5.5 0aea550c6c Pasture: WriteFreely 0.17.2
WriteFreely joins the pasture, built from its release with openssl beside it (it makes each blog's keys with the
command); scenarios/writefreely.sh has alice follow a blog and receive its post as a titled Article, its edit and its
deletion, then unfollow: 13 checks, with no change to PrivaPub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 00:28:11 +02:00
thepraandClaude Opus 5.5 5721bfd796 Pasture: snac2 2.95
snac2 joins the pasture, built from its tag (images/snac2); scenarios/snac.sh drives its Mastodon API and reads its
own files, since its timelines lag behind what it has taken in: follows, posts, replies, likes, boosts, a poll, edits,
deletions, direct messages, the unfollow and statistics, 27 checks, with no change to PrivaPub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 00:19:56 +02:00
thepraandClaude Opus 5.5 1c0dfeb9a4 Pasture: Mitra 5.9.1
Mitra joins the pasture (peers/mitra.sh) on the shared Postgres; scenarios/mitra.sh drives its Mastodon API through
follows, posts, replies, likes, reposts, a reaction, a poll, edits, deletions, direct messages, the unfollow and
statistics: 28 checks, with no change to PrivaPub. Mitra carries FEP-8b32 proofs made with Ed25519, which PrivaPub does
not verify yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 23:35:35 +02:00
thepraandClaude Opus 5.5 f6fc0c535c Relays: PrivaPub reads from the relays its configuration names
Federation:Relays names relays by their actor (or inbox) address; the instance actor follows Public at each, as Mastodon
subscribes, a minute after start and every six hours (asked again a day after no answer or a refusal, undone when a
relay is no longer named). What an accepted relay passes on comes to the federated timeline and nobody's home: a public
post it forwards (Activity-Relay), read again from its origin like any forwarded post, and a post it announces
(aode-relay), kept as its author's and never as the relay's boost. Nothing of a persona's is sent to a relay; sending
public posts there waits for the owner.

The pasture gains both relays (peers/relay.sh, peers/aoderelay.sh) and scenarios/relay.sh, 13 checks. The village
backlog is clean: 2574 checks pass, one known gap (Misskey's).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 23:11:59 +02:00
thepraandClaude Opus 5.5 1c7d1ece9c Pins across servers, both ways
An account elsewhere that pins or unpins one of its posts (Add or Remove on its `featured`) now shows those pins on its
profile here (`pinned=true`), in its order and its public posts only; a community's announce of a moderator's Add does
the same for the community. The `featured` collection itself is read with the account's counts, at most once a day, so
pins made before PrivaPub ever saw an account show too. Any other target (Smithereen's wall, a community's moderators)
is dropped. A persona's pin and unpin go to the post's audience as Add and Remove on /trophies, as Mastodon sends them.

Checked live against Mastodon (scenarios/pins.sh, 8 checks). The town's checker learnt three peer rules from the
village: Misskey and Sharkey keep a forwarded reply only with its author's LD signature (only Mastodon signs), they
count no renote by a bot, and Mastodon never sees a Lemmy vote on a post in a community. The village of 2026-10-05
checks clean, 2454 of 2454.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 22:48:57 +02:00
thepraandClaude Opus 5.5 5f150b7a59 Pasture: Smithereen 1.0.3, and failed server descriptions retried
Smithereen joins the pasture (tools/pasture/peers/smithereen.sh): its image on the shared MySQL, with imgproxy and a
file server behind Caddy, a JDK trust store with the pasture's CA, accounts from its signup form, and a password grant
for a local application. scenarios/smithereen.sh drives its VKontakte-like API: friends as mutual follows, wall posts,
comments, likes, reposts (quotes there), polls, edits, deletions, private messages, the unfollow and statistics, 30
checks. A post on someone else's wall never reaches PrivaPub, since Smithereen sends it only to servers whose actors
publish a wall: G-0009, waiting for the owner.

PrivaPub: a server description that failed (Smithereen serves no NodeInfo until it has a description) frees its week,
so the server's next arrival asks again instead of a week later.

The shared Postgres takes 400 connections: at 100 the village seed ran it dry (Sharkey's API answered 500, Misskey
dropped deliveries). The seeder records a follow that stands from an earlier seed when the step itself fails, so the
checker no longer expects that follower to see nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 22:03:35 +02:00
thepraandClaude Opus 5.5 7b0377c85f Moves carry follows over; first DMs to Lemmy 0.19 and Mbin go as ChatMessage
Two owner decisions of 2026-10-05, both recorded in ROADMAP:
- After a verified Move the personas following the old account follow the new one, in the same lists, and a mute or
  block of the old account carries over, as Mastodon does it.
- A direct message to one account on a server whose NodeInfo names Lemmy before 1.0 or Mbin goes as a ChatMessage,
  the one place PrivaPub decides by a server's software (invariant 17). G-0008 is closed.

Mbin addresses its private messages to the recipient's profile page, so a Create addressed to a persona's /@name now
reaches the persona. Checked live: moves 8/8, Lemmy 0.19 30/30, Mbin 26/26 with messages both ways. The software
theory runs alone, since every test's peer shares 127.0.0.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 20:57:43 +02:00
thepraandClaude Opus 5.5 f3e8cce2ed Pasture: PrivaPub's uploads live in a volume
They lived in the container, so a container made again (a run with other settings)
lost every picture the town had posted from PrivaPub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 20:30:45 +02:00
thepraandClaude Opus 5.5 eeb873b816 Load: a flood of fake servers; unique-key lookups no longer scan
tools/pasture/flood/flood.cs answers as twenty fake servers (flood1..20.test)
and sends signed Creates, Likes and Follows at a set rate; load.sh measures
the answers, the queue's wait and processing times, its drain and a persona's
home timeline meanwhile (docs/LOAD.md has the method and the runs).

What the runs found:
- Every unique index was partial on $type: "string", which MongoDB never uses
  for an equality lookup, so every post by ObjectURI, actor by ActorURI,
  deleted object, domain block, remote instance and the rest was a
  collection scan (280 ms a post lookup at 30 000 posts). They are partial on
  $gt: "" now, which an equality on a string implies; MongoDB.Entities
  rebuilds them in place at the next start.
- Two inbox workers capped intake near 110 activities a second:
  Federation:InboxConcurrency and DeliveryConcurrency (default 8) set them.
- The indexes the plan listed as missing: a post's boosts and replies, a
  persona's boosts, who follows an actor, timeline rows by author, a post's
  likes and pins.

At 300 activities a second (200 let through, the rest 429 by the per-origin
limit) the queue wait went from 29 s to 6 ms at p50; with the limits lifted
PrivaPub processes about 900 a second, each in under 10 ms, and the home
timeline stays under 20 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 19:16:30 +02:00
thepraandClaude Opus 5.5 f1e743c331 An account that moves shows where it went
PrivaPub dropped Move as an unknown type and showed no `moved` on accounts.
Now a Move is believed as Mastodon believes it: the moving account sends it
about itself, and the new account, read again from its own server, names it
in alsoKnownAs (now kept on remote accounts). The old account then shows the
new one as `moved` in the Mastodon API. The personas following it keep
following it: following the new account on their behalf would tell another
server about them, so that waits for the owner.

Checked live against GoToSocial (scenarios/moves.sh: an alias, a move, 6
checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 18:39:46 +02:00
thepraandClaude Opus 5.5 1265611f9e A group's owner sees its members and requests, declines and removes
/clientapi/group had no way to see who is in a group or asks to join it,
so a circle's owner could not answer a request from elsewhere. Now:
- GET /clientapi/group/members: the members (local and remote, with their
  role) and the pending requests, for the group's owner and moderators
  only;
- POST /clientapi/group/reject: declines a request, telling the asker's
  server with a Reject of its Follow;
- POST /clientapi/group/remove: takes a member out (never the owner): a
  persona here stops following the group, one elsewhere gets a Reject of
  its Follow, as Mastodon removes a follower.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 18:24:29 +02:00
thepraandClaude Opus 5.5 f38ac73615 A direct message answers in the form its recipient writes in
Lemmy 0.19 (most of the threadiverse) and Mbin take a private message only
as a ChatMessage and refuse a direct Note. A direct message to one account
elsewhere that writes to us as ChatMessages now goes out as one: to it
alone, without a mention in its text, kept on the post (Post.AsChatMessage)
so the served copy and an edit match. No software name decides it.

Live against Lemmy 0.19: alice's answer to lemmyuser's private message and
another persona's message to lemmyuser arrive (29 checks). A first message
to an account that never wrote to anyone here is still a Note, which they
refuse; G-0008 keeps that open for the owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 18:13:51 +02:00
thepraandClaude Opus 5.5 a85ebd30c0 Pasture: Lemmy 0.19 joins, built to trust the pasture's CA
Most of the threadiverse runs Lemmy 0.19, whose release trusts only the
roots its rustls bundles. images/lemmy19 builds 0.19.20 from its tag with
reqwest's rustls-tls-native-roots added, and the pasture runs it as
lemmy19.test. scenarios/lemmy19.sh passes 27 checks with no change to
PrivaPub (communities, threads, comments, votes, its private message,
moderation) and one known gap: 0.19 takes private messages only as
ChatMessage and answers our direct Note 400, like Mbin, so G-0008 now
covers both and waits for the owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 17:32:22 +02:00
thepraandClaude Opus 5.5 bd5901d15e Town: the report shows replies passed on; the backlog after wave 2's first peers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 17:02:05 +02:00
thepraandClaude Opus 5.5 6b08da458a Pasture: NodeBB joins
NodeBB 4.16.1 runs in the pasture on the pasture's Mongo, set up by its
automated setup, with an API token written where NodeBB keeps them.
scenarios/nodebb.sh passes its 23 checks with no change to PrivaPub: a
category followed and its topic as a titled thread, replies both ways, a
follow of alice and her post there, votes both ways, an edit and a
deletion, a chat both ways, the unfollow and statistics.

NodeBB never federates a topic's lock, and its API follows an account
elsewhere only when named by its handle; both are in docs/INTEROP.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 16:58:08 +02:00
thepraandClaude Opus 5.5 26346cde30 Mbin joins the pasture; a magazine's own threads and locks are taken
Mbin 1.10.1 runs in the pasture (its image, a messenger worker, a RabbitMQ
of its own, its API limits raised), and peers/mbin_token.py gets mbuser's
token through the authorization-code flow. scenarios/mbin.sh: 24 checks and
one known gap, magazines both ways, titled threads, a Note to a magazine as
a microblog post, comments, favourites and upvotes both ways, a moderator's
lock, unlock and removal, the unfollow and statistics.

What it showed:
- Mbin sends a magazine's threads to its subscribers as the author's Create,
  the magazine as its audience, never announced. A post whose group is
  followed here and lives on the post's own server is now kept as if
  announced; the same from another server is not.
- A moderator's lock is a bare Lock (and Undo{Lock}): LockHandler takes it
  from the post's own server only.
- Mbin takes private messages only as ChatMessage and its actors say
  nothing about it; PrivaPub never decides by a server's software, so this
  stays open as G-0008 for the owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 16:41:50 +02:00
thepraandClaude Opus 5.5 47d6e22988 PieFed joins the pasture; the instance actor answers at the root
PieFed 1.7.17 (dockurr's image of the release) runs in the pasture with its
Celery worker on the shared Postgres and Redis, and scenarios/piefed.sh
checks it both ways: 29 checks, communities, titled threads, comments, votes
up and down, a community poll and a vote in it, private messages, a
moderator's lock, unlock and removal, the unfollow and statistics.

What it showed:
- PieFed sends a community's announces to the inbox of the Application at a
  peer's root (as Lemmy serves its site actor) and to /inbox otherwise.
  PrivaPub answered 404 at its root, so every announce went to an /inbox it
  does not have. The instance actor now answers at / for ActivityPub
  requests, unsigned under SecureMode as at its own address.
- PieFed keeps serving a thread its moderator removed, so the removal could
  never be checked against the post's origin. A community on the post's own
  server now speaks for it; one elsewhere still waits for the origin to say
  the post is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 15:44:13 +02:00
thepraandClaude Opus 5.5 0646de22bd Replies to a persona's posts reach its followers; personas join remote events
Two owner decisions of 2026-10-05, recorded in the roadmap.

Replies passed on ("the fediverse is broken without"): 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 it, as
Mastodon forwards it, never to the replier's own server, never for a
local-only or group post; its edit and deletion follow. Only an activity its
own actor delivered is passed on (Arrival.Raw), so nothing forwarded is
forwarded again. The town checks it as relay.reply cells (specs/relay-five:
882 checks pass); Mastodon takes a passed-on activity only with an LD
signature, which GoToSocial and Akkoma don't add, and the checker knows it.

Events: a persona joins another server's event with a Join and leaves it with
a Leave, both to the organiser only, through
POST /api/privapub/v1/statuses/:id/join|leave; the organiser's Accept or
Reject is routed by our join id and shows as privapub.event.participation.
Events by invitation or taken on another site are refused before anything
is sent. Mobilizon's scenario joins and leaves an event (28 checks) and keeps
one for decePubClient's e2e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 14:55:20 +02:00
thepraandClaude Opus 5.5 2b42377b3f Roadmap: wave 1 is done
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 12:02:46 +02:00
thepraandClaude Opus 5.5 8ae265c8f8 Pasture: Funkwhale joins, closing wave 1
Funkwhale 2.0.11: the API, a Celery worker with its beat and the front's nginx sharing one volume, on the shared Postgres
and Redis, trusting the pasture's CA through REQUESTS_CA_BUNDLE; fwuser and its OAuth token made by its own tools.
scenarios/funkwhale.sh passes its 16 checks: alice follows a channel, a track uploaded to it arrives as Audio with its
file and duration, its deletion reaches PrivaPub, the unfollow, statistics.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 12:02:25 +02:00
thepraandClaude Opus 5.5 9f0b25e92b Funkwhale's answers, deletions and NodeInfo are understood
Three shapes Funkwhale 2.0 sends, each of which lost something:
- its Accept is named after the Follow it answers, on our origin (`…#follows/<uuid>/accept`), and was refused as off its
  actor's origin, so no follow of a channel completed: an Accept or Reject whose id extends the id of the activity it
  answers, on our origin, is now taken;
- a channel deletes its uploads in one Delete without an id, their ids in a list as the object's id: each is deleted;
- its NodeInfo discovery names the document under its swagger schema's URL: a link whose path names NodeInfo is taken
  when no rel is known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 12:02:25 +02:00
thepraandClaude Opus 5.5 3866558d1d Roadmap: wave 1 and GoToSocial's interaction policies
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 11:46:16 +02:00
thepraandClaude Opus 5.5 fb90558dec Town: client cells from their latest run; the backlog after a fresh village
decePub's e2e runs append to out/client.jsonl. The report kept the newest row per test and account, and the backlog
every row, so a failure from a run that picked other accounts stayed in both after later runs passed. Each client
cell now counts as its latest run left it (check.latest_client: the rows within an hour of its newest).

The backlog comes from a fresh village on today's build: 2556 checks pass and 4 fail, three of them peer-to-peer
counts, and one GoToSocial losing a status from its cache (an Update handled as a Create; a restart heals it),
recorded in INTEROP.md rather than as a gap, which would hide our own edits failing there. The community vote and the
Lemmy thread that failed before were data from older builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 11:45:57 +02:00
thepraandClaude Opus 5.5 5860b71223 GoToSocial's interaction policies are honoured
A remote post's canReply, canLike and canAnnounce (with the older always and approvalRequired) are kept beside
canQuote and judged for each persona: let in at once when the rule names the public, the persona, the author's
followers while it follows the author, or the accounts the author follows while the author follows it; asked first
when only the manual list names it; refused (422) otherwise. Asked first, a ReplyRequest, LikeRequest or
AnnounceRequest with the interaction as its instrument goes to the author alone, and the interaction waits
(privapub.approval: pending). The author's Accept brings an authorization, verified on the author's origin as naming the
interaction and the post; the reply then goes out with replyAuthorization, the boost with announceAuthorization, the
like with likeAuthorization. A Reject leaves the reply ours alone and takes a like or a boost back. As a third party, a
reply a policy does not let in at once is kept only with an authorization that verifies. Clients see the rules as
GoToSocial's interaction_policy.

Checked live against GoToSocial 0.22.1: the scenario's nine new checks pass (64 in all), a reply and a like approved
through its interaction requests and a boost refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 11:43:58 +02:00
thepraandClaude Opus 5.5 c65a44ba88 Pasture: Gancio joins
Gancio 1.28.2 on its own sqlite, its config.json written before the first start (without it Gancio waits in its setup
wizard), trusting Caddy's CA through NODE_EXTRA_CA_CERTS; fediverse replies are kept as event resources. Its one
Application actor publishes the agenda, and scenarios/gancio.sh passes its 17 checks with no change to PrivaPub: the
follow, an event with its start, end and place, a reply kept as a resource, the edit and the deletion, the unfollow,
statistics.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 11:18:50 +02:00
thepraandClaude Opus 5.5 761cfc2cce Pasture: Mobilizon joins
Mobilizon 5.2.4 on a PostGIS of its own (it needs the extension, which the shared Postgres has not got), trusting
Caddy's CA through the bundle mounted over certifi's and castore's files (hackney trusts only those), with geocoding
pointed at a closed local port. scenarios/mobilizon.sh passes its 23 checks: alice follows a group; the event its
organiser makes arrives as an Event with its start, end and located place; comments both ways; the organiser's edit,
closing the comments (PrivaPub then refuses a reply) and deletes of a comment and the event; a group post with its
title; the unfollow; statistics. An event made through Mobilizon's API without options has its comments closed, so
the scenario opens them. No RSVP yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 10:54:32 +02:00
thepraandClaude Opus 5.5 2d293a6148 An organiser's edits to a group's event follow its server
Mobilizon's organiser sends the Create, Update and Delete of an event attributed to the group, which announces the
Event itself. PrivaPub refused the organiser's activities as misattributed (400) and kept the event through the
group's Announce, so an edit was lost and a deletion left the event in place. An object attributed to another account
of the actor's own server is now that server's to vouch for: created or edited as the server has it, under the account
it is attributed to, and deleted once the server answers 404 or 410. Attributed to an account elsewhere, it is still
refused. Checked against Mobilizon 5.2.4 in the pasture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 10:54:32 +02:00
thepraandClaude Opus 5.5 7f6837ccb1 NodeInfo is read as Mobilizon serves it
Mobilizon sends its NodeInfo as `application/json; profile=http://…#` with the URL unquoted, which .NET cannot parse,
so the document was refused for its content type and the server never described; and it names its software
"Mobilizon" where NodeInfo wants lower case. The media type is now read from the raw header when the parsed one is
missing, and software names are lowercased, so one software is counted under one name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 10:45:18 +02:00
thepraandClaude Opus 5.5 206506f5e9 A follow request sent again carries a new id
Lemmy keeps the ids of the activities it received and never answers one again, so resending the same Follow could not
heal a follow whose Accept it lost: the village's community follow stayed pending through two resends. Each resend is
now the same follow under its own id (<follow id>-again-<n>), and an Accept naming any of them answers the follow;
the Undo still embeds the follow, so servers match it by its actor and object. Sent this way, the stuck follow was
accepted at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 10:45:18 +02:00
thepraandClaude Opus 5.5 a5d493a8c9 Pasture: Friendica joins, in the scenarios and the town
Friendica 2026.05 on the shared MySQL and Redis, with its worker daemon as a sidecar. friendica_settle repairs what
its install leaves: the system user has no name, so the system account that signs its fetches is never made; the web
container cannot see the sidecar's daemon, so a queued job waits for the five-minute cron unless the daemon is
declared running; its log needs a file and debugging on. Accounts are saved through its API with locked=0 (a number:
"true" reads as 0, unlocked), which makes them soapbox pages that take followers without following back, and each
one's outbox is read once: a Follow that reaches an account Friendica has not cached makes it fetch the account from
itself, signed, and checking that signature recursed for five minutes, holding PrivaPub's first follow past its timeout.

scenarios/friendica.sh passes its 25 checks from a clean install: follows and unfollows, posts (a titled one with its
title), comments, likes, Friendica's dislike as a downvote, boosts, edits (through its web editor: its Mastodon API
never federates one) and deletes, both ways.

The town gets a Friendica driver (HTTP Basic, its MySQL read through mysql_json, edits in the web editor, no bookmark
on a reply) and specs/friendica-pair.json, which passes its 275 checks. On the way:
- the checker knows Friendica's thread model: a non-public reply reaches an account only under posts it holds, and a
  Friendica account's non-public reply in a thread another server owns reaches nobody else there;
- the seeder answers a follow request the target still holds whatever the follower's server says: Friendica reports a
  follow of someone already following its account as made at once (and shows that persona's followers-only posts
  while a locked persona still holds the request);
- the selftest skips polls where a platform has none; the shared MySQL helpers move to peers/shared.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 10:14:02 +02:00
thepraandClaude Opus 5.5 e485f7bd47 An id reused for another activity is not a copy
Friendica's activity ids are uniqid(): a short prefix and the microsecond. Two of its processes answering two follows at
once gave both Accepts one id, and PrivaPub, queueing each inbox activity once per id, dropped the second as a copy:
that follow stayed pending on our side while Friendica counted the persona as a follower (seen in the town's Friendica
pair). An id that comes back carrying another type, actor or object is now queued apart; a true copy is still dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 09:52:02 +02:00
thepraandClaude Opus 5.5 7eb7c017a5 Forwarded activities are believed as far as their origin vouches
A thread's server passes on what happens in it, signed with its own key: Mastodon forwards the replies to its
accounts' posts and their deletions, Friendica every activity in its threads. PrivaPub answered them 401, which also
tells a sender its signature failed. Now they get 202 and nothing in them is believed: a forwarded Create or Update is
taken as its object reads at the actor's origin, a Delete of a public or unlisted copy once that origin answers 404 or
410 (RemoteActorService.IsGone; FederationHttp remembers the status of a refusal), anything else is let go, and our own
activities coming back are ignored. A forwarded copy has its own dedupe key, so one that failed never hides the
author's own delivery.

A reply in the thread of someone followed here is kept, as Mastodon keeps them. Mastodon delivers a reply to the
followers of the account it answers; PrivaPub dropped those as unaddressed, which the pasture showed: the outsider's
reply its Mastodon scenario said was never delivered had been, and was thrown away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 09:34:08 +02:00
thepraandClaude Opus 5.5 01808fa644 Follow requests nobody answers are sent again
A Follow was resent only when the persona followed again. Lemmy 1.0
sends nothing it queued for a server before it started sending there, so
the Accept of a community follow made on first contact was lost for good:
the village's persona stayed "requested" a day while Lemmy listed her as a
follower, and every post the community announced was refused as not
followed. A request still unanswered is now sent again, the same activity,
after 15 minutes, an hour, 6 hours, a day, two and four days
(FollowResender, every 15 minutes); a server that holds the follow answers
the copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 07:33:59 +02:00
thepraandClaude Opus 5.5 7e37f56db6 A community's vote on our reply counts, followed or not
Lemmy sends a vote to the community alone, which relays it to the post's
server. A persona's reply in a thread of a community nobody here follows
lost its Lemmy upvotes: the relay was dropped as "not followed". Such a
relay is now taken when the vote (or its undo) is on one of our posts in a
thread rooted in that community; from any other unfollowed group it is
still dropped. For that, a post keeps the community its `audience` names
(FEP-1b12) however it arrived, fetched for a thread as well as announced.

Found by the town's village (p191: 1 like counted of 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 07:29:12 +02:00
thepraandClaude Opus 5.5 8686275b69 The statistics name RFC 9421 signatures
A delivery signed with RFC 9421 was counted under "other": its algorithm
(rsa-v1_5-sha256) has an underscore, which a signature name could not
hold. It is now counted as rfc9421:rsa-v1_5-sha256, as WordPress's are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 07:04:35 +02:00
thepraandClaude Opus 5.5 01e5aa4ddd Pasture: Pixelfed and WordPress join
Pixelfed 0.14.4 (FrankenPHP with Horizon and the scheduler, on the shared
Postgres and Redis) and WordPress 6 with ActivityPub 9.3.1 (on a new shared
MySQL, WP-Cron run by a sidecar) are peers now, each with its scenario:
Pixelfed 26 checks (photos both ways with alt text, its place arriving as
Rome with its coordinates, comments, likes, a boost, edits and deletes),
WordPress 17 (an Article with its title, our reply, like and boost kept as
comments of their kinds, its edit and removal). Pixelfed has a town driver
and a pair spec: 145 checks pass, 2 expected (G-0007).

What they showed: Pixelfed names our posts by their page (fixed in
26dac40); on PostgreSQL its migration making caption nullable never runs,
so every remote boost failed (the pasture applies it); it files a DM it
fetches as followers-only (G-0007, upstream); Passport refuses a token
whose user id equals its client's id. WordPress signs with RFC 9421
first, which PrivaPub now verifies (c5a69d2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 07:01:38 +02:00
thepraandClaude Opus 5.5 c5a69d240a RFC 9421 signatures are verified, not refused
WordPress's ActivityPub plugin (and Ghost and Fedify) sign with RFC 9421
first and fall back to draft-cavage only after a refusal, so each first
delivery cost two requests and a 401 in our statistics. Now a request
carrying Signature-Input is verified as an HTTP message signature: its
covered components (the method and our own public target, the body's
Content-Digest), its created and expires, with the actor's RSA key under
PKCS#1 v1.5 or PSS. Deliveries and signed fetches both take it; the
ledger names the scheme (rfc9421:rsa-v1_5-sha256). What PrivaPub sends
stays draft-cavage, which every server reads. Ed25519 waits for FEP-521a
keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 06:48:22 +02:00
thepraandClaude Opus 5.5 26dac40720 A post named by its page is found as by its id
Pixelfed names one of our posts by the address of its page
(/@name/<post id>, the post's url) in its Like, Announce and their Undo,
so its likes were dropped as unknown objects. Before an activity is
handled, such a reference to a post of ours, as its object or the object
of the activity it undoes, is replaced by the post's id. Found by the
pasture's new Pixelfed peer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 06:40:46 +02:00
thepraandClaude Opus 5.5 34c0f696af A community's moderators lock threads and ban members
Lemmy's moderation reached PrivaPub only as removals. Now a remote
community's lock and ban, relayed in its Announce, apply too:

- a lock (Announce{Lock}, or commentsEnabled false on the post) refuses
  replies to the thread, ours included, until Undo{Lock}; statuses say so
  in privapub.locked;
- a ban of a persona (Announce{Block} with the community as target, or the
  moderator's own Block sent straight to us, which is the community's ban
  and never the moderator's block of the persona) shows as blocked_by on
  the community and refuses the persona's posts and replies there until
  the Undo.

The Lemmy scenario's removal was an expected failure only because it gave
up before Lemmy's 30-second batch; it now waits, and checks the lock and
the ban live (Lemmy refuses a lock or an unban without a reason): 29
checks, none expected to fail. G-0003 and G-0006 are closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 06:10:33 +02:00
thepraandClaude Opus 5.5 4a9a3d3235 A handle finds its account, not posts and a made-up hashtag
Since status search by words, searching "@name@host" also answered posts
sharing the handle's words and a hashtag "#@name@host" made of the query,
so a client that opens the profile when the account is the only result
(decePub's search) stayed on the list. A handle now searches accounts only,
and a hashtag result is offered only when the query is one (letters, marks,
digits and underscores, with a letter).

Found by decePub's end-to-end tests: every profile follow test failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 06:10:25 +02:00
thepraandClaude Opus 5.5 17922f4dfd Conversations page, and know what is unread
/api/v1/conversations answered one page, never unread, its read endpoint
did nothing, and DELETE was missing; each conversation cost a query per
member. Now each conversation keeps its newest post (DmGroup.LastPostId,
set as posts arrive, learnt once by migration _013) and pages by it as
Mastodon does, and each persona's ConversationState holds what it read and
what it took off its list:

- unread when someone else wrote last, after what the persona read;
- read marks it so, and writing in a conversation reads it;
- DELETE takes it off the list until a newer message brings it back.

The list reads its states, newest posts, members and accounts in a few
queries per page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 05:37:28 +02:00
thepraandClaude Opus 5.5 ea5e607779 Reports and statistics answer in ClientModels' shapes
The moderators' reports and the admin statistics (overview, hosts,
crawler) were anonymous objects, so a client could read them only as loose
JSON. They are now ViewReport, ViewStatisticsOverview, ViewHostsPage and
ViewCrawler in PrivaPub.ClientModels, with the same JSON as before, for
decePub's Administration page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 05:32:32 +02:00
thepraandClaude Opus 5.5 b146e182c6 Pasture: scenarios run again on the same pasture, and Mastodon's threads
GoToSocial's and Mastodon's scenarios passed once on a fresh pasture only:
their locked follows ended followed, so a second run found no request to
reject, and Mastodon's ended with mastouser blocking alice_masto, so every
follow after it was rejected. Each now unfollows first, and Mastodon's
undoes its block (checking that its Undo{Block} reaches our blocked_by) and
its lock; a run cut short is undone at the start.

Mastodon's scenario also checks the thread backfill live: a reply by an
account nobody here follows is never delivered, and joins the thread once
alice_masto opens it, read from Mastodon's FEP-7888 context.

interop.sh keeps the results of the peers it does not run, so the report
merges a partial rerun. All seven scenarios: 276 pass, 0 fail, 1 expected
(Lemmy moderation, P7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 05:32:01 +02:00
thepraandClaude Opus 5.5 0695c08ac3 A remote thread brings the replies that never reached us
The context of a remote post showed only what PrivaPub happened to hold:
replies from servers nobody here follows were never seen, and only the
ancestors were ever fetched. Now a persona opening a public remote thread
queues FetchReplies for the post and its root, at most once an hour each.

The job reads the thread's own collection first (FEP-7888 `context`, which
Mastodon 4.5+ serves with every reply at any depth; posts or, as FEP-f228
allows, the activities that made them), and otherwise the post's `replies`
(PeerTube's `comments`) and the replies' own, two levels down. At most 5
pages and 100 posts a job, signed by the instance actor, never a persona;
each post is fetched from its own origin and stored through StoreContext,
so only public and unlisted ones are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 04:54:38 +02:00
thepraandClaude Opus 5.5 bbeeda7268 A vote turned the other way replaces the first
Lemmy turns an upvote into a downvote with a Dislike alone (and back with a
Like), so the like stayed counted next to the downvote. Now an account has
one vote on a post: a Dislike takes its like away, a Like its downvote.

The Lemmy scenario's relayed votes were never failing for that reason only:
Lemmy 1.0 sends what it queued every 30 seconds, and the check gave up after
30. It waits a minute now, and both votes are plain checks: 22 pass, the
moderator's removal stays an expected failure (P7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 04:35:23 +02:00
thepraandClaude Opus 5.5 325f7605f2 A notifications marker reads what it passes
Saving the notifications marker (/api/v1/markers) moved the marker but left
every notification unread, so a Mastodon client's unread count never fell.
The marker now marks read every notification up to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 04:18:46 +02:00
thepraandClaude Opus 5.5 1d12dff033 A notification told alone names its group
v1 notifications, and those a stream tells, carried an 'ungrouped' key, so
a client reading grouped notifications could not fold a new like into the
group of likes it already shows. Each now names the group it belongs to by
default (likes and boosts of one post, follows within an hour).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 04:17:27 +02:00
thepraandClaude Opus 5.5 aecbf620a8 A community's comments reach its microblog members
A comment in a community was announced only as Announce{Create}, which
Akkoma, Mastodon and Misskey drop: their members never saw it (the village
found Akkoma's missing). A comment is now also announced as itself, as a
new post already was; Lemmy answers that form 400, harmlessly. Nothing is
decided by the follower's software.

The town's checker expects a followers-only quote to stay with the
followers, and G-0003 covers only Lemmy-hosted communities (their relayed
moderation), since relayed likes count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 04:12:53 +02:00
thepraandClaude Opus 5.5 9268968fd4 Streaming: what happens, told as it happens
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
2026-10-05 04:04:08 +02:00
thepraandClaude Opus 5.5 b274ce1268 Town: one report for several runs
A village and the pair runs (Hollo, Iceshrimp.NET, Pleroma) are checked
apart; town.sh report takes them together and adds their cells up, so one
matrix shows every server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 03:54:29 +02:00
thepraandClaude Opus 5.5 40217f3261 Pasture: PeerTube joins, and its videos play through us
PeerTube 8.3.1 runs on the shared Postgres and Redis, transcoding off. Its
scenario passes 24 checks: a persona follows a channel; a new video comes as
the channel's Announce and reaches the persona's home as a boost, playable
through the media proxy with byte ranges; comments both ways thread; a like
and its undo count; renaming and deletion arrive; the unfollow; statistics.

The town's seeder also takes reruns on the same accounts in its stride: a
Lemmy community already made is found, a circle member already approved
asks nothing again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 03:52:29 +02:00
thepraandClaude Opus 5.5 d61eac0a01 ROADMAP: P9's server side is done
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 03:35:24 +02:00
thepraandClaude Opus 5.5 b2570eab38 Statistics counters survive two flushes at once
Two ledger flushes that upsert the same new day (or touch the same new
instance) at once made one of them fail on the unique key, losing its
counts; MongoDB retries that only for single-document updates. The loser
now tries again and adds to the document the winner made. Seen as a rare
failure of LedgerOverHttpTests in full runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 03:35:10 +02:00
thepraandClaude Opus 5.5 71ca134599 The directory's pages are cut the same way every time
Many personas share a published day, and a page cut through such a day could
take a different few of them each time, skipping some between two pages: the
cut now takes the whole day at its edge. The test reads the directory page by
page until it finds its persona and checks each page's order, since other
tests make personas between two pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 03:35:10 +02:00
thepraandClaude Opus 5.5 d4226a289c Posts found by their words
/api/v2/search now searches statuses by words for a signed-in persona, in
Mastodon's scope: what it wrote, boosted, favourited, bookmarked or was
named in, and public posts of authors who let themselves be indexed
(indexable, off by default). Newest first, only what the persona may see,
through a text index over the posts' words in every language alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 03:16:40 +02:00
thepraandClaude Opus 5.5 83d4148de1 Trends and the directory, from what PrivaPub holds
trends/tags, trends/statuses and trends/links replace the stubs. Hashtags
and links rise with the number of different authors using them today
against the week before, two at least; posts with their favourites, boosts
and replies, halving every twelve hours. Only public posts PrivaPub already
holds count, nothing behind a content warning, and a post trends only if
its author is discoverable. Computed at most every ten minutes.

The directory lists discoverable accounts by their latest public post, or
by the day they say they joined: a persona's published day, never its
creation, with ties broken by a hash of the name, so personas made together
are not told apart by their order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 03:14:56 +02:00
thepraandClaude Opus 5.5 5f72435bfb Town: Misskey's renote count outlives an undone boost (G-0005)
decePub's e2e boost on Misskey undoes its boost and finds the count still
up: Misskey deletes the renote but its NoteDeleteService lowers only
repliesCount. Recorded as a peer gap, so the cell is a known failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 03:10:09 +02:00
thepraandClaude Opus 5.5 7bfd7ce285 Followed hashtags, and a tag's week of use
tags/:name shows the tag's last seven days in public posts PrivaPub holds
(uses and authors, as Mastodon gives them) and whether the persona follows
it; follow, unfollow and followed_tags replace the stubs. A public post that
is neither a boost nor a reply comes, as it arrives, to the homes of those
following one of its tags. Nothing is fetched for a followed tag and no
other server hears of it. Posts are indexed by tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 03:04:27 +02:00
thepraandClaude Opus 5.5 864dae4469 A boost reaches the author's server once
A boost and its undo went to the author's personal inbox and to the shared
inbox of its server's followers: two copies at once, which Misskey counted
twice as it processed them and undid once (seen in decePub's e2e boost on
Misskey). The author's server now gets them through its shared inbox, as
Mastodon sends them; a like still goes to the author's own inbox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 02:57:33 +02:00
thepraandClaude Opus 5.5 cafd2e6db3 Scheduled posts: written now, published at their time
POST /api/v1/statuses with scheduled_at no longer posts at once: the post is
kept as asked (at least five minutes ahead; 300 waiting, 25 a day, as
Mastodon allows), its media kept from the janitor, and a PublishScheduled job
publishes it at its time as the persona. scheduled_statuses lists, moves and
drops them; a moved post's old job finds it not due. Idempotency-Key holds
for scheduling too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 02:55:17 +02:00
thepraandClaude Opus 5.5 73c362de96 Town: Pleroma joins, and a rerun finds what it made
Pleroma 2.10.2 runs in the pasture from its OTP release (libvips, the
dedupe question, and hackney pointed at the system CA bundle); its driver is
Akkoma's on its own container and database. The pair passes 241 cells, after
its first run showed Pleroma dropping Follows it could not verify yet.

The PrivaPub driver finds the group an earlier run made instead of failing
on the name; the seeder posts a quote or reply of a post from its own round
after that post.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 02:53:58 +02:00
thepraandClaude Opus 5.5 5b3e456e09 Filters: words and posts a persona would rather not see
Mastodon's v2 filters replace the empty stubs: a filter's title, contexts,
action (warn, hide, blur) and expiry, its keywords (whole words or not,
taken as JSON objects, listed or numbered form fields, with id and _destroy
on update) and its statuses, each with their own endpoints; the v1 API is
the same filters seen keyword by keyword. Every status a persona reads
carries the filters it matches in `filtered` (a boost as what it boosts),
matched as Mastodon matches: warning, title, text, poll options and media
descriptions. Clients apply context and action. Filters never federate, and
go with a deleted persona.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 02:48:49 +02:00
thepraandClaude Opus 5.5 b710493701 A boost or a favourite given again is sent again
Every boost of a post was announce-{post}, and every favourite like-{post}:
after an Undo, giving it again made the same activity id, which the
delivery queue had already delivered and dropped (and a server that
remembers ids would ignore). Found by decePub's e2e actions against the
pasture, which unboost and boost again. Now each boost and favourite is an
activity of its own, as Mastodon's are, and an Undo names the one it ends;
a concurrent second boost is removed after saving. An edit's Update is
named to the millisecond, a quote approval's to the tick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 02:48:35 +02:00
thepraandClaude Opus 5.5 ce473f2741 Follow requests asked again, and followed accounts found by name
A server can take a Follow with 202 and drop it afterwards, as Pleroma does
while it cannot fetch our actor; the request then stayed pending for good.
Following again now sends an unanswered request once more, the same
activity, at most once an hour (a delivery's `again` key).

accounts/search takes following=true: only accounts the persona follows,
by the start of their name, display name or server, never resolved; a
client fills a list with it. "already take" becomes "already taken".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 02:40:40 +02:00
thepraandClaude Opus 5.5 02a05d7a9a Town: Iceshrimp.NET joins, and its mentions go nowhere new
Iceshrimp.NET 2026.1.2-beta runs in the pasture with AuthorizedFetch on;
its driver registers through the native API and takes Mastodon tokens from
its OAuth form. The pair passes 225 cells. A new Iceshrimp note reaches its
author's followers only, never an account it mentions or answers, until it
is edited (G-0004, peer): delivery cells now say when a server holds a post
only because it was addressed.

The checker credits a seeder's fetch to the object fetched, not to the reply
that needed it; the seeder skips the wait for an accept whose follow was
already accepted; the PrivaPub driver signs its roots in again when a run
reuses saved sessions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 01:54:36 +02:00
thepraandClaude Opus 5.5 0614bdcf18 Lists: a persona's followed accounts, read apart
Mastodon's lists replace the empty stubs: CRUD, members (only accounts the
persona follows; a follow that ends takes its memberships with it),
accounts/:id/lists, and timelines/list/:id from the persona's home entries
with the replies policy (followed, list, none; self-replies and replies to
the persona always). An exclusive list's members stay out of home. Lists
never federate, and go with a deleted persona.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 01:54:30 +02:00
thepraandClaude Opus 5.5 4b6d261739 Town: decePub's cells in the report, a Hollo pair, kinder waits
The report merges decePub's end-to-end cells (out/client.jsonl, the
latest result of each test) into the matrix under the observer
"decepub", and names its run without the workstation's paths. A second
spec, hollo-pair, seeds PrivaPub and Hollo alone (258 of 261 checks pass;
the three left found the emoji variation selector bug fixed in
PrivaPub), and Hollo accounts may join circles. The seeder waits up to
90 s for a follow request, two of a sender's retries after a refused
first delivery (Hollo answers its first concurrent deliveries 500 while
it creates its tables). town.sh drive prints its JSON unescaped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 01:24:17 +02:00
thepraandClaude Opus 5.5 f70ec59359 One emoji, one reaction: variation selectors made canonical
A heart reaction arrived as ❤ from some servers and as ❤️ (with the
emoji variation selector) from others, and a persona's own reaction kept
whatever it was given: the same emoji was two reactions, counted apart.
The selector is now dropped and put back only on a symbol that shows as
text without it (U+2000 to U+2BFF), so every heart is ❤️ and every 🔥
is 🔥. Found by the town's Hollo pair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 00:58:48 +02:00
thepraandClaude Opus 5.5 ed08f80f05 Votes a community relays count
Lemmy, PieFed and Mbin relay their members' votes, and the undoing of
them, inside the community's Announce; PrivaPub dropped them all as
unsupported (G-0003, the Lemmy scenario's expected failures). A relayed
Like, Dislike or Undo from a community a persona follows is now handled
as if its actor had sent it. The community vouches for what accounts on
its own server do and for what is done to its own posts, as Lemmy trusts
it (refetching every vote would not scale); a vote from elsewhere on
anything else is believed only once fetched from its own origin.
Moderation relayed the same way is still open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 00:55:04 +02:00
thepraandClaude Opus 5.5 2873344690 The town: a fake community across the pasture, checked for coherence
tools/pasture/town/ (run through tools/pasture/town.sh) seeds a fake
community across every running peer and checks that all of them, and
PrivaPub, agree on what happened:

- drivers per platform on four dialect bases (Mastodon API, Misskey API,
  Lemmy API, PrivaPub with /clientapi), each with a selftest against
  its own server; what a server holds is read from its database, never
  by making it fetch;
- a deterministic generator (specs/village.json: 23 accounts on seven
  servers, roots with several personas, circles and communities, a
  cross-server follow graph, posts of every kind and visibility, reply
  rounds, likes, boosts, reactions, votes, edits, deletes, blocks,
  mutes and a report) and a seeder that keeps a ledger of what happened;
- a sweep that expects delivery and confinement per server, what each
  account sees, counts, threads, edits, deletes, follows and privacy
  rows (sibling keys, published days, canary root credentials in every
  peer's database, located posts that never leave), with what the peers
  do on purpose modelled (Misskey drops orphan replies, Lemmy keeps only
  community content, edits go to the post's own audience);
- known gaps (gaps.json) turn failures into xfail and passes into xpass;
  a self-contained report.html, and docs/INTEROP-BACKLOG.md.

The pasture moves to a public-looking subnet (peers with no private
address switch can join), takes PASTURE_PORT when 6971 is in use, adds
peers to a running pasture (run.sh add, Caddy recreated with its CA
kept), removes its volumes on down, writes every scenario check to
out/scenarios.jsonl, serves decePub as decepub.test for its e2e tests,
lifts GoToSocial's and Lemmy's own rate limits, trusts Caddy in
Mastodon (TRUSTED_PROXY_IP) and gains Hollo (Fedify), whose one login
owning several accounts is the nearest peer to PrivaPub's personas.

The first village found the four PrivaPub bugs fixed in the commits
before this one; the second run, on the fixed server, passes 2319 checks
with 11 failures left, all between peers or from Lemmy's send worker,
which the seeder now warms up first. ROADMAP records the owner's
decisions of 2026-10-04 (the town, and P9 back from the cut list).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 00:32:54 +02:00
thepraandClaude Opus 5.5 d6131af289 A public reply names the author it answers in cc
A reply whose text had lost the @name of the author it answers (decePub
prefills it, a reader may delete it) was addressed to nobody on the
author's server. PrivaPub delivered it to the author's inbox anyway, but
GoToSocial keeps only what is addressed to someone there, so the reply
never appeared under the post. A public or unlisted reply to a remote
post now names its parent's author in cc, as Pleroma does; the reply
already says whom it answers, so nothing new is revealed. The parent
author's actor id is kept on the reply (InReplyToActorURI).

Found by decePub's end-to-end tests on the town.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 00:22:48 +02:00
thepraandClaude Opus 5.5 6b55b5c35c A status asked for by id is shown, and remote warnings are their own
GET /api/v1/statuses/:id applied the viewer's own blocks and mutes and
answered 200 with null for a post of an author the viewer had blocked;
decePub's thread page then showed nothing. As in Mastodon, a status asked
for by id (or acted on) is shown whatever the viewer blocked or muted,
and only lists leave such posts out; when nothing can be shown the
answer is 404, never null, and search leaves it out.

A remote post marked sensitive with an empty summary (Akkoma's sensitive
media, Lemmy's NSFW) got an invented "Content warning" that hid its
words. A remote post now keeps its own summary: sensitive without one
hides the media only. A local post warned without words still gets the
default warning.

Both found by decePub's end-to-end tests on the town.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 00:09:51 +02:00
thepraandClaude Opus 5.5 d405269526 A remote account's Block of a persona is enforced
Inbound Block was dropped as an unknown type (the pasture's last
Mastodon expected failure, G-0002). Now, as Mastodon does it: the
follows between the blocker and the persona end here too (nothing is
sent back; the blocker already ended its side), the blocker's posts and
notifications are hidden from the persona and kept out of its home, the
persona's posts are no longer addressed to the blocker by mention or
reply, and the relationship says blocked_by. Undo{Block} lifts it. The
block is kept in BlockedBy, unique per persona and blocker.

The Mastodon scenario checks blocked_by instead of expecting a failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 23:49:15 +02:00
thepraandClaude Opus 5.5 08acdc0503 Personas a remote post is addressed to see it, named or not
A remote post decided who here may see it by its Mention tags alone. A
followers-only post addressed in to or cc to a persona without naming
it (Akkoma's to[], or a GoToSocial edit that took the @name out while
the post stayed addressed to the persona) was hidden from that persona.
Such personas are now kept as silent mentions, as Mastodon does: they
see the post and it reaches their home, and no list shows them as
mentioned. An edit never narrows who a post was for.

The GoToSocial note that showed it is the first captured fixture
(Fixtures/gotosocial), and parses with its lone tag, to and cc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 23:42:23 +02:00
thepraandClaude Opus 5.5 b9286c2c6e Deliveries refused with 409 or 422 get three tries
Mastodon answers 422 when two first contacts from one actor race to
create its account (ActiveRecord::RecordInvalid on the unique uri), and
409 while another worker holds its lock. A persona that followed two
Mastodon accounts at once had one Follow refused that way; the job died
on its first attempt and the persona waited on "requested" forever.
Both answers are now retried twice on the usual backoff before they
count as refusals.

Found by the town (a village of 23 accounts on seven servers).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 23:42:23 +02:00
thepraandClaude Opus 5.5 128ff89426 Followers and circle members may like, react, vote and downvote
LikeHandler.MaySee let a remote account interact with a followers-only
post only when the post named it in to, cc or its mentions. Our own
followers-only posts are addressed to the followers collection, never to
each follower, and circle posts were not considered at all, so every
like, reaction, downvote and poll vote from a follower on a
followers-only post, and from a member on a circle post, was dropped as
"not-visible". Likes, dislikes, reactions and poll votes now ask
VisibilityPolicy.RemoteCanSee: anyone for public and unlisted posts, an
addressed account for a DM, an accepted follower or an addressed account
for followers-only, a foreign member for a circle post, and never a
server's instance actor (SignedFetchAuthorizer's alias is for refetches
only).

Found by planning the town's multi-server interaction checks (G-0001).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 23:05:22 +02:00
328 changed files with 24673 additions and 656 deletions

No files matched your search

+2
View File
@@ -402,6 +402,8 @@ FodyWeavers.xsd
PrivaPub/media-store/
PrivaPub/media-store-proxy/
tools/pasture/.publish/
tools/pasture/.flood/
.claude/worktrees/
tools/pasture/.ca/
tools/pasture/.state/
tools/pasture/out/
+288 -14
View File
@@ -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 to PieFed and Mbin, 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;
@@ -83,9 +86,14 @@ PrivaPub/ ASP.NET Core Web API, net10.0
(authoritative fetch, key verification, WebFinger), ActorDocument (parser)
Objects/ Origin, ActivityJson, NoteParser, Addressing, ContentSanitizer
Moderation/ DomainBlocks (suspend / silence / reject media)
Signing/ HttpSignatures (draft-cavage sign/verify)
Signing/ HttpSignatures (draft-cavage sign/verify), MessageSignatures (RFC 9421 verify),
RequestSignature (whichever a request carries), IntegrityProofs (FEP-8b32
eddsa-jcs-2022 by a persona's Ed25519 key, FEP-521a Multikeys), Jcs (RFC 8785)
Inbox/ InboxReceiver (verify, queue, 202) → InboxProcessor (job) → Handlers/{Follow,Accept,Reject,
Undo,Create,Update,Delete,Like,Announce}; RemotePosts (build, fetch parents, FetchAncestors)
Undo,Create,Update,Delete,Like,Announce}; RemotePosts (build, fetch parents, FetchAncestors);
RemoteReplies (FetchReplies: a thread's `context`, else `replies` two levels down);
Forwarded (what a thread's server passes on, believed as far as the origin vouches);
ReplyRelay (third-party replies to a persona's posts passed on to its followers)
Outbox/ OutboxPublisher (who a post goes to), DeliveryService (queues jobs) + DeliveryJobHandler
Rendering/ ActivityPubRenderer (Mastodon @context, actors, notes, collections)
Domain/
@@ -95,7 +103,9 @@ PrivaPub/ ASP.NET Core Web API, net10.0
Privacy/ VisibilityPolicy (IsPublic expression, CanSee)
Relationships/ RelationshipService (blocks, mutes, account domain blocks; Hidden), ReportService
Media/ MediaService (libvips, ffmpeg remux, blurhash), MediaProxy, MediaJanitor
Domain/Statuses/ StatusService: publish, edit, remove, favourite, reblog, for a persona (both client APIs use it)
Domain/Statuses/ StatusService: publish, edit, remove, favourite, reblog, for a persona (both client APIs use it);
InteractionApprovals: GoToSocial's canReply/canLike/canAnnounce, judged, asked and answered;
Participations: a persona's Join and Leave of a remote event, and the organiser's answer
Api/Mastodon/
Auth/ OpenIddict setup (keys in Mongo), MastodonScopes, TokenController, OAuthPruner
Infrastructure/ MastodonController (avatar context, scopes, errors, Link), MastodonParams, MastodonJson, Page
@@ -158,16 +168,28 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
`HttpClient` for federation. The only other outbound traffic is SMTP and `GeoUpdater`'s monthly DB-IP Lite download
(its own `geo` client, a fixed HTTPS host, size-capped, the file checked before it is swapped in).
2. **Every fetch is signed by the instance actor** (`privapub`), never by a persona; deliveries are signed by the acting
avatar or group. Both are draft-cavage rsa-sha256 over `(request-target) host date` (+ `digest` on bodies).
avatar or group. Both are draft-cavage rsa-sha256 over `(request-target) host date` (+ `digest` on bodies). Inbound,
an RFC 9421 signature (`Signature-Input`) is verified too, its target against our public address. A persona's own
activity going to a relay also carries a FEP-8b32 proof by its Ed25519 key (`Avatar.SigningKey`, one per persona,
never shared), and nothing else does: Mitra refuses a proof by a key it has not read. A forwarded activity is taken
as it came only when its proof verifies with a Multikey of its actor's.
3. **A remote document is believed only from its own address.** `RemoteActorService.FetchObject` requires the
document's `id` to be the URL it was served from (a same-origin alias is followed once). A key is accepted only if
the actor lists it, its `owner` is the actor and it shares the actor's origin.
4. **Inbound inboxes verify everything before acting:**
- the signature covers `(request-target)`, `host`, `digest` and `date` or `(created)`;
- the Digest matches the body and the date is at most an hour old and fifteen minutes ahead;
- the signature verifies against the key owner's key, and the activity's `actor` is the key owner;
- the signature verifies against the key owner's key, and the activity's `actor` is the key owner, or else it was
forwarded (`Forwarded`: a thread's server passing on what happens in it, as Mastodon and Friendica do). A forwarded
activity proves nothing about its author: answered 202, a Create or Update is taken as its object reads at the
actor's origin now, a Delete of a public or unlisted copy once that origin answers 404 or 410
(`RemoteActorService.IsGone`), anything else let go. Forwarded copies have their own dedupe key, so a copy that
failed never hides the author's own delivery;
- an activity is queued once per id, but an id that comes back carrying another type, actor or object is a second
activity, not a copy (Friendica's ids are `uniqid()`, which two of its processes can share), and is queued apart;
- the activity's `id`, and any object it creates, updates or deletes, is on the actor's origin; a cross-origin
object is refetched from its own origin.
object is refetched from its own origin. One attributed to another account of the actor's server (Mobilizon's
organiser and the group's event) is taken as that server has it, and deleted once it answers 404 or 410.
5. **Status codes:**
- bad or missing signature: **401**;
- malformed or forged body: **400**;
@@ -195,9 +217,13 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
when a refetch answers 404, which is why this matters.
**Communities** are FEP-1b12 groups: `GroupDistributor` announces the whole activity (plus the object for new posts,
for Mastodon), top-level posts are `Page`s with a `name`, posting follows `Group.PostingPolicy`.
Located posts (`LocalGeo`) are the only local-only posts.
Located posts (`LocalGeo`) are the only local-only posts. The Mastodon API looks past them everywhere, homes
included (the fan-out gives them no `TimelineEntry`, not even their author's): they are read through
`/clientapi/post/nearby` and deleted through `/clientapi/post/delete`.
9. **A DM joins a conversation only by `DmGroup.ParticipantsKey`**, the exact set of its participants; a remote
`context` decides nothing. DMs are `Post`s with `Visibility = Direct` and a `ConversationId` (`DmPost` is legacy).
`DmGroup.LastPostId` pages `/api/v1/conversations`, and `ConversationState` keeps each persona's read and removed
marks (`Domain/Statuses/ConversationStates.cs`; writing in a conversation reads it).
10. **Nothing slow happens inside a request.** Deliveries and inbox processing are `Job`s (`Infrastructure/Jobs`):
leased, retried on Mastodon's curve, at most two per host, paused per host by `RemoteInstance`. The inbox answers
202 once it has verified and queued; a handler must be idempotent (unique `ObjectURI`, job `DedupeKey`).
@@ -216,6 +242,20 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
fetched as context. Followers-only is detected by the author's stored `followers` URL.
14. **Home timelines are written, not computed:** every stored or created post goes through `Fanout.Distribute`, and
every delete removes its `TimelineEntry` rows. Local deletes are soft (content cleared, 410 Tombstone).
15. **What PrivaPub passes on is exactly what it received** (owner decision 2026-10-05). A public or unlisted reply from
another server to a persona's public, unlisted or followers-only post, and its Update and Delete, go to the
persona's followers as the activity arrived (`Arrival.Raw`, `Federation/Inbox/ReplyRelay.cs`), signed by the
persona for the transport only: never to the replier's own server, never for a local-only or group post. Only an
activity its own actor delivered carries `Raw`, so a forwarded one is never passed on again.
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>` id), each only from the origin of what it answers.
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
@@ -239,6 +279,11 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
6. Advertise `4.2.0 (compatible; PrivaPub)` until streaming and Web Push exist too; `api_versions.mastodon = 7` stays,
because clients show quotes from it, so everything a 4.3+ client calls because of it must answer: grouped
notifications (`/api/v2/notifications`, its unread count, groups, accounts and dismiss) and the notification policy.
The policy is real (`Domain/Social/NotificationPolicies.cs`, applied in `Notifications.Add`): accepted by default,
it filters (into a `NotificationRequest` per account, out of every list and count unless `include_filtered`) or drops
what strangers, accounts that do not follow the persona (or only for three days), accounts newer than 30 days,
unasked private mentions and silenced accounts send; accepting a request lets that account in for good
(`NotificationPermission`).
7. **What a Mastodon `Status` cannot say goes in `Status.privapub`** (`PrivaPubStatus`): object type, title, excerpt,
cover, the author's source, link, video, audio and event details, a remote post's own place (Pixelfed's `location`,
never federated again), and up/down votes. Every media URL in it goes
@@ -289,7 +334,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
communities on Lemmy, PieFed and Mbin), 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.
@@ -362,7 +408,8 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
same rule writes `canQuote`, answers `QuoteRequest`s (`QuoteService.ReceiveRequest`) and fills `quote_approval`, so they
cannot disagree. A persona quoting another persona gets a parrot-licence too, so other servers see an approved quote.
- **A server is described on arrival, never on read.** The first record from a host enqueues `DescribeInstance` (its
NodeInfo, at most once a week, into `RemoteInstance`), so opening the details view tells nobody anything.
NodeInfo, at most once a week, into `RemoteInstance`), so opening the details view tells nobody anything. A
description that failed frees its week, so the server's next arrival (an hour on at the soonest) asks again.
- **Post ids are the timeline order, so they follow arrival, not `published`.** `PrivacyIds.Arrived` gives a remote post
published within the last hour (or in the future) a fresh `ObjectId`, which sorts after every post already stored,
and only backfill keeps a `published`-derived id. With `published` ids a reply arriving in the same second could sort
@@ -437,6 +484,10 @@ tools/pasture/interop.sh [gts mastodon ...] # each peer
tools/pasture/run.sh down # removes every pasture container and volume
```
- **A change to what PrivaPub sends** (the `@context`, an actor's properties, an activity's shape, a delivery's headers)
is run against every peer's scenario before it is committed, not only the peers it was written for. One reader
stricter than the rest fails on it alone: the 2026-10-06 `proof` term passed every peer but Smithereen, whose JSON-LD
1.0 reader refused every document carrying it.
- **Layout:** `lib/pasture.sh` (network, Caddy, Mongo, PrivaPub), `peers/<name>.sh` (`<name>_up`, plus `peers/shared.sh`
for the Postgres and Redis several peers share), `lib/interop.sh` (`ok`, `ko`, `xf` for a check expected to fail until
a later phase, `privapub_token <persona>`, `stats_check <host> <software>`), `scenarios/<name>.sh`. Each peer talks to
@@ -459,9 +510,10 @@ tools/pasture/run.sh down # removes e
delete is checked as a 404 on `/api/v1/statuses/{id}`.
- It creates its accounts locked, so the scenario approves alice's request through `/api/v1/follow_requests`, which
also checks our pending (`requested`) state and the manual Accept.
- 55 checks: discovery and follows both ways; posts and CW; a reply and its notification; likes and boosts both
- 64 checks: discovery and follows both ways; posts and CW; a reply and its notification; likes and boosts both
ways; DMs both ways and off public timelines; polls both ways; quote policy; link cards; edits and deletes both
ways; communities and circles; locked personas; unfollow; block and unblock; statistics.
ways; communities and circles; locked personas; interaction policies (a reply and a like asked for and approved
through `/api/v1/interaction_requests`, a boost refused); unfollow; block and unblock; statistics.
- It files a circle post as a direct message and shows it only to the accounts it mentions, so like a DM it is
checked in the member's `/api/v1/conversations`, never by URI.
- **Mastodon (4.7.3):** web and sidekiq on the shared Postgres and Redis, `ALLOWED_PRIVATE_ADDRESSES` for the network.
@@ -469,7 +521,8 @@ tools/pasture/run.sh down # removes e
deliveries are checked through `/api/v1/accounts/:id/statuses` of the sender as Mastodon knows them, or
`Status.exists?` through `rails runner`. Its actors are numbered (`/ap/users/<id>`), so look URIs up rather than
build them. Circle posts and a followers-only post survive its signed refetch (`ActivityPub::FetchRemoteStatusService`
through `rails runner`). Inbound Block is the one expected failure until P7.
through `rails runner`). Its block of a persona, and the unblock that ends the run, are checked through our
`blocked_by`; the run starts with that unblock too, since Mastodon rejects every follow from an account it blocks.
- **Misskey (2026.10.0):** one container on the shared Postgres and Redis. A new Misskey federates with nobody
(`federation: none`) until `admin/update-meta` says `all`, which `misskey_up` does. Its API is `POST /api/<endpoint>`
with the token as `i` (`mk` in the scenario); `users/relation` answers a list, `users/notes` leaves replies out unless
@@ -482,7 +535,7 @@ tools/pasture/run.sh down # removes e
shipped in the release (CAStore, certifi), so the entrypoint appends Caddy's CA there. `pleroma_ctl` passes its
arguments on unquoted: no value may contain a space. Its Linkify never takes `@user@host.test` for a mention, so the
scenario addresses alice with Pleroma's `to[]`; its API never reports a remote blocker as `blocked_by`, so the block is
read from its `user_relationships`. 44 checks.
read from its `user_relationships`. `akkoma_token <user>` signs one of its users in. 45 checks.
- **Lemmy (1.0.0-beta.2):** the backend alone on the shared Postgres, its admin made by `setup` in the generated
`config.hjson`. It trusts Caddy's CA through `SSL_CERT_FILE` and reaches the network through
`DANGER_FEDERATION_ALLOW_LOCAL_IP=1`; 0.19 cannot join (its rustls trusts only its bundled roots). Its API is
@@ -490,7 +543,189 @@ tools/pasture/run.sh down # removes e
activity at `warn` (`LEMMY_LOG` sets `RUST_LOG`); the reason is in the 400's body. It answers our community's echo of
its own activity and every bare `Announce{object}` 400 by design, and the echo is still needed (see
`docs/INTEROP.md`, Lemmy). A new Lemmy never sends what it queued for a server before its send worker for that
server started, so the scenario waits for that worker (`lm_worker`) before its first follow. 20 checks; relayed votes and a moderator's removal are expected failures (P7).
server started, so the scenario waits for that worker (`lm_worker`) before its first follow, and it sends what it
queued every 30 seconds, so a vote or a moderator's act takes up to a minute. Its lock, unlock and unban need a
`reason`, or it refuses them and federates nothing. 29 checks, among them a moderator's lock, ban and removal.
- **PieFed (1.7.17):** dockurr's image of the release, its web app (with PieFed's own cron, `CRON=true`, for its send
queue) and a Celery worker on the shared Postgres and Redis (dbs 10 and 11), sharing the `pasture-piefed-media`
volume. httpx trusts only certifi's bundle, so the pasture's is mounted over it. `flask init-db` reads its admin
(pfuser) from stdin and drops every table it finds, so it runs once, after the web app's migrations ("Starting
Gunicorn"). Its API is Lemmy's v3 under `/api/alpha` with a JWT (`pf` in the scenario); `resolve_object` answers a
view (`community.community.id`). It sends a community's announces to the Application at a peer's root, assuming
`/inbox` when there is none, and keeps serving a thread its moderator removed. `scenarios/piefed.sh`, 29 checks:
communities both ways, threads with titles, comments, votes up and down both ways, a community poll and alice's vote,
private messages both ways, a moderator's lock, unlock and removal, the unfollow, statistics.
- **Mbin (1.10.1):** its own image (FrankenPHP serving plain HTTP behind Caddy, `SERVER_NAME=:80`) and a messenger
worker (`mbin_worker`, consuming again whenever it stops: once, the shared Postgres restarted under it and Mbin sent
nothing for a day), on the shared Postgres and Redis (db 12) with a RabbitMQ of its own (its transports carry AMQP options; it
runs as its own user on its own volume, or it cannot read the `.erlang.cookie` it wrote as root). The pasture's bundle
is mounted over the system one; its API's rate limits (two threads every six minutes) are raised by a copy of its
`rate_limiter.yaml`. Its admin mbuser is made by its console; `peers/mbin_token.py` gets mbuser's OAuth token through
the authorization-code flow (login form, consent), since a client-credentials client acts as a bot that may not vote.
Mbin names what it makes during a request after the request's host, so every call says `Host: mbin.test`, never
the workstation's port. Its access token lasts an hour, and the scenario gets a new one when it has expired. Its API
never starts a conversation with an account elsewhere, so mbuser answers in the thread alice began; Mbin addresses
that answer to alice's profile page. `scenarios/mbin.sh`, 26 checks: magazines both ways, threads with titles, a Note
to a magazine as a microblog post, comments both ways, favourites and upvotes both ways, private messages both ways,
a moderator's lock, unlock and removal, the unfollow, statistics.
- **NodeBB (4.16.1):** the official image on the pasture's Mongo (database `nodebb`), set up once by its automated
setup (`SETUP` with `NODEBB_*` variables, admin nbuser) into the `pasture-nodebb-config` volume, trusting the CA
through `NODE_EXTRA_CA_CERTS`; its image runs `npm install` at every start. Its API (`/api/v3`) takes a bearer token
that `nodebb_settle` writes where NodeBB keeps its tokens. A remote account's uid there is its actor's URL, but its
API follows one only when named by its handle; a chat room is made, then written in. `scenarios/nodebb.sh`, 23
checks: a category followed and its topic as a titled thread, replies both ways, nbuser following alice, votes both
ways, an edit and a deletion, a chat both ways, the unfollow, statistics.
- **Lemmy 0.19 (0.19.20), as `lemmy19.test`:** what most of the threadiverse runs. Its release trusts only the roots its
rustls bundles, so `images/lemmy19` builds the tag with reqwest's `rustls-tls-native-roots` added (about ten minutes
the first time) and the pasture's bundle is mounted as the system's. Its config names the database as `uri` (1.0:
`connection`) and its API is v3 (`/api/v3`, `sort=New`, `resolve_object` answering views). It takes private messages
only as `ChatMessage`, which PrivaPub sends it, a first message too (invariant 17). `scenarios/lemmy19.sh`, 30
checks.
- **Threads (`scenarios/threads.sh`, needs mastodon):** mastouser follows alice, carol answers alice's public post,
and opening the thread on Mastodon finds carol's reply through alice's `replies` (the scenario makes Mastodon's copy
look ten minutes old: Mastodon reads a thread's replies only five minutes after it first holds the post). 5 checks.
- **Followers synchronisation (`scenarios/followsync.sh`, needs mastodon):** sent: PrivaPub forgets mastouser's follow
of alice (mongo), alice's followers-only post naming mastouser makes Mastodon read her roll-call and drop it; then
Mastodon forgets a follow (rails) and alice's next followers-only post makes it send the `Undo` PrivaPub applies.
Received: alice and bob follow mastouser; Mastodon forgets alice's follow and mastouser's followers-only post makes
PrivaPub end it, then PrivaPub forgets it and the next makes PrivaPub undo it there. Follows are checked in Mastodon's
database: its API caches relationships for a day. 15 checks.
- **Pins (`scenarios/pins.sh`, needs mastodon):** a Mastodon account pins and unpins while alice follows it, alice pins
and unpins while it follows her, a fresh account's earlier pin shows once PrivaPub resolves it, and following it brings
its earlier posts (its outbox). 9 checks.
- **Vernissage (1.43.0):** its image on SQLite in the `pasture-vernissage` volume, its queues on the shared Redis's
database 0 (it federates nothing without them; every other database is taken), its seeded admin (admin / admin)
logged in by the scenario. `scenarios/vernissage.sh`, 19 checks.
- **BookWyrm (0.9.3):** its image, migrated, on the shared Postgres (database `bookwyrm`) and Redis (databases 14
and 15), gunicorn and one Celery worker (federation runs there). It has no client API: bwuser (named
`bwuser@bookwyrm.test`, as its signup names users), a book and every status come from its Django shell
(`bookwyrm_shell`, `-v 0`). `scenarios/bookwyrm.sh`, 20 checks.
- **Ghost (6.67) with its ActivityPub service (1.2.14):** Ghost and the service on the shared MySQL (databases `ghost`
and `activitypub`, the service's migrations run first), routed by Caddy as Ghost's own proxy does. The owner comes
from Ghost's setup API (a password that repeats the site's name is refused), staff device verification is off, and
Ghost starts again once so it sets up its publishing webhooks. Calls to the service say `Host: ghost.test`, or it
cannot find the site's keys. `scenarios/ghost.sh`, 22 checks.
- **Owncast (0.3.0):** its image, its data in the `pasture-owncast` volume, federation turned on through its admin
API (`owncast_admin`, its default admin / abc123) with the server's address and the actor's name.
`scenarios/owncast.sh`, 13 checks.
- **WriteFreely (0.17.2):** built from its release tarball with `openssl` beside it (`images/writefreely`, it makes
each blog's keys with the command), on SQLite in the `pasture-writefreely` volume, which the scenario reads from the
host. wfuser and its blog come from its CLI; its API takes a login. `scenarios/writefreely.sh`, 13 checks.
- **snac2 (2.95):** built from its tag (`images/snac2`, the project publishes no image), its data in the
`pasture-snac` volume from `snac init` and `snac adduser`; the password comes from `snac resetpwd` and the token from
its login form and a code grant. Its API lags behind what it has taken in, so `scenarios/snac.sh` reads its files
(`snac_stored`, `snac_in_timeline`). 27 checks.
- **Mitra (5.9.1):** its image on the shared Postgres (database `mitra`), as `mitra.test`, with the pasture's bundle as
the system's roots; mitrauser comes from its CLI and its token from a password grant. Its search resolves an account
elsewhere only without `type`. `scenarios/mitra.sh`, 28 checks.
- **Relays (`peers/relay.sh` Activity-Relay 2.0.9 as `relay.test`, on the shared Redis's database 13;
`peers/aoderelay.sh` aode-relay 0.3.129 as `aoderelay.test`):** `appsettings.Pasture.json` names both in
`Federation:Relays`, so PrivaPub subscribes a minute after it starts. `scenarios/relay.sh` has Mastodon subscribe to
each in turn, checks that a post of an account nobody here follows reaches the federated timeline (forwarded by one,
announced by the other), that alice's public post goes to the relays (and through both to Mastodon: Activity-Relay's
forward on its FEP-8b32 proof, after the scenario makes Mastodon read alice's keys anew) and nothing less public, and
has Mastodon leave again, so the town sees no relayed posts. 16 checks.
- **Castopod (1.15.5, `peers/castopod.sh`):** the official image on the shared MySQL (database `castopod`, file cache,
`CP_DISABLE_HTTPS`), its database and superadmin (admin@castopod.test) from `spark`, its REST API switched on in the
`.env` the image rewrites at each start, `spark fediverse:broadcast` looping in the container. The podcast `@pod` is
made and published through its admin pages (`cp_web`, CSRF on each form). `scenarios/castopod.sh`, 15 checks; the
episode's sound is made with ffmpeg on the workstation.
- **Ktistec (3.13.0, `peers/ktistec.sh`):** built from its tag by `images/ktistec` (its own Dockerfile, pinned: a static
Crystal server, its assets with node), SQLite in the `pasture-ktistec` volume, the pasture bundle mounted as
`/etc/ssl/cert.pem`. The server's name and ktuser come from its setup API (every field required, `summary` too);
`ktistec_token` signs in on `/sessions`. `scenarios/ktistec.sh`, 27 checks: its Mastodon API where it has the route,
its outbox API (`kn`) for follows, polls (`poll-duration` is seconds from now), edits, deletions and quotes, and a
copy of its SQLite database (`kt_sql`) for what it holds.
- **Forte (26.9.10, `peers/forte.sh`):** built from its tag by `images/forte` (composer on Hubzilla's PHP image, an nginx
routing through `index.php?req=`), on the shared MySQL (database `forte`), its `.htconfig.php` written to
`.state/forte`, a cron sidecar (`src/Daemon/Run.php Cron`). The pasture bundle is mounted over `library/cacert.pem`,
the bundle its curl uses. ftuser (administrator) and ftfriend are made through its PHP (`forte_eval`: `Account::create`,
`Channel::create` with the role `social` and `autoperms`, or every connection waits). Connecting from the command line
runs the notifier itself (`ft_connect`). `scenarios/forte.sh`.
- **Hubzilla (11.4.1, `peers/hubzilla.sh`):** the hlhd image (php-fpm and nginx on 8080) on the shared MySQL
(database `hubzilla`, its schema loaded from the image), `.htconfig.php` written to `.state/hubzilla` and mounted, a
cron sidecar running `Zotlabs/Daemon/Master.php Cron` each minute. Its addons `pubcrawl` (ActivityPub) and `statistics`
(NodeInfo) are installed with `util/addons`; hzuser (the hub's administrator) and hzfriend are made through its own PHP
(`hz_eval`), as accounts written the way its registration queue would and channels with the role `public` (the older
role names grant connections nothing), the ActivityPub app installed as each channel. Its API (`api/z/1.0`) posts and
edits with HTTP Basic (email and password); liking and dropping go through its web pages signed in (`hz_web`).
`scenarios/hubzilla.sh`, 20 checks and one known gap (G-0010, its unfollow).
- **Smithereen (1.0.3):** its image on the shared MySQL (database `smithereen`, its schema from the image's commit),
with imgproxy and a file server behind Caddy as `smithereen.test` (`/i` and `/s`), trusting the CA through a JDK
store with it added (`JAVA_TOOL_OPTIONS`). MySQL takes its stored functions only with
`log_bin_trust_function_creators`, and a first start that failed there leaves the database without triggers (drop it
and start again). Accounts come from its signup form (opened, with a short description, which its NodeInfo needs) and
are renamed in its database; its API is VKontakte's (`/api/method/<name>?v=1.0`), with a password grant for a local
application the peer script inserts. `scenarios/smithereen.sh`, 40 checks, walls both ways (alice's profile is
told again first, so Smithereen reads her `sm:wall`; `servers.features & 1` says it took PrivaPub for a server with
walls). Its scenario texts avoid apostrophes: `p_home_has` puts them inside a Python string.
- **Load (`load.sh`, needs the `flood` peer):** `flood` (`flood/flood.cs`, published once into `.flood`) answers as
twenty fake servers and sends signed activities at a set rate; `load.sh --rate=N --seconds=N` measures the answers,
the queue's wait and processing times, its drain, and a persona's home timeline meanwhile, and keeps each run in
`out/load/`. `docs/LOAD.md` has the method and the runs.
- **Account moves (`scenarios/moves.sh`, needs gts):** two fresh GoToSocial accounts made by its admin CLI; alice follows
the old one, the new one names it as an alias (`/api/v1/accounts/alias`), the old one moves (`/api/v1/accounts/move`),
and PrivaPub shows it `moved` and moves alice's follow to the new one, which (locked by GoToSocial) approves it.
8 checks.
- **Hollo (0.9.19):** Fedify's microblog server on the shared Postgres, set up through its web form (which
checks `Origin` against `Host`, so the request names `hollo.test` without the port). It needs a 44-character
`SECRET_KEY`, a media directory and a `themeColor`; statuses and votes go as JSON. Town only, no scenario.
- **PeerTube (8.3.1):** the official image on the shared Postgres and Redis (db 4), configured through `PEERTUBE_*`
variables, trusting Caddy's CA through `NODE_EXTRA_CA_CERTS`; `peertube_settle` turns transcoding off (a test video
is served as uploaded) and keeps root's token in `.state/peertube.token`. It wants a bare `Host: peertube.test`.
Its list of comments held for review (`/api/v1/users/me/videos/comments?isHeldForReview=true`) answers 500 in 8.3.1;
the video's owner sees them in the video's threads (`heldForReview`). `scenarios/peertube.sh`, 28 checks.
- **Pleroma (2.10.2):** `images/pleroma` installs the OTP release, pinned by checksum, as Akkoma's does; it also needs
libvips, and `instance gen` asks about deduplicating uploads. Its federation runs on hackney, which trusts only
certifi's compiled-in roots, so the entrypoint points `:pleroma, :http, adapter` at the system CA bundle. It answers
an inbox POST at once and verifies in a worker (`oban_jobs` shows what it refused and why). Town only, no scenario.
- **Iceshrimp.NET (2026.1.2-beta):** on the shared Postgres with AuthorizedFetch on and open registrations, trusting
Caddy's CA through `SSL_CERT_FILE`. Accounts come from its own `/api/iceshrimp/auth/register` (its login cuts the
connection short for a name it does not know), Mastodon API tokens from its OAuth form, which prints the out-of-band
code in the page. Its `jobs` table shows what it queued for whom. Town only, no scenario.
- **Pixelfed (0.14.4):** serversideup's FrankenPHP image on the shared Postgres (database pixelfed) and Redis (dbs 5 and
6), with Horizon and the scheduler as sidecars of the same image sharing its storage volume, and Caddy's CA mounted
as its system bundle. `pixelfed_settle` makes `caption` nullable (its PostgreSQL migration never runs, so every remote
boost failed), creates Passport's keys and restarts the web server so FrankenPHP reads them, numbers the OAuth
clients from a million (Passport refuses a token whose user id equals its client's id), and imports its cities.
Tokens are personal access tokens made through tinker (`App\Models\User`). Every top-level post needs a picture.
`scenarios/pixelfed.sh`, 26 checks; the town's driver and `specs/pixelfed-pair.json`.
- **WordPress (7.1.2, ActivityPub plugin 9.3.1):** the official image on a shared MySQL 8.4 (`shared_mysql_up`, ready
only when it answers over TCP: its first start runs a server without networking), installed and configured by wp-cli,
its CA bundle (`wp-includes/certificates/ca-bundle.crt`) given Caddy's root, and WP-Cron run every five seconds by a
sidecar (`DISABLE_WP_CRON`), since the plugin federates from it. Its automatic updates are off
(`AUTOMATIC_UPDATER_DISABLED`): the sidecar ran one (6 to 7.1.2) whose new CA bundle dropped Caddy's root, and
WordPress reached nobody after it. Authors are actors; the REST API takes application passwords.
`scenarios/wordpress.sh`, 17 checks.
- **Friendica (2026.05):** the official image on the shared MySQL (database friendica) and Redis (db 7, for its cache,
locks and sessions), installed by its autoinstall, with the image's worker daemon (`cron.sh`) as a sidecar sharing
its files. `friendica_settle` repairs what the install leaves: it names the system user (uid 0), or Friendica never
makes the system account that signs its fetches; it declares the daemon running (`worker_daemon_mode` in
`key-value`), or the web container, which cannot see the sidecar's pid, never wakes it and everything waits for its
five-minute cron; it makes the log file and turns logging on. `friendica_user` saves each account through the API
with `locked=0` (a number: "true" reads as 0), which makes it a "soapbox" page that takes followers without asking and
follows nobody back, and reads its outbox once: a Follow that reaches a user Friendica has not cached yet makes it
fetch that user from itself, signed, and checking the signature recurses for about five minutes. Its API takes HTTP
Basic (`nick:password`); an edit through it never federates, so the scenario edits in the web editor.
`scenarios/friendica.sh`, 25 checks; the town's driver and `specs/friendica-pair.json`.
- **Mobilizon (5.2.4):** kaihuri's image of the release on a PostGIS of its own (`shared_postgis_up`: it needs the
extension), with the pasture's bundle mounted over certifi's and castore's CA files (hackney trusts only those) and
the system store, and geocoding pointed at a closed local port. `mobilizon_ctl users.new` makes mzuser (it splits
its arguments on spaces); the API is GraphQL (`/api`), signed in by the `login` mutation. An event made through it
without options has its comments closed. `scenarios/mobilizon.sh`, 27 checks: a group and its events (dates, place),
alice joining an event (Mobilizon's participant row and its Accept) and leaving it, comments both ways, the
organiser's edit, closed comments and deletes, a group post, the unfollow.
- **Gancio (1.28.2):** cisti's image on sqlite in the `pasture-gancio-data` volume, whose `config.json` is written before
the first start (without it Gancio waits in its setup wizard), trusting Caddy's CA through `NODE_EXTRA_CA_CERTS`.
`gancio users create` makes gcadmin; `gancio settings set enable_resources true` keeps fediverse replies. One
Application actor, `relay`, publishes the agenda. Its API takes a token from an OAuth password grant
(`/oauth/login`, client_id `self`); an event is a multipart POST (`--form-string` for text starting with `<`).
`scenarios/gancio.sh`, 17 checks.
- **Funkwhale (2.0.11):** the API (gunicorn), a Celery worker with its beat and the front's nginx, sharing the
`pasture-funkwhale-data` volume, on the shared Postgres (database funkwhale) and Redis (dbs 8 and 9); Python's requests
trusts the pasture's bundle through `REQUESTS_CA_BUNDLE`. `funkwhale-manage fw users create` makes fwuser and an OAuth
access token is made in its Django shell. Its API is `/api/v1` and `/api/v2` (uploads); a track is a fresh tone made
by ffmpeg in its container, since it skips a file it already has. `scenarios/funkwhale.sh`, 16 checks.
- **SecureMode:** `PRIVAPUB_ENV="Federation__SecureMode=true" run.sh up …`, as production runs. A check of what an
unsigned reader sees uses `unserved` and `gone_unsigned` (`lib/interop.sh`), which expect 401 when SecureMode is on and
404 or 410 when it is off.
@@ -498,6 +733,45 @@ tools/pasture/run.sh down # removes e
run.sh up mastodon`, then `interop.sh crawler`. `PRIVAPUB_ENV` passes any setting to the PrivaPub container.
- `run.sh up` replaces every container, Mongo included, so each run starts clean. To keep the data, republish into
`tools/pasture/.publish` and `podman restart pasture-privapub`; that is how a migration is tried on dirty data.
- `run.sh add <peer>` starts more peers next to a running pasture; `run.sh down` removes every pasture container with
its volumes (`--purge` drops Caddy's CA too); `run.sh stats` shows each container's memory.
- **The network looks public** (`PASTURE_SUBNET`, default `11.42.0.0/24`), so peers that refuse private addresses with no
switch (Pixelfed, Mbin, WordPress, Discourse, Fedify) federate. Mastodon then needs `TRUSTED_PROXY_IP` for Caddy, or it
answers every request with an IP spoofing error.
- **`PASTURE_PORT`** moves PrivaPub's workstation port off 6971 when something else holds it; `lib/interop.sh` follows.
- Every `ok`/`ko`/`xf` is also a line of `out/scenarios.jsonl`, which the town's report shows.
#### The town (`tools/pasture/town.sh`, `tools/pasture/town/`)
A fake community seeded across every running peer, then checked for coherence: what each server holds (read from its
database, never by making it fetch), what each account sees through its API, counts, threads, edits, deletes, follows
and the privacy rows. Python 3 standard library only, tabs.
```bash
tools/pasture/town.sh selftest [peer...] # each driver against its own server; run after changing a driver or a pin
tools/pasture/town.sh seed village # specs/village.json: 23 accounts on seven servers, about 11 minutes
tools/pasture/town.sh check village # out/<run>/results.json; --deadline=0 re-sweeps an old run at once
tools/pasture/town.sh report village # out/<run>/report.html, publishable (fake data, no tokens)
tools/pasture/town.sh report village hollo-pair iceshrimp-pair pleroma-pair pixelfed-pair friendica-pair # one matrix
tools/pasture/town.sh backlog --write # docs/INTEROP-BACKLOG.md
```
- **Drivers** (`dialects/`): one class per platform on a dialect base (`mastodon_api`, `misskey_api`, `lemmy_api`,
`privapub`). `stored()` reads the database (shared Postgres through `psql`, the shared MySQL through `mysql_json`,
GoToSocial's sqlite copied out, Mongo);
`seen()` asks the API as one account. A driver that cannot do something raises `Unsupported`, and the planner never
asks for it (`gen.CAPS`).
- **The plan** (`gen.py`) is a pure function of the spec and its seed; each run keeps its own `plan.json`, `ledger.jsonl`
(what happened, one fact per line) and `results.json`. `seed --resume` redoes only failed steps.
- **Cells** are `feature|origin|observer|direction` (`in`, `out`, `via`, `control`, `local`). A known gap is a
`gaps.json` entry: matching failures are `xfail`, matching passes `xpass` (close the gap).
- **What the peers do on purpose**, so the checker expects it: Misskey and Sharkey drop a reply whose parent they cannot
fetch; Lemmy keeps only community content and private messages; servers send edits and deletes to the post's own
audience only; a Misskey heart reaction arrives as a Like.
- Peers' own limits are lifted for the town: GoToSocial `GTS_ADVANCED_RATE_LIMIT_REQUESTS=0`, Lemmy's
`local_site_rate_limit`. Mastodon reserves usernames that contain `mastodon`.
- decePubClient's e2e tests (`decePubClient/tests/e2e/run.sh`) publish the client with the Pasture environment, serve
it as `https://decepub.test` (`peers/decepub.sh`) and write their cells to `out/client.jsonl`.
After the pasture, verify.funfedi.dev and the owner's GoToSocial at social.arasaka.software. **Ask before acting from
the owner's GoToSocial account.**
+222 -21
View File
@@ -8,7 +8,14 @@ PrivaPub is an ActivityPub server written in C#. This document follows
- [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
(`hs2019` hashed with SHA-256, or SHA-512 as some sign it; a `SHA-256=` or `SHA-512=` digest)
- [HTTP Message Signatures](https://www.rfc-editor.org/rfc/rfc9421) (RFC 9421) and Content-Digest (RFC 9530), verified on
deliveries and signed fetches: `rsa-v1_5-sha256` and `rsa-pss-sha512` with RSA keys
- [NodeInfo](https://nodeinfo.diaspora.software/) 2.0 and 2.1
- Object integrity proofs ([FEP-8b32](https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.md),
`eddsa-jcs-2022`, JSON canonicalised by [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785)) by Ed25519 keys published
as Multikeys ([FEP-521a](https://codeberg.org/fediverse/fep/src/branch/main/fep/521a/fep-521a.md)), on what personas
send to relays and verified on what is forwarded
## Tested against
@@ -19,7 +26,32 @@ and every run starting clean, with signed fetches required (as privapub.thepra.d
- **Misskey 2026.10**
- **Sharkey 2025.4.7**
- **Akkoma 3.20.1**
- **Lemmy 1.0.0-beta.2**
- **Lemmy 1.0.0-beta.2**, and **Lemmy 0.19.20** (built with the platform's roots, so it trusts the pasture's CA)
- **PeerTube 8.3.1**
- **Pixelfed 0.14.4**
- **WordPress 7.1.2 with ActivityPub 9.3.1**
- **Friendica 2026.05**
- **Mobilizon 5.2.4**
- **Gancio 1.28.2**
- **Funkwhale 2.0.11**
- **PieFed 1.7.17**
- **Mbin 1.10.1**
- **NodeBB 4.16.1**
- **Smithereen 1.0.3**
- **Mitra 5.9.1**
- **snac2 2.95**
- **WriteFreely 0.17.2**
- **Owncast 0.3.0**
- **Ghost 6.67** with its ActivityPub service 1.2.14
- **BookWyrm 0.9.3**
- **Vernissage 1.43.0**
- **Hubzilla 11.4.1** with its pubcrawl addon
- **Forte 26.9.10** (portable identities: actors served through `/.well-known/apgateway/did:key:…`)
- **Castopod 1.15.5** (podcasts as actors, their episodes with the sound)
- **Ktistec 3.13.0**
- **Activity-Relay 2.0.9** and **aode-relay 0.3.129**, as relays PrivaPub reads from
- in the town only (a seeded community checked server by server): **Hollo 0.9.19**, **Iceshrimp.NET 2026.1.2-beta**,
**Pleroma 2.10.2**
Checked both ways, where the peer has the feature:
- follows, locked accounts included;
@@ -33,14 +65,22 @@ Checked both ways, where the peer has the feature:
- communities and circles;
- blocks and unfollows.
`docs/INTEROP.md` has each peer's evidence and what is still expected to fail. PeerTube, PieFed, Mbin and the others are
`docs/INTEROP.md` has each peer's evidence and what is still expected to fail. The platforms not yet in the pasture are
covered by unit tests written in their documents' shape.
## 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)
- [FEP-2c59: Discovery of a WebFinger address from an ActivityPub actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2c59/fep-2c59.md):
every actor names its handle, and a peer's `webfinger` on another domain than its actor's host (Mastodon's
`LOCAL_DOMAIN`) is the handle shown once that domain's WebFinger points back to the actor; asked again on a rename
- [FEP-d556: Server-Level Actor Discovery Using WebFinger](https://codeberg.org/fediverse/fep/src/branch/main/fep/d556/fep-d556.md)
(WebFinger for the server's origin links its instance actor as `…#Service`) and
[FEP-2677: Identifying the Application Actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2677/fep-2677.md)
(`/.well-known/nodeinfo` links it as `…#Application`)
- [FEP-844e: Capability discovery](https://codeberg.org/fediverse/fep/src/branch/main/fep/844e/fep-844e.md): the instance
actor's `implements`, and every other actor's `generator`, name RFC 9421 (verified with RSA keys)
- [FEP-1b12: Group federation](https://codeberg.org/fediverse/fep/src/branch/main/fep/1b12/fep-1b12.md) (communities; see "Groups")
- [FEP-044f: Consent-respecting quote posts](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md)
(`QuoteAuthorization` at `/parrot-licences/{id}`; checked with Mastodon both ways)
@@ -48,10 +88,20 @@ covered by unit tests written in their documents' shape.
- [FEP-c0e0: Emoji reactions](https://codeberg.org/fediverse/fep/src/branch/main/fep/c0e0/fep-c0e0.md) (`EmojiReact`, and Misskey's
`Like` with content)
- [FEP-5feb: Search indexing consent](https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md) (`indexable`)
- [FEP-521a: Representing actor's public keys](https://codeberg.org/fediverse/fep/src/branch/main/fep/521a/fep-521a.md) and
[FEP-8b32: Object Integrity Proofs](https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.md) (see
"Integrity proofs")
- [FEP-171b: Conversation Containers](https://codeberg.org/fediverse/fep/src/branch/main/fep/171b/fep-171b.md), received:
a thread owner's `Add` of someone's `Create`, `Update` or `Delete` to the thread's context is taken as that activity
passed on by the owner (on its FEP-8b32 proof, or as its origin has it)
- [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md)
(sent and honoured; see "Followers synchronisation")
- [FEP-5624: Per-object reply control policies](https://codeberg.org/fediverse/fep/src/branch/main/fep/5624/fep-5624.md):
read (see "Replies its author approves")
Planned (see `docs/ROADMAP.md`, phases P7 and P8, and the per-platform notes in `docs/INTEROP.md`): FEP-9098 (custom
emoji), FEP-7888 and FEP-f228 (threads), FEP-7628 (Move), FEP-8fcf (followers synchronisation), FEP-8967 (link
attachments), FEP-521a and FEP-8b32 (keys and integrity proofs), FEP-ae0c (relays).
emoji), FEP-7888 and FEP-f228 (threads), FEP-7628 (Move), FEP-8967 (link
attachments), FEP-ae0c (relays).
## Actors
@@ -69,6 +119,8 @@ actor document, a collection, NodeInfo or a delivery relates two avatars of the
| Activities | `/peasants/{name}/grunts/{id}` |
| Quote permissions (`QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{id}` |
| Direct-message context | `/peasants/{name}/whispers/{id}` |
| A persona's wall (FEP-400e) | `/peasants/{name}/graffiti` |
| Followers on the asking server (FEP-8fcf) | `/peasants/{name}/groupies/roll-call` |
| 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.
@@ -78,7 +130,12 @@ The names are the project's own and are stable; resolve actors through WebFinger
personas made on the same day do not share a date. `indexable` is `false` unless the persona turns it on, and
`discoverable` is `true` unless it turns that off (the deploy's own persona, @thepra, is undiscoverable).
- 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.
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 <host>"), one for the whole server with
its own key. It sends the reports Lemmy, PieFed and Mbin take in Lemmy's shape (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
@@ -86,7 +143,8 @@ A group is either a **community** or a **circle**.
- A **community** follows [FEP-1b12](https://codeberg.org/fediverse/fep/src/branch/main/fep/1b12/fep-1b12.md). Posts
addressed to it (in `to`, `cc` or `audience`) are accepted according to its posting policy (followers, anyone, or
moderators only) and the group `Announce`s the whole activity, with `audience` set, to its followers. A new post is
moderators only) and the group `Announce`s the whole activity, with `audience` set, to its followers and to the
author's own server, whether anyone there follows it or not (Lemmy keeps its user's post pending until then). A new post is
also announced as an object so Mastodon shows it. Updates and deletes of community content are announced too. A
top-level post is a `Page` with a `name`. Members are counted at `/flock`; moderators are listed at `/wardens`, which
the actor's `attributedTo` points to, with `postingRestrictedToMods` as Lemmy expects. A mention of a community posts into it.
@@ -95,9 +153,21 @@ A group is either a **community** or a **circle**.
names that member in `cc`, so servers that keep only posts naming one of their accounts (Mastodon, GoToSocial) keep
it; it names no other member. The posts are served only to a signed request from a member, or from the instance actor
of a member's server, and that copy names the member (or the members on that server); anyone else gets 404. A
Mastodon member's replies reach only the people they mention.
Mastodon member's replies reach only the people they mention. Its owner lets a request in with an `Accept` of the
Follow, and declines one or takes a member from elsewhere out with a `Reject` of it, as Mastodon removes a follower.
- Announces from **remote** groups (Lemmy communities) are followed through to the activity: the object is fetched
from its own origin, never taken from the announce.
from its own origin, never taken from the announce. They are taken from a group someone here follows, and also when
they relay a vote on one of our posts in a thread of that group (Lemmy sends a vote to the community alone). A post
keeps the group its `audience` names, however it arrived.
- A post in a remote group followed here that the group's own server delivers itself, as its author's `Create` with the
group as its `audience` (Mbin sends a magazine's threads that way, with no announce), is kept as if announced: the
group's server speaks for posts in it. The same from another server is not.
- A remote community's **moderation** reaches the posts it holds. A removal (`Announce{Delete}`) is believed at once
from a community on the post's own server, which speaks for it (PieFed keeps serving a thread its moderator removed),
and from a community elsewhere once the post's origin answers it gone. A lock sent as it is (`Lock`, Mbin) is taken
from the post's own server only. A lock (`Announce{Lock}`, or `commentsEnabled: false` on the post) refuses replies,
ours included, until `Undo{Lock}`. A ban of a persona (`Announce{Block}` with the community as `target`) shows as
`blocked_by` on the community and refuses the persona's posts and replies there until the `Undo`.
## Activities
@@ -106,28 +176,87 @@ Received:
| Activity | Effect |
|---|---|
| `Follow` | follows an avatar or community; `Accept` is sent unless the community approves members by hand |
| `Accept{Follow}`, `Reject{Follow}` | completes or ends a follow an avatar requested |
| `Accept{Follow}`, `Reject{Follow}` | completes or ends a follow an avatar requested; once a follow holds, the account's latest public posts are read from its outbox's first page (twenty at most, once a day), so its profile is not empty; homes get only what arrives from then on |
| `Accept{Join}`, `Reject{Join}` | from the event's server: a persona's participation in the event is accepted or refused |
| `Undo{Follow, Like, Announce}` | reverses it |
| `Create{Note, Article, Page, Question, Video, Audio, Event, ChatMessage, …}` | stored when a local avatar follows the author, is addressed or mentioned, when it replies to a local post, or when it is addressed to a community the author follows; a public parent is fetched to complete the thread |
| `Create{Note, Article, Page, Question, Video, Audio, Event, ChatMessage, …}` | stored when a local avatar follows the author, is addressed or mentioned, when it replies to a local post, or when it is addressed to a community the author follows (a persona is addressed by its actor id or by its profile page, as Mbin addresses its private messages); a public parent is fetched to complete the thread |
| `Update{Note}` | replaces the content; the previous version is kept |
| `Update{Person}` | refetches the actor |
| `Like` | counted and notified, on posts the liker could see |
| `Dislike` | counted as a downvote (Lemmy, PieFed, Mbin, Friendica); `Undo` takes it back |
| `EmojiReact`, `Like` with an emoji `content` | an emoji reaction (FEP-c0e0; Pleroma, Akkoma, Iceshrimp.NET, Misskey, Sharkey), Unicode or a custom emoji from its `tag`; a `Like` whose content is ❤ stays a favourite; `Undo` takes it back |
| `Join` | answered with `Ignore`: PrivaPub hosts no events yet (FEP-8a8e) |
| `Join` | answered with `Ignore`: PrivaPub hosts no events of its own yet (FEP-8a8e) |
| `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin |
| `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back |
| `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back, though a new post its server publishes under that id after the deletion (Gancio numbers events from the last one kept) is kept |
| `Flag` | becomes a report for this server's moderators |
| `Move` | an account moving: believed as Mastodon believes it, when the account sends it about itself and the new account, read again from its server, names it in `alsoKnownAs`. The old account then shows where it went (`moved`), and, as Mastodon does it (owner decision 2026-10-05), the personas following it follow the new one instead (a Follow to its server, an Undo to the old), in the same lists; a mute or a block of the old account carries over |
| `Add`/`Remove` on the actor's `featured` | the account pins or unpins one of its own posts (fetched from its server when not held); inside a community's announce, the community features a post made in it. Its profile shows them first (`pinned=true`). The collection itself is read with the account's counts, at most once a day. On the account's wall (`sm:wall`), see "Walls". Any other target (a community's moderators) is dropped; one on the account's own server that PrivaPub does not know has its document read again first, at most hourly |
| `Block` of a persona | the follows between them end, the blocker's posts and notifications are hidden from the persona, nothing of the persona's is addressed to the blocker, and the relationship says `blocked_by`; `Undo{Block}` lifts it |
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll votes, `EmojiReact` and its `Undo`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
`Reject{Follow}`, `Like`, `Announce` and their `Undo`, `Flag`. A deleted post answers 410 with a `Tombstone`.
`Reject{Follow}`, `Like`, `Announce` and their `Undo`, `Flag`, `Join` and `Leave` of a remote event, and the replies to a
persona's posts passed on to its followers. A deleted post answers 410 with a `Tombstone`.
- **Attachments** are `Document`s with `mediaType`, `name` (alt text), `blurhash`, `focalPoint`, `width` and `height`.
Uploaded files have all metadata removed.
- **Pinned posts** are the actor's `featured` collection (`/trophies`); `featuredTags` is `/tattoos`.
- **Threads** (owner decision 2026-10-06). A persona's public or unlisted post outside any group names its `replies`
(`…/scribbles/{id}/replies`) and its conversation, `context` (FEP-7888): the root post's `…/scribbles/{id}/context`,
which a reply inherits from its parent, ours or another server's. Both list only the public and unlisted posts
PrivaPub holds, the context every one under the root (at most 500); followers-only, circle, direct and local-only posts
name neither, and their collections answer 404. Mastodon completes a thread from them.
- **Followers synchronisation** (FEP-8fcf, owner decision 2026-10-06). A persona's delivery addressed to its followers
carries a signed `Collection-Synchronization` header: its `followers`, its roll-call (`…/groupies/roll-call`) and the
digest (XOR of each id's SHA-256) of its accepted followers on the receiving server only. The roll-call answers a
signed request with the persona's followers on the signer's server and nobody else's; unsigned, 401. A server whose
view differs mends itself from it, Mastodon both ways (checked live). Mastodon's `Undo{Follow}` for a follow it never
knew of has one id per account (`…#follows//undo`); when it comes again after a new follow, it ends that follow too.
Honoured the other way: when a delivery's header digests the sender's followers here otherwise than the personas
following it, PrivaPub reads the list it names (same origin, signed by the instance actor). A follow the list leaves out
ends, but only when the list is the one the digest describes; a request it lists is taken as accepted; a persona it
lists that follows nothing there sends `Undo{Follow}`, as Mastodon does.
- **Walls** (FEP-400e, Smithereen's; owner decision 2026-10-06). A persona's actor names its `wall`
(`…/graffiti`, `sm:wall`) and Smithereen's `privacySettings`: its followers may write on it (`wallPosting`), everyone
may read it (`wallPostVisibility`). A public post that is not a reply, sent with the wall as its `target` by an account
following the persona, is hosted: the persona is notified (as a mention), it reaches the persona's home and its
followers' here, and the persona's followers' servers and the author's are told with `Add{Note}` (the wall as the
target). Anything else written there is dropped. The persona may delete it (`DELETE /api/v1/statuses/:id`), which
sends `Remove{Note}` with the wall as a plain id; Smithereen deletes the post then, as it does when an owner deletes a
post on its wall. The wall lists the persona's public posts that start a thread and what was written on it, newest
first. An account elsewhere that tells its followers here, with `Add{Note}` on its own wall, of a post written there
has it shown to them as on its wall (`privapub.wall` on the status); a `Remove` there takes it away.
- **Pinned posts** are the actor's `featured` collection (`/trophies`); `featuredTags` is `/tattoos`. A pin or an unpin
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, PieFed or Mbin (owner decision 2026-10-06, the second place
PrivaPub decides by a server's software; PieFed and Mbin once the pasture showed each keeps such a report with its
reason): 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; PieFed drops one from an `Application`; Mbin reads the reason from
`summary` alone. 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 (PieFed would take an account alone from the reporter; that is not
sent yet).
- **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 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
and signed it, the way Mastodon forwards it: to every follower's server but the replier's own. Its `Update` and
`Delete` follow the same way. Nothing is passed on for a local-only post, a group's post or a reply that is not public
or unlisted. A server that receives it checks it as it checks any forwarded activity: Mastodon, Misskey and Sharkey
keep it only when its author LD-signed it (Mastodon does; Misskey 2026.10 and Akkoma do not), others read the reply
from its origin.
- **Events** (owner decision 2026-10-05). A persona joins another server's event with a `Join` and leaves it with a
`Leave`, both sent to the event's organiser only (`…/grunts/join-<id>`, `…/grunts/leave-<id>`). The organiser's
server answers `Accept` (Mobilizon at once for an open event) or `Reject`, which the persona sees as
`privapub.event.participation`: `pending`, `accepted` or `rejected`. An event that takes participants by invitation
or on another site (Mobilizon's `joinMode` `invite` or `external`) is refused before anything is sent (422). Who
takes part is public on the event's server.
A `Create`'s `Note` carries Mastodon's `content`, `contentMap`, `summary` and `sensitive`, plus `Mention` and `Hashtag`
tags.
@@ -137,6 +266,8 @@ tags.
content, one per choice, counted once per voter. Counts are refreshed with an `Update{Question}` at most every three
minutes. Our own votes on other servers' polls are sent the same way to the poll's author only, without `published`.
An incoming `Update` without a newer `updated` only refreshes counts and details; it is never recorded as an edit.
A post's earlier versions in `formerRepresentations` (Pleroma, Akkoma) are its edit history, read as the post itself is:
a post first met after its edits has them, and an edit that carries them brings the versions missed in between.
**Quotes** (FEP-044f, with the older keys):
- **Received.** A quote is read from `quote`, `quoteUrl`, `quoteUri`, `_misskey_quote` or a FEP-e232 `Link` tag.
@@ -159,6 +290,28 @@ An incoming `Update` without a newer `updated` only refreshes counts and details
`Delete{stamp}`.
- Followers-only posts and DMs can never be quoted.
**Interaction policies** (GoToSocial's `canReply`, `canLike`, `canAnnounce`, with the older `always` and
`approvalRequired`):
- **Read** on every remote post; one left out means anyone, automatically. A persona is let in at once when the rule
names the public, the persona itself, the author's followers while it follows the author, or the accounts the author
follows while the author follows it.
- **Shut out:** the client API answers 422 to a reply, favourite or boost the rule does not allow at all.
- **Asked first:** a `ReplyRequest`, `LikeRequest` or `AnnounceRequest` (the interaction as its `instrument`) goes to the
author alone, and the interaction waits (`privapub.approval: pending`). The author's `Accept` brings an authorization
(`result`), which must be on the author's origin, attributed to them and naming the interaction and the post; only then
does the reply go out with `replyAuthorization`, the boost with `announceAuthorization` and the like with
`likeAuthorization`. A `Reject` leaves it ours alone (`rejected`), and takes a like or a boost back.
- **As a third party,** a reply that a post's policy does not let in at once is kept only with an authorization that
verifies the same way. A rule naming a collection we cannot list (followers, following) lets the reply in.
- **Shown** to clients as GoToSocial's `interaction_policy` (`can_favourite`, `can_reply`, `can_reblog`).
- Our own posts state only `canQuote`: anyone may reply to, like and boost them.
**Replies its author approves** (FEP-5624's `canReply`, as PeerTube sets it on a video whose comments are moderated; a
`null` one says nothing): a persona it names, or that the post mentions, may reply, and the reply waits
(`privapub.approval: pending`), its `Create` going to the author alone. The author's `ApproveReply`, signed by the author
and naming the post answered, lets it out to its audience with `replyApproval`; a `RejectReply` leaves it ours alone
(`rejected`). An empty `canReply` refuses every reply (422).
**Custom emoji** (`Emoji` tags) are read on posts, display names, bios and profile fields, at most 64 per object.
**Profiles** keep their header, profile fields, `manuallyApprovesFollowers`, `published`, `movedTo`, `indexable`,
`memorial` and avatar and header descriptions. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show
@@ -182,6 +335,19 @@ The page's OpenGraph and Twitter tags give the title, description and image; the
days and shared by every account on the server. Images are served to clients only through PrivaPub's media proxy.
`Federation:FetchLinkPreviews=false` turns page fetching off.
## Relays
PrivaPub reads from the relays `Federation:Relays` names (by their actor's address, or their inbox's), none by default.
Its instance actor follows `Public` at each, as Mastodon subscribes (`Federation/Relays/Relays.cs`, a minute after start
and every six hours: an unanswered or refused subscription is asked again a day later, and a relay no longer named gets
the `Undo`). What an accepted relay passes on comes to the federated timeline, never to anyone's home: a public post it
forwards as its author sent it (Activity-Relay), read again from its origin like any forwarded post, and a post it
announces (aode-relay), kept as its author's and never as the relay's boost. Nothing else is taken from a relay. A
persona's public post outside any group, its edit and its deletion also go to the relays that accepted us (owner decision
2026-10-06), as Mastodon sends them; nothing less public, and no boost. Mastodon takes a post Activity-Relay forwards
only with an LD signature or an FEP-8b32 proof: PrivaPub's carry a proof, so they reach Mastodon through both kinds of
relay (checked live).
## Server descriptions and the crawler
PrivaPub keeps statistics about servers, never about accounts (see `/stargazing` on the server).
@@ -210,10 +376,28 @@ Posts with a location (shown to nearby users of this server) never leave the ser
- **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.
- **Another account of the same server.** An object attributed to another account of the actor's own server (Mobilizon's
organiser creates, edits and deletes the group's event) is that server's to vouch for: a creation or an edit is
taken as the server has the object, a deletion once it answers 404 or 410. Attributed to an account elsewhere, it is
refused (400).
- **Forwarded activities** (ActivityPub's inbox forwarding: a thread's server passing on the replies in it, signed with
its own key) are answered 202 and believed only as far as their origin vouches for them: a created or edited post
is read again from its author's server, a deletion of a public or unlisted post is applied once that server answers
404 or 410, and anything else is dropped. LD signatures are not verified. A reply forwarded this way is kept when
someone here follows the author of the post it answers, as Mastodon does.
- **Follow requests** still unanswered are sent again after 15 minutes, an hour, 6 hours, a day, two and four days: a
server can take a Follow and lose its answer (Lemmy 1.0 sends nothing it queued for a server before it started
sending there), and one that holds the follow answers it again. Each is the same follow under a new id
(`…/follow-<id>-again-<n>`), since Lemmy ignores an id it has seen; an answer naming any of them counts.
- **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.
- **Threads.** A reply's missing parents are fetched, up to 10 levels. When someone here opens a public remote thread,
its replies are read from their servers, at most once an hour: the thread's `context` collection (FEP-7888) if it has
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
@@ -226,10 +410,16 @@ Posts with a location (shown to nearby users of this server) never leave the ser
carry is deleted (then 410); a persona's boost id sends a browser to the boosted post. `Follow`, `Like`, `Block` and
the `Accept`, `Reject` and `Undo` that answer them do not, because serving them would reveal who follows, likes and
blocks whom. Neither do `Update`, `Delete`, `EmojiReact`, `QuoteRequest` and its answers, `Flag`, `Ignore` or poll
votes. All of them are always sent with their object embedded.
votes. All of them are always sent with their object embedded. Every boost and every favourite is an activity of its
own (`announce-{boost}`, `like-{favourite}`), so one given again after its `Undo` is new to the server it reaches.
- **Hashtags.** A post's `Hashtag` links go to `/tags/{tag}`, a public page of this server's public posts with that tag.
- **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.
- **Delivery.** An activity goes once to each server, to its shared inbox when the server has one, whoever there it
follows, names or answers (a circle post excepted: each member's copy names that member). 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: unreachable, timing out, or a gateway answering 502, 503 or 504. A 500 is
the server failing on that one activity, retried on its own while its other deliveries go on. A server can also take a Follow (202) and drop it afterwards, as Pleroma
does when it cannot yet fetch our actor, so a follow request still unanswered is sent again, same activity, when the
persona follows once more, at most once an hour.
## Known limitations
@@ -239,4 +429,15 @@ Posts with a location (shown to nearby users of this server) never leave the ser
- Collections expose counts, not members: `/groupies` and `/stalking` give the same totals as the Mastodon API's
follower and following counts, and the outbox's `totalItems` is the persona's post count, though only public posts
are listed. `/api/v1/instance/peers` is empty: which servers this one talks to is not published.
- Only `rsa-sha256`-style keys are verified. RFC 9421 signatures are planned.
- HTTP signatures are verified with RSA keys only, under draft-cavage or RFC 9421. What PrivaPub sends is signed with
draft-cavage only, which every server reads.
- **Integrity proofs** (FEP-8b32, FEP-521a). Every persona has an Ed25519 key of its own (never shared between personas),
named in its actor's `assertionMethod` as a `Multikey` (`…#ed25519-key`, `publicKeyMultibase`); the terms are defined
in the actor's own context, so no context document has to be fetched. A persona's activity going to a relay carries a
`DataIntegrityProof` (`eddsa-jcs-2022`, `proofPurpose` `assertionMethod`) over the activity as delivered; Mastodon
4.7 verifies it (checked against its own verifier), so what the relay forwards reaches it. Nothing else carries one:
Mitra takes a proof over the HTTP signature, and refuses one by a key it has not read yet without reading the actor
again, which every server that knew a persona before it had its key would do. What a persona passes on of others'
carries theirs or none. Received: an actor's own Multikeys (at most five, under its id, controlled by it) are kept;
a forwarded activity whose proof one of them verifies is taken as it came, instead of being read again from its origin
(an unknown key of the actor's has the actor read again first).
+20
View File
@@ -0,0 +1,20 @@
namespace PrivaPub.ClientModels.Admin
{
// a report as moderators read it: who sent it ("local" for a persona here, never which one; or the remote actor), whom
// and which posts it is about, and whether it was resolved
public class ViewReport
{
public string ID { get; set; }
public string Reporter { get; set; }
public string TargetAccountId { get; set; }
public string TargetActorURI { get; set; }
public List<string> PostIds { get; set; } = new();
public List<string> ObjectURIs { get; set; } = new();
public string Category { get; set; }
public string Comment { get; set; }
public bool Forwarded { get; set; }
public bool IsResolved { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? ResolvedAt { get; set; }
}
}
@@ -0,0 +1,89 @@
namespace PrivaPub.ClientModels.Admin
{
// /clientapi/admin/statistics/overview: the last days' federation at a glance; counters keyed as the ledger keys them
public class ViewStatisticsOverview
{
public int Days { get; set; }
public Dictionary<string, long> Channels { get; set; } = new();
public Dictionary<string, long> Inbound { get; set; } = new();
public Dictionary<string, long> Outbound { get; set; } = new();
public double? DeliverySuccess { get; set; }
public ViewLatency DeliveryLatencyMs { get; set; } = new();
public ViewHostCounts Hosts { get; set; } = new();
public int Accounts { get; set; }
public ViewLedgerCounts Ledger { get; set; } = new();
}
public class ViewLatency
{
public int? P50 { get; set; }
public int? P95 { get; set; }
}
public class ViewHostCounts
{
public int Active { get; set; }
public long Known { get; set; }
}
public class ViewLedgerCounts
{
public long Written { get; set; }
public long Dropped { get; set; }
public long Failed { get; set; }
}
// /clientapi/admin/statistics/hosts: one page of the servers we exchanged with
public class ViewHostsPage
{
public int Total { get; set; }
public int Page { get; set; }
public List<ViewHostRow> Hosts { get; set; } = new();
public string Attribution { get; set; }
}
public class ViewHostRow
{
public string Host { get; set; }
public string Software { get; set; }
public string Version { get; set; }
public long In { get; set; }
public long Out { get; set; }
public long Refused { get; set; }
public long Failures { get; set; }
public int? LatencyP50 { get; set; }
public int Accounts { get; set; }
public DateTime? DescribedAt { get; set; }
public ViewHostDelivery Delivery { get; set; }
}
public class ViewHostDelivery
{
public int ConsecutiveFailures { get; set; }
public DateTime? UnavailableUntil { get; set; }
public DateTime? LastSuccessAt { get; set; }
public DateTime? LastFailureAt { get; set; }
}
// /clientapi/admin/statistics/crawler: how far the crawler got
public class ViewCrawler
{
public bool Enabled { get; set; }
public long Touched { get; set; }
public long Crawled { get; set; }
public long Due { get; set; }
public long RobotsDisallowed { get; set; }
public List<ViewCrawledHost> Recent { get; set; } = new();
}
public class ViewCrawledHost
{
public string Host { get; set; }
public string Seen { get; set; }
public DateTime? LastCrawledAt { get; set; }
public int? PeersCount { get; set; }
public bool? RobotsDisallowed { get; set; }
public string CrawlError { get; set; }
public string Software { get; set; }
}
}
@@ -0,0 +1,15 @@
namespace PrivaPub.ClientModels.Group
{
// someone in a group, or asking to join it, as its owner and moderators see them
public class ViewGroupMember
{
public string ActorUri { get; set; }
public string Handle { get; set; }
public string Name { get; set; }
public string PictureUrl { get; set; }
public bool IsLocal { get; set; }
public bool IsPending { get; set; }
public string Role { get; set; }//owner, moderator, member
public DateTime Since { get; set; }
}
}
@@ -68,7 +68,9 @@ namespace PrivaPub.Tests.Domain
public Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token) => throw new NotSupportedException();
public Task<LocalActor> FindByUri(string actorUri, CancellationToken token) => throw new NotSupportedException();
public Task<LocalActor> FindByAddress(string address, CancellationToken token) => throw new NotSupportedException();
public Task<LocalActor> GetInstanceActor(CancellationToken token) => throw new NotSupportedException();
public Task<LocalActor> GetReporterActor(CancellationToken token) => throw new NotSupportedException();
public Task<bool> IsUserNameTaken(string userName, CancellationToken token) => throw new NotSupportedException();
public Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token) => throw new NotSupportedException();
public LocalActor FromAvatar(Avatar avatar) => throw new NotSupportedException();
+2
View File
@@ -59,6 +59,8 @@ namespace PrivaPub.Tests.Domain
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
.Where(p => p.Body.Contains(post.ID));
Assert.Empty(outgoing);
// nor reaches a home, its author's included: the Mastodon API looks past it, so there it could not be acted on
Assert.False(await DB.Default.Find<PrivaPub.Models.Social.TimelineEntry>().Match(e => e.PostId == post.ID).ExecuteAnyAsync(token));
}
[Fact]
@@ -0,0 +1,78 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
// a follow that holds reads the account's latest public posts from its outbox, so its profile is not empty
[Trait("Category", "Integration")]
public sealed class BackfillTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
[Fact]
public async Task An_accepted_follow_reads_the_accounts_latest_public_posts_from_its_outbox()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var bob = new RemoteActor(_harness.Peer, "bob");
var mallory = new RemoteActor(_harness.Peer, "mallory", _harness.Peer.B);
var origin = new Uri(bob.Id).GetLeftPart(UriPartial.Authority);
JsonObject Note(string id, string text, RemoteActor author) => new()
{
["id"] = id, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"<p>{text}</p>",
["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"), ["published"] = DateTime.UtcNow.ToString("O")
};
var older = $"{origin}/notes/{Guid.NewGuid():N}";
var newer = $"{origin}/notes/{Guid.NewGuid():N}";
var notBobs = $"{new Uri(mallory.Id).GetLeftPart(UriPartial.Authority)}/notes/{Guid.NewGuid():N}";
_harness.Peer.Serve(new Uri(older).AbsolutePath, Note(older, "an older post", bob).ToJsonString());
_harness.Peer.Serve(new Uri(newer).AbsolutePath, Note(newer, "a newer post", bob).ToJsonString());
_harness.Peer.Serve(new Uri(bob.Id).AbsolutePath + "/outbox", new JsonObject
{
["id"] = bob.Id + "/outbox", ["type"] = "OrderedCollection", ["totalItems"] = 4,
["orderedItems"] = new JsonArray(
new JsonObject { ["type"] = "Create", ["object"] = Note(newer, "a newer post", bob) },
new JsonObject { ["type"] = "Announce", ["object"] = notBobs },
new JsonObject { ["type"] = "Create", ["object"] = notBobs },
new JsonObject { ["type"] = "Create", ["object"] = older })
}.ToJsonString());
var account = await _harness.Remote.GetActor(bob.Id, refresh: false, token);
var followId = alice.ActivityUri($"follow-{Guid.NewGuid():N}");
await DB.Default.SaveAsync(new Following
{
AvatarId = alice.Id, TargetActorURI = bob.Id, TargetAccountId = account.ID, State = FollowState.Requested, FollowActivityURI = followId
}, token);
await _harness.Deliver(bob, "/human-centipede", new JsonObject
{
["id"] = $"{bob.Id}#accept-{Guid.NewGuid():N}", ["type"] = "Accept", ["actor"] = bob.Id,
["object"] = new JsonObject { ["id"] = followId, ["type"] = "Follow", ["actor"] = alice.Uri, ["object"] = bob.Id }
});
var job = await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.BackfillOutbox && j.Payload == bob.Id).ExecuteSingleAsync(token);
await new OutboxBackfill(_harness.Db, _harness.Remote, _harness.RemotePosts).Handle(job, token);
var held = (await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteAsync(token)).Select(p => p.ObjectURI).ToHashSet();
Assert.Equal(new HashSet<string> { older, newer }, held);
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == notBobs).ExecuteAnyAsync(token));
}
}
}
@@ -0,0 +1,75 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
// alone in its collection: every test's peer is 127.0.0.1, and the server row it writes decides for whoever writes there
[Trait("Category", "Integration")]
[Xunit.Collection(nameof(Exclusive))]
public sealed class ChatMessageSoftwareTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static string NewId(RemoteActor actor, string kind) => $"{new Uri(actor.Id).GetLeftPart(UriPartial.Authority)}/{kind}/{Guid.NewGuid():N}";
// the one decision taken by a server's software (owner decision 2026-10-05): Lemmy before 1.0 and Mbin get a
// ChatMessage even from a persona answering a Note; Lemmy 1.0 a Note
[Theory]
[InlineData("lemmy", "0.19.20", "ChatMessage")]
[InlineData("mbin", "1.10.1", "ChatMessage")]
[InlineData("lemmy", "1.0.0-beta.2", "Note")]
public async Task A_direct_message_to_lemmy_before_1_0_or_mbin_is_a_chat_message(string software, string version, string sent)
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var sender = new RemoteActor(_harness.Peer, "pm");
var host = new Uri(sender.Id).Host;
await DB.Default.DeleteAsync<PrivaPub.Models.Jobs.RemoteInstance>(i => i.Host == host);
await DB.Default.SaveAsync(new PrivaPub.Models.Jobs.RemoteInstance { Host = host, Software = software, SoftwareVersion = version }, token);
try
{
var id = NewId(sender, "messages");
await _harness.Deliver(sender, "/human-centipede", new JsonObject
{
["id"] = NewId(sender, "activities/create"), ["type"] = "Create", ["actor"] = sender.Id, ["to"] = new JsonArray(alice.Uri),
["object"] = new JsonObject
{
["id"] = id, ["type"] = "Note", ["attributedTo"] = sender.Id, ["to"] = new JsonArray(alice.Uri),
["content"] = "<p>psst</p>", ["published"] = DateTime.UtcNow.ToString("O")
}
});
var message = await DB.Default.Find<Post>().Match(p => p.ObjectURI == id).ExecuteSingleAsync(token);
var answer = await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft
{
Text = "psst back", Visibility = PostVisibility.Direct, ConversationId = message.ConversationId
}, token);
Assert.True(answer.Ok, answer.Error);
var create = (await _harness.Outgoing(sender.Id + "/inbox")).Last(a => a["type"]!.GetValue<string>() == "Create");
Assert.Equal(sent, create["object"]!["type"]!.GetValue<string>());
}
finally
{
await DB.Default.DeleteAsync<PrivaPub.Models.Jobs.RemoteInstance>(i => i.Host == host);
}
}
}
}
@@ -30,11 +30,12 @@ namespace PrivaPub.Tests.Federation
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
async Task<(RemoteActor Community, RemoteActor Poster, JsonObject Note, Post Post)> Announced()
// a post in a community the persona follows; the community on the poster's server unless another origin is given
async Task<(RemoteActor Community, RemoteActor Poster, JsonObject Note, Post Post)> Announced(string communityOrigin = default)
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var community = new RemoteActor(_harness.Peer, "cats", type: "Group");
var community = new RemoteActor(_harness.Peer, "cats", communityOrigin, type: "Group");
var poster = new RemoteActor(_harness.Peer, "poster");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token);
await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
@@ -109,6 +110,97 @@ namespace PrivaPub.Tests.Federation
Assert.True(await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == post.ObjectURI).ExecuteAnyAsync(token));
}
// PieFed keeps serving a thread its moderator removed: a community on the post's own server speaks for it
[Fact]
public async Task A_removal_a_community_on_the_posts_server_announces_is_taken_though_the_post_is_still_served()
{
var token = TestContext.Current.CancellationToken;
var (community, poster, note, post) = await Announced();
await Announce(community, new JsonObject
{
["id"] = $"{Origin(poster)}/delete/{Guid.NewGuid():N}", ["type"] = "Delete", ["actor"] = poster.Id,
["object"] = note["id"]!.GetValue<string>(), ["summary"] = "off topic"
});
Assert.False(await DB.Default.Find<Post>().Match(p => p.ID == post.ID).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_removal_a_community_elsewhere_announces_waits_for_the_posts_server_to_say_it_is_gone()
{
var token = TestContext.Current.CancellationToken;
var (community, poster, note, post) = await Announced(_harness.Peer.B);
var delete = new JsonObject
{
["id"] = $"{Origin(community)}/delete/{Guid.NewGuid():N}", ["type"] = "Delete", ["actor"] = community.Id,
["object"] = note["id"]!.GetValue<string>()
};
await Announce(community, (JsonObject)delete.DeepClone());
var kept = await DB.Default.Find<Post>().Match(p => p.ID == post.ID).ExecuteAnyAsync(token);
_harness.Peer.Serve(new Uri(note["id"]!.GetValue<string>()).AbsolutePath, new JsonObject { ["id"] = note["id"]!.GetValue<string>(), ["type"] = "Tombstone" }.ToJsonString());
delete["id"] = $"{Origin(community)}/delete/{Guid.NewGuid():N}";
await Announce(community, delete);
Assert.True(kept);
Assert.False(await DB.Default.Find<Post>().Match(p => p.ID == post.ID).ExecuteAnyAsync(token));
}
// Mbin sends a magazine's thread to its subscribers as the author's Create, and a moderator's lock as it is, with no
// announce: the magazine's server speaks for them, another server does not
[Fact]
public async Task A_thread_and_its_lock_that_a_followed_groups_own_server_sends_without_announcing_them_are_taken()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var magazine = new RemoteActor(_harness.Peer, "books", type: "Group");
var poster = new RemoteActor(_harness.Peer, "poster");
var stranger = new RemoteActor(_harness.Peer, "stranger", _harness.Peer.B);
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = magazine.Id }, token);
await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
JsonObject Page(RemoteActor author) => new()
{
["id"] = $"{Origin(author)}/m/books/t/{Guid.NewGuid():N}", ["type"] = "Page", ["name"] = "a thread",
["attributedTo"] = author.Id, ["content"] = "<p>in the magazine</p>",
["to"] = new JsonArray(magazine.Id, Addressing.Public), ["cc"] = new JsonArray(author.Id + "/followers"),
["audience"] = magazine.Id, ["published"] = DateTime.UtcNow.ToString("O")
};
JsonObject Create(RemoteActor author, JsonObject page) => new()
{
["id"] = $"{Origin(author)}/f/object/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Id,
["to"] = page["to"]!.DeepClone(), ["cc"] = page["cc"]!.DeepClone(), ["object"] = page
};
JsonObject Lock(RemoteActor moderator, string uri) => new()
{
["id"] = $"{Origin(moderator)}/f/object/{Guid.NewGuid():N}", ["type"] = "Lock", ["actor"] = moderator.Id, ["object"] = uri
};
var page = Page(poster);
var elsewhere = Page(stranger);
var uri = page["id"]!.GetValue<string>();
await _harness.Deliver(poster, "/human-centipede", Create(poster, page));
await _harness.Deliver(stranger, "/human-centipede", Create(stranger, elsewhere));
var kept = await DB.Default.Find<Post>().Match(p => p.ObjectURI == uri).ExecuteSingleAsync(token);
await _harness.Deliver(stranger, "/human-centipede", Lock(stranger, uri));
var lockedByStranger = (await DB.Default.Find<Post>().OneAsync(kept.ID, token)).LockedAt;
var moderator = new RemoteActor(_harness.Peer, "moderator");
var locking = Lock(moderator, uri);
await _harness.Deliver(moderator, "/human-centipede", locking);
var locked = (await DB.Default.Find<Post>().OneAsync(kept.ID, token)).LockedAt;
await _harness.Deliver(moderator, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(moderator)}/f/object/{Guid.NewGuid():N}", ["type"] = "Undo", ["actor"] = moderator.Id, ["object"] = locking.DeepClone()
});
Assert.Equal(magazine.Id, kept.AudienceURI);
Assert.True(await DB.Default.Find<TimelineEntry>().Match(e => e.PostId == kept.ID && e.AvatarId == alice.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == elsewhere["id"]!.GetValue<string>()).ExecuteAnyAsync(token));
Assert.Null(lockedByStranger);
Assert.NotNull(locked);
Assert.Null((await DB.Default.Find<Post>().OneAsync(kept.ID, token)).LockedAt);
}
[Fact]
public async Task A_fetched_object_does_not_wear_the_signature_of_the_activity_that_caused_the_fetch()
{
+32
View File
@@ -0,0 +1,32 @@
using PrivaPub.Federation.Rendering;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
// The @context on everything we send. Readers that expand JSON-LD 1.0 (Smithereen) refuse a whole document over one
// term only 1.1 knows, so every term stays within 1.0
public class ContextTests
{
static readonly string[] Containers10 = ["@list", "@set", "@index", "@language"];
static readonly string[] Keywords11 = ["@version", "@protected", "@propagate", "@import", "@nest", "@prefix", "@context", "@direction", "@json"];
[Fact]
public void Every_term_is_json_ld_1_0()
{
var terms = ActivityPubRenderer.Context().OfType<JsonObject>().Single();
foreach (var (name, definition) in terms)
{
if (definition is not JsonObject term)
continue;
if (term["@container"] is JsonValue container)
Assert.True(Containers10.Contains(container.GetValue<string>()), $"{name}: @container {container} is JSON-LD 1.1");
Assert.True(term["@container"] is null or JsonValue, $"{name}: an array of containers is JSON-LD 1.1");
Assert.DoesNotContain(term.Select(p => p.Key), Keywords11.Contains);
Assert.NotEqual("@json", term["@type"]?.GetValue<string>());
}
Assert.DoesNotContain(terms.Select(p => p.Key), Keywords11.Contains);
}
}
}
@@ -0,0 +1,142 @@
using MongoDB.Entities;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Tests.Federation
{
// FEP-171b: a thread's owner adds a reply someone else wrote to the thread's context (Hubzilla's Add{Create}), and the
// reply is taken as passed on by the owner, on its own proof; an Add aimed at a collection elsewhere is not a thread's
[Trait("Category", "Integration")]
public sealed class ConversationContainerTests : IAsyncLifetime
{
const string Public = "https://www.w3.org/ns/activitystreams#Public";
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static CancellationToken Token => TestContext.Current.CancellationToken;
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
static JsonObject Create(RemoteActor author, string text, string context, string inReplyTo = default)
{
var noteId = $"{Origin(author)}/item/{Guid.NewGuid():N}";
var note = new JsonObject
{
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"<p>{text}</p>", ["context"] = context,
["to"] = new JsonArray(Public), ["published"] = DateTime.UtcNow.ToString("O")
};
if (inReplyTo != default)
note["inReplyTo"] = inReplyTo;
return new JsonObject
{
["id"] = $"{Origin(author)}/activity/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Id, ["to"] = new JsonArray(Public),
["object"] = note
};
}
async Task<bool> Held(string objectUri) => await DB.Default.Find<PostEntity>().Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(Token);
async Task RunQueued(string innerId)
{
var job = await DB.Default.Find<Job>().Match(j => j.DedupeKey == "inbox|forwarded|" + innerId && j.State == JobState.Pending).ExecuteFirstAsync(Token);
Assert.NotNull(job);
Assert.Equal(JobResult.Done, (await _harness.Processor.Handle(job, Token)).Result);
}
[Fact]
public async Task A_reply_the_threads_owner_adds_to_its_context_is_taken_on_its_proof()
{
var (_, alice) = await _harness.Persona("alice");
var owner = new RemoteActor(_harness.Peer, "owner", ed25519: true);
var commenter = new RemoteActor(_harness.Peer, "commenter", ed25519: true);
await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = owner.Id, TargetInboxURL = owner.Id + "/inbox", State = FollowState.Accepted }, Token);
var context = $"{Origin(owner)}/conversation/{Guid.NewGuid():N}";
var root = Create(owner, "a thread", context);
await _harness.Deliver(owner, "/human-centipede", root);
var rootId = root["object"]!["id"]!.GetValue<string>();
Assert.True(await Held(rootId));
var reply = commenter.Prove(Create(commenter, "a reply in it", context, rootId));
var replyId = reply["object"]!["id"]!.GetValue<string>();
await _harness.Deliver(owner, "/human-centipede", owner.Prove(new JsonObject
{
["id"] = $"{Origin(owner)}/activity/{Guid.NewGuid():N}", ["type"] = "Add", ["actor"] = owner.Id, ["object"] = reply,
["target"] = new JsonObject { ["id"] = context, ["type"] = "Collection", ["attributedTo"] = owner.Id }, ["to"] = new JsonArray(Public)
}));
await RunQueued(reply["id"]!.GetValue<string>());
var stored = await DB.Default.Find<PostEntity>().Match(p => p.ObjectURI == replyId).ExecuteFirstAsync(Token);
Assert.NotNull(stored);
Assert.Equal(commenter.Id, stored.ActorURI);
Assert.Equal((await DB.Default.Find<PostEntity>().Match(p => p.ObjectURI == rootId).ExecuteFirstAsync(Token)).ID, stored.AnsweringToPostId);
}
// Forte gives a post's Update the id of its Create, and sends it only added to the thread's context
[Fact]
public async Task An_update_added_under_its_creates_id_is_taken_as_the_edit()
{
var (_, alice) = await _harness.Persona("alice");
var owner = new RemoteActor(_harness.Peer, "owner", ed25519: true);
await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = owner.Id, TargetInboxURL = owner.Id + "/inbox", State = FollowState.Accepted }, Token);
var context = $"{Origin(owner)}/conversation/{Guid.NewGuid():N}";
var create = owner.Prove(Create(owner, "as first written", context));
JsonObject Added(JsonObject inner) => owner.Prove(new JsonObject
{
["id"] = $"{Origin(owner)}/activity/{Guid.NewGuid():N}", ["type"] = "Add", ["actor"] = owner.Id, ["object"] = inner,
["target"] = new JsonObject { ["id"] = context, ["type"] = "Collection", ["attributedTo"] = owner.Id }
});
await _harness.Deliver(owner, "/human-centipede", Added(create));
await RunQueued(create["id"]!.GetValue<string>());
var update = create.DeepClone().AsObject();
update.Remove("proof");
update["type"] = "Update";
update["object"]!["content"] = "<p>as edited</p>";
update["object"]!["updated"] = DateTime.UtcNow.AddSeconds(1).ToString("O");
owner.Prove(update);
await _harness.Deliver(owner, "/human-centipede", Added(update));
var job = await DB.Default.Find<Job>().Match(j => j.DedupeKey.StartsWith("inbox|forwarded|" + update["id"]!.GetValue<string>() + "|") && j.State == JobState.Pending)
.ExecuteFirstAsync(Token);
Assert.NotNull(job);
Assert.Equal(JobResult.Done, (await _harness.Processor.Handle(job, Token)).Result);
var noteId = create["object"]!["id"]!.GetValue<string>();
Assert.Contains("as edited", (await DB.Default.Find<PostEntity>().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(Token)).ContentHtml);
}
[Fact]
public async Task An_add_to_a_collection_on_another_server_is_no_threads()
{
var owner = new RemoteActor(_harness.Peer, "owner", ed25519: true);
var commenter = new RemoteActor(_harness.Peer, "commenter", _harness.Peer.B, ed25519: true);
var reply = commenter.Prove(Create(commenter, "elsewhere", $"{Origin(commenter)}/conversation/1"));
await _harness.Deliver(owner, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(owner)}/activity/{Guid.NewGuid():N}", ["type"] = "Add", ["actor"] = owner.Id, ["object"] = reply,
["target"] = $"{Origin(commenter)}/conversation/1"
});
Assert.False(await DB.Default.Find<Job>().Match(j => j.DedupeKey == "inbox|forwarded|" + reply["id"]!.GetValue<string>()).ExecuteAnyAsync(Token));
}
}
}
+143
View File
@@ -0,0 +1,143 @@
using MongoDB.Entities;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
// pins across servers: an account elsewhere features its own posts (Add and Remove on its `featured` collection, and
// the collection itself), a community features posts made in it, and a persona's pin is told to its followers
[Trait("Category", "Integration")]
public sealed class FeaturedTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
// a public note of the actor's, served where its id points
string Note(RemoteActor author, string text, string audience = default)
{
var path = $"/notes/{Guid.NewGuid():N}";
var note = new JsonObject
{
["id"] = Origin(author) + path, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"<p>{text}</p>",
["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"), ["published"] = DateTime.UtcNow.ToString("O")
};
if (audience != default)
note["audience"] = audience;
_harness.Peer.Serve(path, note.ToJsonString());
return Origin(author) + path;
}
JsonObject Featuring(RemoteActor actor, string type, string objectUri, string target = default) => new()
{
["id"] = $"{actor.Id}#{type}-{Guid.NewGuid():N}", ["type"] = type, ["actor"] = actor.Id, ["object"] = objectUri,
["target"] = target ?? actor.Id + "/featured"
};
Task<List<RemoteFeatured>> FeaturedBy(RemoteActor actor) =>
DB.Default.Find<RemoteFeatured>().Match(f => f.ActorURI == actor.Id).Sort(f => f.Position, Order.Ascending).ExecuteAsync(TestContext.Current.CancellationToken);
[Fact]
public async Task An_account_features_its_own_post_and_takes_it_back_but_nothing_else()
{
var bob = new RemoteActor(_harness.Peer, "bob");
var carol = new RemoteActor(_harness.Peer, "carol", _harness.Peer.B);
var mine = Note(bob, "pinned");
var theirs = Note(carol, "not bob's");
var older = Note(bob, "pinned before");
await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Add", older));
await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Add", mine));
await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Add", theirs));
// Smithereen's wall is a collection of the actor's too, but no pin
await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Add", Note(bob, "on the wall"), bob.Id + "/wall"));
Assert.Equal(new[] { mine, older }, (await FeaturedBy(bob)).Select(f => f.ObjectURI));
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == mine).ExecuteSingleAsync(TestContext.Current.CancellationToken);
Assert.Equal(post.ID, (await FeaturedBy(bob))[0].PostId);
await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Remove", mine));
Assert.Equal(new[] { older }, (await FeaturedBy(bob)).Select(f => f.ObjectURI));
}
[Fact]
public async Task The_featured_collection_is_read_in_its_order_from_the_accounts_own_server()
{
var token = TestContext.Current.CancellationToken;
var bob = new RemoteActor(_harness.Peer, "bob");
var first = Note(bob, "first pin");
var second = Note(bob, "second pin");
_harness.Peer.Serve(new Uri(bob.Id).AbsolutePath + "/featured", new JsonObject
{
["id"] = bob.Id + "/featured", ["type"] = "OrderedCollection", ["totalItems"] = 2,
["orderedItems"] = new JsonArray(new JsonObject { ["id"] = first, ["type"] = "Note" }, second)
}.ToJsonString());
var account = await _harness.Remote.GetActor(bob.Id, refresh: false, token);
await _harness.Featured.Sync(account, token);
Assert.Equal(new[] { first, second }, (await FeaturedBy(bob)).Select(f => f.ObjectURI));
}
[Fact]
public async Task A_personas_pin_and_unpin_reach_its_followers_as_add_and_remove()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var follower = new RemoteActor(_harness.Peer, "fan");
await _harness.FollowedBy(alice, follower);
await _harness.Posts.InsertPost(root, new PrivaPub.ClientModels.Post.InsertPostForm { AvatarId = alice.Id, Text = "worth a pin" }, token);
var post = await DB.Default.Find<Post>().Match(p => p.GroupUserId == alice.Id).ExecuteFirstAsync(token);
await _harness.Outbox.PublishFeatured(alice.Id, post, featured: true, token);
await _harness.Outbox.PublishFeatured(alice.Id, post, featured: false, token);
var sent = (await _harness.Outgoing(follower.SharedInbox)).Concat(await _harness.Outgoing(follower.Id + "/inbox"))
.Where(a => a["type"]!.GetValue<string>() is "Add" or "Remove").ToList();
Assert.Equal(new[] { "Add", "Remove" }, sent.Select(a => a["type"]!.GetValue<string>()));
Assert.All(sent, a => Assert.Equal((alice.PostUri(post.ID), alice.Featured), (a["object"]!.GetValue<string>(), a["target"]!.GetValue<string>())));
}
[Fact]
public async Task A_community_features_a_post_made_in_it()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var community = new RemoteActor(_harness.Peer, "books", type: "Group");
var member = new RemoteActor(_harness.Peer, "reader", _harness.Peer.B);
var account = await _harness.Remote.GetActor(community.Id, refresh: false, token);
await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = community.Id, TargetAccountId = account.ID, State = FollowState.Accepted }, token);
var post = Note(member, "a thread in the community", community.Id);
await _harness.Deliver(community, "/human-centipede", new JsonObject
{
["id"] = $"{community.Id}#announce-{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = community.Id,
["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"),
["object"] = new JsonObject
{
["id"] = $"{Origin(member)}/activities/{Guid.NewGuid():N}", ["type"] = "Add", ["actor"] = member.Id, ["object"] = post,
["target"] = community.Id + "/featured"
}
});
Assert.Equal(new[] { post }, (await FeaturedBy(community)).Select(f => f.ObjectURI));
}
}
}
@@ -0,0 +1,282 @@
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.Social;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
// FEP-8fcf (owner decision 2026-10-06): a delivery addressed to a persona's followers tells the receiving server, in a signed
// header, a digest of the persona's followers there; the roll-call it names lists them, to that server only
[Trait("Category", "Integration")]
[Xunit.Collection(nameof(Exclusive))]
public sealed class FollowersSynchronizationTests : 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<RemoteInstance>(i => PeerHosts.Contains(i.Host));
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static CancellationToken Token => TestContext.Current.CancellationToken;
[Fact]
public void The_digest_is_the_xor_of_each_ids_sha256_whatever_the_order()
{
Assert.Equal(new string('0', 64), FollowersSynchronization.Digest([]));
Assert.Equal(FollowersSynchronization.Digest(["https://a.example/users/one", "https://a.example/users/two"]),
FollowersSynchronization.Digest(["https://a.example/users/two", "https://a.example/users/one"]));
Assert.Equal(Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes("https://a.example/users/one"))),
FollowersSynchronization.Digest(["https://a.example/users/one"]));
}
async Task<HttpRequestRecord> Delivered(LocalActor signer, string inbox, JsonObject activity)
{
await _harness.Delivery.Enqueue(signer, new[] { inbox }, activity, Token);
var job = await DB.Default.Find<Job>().Match(j => j.DedupeKey == $"{activity["id"]!.GetValue<string>()}|{inbox}").ExecuteSingleAsync(Token);
var handler = new DeliveryJobHandler(_harness.Local, Peer.Http(), new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions())),
NullLogger<DeliveryJobHandler>.Instance);
Assert.Equal(JobResult.Done, (await handler.Handle(job, Token)).Result);
return Assert.Single(_harness.Peer.Requests, r => r.Body?.Contains(activity["id"]!.GetValue<string>()) == true);
}
static JsonObject Create(LocalActor author, params string[] to) => new()
{
["id"] = $"{author.Uri}/grunts/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Uri, ["to"] = new JsonArray(to.Select(t => (JsonNode)t).ToArray()),
["object"] = new JsonObject { ["type"] = "Note", ["content"] = "hi", ["to"] = new JsonArray(to.Select(t => (JsonNode)t).ToArray()) }
};
[Fact]
public async Task A_delivery_to_followers_carries_a_signed_digest_of_the_followers_on_that_server_only()
{
var (_, alice) = await _harness.Persona("alice");
var one = new RemoteActor(_harness.Peer, "one");
var two = new RemoteActor(_harness.Peer, "two");
var far = new RemoteActor(_harness.Peer, "far", _harness.Peer.B);
await _harness.FollowedBy(alice, one);
await _harness.FollowedBy(alice, two);
await _harness.FollowedBy(alice, far);
await DB.Default.SaveAsync(new Follower
{
LocalActorId = alice.Id, LocalActorKind = alice.Kind, ActorURI = _harness.Peer.A + "/users/asking", InboxURL = _harness.Peer.A + "/users/asking/inbox", IsAccepted = false
}, Token);
_harness.Peer.Answer("/inbox", 202);
var received = await Delivered(alice, _harness.Peer.A + "/inbox", Create(alice, alice.Followers));
Assert.Equal(alice.Followers, Value(received, "collectionId"));
Assert.Equal(alice.Followers + "/roll-call", Value(received, "url"));
Assert.Equal(FollowersSynchronization.Digest([one.Id, two.Id]), Value(received, "digest"));
var signature = HttpSignatures.Parse(received.Signature);
Assert.Equal(new[] { "(request-target)", "host", "date", "digest", "collection-synchronization" }, signature.Headers);
var signingString = $"(request-target): post /inbox\nhost: {received.Headers["Host"]}\ndate: {received.Headers["Date"]}\ndigest: {received.Headers["Digest"]}\n"
+ $"collection-synchronization: {received.Headers[FollowersSynchronization.Header]}";
using var key = RSA.Create();
key.ImportFromPem(alice.PublicKeyPem);
Assert.True(key.VerifyData(Encoding.UTF8.GetBytes(signingString), signature.Signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
}
static string Value(HttpRequestRecord received, string name)
{
var values = received.Headers[FollowersSynchronization.Header].Split(',', StringSplitOptions.TrimEntries)
.Select(p => p.Split('=', 2)).ToDictionary(p => p[0], p => p[1].Trim('"'));
return values[name];
}
[Fact]
public async Task A_delivery_that_is_not_for_followers_carries_none()
{
var (_, alice) = await _harness.Persona("alice");
var recipient = new RemoteActor(_harness.Peer, "recipient");
await _harness.FollowedBy(alice, recipient);
_harness.Peer.Answer("/inbox", 202);
var received = await Delivered(alice, _harness.Peer.A + "/inbox", Create(alice, recipient.Id));
Assert.False(received.Headers.ContainsKey(FollowersSynchronization.Header));
Assert.DoesNotContain("collection-synchronization", HttpSignatures.Parse(received.Signature).Headers);
}
// Mastodon undoes a follow it never knew of with the same id each time ({actor}#follows//undo): when it comes again,
// after a new follow, it ends that one too; a copy of it, with nothing to end, stays a copy
[Fact]
public async Task An_undo_of_a_follow_sent_again_after_a_new_follow_ends_that_one_too()
{
var (_, alice) = await _harness.Persona("alice");
var bob = new RemoteActor(_harness.Peer, "bob");
var undo = new JsonObject
{
["id"] = bob.Id + "#follows//undo", ["type"] = "Undo", ["actor"] = bob.Id,
["object"] = new JsonObject { ["id"] = bob.Id + "#follows/", ["type"] = "Follow", ["actor"] = bob.Id, ["object"] = alice.Uri }
};
Task<bool> Follows() => DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id && f.ActorURI == bob.Id).ExecuteAnyAsync(Token);
await _harness.FollowedBy(alice, bob);
await _harness.Deliver(bob, "/human-centipede", undo.DeepClone());
Assert.False(await Follows());
await _harness.FollowedBy(alice, bob);
await _harness.Deliver(bob, "/human-centipede", undo.DeepClone());
Assert.False(await Follows());
Assert.Equal("duplicate", (await _harness.Deliver(bob, "/human-centipede", undo.DeepClone())).Reason);
}
// received: bob's delivery claims a digest of his followers here, and lists them at his own synchronisation URL
async Task Claimed(RemoteActor bob, string digest, params string[] listed)
{
var path = new Uri(bob.Id).AbsolutePath + "/followers/sync";
_harness.Peer.Serve(path, new JsonObject
{
["id"] = bob.Id + "/followers/sync", ["type"] = "OrderedCollection",
["orderedItems"] = new JsonArray(listed.Select(l => (JsonNode)l).ToArray())
}.ToJsonString());
var note = $"{bob.Id}/notes/{Guid.NewGuid():N}";
var request = bob.Post(Harness.Host, "/human-centipede", new JsonObject
{
["id"] = note + "/activity", ["type"] = "Create", ["actor"] = bob.Id, ["to"] = new JsonArray(bob.Id + "/followers"),
["object"] = new JsonObject { ["id"] = note, ["type"] = "Note", ["attributedTo"] = bob.Id, ["to"] = new JsonArray(bob.Id + "/followers"), ["content"] = "hi" }
});
request.Headers[FollowersSynchronization.Header] = $"collectionId=\"{bob.Id}/followers\", url=\"{bob.Id}/followers/sync\", digest=\"{digest}\"";
await _harness.Receiver.Receive(request, default, Token);
var job = await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.SynchronizeFollowing && j.State == JobState.Pending && j.Payload.Contains(bob.Id))
.ExecuteSingleAsync(Token);
var handler = new FollowingSynchronization(_harness.Db, _harness.Local, _harness.Remote, _harness.Follows, _harness.Delivery);
Assert.Equal(JobResult.Done, (await handler.Handle(job, Token)).Result);
}
async Task<Following> Follows(LocalActor persona, RemoteActor bob, FollowState state)
{
var following = new Following
{
AvatarId = persona.Id, TargetActorURI = bob.Id, TargetInboxURL = bob.Id + "/inbox", State = state, FollowActivityURI = persona.ActivityUri($"follow-{Guid.NewGuid():N}")
};
await DB.Default.SaveAsync(following, Token);
return following;
}
Task<Following> Following(LocalActor persona, RemoteActor bob) =>
DB.Default.Find<Following>().Match(f => f.AvatarId == persona.Id && f.TargetActorURI == bob.Id).ExecuteFirstAsync(Token);
[Fact]
public async Task A_claim_that_differs_is_mended_from_the_accounts_own_list()
{
var (_, alice) = await _harness.Persona("alice");
var (_, carol) = await _harness.Persona("carol");
var (_, dave) = await _harness.Persona("dave");
var bob = new RemoteActor(_harness.Peer, "bob");
await Follows(alice, bob, FollowState.Accepted);
await Follows(carol, bob, FollowState.Requested);
await Claimed(bob, FollowersSynchronization.Digest([carol.Uri, dave.Uri]), carol.Uri, dave.Uri);
Assert.Null(await Following(alice, bob));
Assert.Equal(FollowState.Accepted, (await Following(carol, bob)).State);
var undos = (await _harness.Outgoing(bob.Id + "/inbox")).Where(a => a["type"]!.GetValue<string>() == "Undo").ToList();
Assert.Equal(new[] { alice.Uri, dave.Uri }.Order(), undos.Select(u => u["actor"]!.GetValue<string>()).Order());
var daves = Assert.Single(undos, u => u["actor"]!.GetValue<string>() == dave.Uri);
Assert.Equal(("Follow", bob.Id), (daves["object"]!["type"]!.GetValue<string>(), daves["object"]!["object"]!.GetValue<string>()));
}
[Fact]
public async Task A_list_the_claim_does_not_describe_ends_no_follow()
{
var (_, alice) = await _harness.Persona("alice");
var bob = new RemoteActor(_harness.Peer, "bob");
await Follows(alice, bob, FollowState.Accepted);
await Claimed(bob, FollowersSynchronization.Digest(["https://privapub.test/peasants/someone"]));
Assert.Equal(FollowState.Accepted, (await Following(alice, bob)).State);
}
[Fact]
public async Task A_claim_that_agrees_reads_nothing()
{
var (_, alice) = await _harness.Persona("alice");
var bob = new RemoteActor(_harness.Peer, "bob");
await Follows(alice, bob, FollowState.Accepted);
await Claimed(bob, FollowersSynchronization.Digest([alice.Uri]));
Assert.DoesNotContain(_harness.Peer.Requests, r => r.Path.EndsWith("/followers/sync"));
Assert.Equal(FollowState.Accepted, (await Following(alice, bob)).State);
}
}
[Trait("Category", "Integration")]
public sealed class RollCallTests : IAsyncLifetime
{
PrivaPubHost _host;
HttpClient _client;
Peer _peer;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
_client = _host.Client();
_peer = await Peer.Start();
}
public async ValueTask DisposeAsync()
{
_client?.Dispose();
if (_peer != default)
await _peer.DisposeAsync();
}
static string[] Items(JsonObject collection) => collection["orderedItems"]!.AsArray().Select(i => i!.GetValue<string>()).ToArray();
[Fact]
public async Task The_roll_call_lists_the_followers_on_the_signing_server_and_asks_for_a_signature()
{
var token = TestContext.Current.CancellationToken;
var persona = await _host.Persona(await _host.SignUp(), "rollcall");
var near = new RemoteActor(_peer, "near");
var server = new RemoteActor(_peer, "instance", type: "Application");
var far = new RemoteActor(_peer, "far", _peer.B);
foreach (var follower in new[] { near, far })
await DB.Default.SaveAsync(new Follower
{
LocalActorId = persona.Id, LocalActorKind = LocalActorKind.Person, ActorURI = follower.Id, InboxURL = follower.Id + "/inbox"
}, token);
var path = $"/peasants/{persona.UserName}/groupies/roll-call";
var asServer = await _client.Fetch(server.SignedGet(path));
var asFar = await _client.Fetch(far.SignedGet(path));
Assert.Equal(HttpStatusCode.OK, asServer.Status);
Assert.Equal(persona.ActorUri() + "/groupies/roll-call", asServer.Json["id"]!.GetValue<string>());
Assert.Equal(new[] { near.Id }, Items(asServer.Json));
Assert.Equal(new[] { far.Id }, Items(asFar.Json));
Assert.Equal(HttpStatusCode.Unauthorized, (await _client.Fetch(path)).Status);
}
}
}
@@ -0,0 +1,97 @@
using MongoDB.Entities;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
// Pleroma's and Akkoma's edited posts carry their earlier versions (formerRepresentations): a post first met after its
// edits has its history, and an edit tells the ones missed in between
[Trait("Category", "Integration")]
public sealed class FormerRepresentationsTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
static readonly DateTime Start = DateTime.UtcNow.AddHours(-1);
static JsonObject Version(string text, int minutes) => new()
{
["type"] = "Note", ["content"] = $"<p>{text}</p>", ["published"] = Start.ToString("O"),
["updated"] = minutes == 0 ? default : Start.AddMinutes(minutes).ToString("O")
};
async Task<(RemoteActor Carol, JsonObject Note)> Followed(string text, int minutes, params JsonObject[] former)
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var carol = new RemoteActor(_harness.Peer, "carol");
await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = carol.Id, State = FollowState.Accepted }, token);
var note = Version(text, minutes);
note["id"] = $"{Origin(carol)}/objects/{Guid.NewGuid():N}";
note["attributedTo"] = carol.Id;
note["to"] = new JsonArray(Addressing.Public);
note["cc"] = new JsonArray($"{carol.Id}/followers");
if (former.Length > 0)
note["formerRepresentations"] = new JsonObject { ["type"] = "OrderedCollection", ["totalItems"] = former.Length, ["orderedItems"] = new JsonArray(former) };
return (carol, note);
}
Task Deliver(RemoteActor carol, string type, JsonObject note) => _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(carol)}/activities/{Guid.NewGuid():N}", ["type"] = type, ["actor"] = carol.Id,
["to"] = new JsonArray(Addressing.Public), ["object"] = note.DeepClone()
});
Task<Post> Stored(JsonObject note) =>
DB.Default.Find<Post>().Match(p => p.ObjectURI == note["id"]!.GetValue<string>()).ExecuteSingleAsync(TestContext.Current.CancellationToken);
[Fact]
public async Task A_post_first_met_after_its_edits_has_its_history()
{
var (carol, note) = await Followed("third", 40, Version("second", 20), Version("first", 0));
await Deliver(carol, "Create", note);
var post = await Stored(note);
Assert.Equal(["<p>first</p>", "<p>second</p>"], post.Revisions.Select(r => r.ContentHtml));
Assert.Equal("<p>third</p>", post.ContentHtml);
}
[Fact]
public async Task An_edit_tells_the_versions_missed_in_between()
{
var (carol, note) = await Followed("first", 0);
await Deliver(carol, "Create", note);
var edited = (JsonObject)note.DeepClone();
edited["content"] = "<p>third</p>";
edited["updated"] = Start.AddMinutes(40).ToString("O");
edited["formerRepresentations"] = new JsonObject
{
["type"] = "OrderedCollection", ["totalItems"] = 2, ["orderedItems"] = new JsonArray(Version("second", 20), Version("first", 0))
};
await Deliver(carol, "Update", edited);
var post = await Stored(note);
Assert.Equal(["<p>first</p>", "<p>second</p>"], post.Revisions.Select(r => r.ContentHtml));
Assert.Equal("<p>third</p>", post.ContentHtml);
}
}
}
+167
View File
@@ -0,0 +1,167 @@
using MongoDB.Entities;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
using static PrivaPub.Tests.Support.FederatedSeeds;
namespace PrivaPub.Tests.Federation
{
// Inbox forwarding: a thread's server passes on the activities in it, signed with its own key (Mastodon the replies to
// its accounts' posts and their deletions, Friendica everything in its threads). They used to be refused with 401.
[Trait("Category", "Integration")]
public sealed class ForwardedTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
PrivaPub.Models.Statistics.InteractionEvent Processed(string activity) => _harness.Ledger.Of("in").Last(e => e.Activity == activity);
PrivaPub.Models.Statistics.InteractionEvent Received(string activity) => _harness.Ledger.Of("recv").Last(e => e.Activity == activity);
Task<Post> Stored(string objectUri) =>
DB.Default.Find<Post>().Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(TestContext.Current.CancellationToken);
// alice follows the thread's author (on its own server), whose post she holds; bob, elsewhere, replies to it
async Task<(RemoteActor Owner, RemoteActor Bob, JsonObject Reply)> Thread()
{
var (_, alice) = await _harness.Persona("alice");
var owner = new RemoteActor(_harness.Peer, "owner", _harness.Peer.B);
var bob = new RemoteActor(_harness.Peer, "bob");
await Follows(alice.Id, owner);
var post = PublicNote(owner, "<p>the thread</p>");
await _harness.Deliver(owner, "/human-centipede", Create(owner, post));
Assert.NotNull(await Stored(IdOf(post)));
var reply = PublicNote(bob, "<p>bob's reply as he wrote it</p>", owner.Id);
reply["inReplyTo"] = IdOf(post);
_harness.Peer.Serve(new Uri(IdOf(reply)).AbsolutePath, reply.ToJsonString());
return (owner, bob, reply);
}
[Fact]
public async Task A_reply_the_threads_server_forwards_is_kept_as_its_author_wrote_it()
{
var (owner, bob, reply) = await Thread();
var claimed = (JsonObject)reply.DeepClone();
claimed["content"] = "<p>what the forwarder says bob wrote</p>";
var result = await _harness.Deliver(owner, "/human-centipede", Create(bob, claimed));
Assert.Equal((202, "forwarded"), (result.StatusCode, result.Reason));
var stored = await Stored(IdOf(reply));
Assert.NotNull(stored);
Assert.Equal(bob.Id, stored.ActorURI);
Assert.Contains("as he wrote it", stored.ContentHtml);
Assert.Equal(("accepted", "stored"), (Processed("Create").Outcome, Processed("Create").Reason));
Assert.True((await DB.Default.Find<PrivaPub.Models.Federation.ObjectRecord>().Match(r => r.ObjectURI == IdOf(reply)).ExecuteFirstAsync(TestContext.Current.CancellationToken)).Refetched);
}
[Fact]
public async Task A_forwarded_edit_applies_the_post_as_its_origin_has_it_now()
{
var (owner, bob, reply) = await Thread();
await _harness.Deliver(owner, "/human-centipede", Create(bob, reply));
var edited = (JsonObject)reply.DeepClone();
edited["content"] = "<p>bob's reply, edited</p>";
edited["updated"] = DateTime.UtcNow.AddMinutes(1).ToString("O");
_harness.Peer.Serve(new Uri(IdOf(reply)).AbsolutePath, edited.ToJsonString());
var claimed = (JsonObject)edited.DeepClone();
claimed["content"] = "<p>an edit bob never made</p>";
await _harness.Deliver(owner, "/human-centipede", Activity(bob, "Update", claimed));
Assert.Contains("bob's reply, edited", (await Stored(IdOf(reply))).ContentHtml);
}
[Fact]
public async Task A_forwarded_deletion_waits_for_the_origin_to_say_the_post_is_gone()
{
var (owner, bob, reply) = await Thread();
await _harness.Deliver(owner, "/human-centipede", Create(bob, reply));
await _harness.Deliver(owner, "/human-centipede", Activity(bob, "Delete", JsonValue.Create(IdOf(reply))!));
Assert.Equal(("dropped", "forwarded-not-gone"), (Processed("Delete").Outcome, Processed("Delete").Reason));
Assert.NotNull(await Stored(IdOf(reply)));
_harness.Peer.Answer(new Uri(IdOf(reply)).AbsolutePath, 410);
await _harness.Deliver(owner, "/human-centipede", Activity(bob, "Delete", JsonValue.Create(IdOf(reply))!));
Assert.Null(await Stored(IdOf(reply)));
}
// the origin answers 404 for a followers-only post to our instance actor too: only its author's server says it is gone
[Fact]
public async Task A_forwarded_deletion_of_a_followers_only_post_is_left_to_its_author()
{
var (owner, bob, reply) = await Thread();
await _harness.Deliver(owner, "/human-centipede", Create(bob, reply));
await DB.Default.Update<Post>().Match(p => p.ObjectURI == IdOf(reply)).Modify(p => p.Visibility, PostVisibility.FollowersOnly).ExecuteAsync(TestContext.Current.CancellationToken);
_harness.Peer.Answer(new Uri(IdOf(reply)).AbsolutePath, 404);
await _harness.Deliver(owner, "/human-centipede", Activity(bob, "Delete", JsonValue.Create(IdOf(reply))!));
Assert.Equal(("dropped", "forwarded-private"), (Processed("Delete").Outcome, Processed("Delete").Reason));
Assert.NotNull(await Stored(IdOf(reply)));
}
// a forwarded copy is kept apart from the author's own delivery, which carries what the copy could not
[Fact]
public async Task The_authors_own_delivery_is_still_taken_after_a_forwarded_copy_that_failed()
{
var (owner, bob, reply) = await Thread();
_harness.Peer.Answer(new Uri(IdOf(reply)).AbsolutePath, 404);
var create = Create(bob, reply);
await _harness.Deliver(owner, "/human-centipede", create);
Assert.Null(await Stored(IdOf(reply)));
var direct = await _harness.Deliver(bob, "/human-centipede", create);
Assert.Equal("queued", direct.Reason);
Assert.NotNull(await Stored(IdOf(reply)));
}
// what cannot be checked against its origin (a vote, a follow, someone else's post) is let go, answered 202: a 401
// tells the forwarder its signature failed
[Fact]
public async Task What_a_forwarder_cannot_vouch_for_is_let_go_without_an_error()
{
var (owner, bob, reply) = await Thread();
var like = new JsonObject { ["id"] = NewId(bob, "likes"), ["type"] = "Like", ["actor"] = bob.Id, ["object"] = IdOf(reply) };
var result = await _harness.Deliver(owner, "/human-centipede", like);
Assert.Equal((202, "forwarded-ignored"), (result.StatusCode, result.Reason));
Assert.Equal("dropped", Received("Like").Outcome);
var onTheForwardersServer = PublicNote(owner, "<p>a post of the forwarder's</p>");
onTheForwardersServer["attributedTo"] = bob.Id;
result = await _harness.Deliver(owner, "/human-centipede", Create(bob, onTheForwardersServer));
Assert.Equal((202, "forwarded-ignored"), (result.StatusCode, result.Reason));
Assert.Null(await Stored(IdOf(onTheForwardersServer)));
// our own, coming back from a thread on another server
var (_, carol) = await _harness.Persona("carol");
var ours = new JsonObject
{
["id"] = carol.Uri + "/grunts/create-" + Guid.NewGuid().ToString("N"),
["type"] = "Create",
["actor"] = carol.Uri,
["object"] = new JsonObject { ["id"] = carol.Uri + "/scribbles/" + Guid.NewGuid().ToString("N"), ["type"] = "Note", ["attributedTo"] = carol.Uri }
};
result = await _harness.Deliver(owner, "/human-centipede", ours);
Assert.Equal((202, "forwarded-ignored"), (result.StatusCode, result.Reason));
}
}
}
+37
View File
@@ -90,6 +90,28 @@ namespace PrivaPub.Tests.Federation
Assert.Contains(announces, a => a["object"] is JsonValue);
}
[Fact]
public async Task A_comment_in_a_community_is_announced_activity_and_object_too()
{
var (_, owner) = await _harness.Persona("owner");
var (_, community) = await Group(GroupKind.Community, PostingPolicy.Followers, owner.Id);
var lemmy = new RemoteActor(_harness.Peer, "lemmy");
var akkoma = new RemoteActor(_harness.Peer, "akkoma");
await _harness.FollowedBy(community, lemmy);
await _harness.FollowedBy(community, akkoma);
var thread = Create(lemmy, new[] { community.Uri, Addressing.Public }, community.Uri);
await _harness.Deliver(lemmy, "/human-centipede", thread);
var comment = Create(lemmy, new[] { community.Uri, Addressing.Public }, community.Uri);
comment["object"]!["inReplyTo"] = thread["object"]!["id"]!.GetValue<string>();
await _harness.Deliver(lemmy, "/human-centipede", comment);
var commentId = comment["object"]!["id"]!.GetValue<string>();
var announces = (await _harness.Outgoing(akkoma.SharedInbox)).Where(a => a["actor"]!.GetValue<string>() == community.Uri).ToList();
Assert.Contains(announces, a => a["object"] is JsonObject inner && inner["object"]?["id"]?.GetValue<string>() == commentId);
Assert.Contains(announces, a => a["object"] is JsonValue bare && bare.GetValue<string>() == commentId);
}
[Fact]
public async Task An_open_community_takes_posts_from_anyone()
{
@@ -103,6 +125,21 @@ namespace PrivaPub.Tests.Federation
Assert.True(await DB.Default.Find<Post>().Match(p => p.GroupId == community.Id && p.ActorURI == stranger.Id).ExecuteAnyAsync(token));
}
// Lemmy keeps its user's post pending until the community announces it back, whether anyone there follows the
// community or not
[Fact]
public async Task A_post_into_a_community_is_announced_back_to_its_authors_server_though_nobody_there_follows_it()
{
var (_, owner) = await _harness.Persona("owner");
var (_, community) = await Group(GroupKind.Community, PostingPolicy.Anyone, owner.Id);
var stranger = new RemoteActor(_harness.Peer, "stranger", sharedInbox: true);
await _harness.Deliver(stranger, "/human-centipede", Create(stranger, new[] { community.Uri, Addressing.Public }, community.Uri));
var announces = (await _harness.Outgoing(stranger.SharedInbox)).Where(a => a["actor"]!.GetValue<string>() == community.Uri).ToList();
Assert.Contains(announces, a => a["object"] is JsonObject inner && inner["type"]!.GetValue<string>() == "Create");
}
[Fact]
public async Task Mentioning_a_community_posts_into_it_as_a_titled_page()
{
@@ -101,6 +101,33 @@ namespace PrivaPub.Tests.Federation
Assert.Equal("the signature has expired",
Check(MastodonRequest(extra: $",expires=\"{Now.AddMinutes(-20).ToUnixTimeSeconds()}\"")));
// hs2019 leaves the hash to the key: some sign RSA with SHA-512, which rsa-sha256 does not allow; and a SHA-512 digest
[Fact]
public void Accepts_hs2019_hashed_with_sha512_and_a_sha512_digest()
{
var context = new DefaultHttpContext();
context.Request.Method = "POST";
context.Request.Host = new HostString(Host);
context.Request.Path = "/peasants/bob/mouth";
context.Features.Get<IHttpRequestFeature>().RawTarget = "/peasants/bob/mouth";
var date = Now.ToString("r", CultureInfo.InvariantCulture);
var digest = "SHA-512=" + Convert.ToBase64String(SHA512.HashData(Body));
context.Request.Headers["Date"] = date;
context.Request.Headers["Digest"] = digest;
var signingString = $"(request-target): post /peasants/bob/mouth\nhost: {Host}\ndate: {date}\ndigest: {digest}";
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA512, RSASignaturePadding.Pkcs1));
string Signed(string algorithm) => $"keyId=\"{KeyId}\",algorithm=\"{algorithm}\",headers=\"(request-target) host date digest\",signature=\"{signature}\"";
context.Request.Headers["Signature"] = Signed("hs2019");
var parameters = HttpSignatures.Parse(context.Request.Headers["Signature"].ToString());
Assert.Null(HttpSignatures.CheckRequest(context.Request, parameters, Body, Now));
Assert.True(HttpSignatures.Verify(PublicKey, HttpSignatures.SigningString(context.Request, parameters), parameters.Signature, parameters.Algorithm));
context.Request.Headers["Signature"] = Signed("rsa-sha256");
parameters = HttpSignatures.Parse(context.Request.Headers["Signature"].ToString());
Assert.False(HttpSignatures.Verify(PublicKey, HttpSignatures.SigningString(context.Request, parameters), parameters.Signature, parameters.Algorithm));
}
[Fact]
public void Signs_with_the_raw_request_target()
{
@@ -0,0 +1,77 @@
using MongoDB.Entities;
using PrivaPub.Domain.Relationships;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
// A remote account's Block of a persona, as Mastodon sends it (the pasture's last Mastodon expected failure, G-0002)
[Trait("Category", "Integration")]
public sealed class InboundBlockTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
[Fact]
public async Task A_block_ends_the_follows_both_ways_hides_the_blocker_and_its_undo_lifts_it()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var carol = new RemoteActor(_harness.Peer, "carol");
await _harness.FollowedBy(alice, carol);
await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = carol.Id, State = FollowState.Accepted }, token);
var block = new JsonObject
{
["id"] = $"{Origin(carol)}/blocks/{Guid.NewGuid():N}", ["type"] = "Block", ["actor"] = carol.Id, ["object"] = alice.Uri
};
await _harness.Deliver(carol, $"/peasants/{alice.UserName}/mouth", block);
Assert.True(await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == alice.Id && b.ActorURI == carol.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id && f.ActorURI == carol.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == carol.Id).ExecuteAnyAsync(token));
Assert.Contains(carol.Id, await Hidden.AuthorsHiddenFrom(alice.Id, new[] { carol.Id }, forNotifications: false, token));
Assert.Contains(alice.Id, await Hidden.RecipientsHiding(new[] { alice.Id }, carol.Id, token));
await _harness.Deliver(carol, $"/peasants/{alice.UserName}/mouth", new JsonObject
{
["id"] = $"{Origin(carol)}/undos/{Guid.NewGuid():N}", ["type"] = "Undo", ["actor"] = carol.Id, ["object"] = block
});
Assert.False(await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == alice.Id && b.ActorURI == carol.Id).ExecuteAnyAsync(token));
Assert.Empty(await Hidden.AuthorsHiddenFrom(alice.Id, new[] { carol.Id }, forNotifications: false, token));
}
[Fact]
public async Task A_block_of_someone_who_is_not_ours_is_dropped()
{
var token = TestContext.Current.CancellationToken;
var carol = new RemoteActor(_harness.Peer, "carol");
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(carol)}/blocks/{Guid.NewGuid():N}", ["type"] = "Block", ["actor"] = carol.Id,
["object"] = $"{Origin(carol)}/users/somebody-else"
});
Assert.False(await DB.Default.Find<BlockedBy>().Match(b => b.ActorURI == carol.Id).ExecuteAnyAsync(token));
}
}
}
@@ -2,6 +2,7 @@ using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
@@ -106,6 +107,75 @@ namespace PrivaPub.Tests.Federation
Assert.NotNull(message.ConversationId);
}
// Mbin addresses its private messages to the recipient's profile page (its `url`), not to the actor
[Fact]
public async Task A_chat_message_addressed_to_a_personas_profile_page_reaches_the_persona()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mbin = new RemoteActor(_harness.Peer, "mbuser");
var messageId = NewId(mbin, "f/object");
await _harness.Deliver(mbin, "/human-centipede", new JsonObject
{
["id"] = NewId(mbin, "f/object"), ["type"] = "Create", ["actor"] = mbin.Id, ["to"] = new JsonArray(alice.HtmlUrl), ["cc"] = new JsonArray(),
["object"] = new JsonObject
{
["id"] = messageId, ["type"] = "ChatMessage", ["attributedTo"] = mbin.Id, ["to"] = new JsonArray(alice.HtmlUrl), ["cc"] = new JsonArray(),
["content"] = "<p>an answer</p>", ["mediaType"] = "text/html", ["published"] = DateTime.UtcNow.ToString("O")
}
});
var message = await DB.Default.Find<Post>().Match(p => p.ObjectURI == messageId).ExecuteFirstAsync(token);
Assert.NotNull(message);
Assert.Equal(PostVisibility.Direct, message.Visibility);
var conversation = await DB.Default.Find<DmGroup>().OneAsync(message.ConversationId, token);
Assert.Equal(new[] { alice.Id, mbin.Id }.Order(StringComparer.Ordinal), conversation.Members.Select(m => m.AvatarId).Order(StringComparer.Ordinal));
}
// Lemmy 0.19 and Mbin take a private message only as a ChatMessage: someone who writes to us that way is answered in
// kind, anyone else with a Note
[Fact]
public async Task A_direct_message_answers_in_the_form_its_recipient_writes_in()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var lemmy = new RemoteActor(_harness.Peer, "pm");
var mastodon = new RemoteActor(_harness.Peer, "dm");
async Task<Post> Received(RemoteActor sender, string type)
{
var id = NewId(sender, "messages");
await _harness.Deliver(sender, "/human-centipede", new JsonObject
{
["id"] = NewId(sender, "activities/create"), ["type"] = "Create", ["actor"] = sender.Id, ["to"] = new JsonArray(alice.Uri),
["object"] = new JsonObject
{
["id"] = id, ["type"] = type, ["attributedTo"] = sender.Id, ["to"] = new JsonArray(alice.Uri),
["content"] = "<p>psst</p>", ["published"] = DateTime.UtcNow.ToString("O")
}
});
return await DB.Default.Find<Post>().Match(p => p.ObjectURI == id).ExecuteSingleAsync(token);
}
async Task<JsonObject> Answer(RemoteActor to, Post message)
{
var answer = await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft
{
Text = "psst back", Visibility = PostVisibility.Direct, ConversationId = message.ConversationId
}, token);
Assert.True(answer.Ok, answer.Error);
return (JsonObject)(await _harness.Outgoing(to.Id + "/inbox")).Last(a => a["type"]!.GetValue<string>() == "Create")["object"]!;
}
var chat = await Answer(lemmy, await Received(lemmy, "ChatMessage"));
var note = await Answer(mastodon, await Received(mastodon, "Note"));
Assert.Equal("ChatMessage", chat["type"]!.GetValue<string>());
Assert.Equal(lemmy.Id, Assert.Single(chat["to"]!.AsArray())!.GetValue<string>());
Assert.Null(chat["cc"]);
Assert.DoesNotContain("h-card", chat["content"]!.GetValue<string>());
Assert.Equal("Note", note["type"]!.GetValue<string>());
}
[Fact]
public async Task An_unreachable_key_asks_the_sender_to_retry_instead_of_refusing()
{
@@ -121,6 +191,68 @@ namespace PrivaPub.Tests.Federation
Assert.Equal(300, result.RetryAfterSeconds);
}
// Forte follows a persona by its profile page, as WebFinger names it, rather than by its actor's id; so does its Undo
[Fact]
public async Task A_follow_of_a_personas_profile_page_follows_the_persona()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var forte = new RemoteActor(_harness.Peer, "forte");
var follow = new JsonObject { ["id"] = NewId(forte, "follow"), ["type"] = "Follow", ["actor"] = forte.Id, ["object"] = alice.HtmlUrl };
Assert.Equal(202, (await _harness.Deliver(forte, "/human-centipede", follow)).StatusCode);
Assert.True(await DB.Default.Find<PrivaPub.Models.Federation.Follower>().Match(f => f.LocalActorId == alice.Id && f.ActorURI == forte.Id && f.IsAccepted).ExecuteAnyAsync(token));
Assert.Contains(await _harness.Outgoing(forte.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Accept");
await _harness.Deliver(forte, "/human-centipede", new JsonObject
{
["id"] = NewId(forte, "undo"), ["type"] = "Undo", ["actor"] = forte.Id,
["object"] = new JsonObject { ["type"] = "Follow", ["actor"] = forte.Id, ["object"] = alice.HtmlUrl }
});
Assert.False(await DB.Default.Find<PrivaPub.Models.Federation.Follower>().Match(f => f.LocalActorId == alice.Id && f.ActorURI == forte.Id).ExecuteAnyAsync(token));
}
// Castopod announces an episode with a note that only links to it: the post takes the episode's sound, title and cover
[Fact]
public async Task A_note_that_only_links_its_authors_episode_carries_the_episodes_sound()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var podcast = new RemoteActor(_harness.Peer, "pod");
await DB.Default.SaveAsync(new PrivaPub.Models.Social.Following
{
AvatarId = alice.Id, TargetActorURI = podcast.Id, TargetInboxURL = podcast.Id + "/inbox", State = PrivaPub.Models.Social.FollowState.Accepted
}, token);
var origin = new Uri(podcast.Id).GetLeftPart(UriPartial.Authority);
var episodePath = $"/episodes/{Guid.NewGuid():N}";
_harness.Peer.Serve(episodePath, new JsonObject
{
["id"] = origin + episodePath, ["type"] = "PodcastEpisode", ["attributedTo"] = podcast.Id, ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"),
["description"] = new JsonObject { ["type"] = "Note", ["content"] = "the meadow", ["mediaType"] = "text/markdown" },
["image"] = new JsonObject { ["type"] = "Image", ["url"] = origin + "/cover.png" },
["audio"] = new JsonObject
{
["id"] = origin + "/one.mp3", ["type"] = "Audio", ["name"] = "Episode one", ["duration"] = 4.0,
["url"] = new JsonObject { ["href"] = origin + "/one.mp3", ["type"] = "Link", ["mediaType"] = "audio/mpeg" }
}
}.ToJsonString());
var noteId = NewId(podcast, "posts");
await _harness.Deliver(podcast, "/human-centipede", new JsonObject
{
["id"] = noteId + "/activity", ["type"] = "Create", ["actor"] = podcast.Id, ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"),
["object"] = new JsonObject
{
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = podcast.Id, ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"),
["content"] = $"<a href=\"{origin + episodePath}\">Episode one</a><br/>", ["published"] = DateTime.UtcNow.ToString("O")
}
});
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(token);
Assert.Equal(origin + "/one.mp3", Assert.Single(post.Media).RemoteURL);
Assert.Equal(("Episode one", origin + "/cover.png", 4.0), (post.Title, post.CoverURL, post.Audio.DurationSeconds));
}
[Fact]
public async Task A_join_of_a_local_post_is_answered_with_ignore()
{
+341 -4
View File
@@ -42,6 +42,204 @@ namespace PrivaPub.Tests.Federation
PrivaPub.Models.Statistics.InteractionEvent Processed(string activity) => _harness.Ledger.Of("in").Last(e => e.Activity == activity);
// a follow request nobody answers is sent again after 15 minutes, an hour, 6 hours... (Lemmy 1.0 lost the Accept of a
// follow it took before it started sending to us, and the community's posts were refused as not followed)
[Fact]
public async Task A_follow_request_nobody_answers_is_sent_again_with_a_growing_pause_until_it_is()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var quiet = new RemoteActor(_harness.Peer, "quiet");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = quiet.Id }, token);
var following = await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == quiet.Id).ExecuteSingleAsync(token);
async Task<int> Follows() => (await _harness.Outgoing(quiet.Id + "/inbox")).Count(a => a["type"]!.GetValue<string>() == "Follow");
var made = following.CreatedAt;
Assert.Equal(1, await Follows());
// (the store is shared with other tests' requests: what this one got is counted on its own inbox)
async Task<int> After(TimeSpan wait)
{
var before = await Follows();
await _harness.Follows.ResendPending(made + wait, token);
return await Follows() - before;
}
Assert.Equal(0, await After(TimeSpan.FromMinutes(10)));
Assert.Equal(1, await After(TimeSpan.FromMinutes(16)));
Assert.Equal(0, await After(TimeSpan.FromMinutes(30)));
Assert.Equal(1, await After(TimeSpan.FromMinutes(77)));
Assert.Equal(3, await Follows());
// each one again under its own id (Lemmy never answers an id it has seen), and an answer naming it counts
var sentIds = (await _harness.Outgoing(quiet.Id + "/inbox")).Select(a => a["id"]!.GetValue<string>()).ToList();
Assert.Equal(new[] { following.FollowActivityURI, following.FollowActivityURI + "-again-1", following.FollowActivityURI + "-again-2" }, sentIds);
// a request older than the count is sent again too; an answered one never
await DB.Default.Update<Following>().MatchID(following.ID).Modify(b => b.Unset(f => f.Resent)).Modify(b => b.Unset(f => f.ResentAt)).ExecuteAsync(token);
Assert.Equal(1, await After(TimeSpan.FromDays(1)));
await _harness.Deliver(quiet, "/human-centipede", new JsonObject
{
["id"] = NewId(quiet, "accepts"), ["type"] = "Accept", ["actor"] = quiet.Id, ["object"] = following.FollowActivityURI + "-again-2"
});
Assert.Equal(FollowState.Accepted, (await DB.Default.Find<Following>().OneAsync(following.ID, token)).State);
Assert.Equal(0, await After(TimeSpan.FromDays(30)));
}
// Pixelfed names our post by its page (/@name/<id>, the post's url) in its Like, Announce and their Undo: the post is
// found the same as by its id
// Friendica's activity ids are uniqid(), a prefix and the microsecond: two of its processes answering two follows at
// once gave both Accepts one id, and the second was dropped as a copy of the first, leaving that follow pending
[Fact]
public async Task Two_activities_a_server_gave_one_id_are_both_taken_and_a_true_copy_once()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var dario = new RemoteActor(_harness.Peer, "dario");
var jun = new RemoteActor(_harness.Peer, "jun");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = dario.Id }, token);
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = jun.Id }, token);
Task<Following> Of(RemoteActor target) =>
DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == target.Id).ExecuteSingleAsync(token);
async Task<JsonObject> Accept(RemoteActor by, string id) => new()
{
["id"] = id,
["type"] = "Accept",
["actor"] = by.Id,
["object"] = new JsonObject { ["id"] = (await Of(by)).FollowActivityURI, ["type"] = "Follow", ["actor"] = alice.Uri, ["object"] = by.Id }
};
var shared = NewId(dario, "activity");
var first = await _harness.Deliver(dario, "/human-centipede", await Accept(dario, shared));
var second = await _harness.Deliver(jun, "/human-centipede", await Accept(jun, shared));
var copy = await _harness.Deliver(jun, "/human-centipede", await Accept(jun, shared));
Assert.Equal(("queued", "queued", "duplicate"), (first.Reason, second.Reason, copy.Reason));
Assert.Equal(FollowState.Accepted, (await Of(dario)).State);
Assert.Equal(FollowState.Accepted, (await Of(jun)).State);
}
// Mobilizon: the organiser creates, edits and deletes an event attributed to the group, which announces it. They used
// to be refused as misattributed (400), so the edit was lost and the deletion left the event in place
[Fact]
public async Task An_event_its_organiser_edits_and_deletes_for_a_group_on_the_same_server_follows_that_server()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var group = new RemoteActor(_harness.Peer, "group", type: "Group");
var organiser = new RemoteActor(_harness.Peer, "organiser");
await Follows(alice.Id, group);
var path = $"/events/{Guid.NewGuid():N}";
JsonObject Event(string name) => new()
{
["id"] = _harness.Peer.A + path, ["type"] = "Event", ["name"] = name, ["content"] = "<p>bring bread</p>",
["attributedTo"] = group.Id, ["actor"] = organiser.Id, ["startTime"] = "2026-10-08T18:00:00Z",
["to"] = new JsonArray(PrivaPub.Federation.Objects.Addressing.Public), ["cc"] = new JsonArray(group.Id + "/followers")
};
_harness.Peer.Serve(path, Event("A picnic").ToJsonString());
Task<Post> Stored() => DB.Default.Find<Post>().Match(p => p.ObjectURI == _harness.Peer.A + path).ExecuteFirstAsync(token);
var created = await _harness.Deliver(organiser, "/human-centipede", Create(organiser, Event("A picnic")));
Assert.Equal(202, created.StatusCode);
Assert.Equal(group.Id, (await Stored()).ActorURI);
var moved = Event("A picnic, moved indoors");
moved["updated"] = DateTime.UtcNow.AddMinutes(1).ToString("O");
_harness.Peer.Serve(path, moved.ToJsonString());
await _harness.Deliver(organiser, "/human-centipede", Activity(organiser, "Update", Event("what the activity claims")));
Assert.Equal("A picnic, moved indoors", (await Stored()).Title);
await _harness.Deliver(organiser, "/human-centipede", Activity(organiser, "Delete", JsonValue.Create(_harness.Peer.A + path)!));
Assert.NotNull(await Stored());
_harness.Peer.Answer(path, 410);
await _harness.Deliver(organiser, "/human-centipede", Activity(organiser, "Delete", JsonValue.Create(_harness.Peer.A + path)!));
Assert.Null(await Stored());
// attributed to someone of another server, it is still refused
var elsewhere = new RemoteActor(_harness.Peer, "elsewhere", _harness.Peer.B);
var claimed = Event("Someone else's");
claimed["id"] = _harness.Peer.A + $"/events/{Guid.NewGuid():N}";
claimed["attributedTo"] = elsewhere.Id;
Assert.Equal(400, (await _harness.Deliver(organiser, "/human-centipede", Create(organiser, claimed))).StatusCode);
}
// Funkwhale names its Accept after the Follow it answers (`<our Follow's id>/accept`), on our origin, not its own
[Fact]
public async Task An_answer_named_after_our_follow_is_taken_and_one_naming_someone_elses_is_not()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var channel = new RemoteActor(_harness.Peer, "channel");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = channel.Id }, token);
var following = await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == channel.Id).ExecuteSingleAsync(token);
var elsewhere = await _harness.Deliver(channel, "/human-centipede", new JsonObject
{
["id"] = "https://privapub.test/peasants/somebody/accept", ["type"] = "Accept", ["actor"] = channel.Id, ["object"] = following.FollowActivityURI
});
Assert.Equal(400, elsewhere.StatusCode);
var named = await _harness.Deliver(channel, "/human-centipede", new JsonObject
{
["id"] = following.FollowActivityURI + "/accept", ["type"] = "Accept", ["actor"] = channel.Id,
["object"] = new JsonObject { ["id"] = following.FollowActivityURI, ["type"] = "Follow", ["actor"] = alice.Uri, ["object"] = channel.Id }
});
Assert.Equal(202, named.StatusCode);
Assert.Equal(FollowState.Accepted, (await DB.Default.Find<Following>().OneAsync(following.ID, token)).State);
}
// Funkwhale's channel deletes its uploads in one Delete, their ids in a list as the object's id (and the Delete has
// no id of its own)
[Fact]
public async Task A_delete_naming_several_objects_removes_each()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var channel = new RemoteActor(_harness.Peer, "channel");
await Follows(alice.Id, channel);
var first = PublicNote(channel, "<p>a first track</p>");
var second = PublicNote(channel, "<p>a second track</p>");
await _harness.Deliver(channel, "/human-centipede", Create(channel, first));
await _harness.Deliver(channel, "/human-centipede", Create(channel, second));
Task<bool> Held(JsonObject note) => DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(note)).ExecuteAnyAsync(token);
Assert.True(await Held(first) && await Held(second));
var result = await _harness.Receiver.Receive(channel.Post(Harness.Host, "/human-centipede", new JsonObject
{
["type"] = "Delete", ["actor"] = channel.Id,
["object"] = new JsonObject { ["id"] = new JsonArray(IdOf(first), IdOf(second)), ["type"] = "Audio" }
}), default, token);
Assert.Equal(202, result.StatusCode);
var listed = IdOf(first);
var job = await DB.Default.Find<PrivaPub.Models.Jobs.Job>().Match(j => j.Kind == PrivaPub.Models.Jobs.JobKind.ProcessInbox
&& j.State == PrivaPub.Models.Jobs.JobState.Pending && j.Payload.Contains(listed)).Sort(j => j.CreatedAt, MongoDB.Entities.Order.Descending)
.ExecuteFirstAsync(token);
await _harness.Processor.Handle(job, token);
Assert.False(await Held(first));
Assert.False(await Held(second));
}
[Fact]
public async Task A_like_naming_a_post_by_its_page_counts_and_its_undo_too()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var fan = new RemoteActor(_harness.Peer, "fan");
var post = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "a picture" }, token)).Post;
var page = alice.PostHtmlUrl(post.ID);
Assert.NotEqual(post.ObjectURI, page);
var like = new JsonObject { ["id"] = NewId(fan, "likes"), ["type"] = "Like", ["actor"] = fan.Id, ["object"] = page };
await _harness.Deliver(fan, "/human-centipede", like);
Assert.Equal("accepted", Processed("Like").Outcome);
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
await _harness.Deliver(fan, "/human-centipede", Activity(fan, "Undo", like));
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
// a page that is no post of ours stays as it is
var elsewhere = new JsonObject { ["id"] = NewId(fan, "likes"), ["type"] = "Like", ["actor"] = fan.Id, ["object"] = alice.PostHtmlUrl("000000000000000000000000") };
await _harness.Deliver(fan, "/human-centipede", elsewhere);
Assert.Equal(("dropped", "unknown-object"), (Processed("Like").Outcome, Processed("Like").Reason));
}
[Fact]
public async Task An_actor_update_refreshes_the_account_from_its_origin_even_when_its_updated_is_older()
{
@@ -147,7 +345,7 @@ namespace PrivaPub.Tests.Federation
}
[Fact]
public async Task A_followed_community_announcing_a_vote_or_its_undo_is_recorded_and_changes_nothing_yet()
public async Task A_vote_a_followed_community_relays_counts_and_its_undo_takes_it_back()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
@@ -168,20 +366,159 @@ namespace PrivaPub.Tests.Federation
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", like));
var liked = Processed("Announce");
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(voter, "Undo", like)));
var unliked = Processed("Announce");
await _harness.Deliver(stranger, "/human-centipede", Activity(stranger, "Announce", like));
var unfollowed = Processed("Announce");
//votes relayed by a community are a documented gap (docs/INTEROP.md, Lemmy: "Announces of activities other than Create")
Assert.Equal(("dropped", "unsupported", "Like"), (liked.Outcome, liked.Reason, liked.Object));
Assert.Equal(("dropped", "unsupported", "Undo"), (unliked.Outcome, unliked.Reason, unliked.Object));
//Lemmy relays its members' votes inside the community's Announce (G-0003): a voter on the community's own server
//is vouched for by it, and the vote is handled as if the voter had sent it; a community nobody follows is ignored
Assert.Equal("accepted", liked.Outcome);
Assert.Equal("accepted", unliked.Outcome);
Assert.Equal(("dropped", "not-followed"), (unfollowed.Outcome, unfollowed.Reason));
var after = await DB.Default.Find<Post>().OneAsync(post.ID, token);
Assert.Equal(0, after.FavouritesCount);
Assert.False(await DB.Default.Find<Favourite>().Match(f => f.PostId == post.ID).ExecuteAnyAsync(token));
}
// Lemmy sends a vote to the community alone: a vote on our reply in a thread of a community nobody here follows still
// reaches us through it, and only through that community (found by the town's village)
[Fact]
public async Task A_vote_on_our_reply_in_an_unfollowed_communitys_thread_counts_through_that_community_only()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var community = new RemoteActor(_harness.Peer, "cats", type: "Group");
var other = new RemoteActor(_harness.Peer, "dogs", type: "Group");
var poster = new RemoteActor(_harness.Peer, "poster");
var voter = new RemoteActor(_harness.Peer, "voter");
var page = PublicNote(poster, "<p>a thread</p>", community.Id);
page["type"] = "Page";
page["name"] = "a thread";
page["audience"] = community.Id;
_harness.Peer.Serve(new Uri(IdOf(page)).AbsolutePath, page.ToJsonString());
var root = await _harness.RemotePosts.StoreContext(IdOf(page), 0, token);
Assert.Equal(community.Id, root.AudienceURI);
var reply = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "my answer", InReplyTo = root.ID }, token)).Post;
var like = new JsonObject { ["id"] = NewId(voter, "likes"), ["type"] = "Like", ["actor"] = voter.Id, ["object"] = reply.ObjectURI };
await _harness.Deliver(other, "/human-centipede", Activity(other, "Announce", like));
Assert.Equal(("dropped", "not-followed"), (Processed("Announce").Outcome, Processed("Announce").Reason));
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", like));
Assert.Equal("accepted", Processed("Announce").Outcome);
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(reply.ID, token)).FavouritesCount);
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(voter, "Undo", like)));
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(reply.ID, token)).FavouritesCount);
}
// Lemmy's moderators lock a community's post and ban members, and the community relays both inside its Announce
// (G-0006): a locked post takes no reply, a banned persona posts nothing there, until the Undo
[Fact]
public async Task A_community_locks_a_post_and_bans_a_persona_until_it_undoes_either()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var community = new RemoteActor(_harness.Peer, "cats", type: "Group");
var poster = new RemoteActor(_harness.Peer, "poster");
var moderator = new RemoteActor(_harness.Peer, "moderator");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token);
await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
JsonObject Page(string text, bool commentsEnabled = true)
{
var page = PublicNote(poster, $"<p>{text}</p>", community.Id);
page["type"] = "Page";
page["name"] = text;
page["audience"] = community.Id;
page["commentsEnabled"] = commentsEnabled;
_harness.Peer.Serve(new Uri(IdOf(page)).AbsolutePath, page.ToJsonString());
return page;
}
async Task<Post> Announced(JsonObject page)
{
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, page)));
return await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(page)).ExecuteSingleAsync(token);
}
Task<StatusOutcome> Reply(Post post) => _harness.Statuses.Publish(alice, new StatusDraft { Text = "a reply", InReplyTo = post.ID }, token);
var post = await Announced(Page("a thread"));
var lockIt = new JsonObject { ["id"] = NewId(moderator, "locks"), ["type"] = "Lock", ["actor"] = moderator.Id, ["object"] = post.ObjectURI };
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", lockIt));
Assert.Equal(("accepted", "locked"), (Processed("Announce").Outcome, Processed("Announce").Reason));
var refused = await Reply(post);
Assert.Equal((422, "Validation failed: This thread is locked"), (refused.Status, refused.Error));
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(moderator, "Undo", lockIt)));
Assert.Null((await DB.Default.Find<Post>().OneAsync(post.ID, token)).LockedAt);
Assert.True((await Reply(post)).Ok);
// a post its community serves locked arrives locked
Assert.NotNull((await Announced(Page("born locked", commentsEnabled: false))).LockedAt);
var ban = new JsonObject { ["id"] = NewId(moderator, "bans"), ["type"] = "Block", ["actor"] = moderator.Id, ["object"] = alice.Uri, ["target"] = community.Id };
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", ban));
Assert.Equal(("accepted", "banned"), (Processed("Announce").Outcome, Processed("Announce").Reason));
var banned = await Reply(post);
Assert.Equal((422, "Validation failed: This community banned you"), (banned.Status, banned.Error));
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(moderator, "Undo", ban)));
Assert.Equal(("accepted", "unbanned"), (Processed("Announce").Outcome, Processed("Announce").Reason));
Assert.True((await Reply(post)).Ok);
// Lemmy also sends the ban straight to the persona's server, as its moderator's Block: still the community's ban,
// never the moderator's own block of the persona
var direct = new JsonObject { ["id"] = NewId(moderator, "bans"), ["type"] = "Block", ["actor"] = moderator.Id, ["object"] = alice.Uri, ["target"] = community.Id };
await _harness.Deliver(moderator, "/human-centipede", direct);
Assert.Equal(("accepted", "banned"), (Processed("Block").Outcome, Processed("Block").Reason));
Assert.False(await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == alice.Id && b.ActorURI == moderator.Id).ExecuteAnyAsync(token));
Assert.Equal(422, (await Reply(post)).Status);
await _harness.Deliver(moderator, "/human-centipede", Activity(moderator, "Undo", direct));
Assert.True((await Reply(post)).Ok);
var elsewhere = new JsonObject { ["id"] = NewId(moderator, "bans"), ["type"] = "Block", ["actor"] = moderator.Id, ["object"] = poster.Id, ["target"] = community.Id };
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", elsewhere));
Assert.Equal(("dropped", "not-ours"), (Processed("Announce").Outcome, Processed("Announce").Reason));
}
// a voter on another server than the community is believed for the community's own posts, as Lemmy trusts it; for
// anything else only once its vote is fetched from its own origin
[Fact]
public async Task A_relayed_vote_from_another_server_counts_on_the_communitys_posts_and_elsewhere_only_once_fetched()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var community = new RemoteActor(_harness.Peer, "cats", type: "Group");
var poster = new RemoteActor(_harness.Peer, "poster");
var voter = new RemoteActor(_harness.Peer, "voter", origin: _harness.Peer.B);
var forger = new RemoteActor(_harness.Peer, "forger", origin: _harness.Peer.B);
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token);
await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
var page = PublicNote(poster, "<p>a post</p>", community.Id);
page["type"] = "Page";
page["name"] = "a title";
_harness.Peer.Serve(new Uri(IdOf(page)).AbsolutePath, page.ToJsonString());
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, page)));
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(page)).ExecuteSingleAsync(token);
var other = await OwnPost(alice, token);
var like = new JsonObject { ["id"] = NewId(voter, "likes"), ["type"] = "Like", ["actor"] = voter.Id, ["object"] = post.ObjectURI };
var forged = new JsonObject { ["id"] = NewId(forger, "likes"), ["type"] = "Like", ["actor"] = forger.Id, ["object"] = other.ObjectURI };
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", like));
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", forged));
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
Assert.True(await DB.Default.Find<Favourite>().Match(f => f.PostId == post.ID && f.ActorURI == voter.Id).ExecuteAnyAsync(token));
Assert.Equal(("dropped", "fetch-failed"), (Processed("Announce").Outcome, Processed("Announce").Reason));
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(other.ID, token)).FavouritesCount);
}
// a public post of a persona's own, outside any community
static async Task<Post> OwnPost(PrivaPub.Federation.Actors.LocalActor author, CancellationToken token)
{
var post = new Post { GroupUserId = author.Id, AuthorAccountId = author.Id, Text = "not the community's" };
post.ID = (string)post.GenerateNewID();
post.ObjectURI = author.PostUri(post.ID);
await DB.Default.SaveAsync(post, token);
return post;
}
[Fact]
public async Task A_locked_persona_holds_a_follow_until_it_decides_and_answers_with_the_original_follow()
{
@@ -171,12 +171,19 @@ namespace PrivaPub.Tests.Federation
var token = TestContext.Current.CancellationToken;
var alice = await LocalAvatar("alice");
var mallory = new RemoteActor(_peer, "mallory");
var victim = new RemoteActor(_peer, "victim");
var victim = new RemoteActor(_peer, "victim", _peer.B);
var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, attributedTo: victim.Id));
Assert.Equal(400, result.StatusCode);
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == victim.Id).ExecuteAnyAsync(token));
// a colleague on mallory's own server is that server's to vouch for: believed only as the server has it, and
// it has no such note
var colleague = new RemoteActor(_peer, "colleague");
result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, attributedTo: colleague.Id));
Assert.Equal(202, result.StatusCode);
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == colleague.Id).ExecuteAnyAsync(token));
}
[Fact]
@@ -263,6 +270,28 @@ namespace PrivaPub.Tests.Federation
Assert.Equal(alice.Id, mention.AccountId);
}
// GoToSocial kept a persona in cc after an edit took the @name out, and Akkoma addresses with to[] alone: the persona it
// is addressed to sees a followers-only post as a silent mention, which no list shows as a mention (found by the town)
[Fact]
public async Task A_followers_only_post_addressed_to_a_persona_without_naming_it_is_a_silent_mention()
{
var token = TestContext.Current.CancellationToken;
var alice = await LocalAvatar("alice");
var bob = new RemoteActor(_peer, "bob");
var create = PublicCreate(bob, default, alice.Uri);
create["object"]!["to"] = bob.Id + "/followers";
create["object"]!["cc"] = alice.Uri;
await Deliver(bob, "/human-centipede", create);
var post = await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token);
Assert.Equal(PostVisibility.FollowersOnly, post.Visibility);
var mention = Assert.Single(post.Mentions);
Assert.True(mention.Silent);
Assert.Equal(alice.Id, mention.AccountId);
Assert.True(await PrivaPub.Domain.Privacy.VisibilityPolicy.CanSee(post, alice.Id, token));
}
[Fact]
public async Task A_suspended_domain_is_dropped_before_its_key_is_fetched()
{
@@ -0,0 +1,175 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Signing;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support;
using System.Text;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
// FEP-8b32 proofs (eddsa-jcs-2022) by a persona's Ed25519 key, which its actor names as a FEP-521a Multikey
public sealed class IntegrityProofTests
{
[Fact]
public void Canonical_json_is_rfc_8785s()
{
// RFC 8785, section 3.2.2
var input = JsonNode.Parse("{\"numbers\":[333333333.33333329,1E30,4.50,2e-3,0.000000000000000000000000001],"
+ "\"string\":\"\\u20ac$\\u000F\\u000aA'\\u0042\\u0022\\u005c\\\\\\\"\\/\",\"literals\":[null,true,false]}");
Assert.Equal("{\"literals\":[null,true,false],\"numbers\":[333333333.3333333,1e+30,4.5,0.002,1e-27],\"string\":\"€$\\u000f\\nA'B\\\"\\\\\\\\\\\"/\"}",
Jcs.Serialize(input));
}
[Fact]
public void Base58_and_multikeys_read_back_what_they_write()
{
Assert.Equal("2NEpo7TZRRrLZSi2U", IntegrityProofs.Base58(Encoding.ASCII.GetBytes("Hello World!")));
Assert.Equal("112", IntegrityProofs.Base58(new byte[] { 0, 0, 1 }));
Assert.Equal(new byte[] { 0, 0, 1 }, IntegrityProofs.FromBase58("112"));
var publicKey = IntegrityProofs.PublicKey(IntegrityProofs.NewSeed());
var multikey = IntegrityProofs.Multikey(publicKey);
Assert.StartsWith("z6Mk", multikey);
Assert.Equal(publicKey, IntegrityProofs.FromMultikey(multikey));
}
[Fact]
public void A_proof_verifies_with_its_key_and_with_nothing_changed()
{
var seed = IntegrityProofs.NewSeed();
var activity = new JsonObject
{
["@context"] = ActivityPubRenderer.Context(), ["id"] = "https://privapub.test/peasants/alice/grunts/1", ["type"] = "Create",
["actor"] = "https://privapub.test/peasants/alice", ["object"] = new JsonObject { ["type"] = "Note", ["content"] = "<p>ciao, è così</p>", ["width"] = 4.5 }
};
activity["proof"] = IntegrityProofs.Create(activity, "https://privapub.test/peasants/alice#ed25519-key", seed, DateTime.UtcNow);
var delivered = JsonNode.Parse(activity.ToJsonString())!.AsObject();
Assert.True(IntegrityProofs.Verify(delivered, IntegrityProofs.PublicKey(seed)));
Assert.Equal("assertionMethod", delivered["proof"]!["proofPurpose"]!.GetValue<string>());
var changed = delivered.DeepClone().AsObject();
changed["object"]!["content"] = "<p>something else</p>";
Assert.False(IntegrityProofs.Verify(changed, IntegrityProofs.PublicKey(seed)));
Assert.False(IntegrityProofs.Verify(delivered, IntegrityProofs.PublicKey(IntegrityProofs.NewSeed())));
}
[Fact]
public void A_persona_with_a_key_names_it_as_a_multikey()
{
var seed = IntegrityProofs.NewSeed();
var actor = ActivityPubRenderer.Actor(new LocalActor { Id = "a", UserName = "alice", BaseAddress = Harness.Base, Kind = LocalActorKind.Person, SigningKey = seed });
var keyless = ActivityPubRenderer.Actor(new LocalActor { Id = "b", UserName = "bob", BaseAddress = Harness.Base, Kind = LocalActorKind.Person });
var key = Assert.Single(actor["assertionMethod"]!.AsArray())!;
Assert.Equal(($"{Harness.Base}/peasants/alice#ed25519-key", "Multikey", $"{Harness.Base}/peasants/alice"),
(key["id"]!.GetValue<string>(), key["type"]!.GetValue<string>(), key["controller"]!.GetValue<string>()));
Assert.Equal(IntegrityProofs.PublicKey(seed), IntegrityProofs.FromMultikey(key["publicKeyMultibase"]!.GetValue<string>()));
Assert.Null(keyless["assertionMethod"]);
}
}
[Trait("Category", "Integration")]
[Xunit.Collection(nameof(Exclusive))]
public sealed class DeliveredProofTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
// what goes to a relay carries the persona's proof, to be forwarded on its strength; what goes to a server directly
// does not (Mitra refuses a proof by a key it has not read yet); another's activity passed on never gets ours
[Fact]
public async Task A_personas_activity_goes_to_a_relay_with_its_proof_and_elsewhere_without()
{
var token = TestContext.Current.CancellationToken;
var (_, persona) = await _harness.Persona("alice");
var seed = IntegrityProofs.NewSeed();
await DB.Default.Update<Avatar>().MatchID(persona.Id).Modify(a => a.SigningKey, seed).ExecuteAsync(token);
var alice = _harness.Local.FromAvatar(await DB.Default.Find<Avatar>().MatchID(persona.Id).ExecuteSingleAsync(token));
var relayInbox = $"{_harness.Peer.A}/relay-{Guid.NewGuid():N}/inbox";
var serverInbox = _harness.Peer.A + "/proofs/inbox";
await DB.Default.SaveAsync(new RelaySubscription
{
Configured = relayInbox, ActorURI = relayInbox.Replace("/inbox", "/actor"), InboxURL = relayInbox, State = RelayState.Accepted
}, token);
var own = new JsonObject { ["id"] = alice.ActivityUri(Guid.NewGuid().ToString("N")), ["type"] = "Create", ["actor"] = alice.Uri, ["object"] = new JsonObject { ["type"] = "Note" } };
var passedOn = new JsonObject { ["id"] = $"https://elsewhere.example/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = "https://elsewhere.example/users/bob" };
try
{
await _harness.Delivery.Enqueue(alice, new[] { relayInbox, serverInbox }, own, token);
await _harness.Delivery.Enqueue(alice, new[] { relayInbox }, passedOn, token);
var toRelay = await _harness.Outgoing(relayInbox);
var signed = Assert.Single(toRelay, a => a["actor"]!.GetValue<string>() == alice.Uri);
Assert.Equal(alice.AssertionKeyId, signed["proof"]!["verificationMethod"]!.GetValue<string>());
Assert.True(IntegrityProofs.Verify(signed, IntegrityProofs.PublicKey(seed)));
Assert.Null(Assert.Single(toRelay, a => a["actor"]!.GetValue<string>() != alice.Uri)["proof"]);
Assert.Null(Assert.Single(await _harness.Outgoing(serverInbox))["proof"]);
Assert.Null(own["proof"]);
}
finally
{
await DB.Default.DeleteAsync<RelaySubscription>(s => s.InboxURL == relayInbox);
}
}
// a post forwarded by another server, which its origin does not serve: taken on its author's proof, never without
[Fact]
public async Task A_forwarded_post_with_its_authors_proof_is_taken_as_it_came()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var author = new RemoteActor(_harness.Peer, "author", ed25519: true);
var forwarder = new RemoteActor(_harness.Peer, "forwarder", _harness.Peer.B);
await DB.Default.SaveAsync(new PrivaPub.Models.Social.Following
{
AvatarId = alice.Id, TargetActorURI = author.Id, TargetInboxURL = author.Id + "/inbox", State = PrivaPub.Models.Social.FollowState.Accepted
}, token);
JsonObject Create(string text)
{
var noteId = $"{new Uri(author.Id).GetLeftPart(UriPartial.Authority)}/notes/{Guid.NewGuid():N}";
return new JsonObject
{
["@context"] = "https://www.w3.org/ns/activitystreams", ["id"] = noteId + "/activity", ["type"] = "Create", ["actor"] = author.Id,
["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"),
["object"] = new JsonObject
{
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"<p>{text}</p>",
["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"), ["published"] = DateTime.UtcNow.ToString("O")
}
};
}
var proven = author.Prove(Create("proven"));
var tampered = author.Prove(Create("as written"));
tampered["object"]!["content"] = "<p>changed on the way</p>";
var bare = Create("unproven");
await _harness.Deliver(forwarder, "/human-centipede", proven);
await _harness.Deliver(forwarder, "/human-centipede", tampered);
await _harness.Deliver(forwarder, "/human-centipede", bare);
Task<bool> Held(JsonObject create) => DB.Default.Find<PrivaPub.Models.Post.Post>().Match(p => p.ObjectURI == create["object"]!["id"]!.GetValue<string>()).ExecuteAnyAsync(token);
Assert.True(await Held(proven));
Assert.False(await Held(tampered));
Assert.False(await Held(bare));
}
}
}
@@ -0,0 +1,284 @@
using MongoDB.Entities;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
using static PrivaPub.Tests.Support.FederatedSeeds;
namespace PrivaPub.Tests.Federation
{
// GoToSocial's interaction policies: who may reply to, like and boost a post at once, who must ask its author first, and
// who may not
[Trait("Category", "Integration")]
public sealed class InteractionPolicyTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static JsonObject Rule(params string[] automatic) => Rule(automatic, Array.Empty<string>());
static JsonObject Rule(string[] automatic, string[] manual) => new()
{
["automaticApproval"] = new JsonArray(automatic.Select(a => (JsonNode)a).ToArray()),
["manualApproval"] = new JsonArray(manual.Select(m => (JsonNode)m).ToArray())
};
// bob's public post, which alice follows bob to see, under the policy given
async Task<(PrivaPub.Federation.Actors.LocalActor Alice, RemoteActor Bob, Post Post, RemoteActor Fan)> Posted(JsonObject policy)
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var bob = new RemoteActor(_harness.Peer, "bob");
var fan = new RemoteActor(_harness.Peer, "fan");
await Follows(alice.Id, bob);
await _harness.FollowedBy(alice, fan);
var note = PublicNote(bob, "<p>a post with rules</p>");
note["interactionPolicy"] = policy;
await _harness.Deliver(bob, "/human-centipede", Create(bob, note));
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token);
return (alice, bob, post, fan);
}
Task<List<JsonObject>> To(RemoteActor actor) => _harness.Outgoing(actor.Id + "/inbox");
Task<List<JsonObject>> ToShared(RemoteActor actor) => _harness.Outgoing(actor.SharedInbox);
JsonObject Authorization(RemoteActor author, string type, string interaction, Post target)
{
var path = $"/authorizations/{Guid.NewGuid():N}";
var stamp = new JsonObject
{
["id"] = Origin(author) + path, ["type"] = type, ["attributedTo"] = author.Id,
["interactingObject"] = interaction, ["interactionTarget"] = target.ObjectURI
};
_harness.Peer.Serve(path, stamp.ToJsonString());
return stamp;
}
[Fact]
public async Task A_reply_the_policy_lets_in_at_once_goes_out_as_always_and_one_it_shuts_out_is_refused()
{
var token = TestContext.Current.CancellationToken;
var (alice, bob, post, _) = await Posted(new JsonObject { ["canReply"] = Rule("https://www.w3.org/ns/activitystreams#Public") });
var reply = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "an open reply", InReplyTo = post.ID }, token);
Assert.Equal(ApprovalState.None, reply.Post.Approval);
Assert.Contains(await To(bob), a => a["type"]!.GetValue<string>() == "Create");
var (carol, dave, shut, _) = await Posted(new JsonObject { ["canReply"] = Rule(new string[0], new string[0]) });
var refused = await _harness.Statuses.Publish(carol, new StatusDraft { Text = "a shut-out reply", InReplyTo = shut.ID }, token);
Assert.Equal(422, refused.Status);
}
[Fact]
public async Task A_reply_its_author_must_approve_is_asked_for_and_goes_out_with_the_authorization_once_given()
{
var token = TestContext.Current.CancellationToken;
var (alice, bob, post, fan) = await Posted(new JsonObject
{
["canReply"] = Rule(new[] { "https://dummy.invalid/nobody" }, new[] { "https://www.w3.org/ns/activitystreams#Public" })
});
var reply = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "may I?", InReplyTo = post.ID }, token)).Post;
Assert.Equal(ApprovalState.Pending, reply.Approval);
var request = Assert.Single(await To(bob), a => a["type"]!.GetValue<string>() == "ReplyRequest");
Assert.Equal(post.ObjectURI, request["object"]!.GetValue<string>());
Assert.Equal(reply.ObjectURI, request["instrument"]!["id"]!.GetValue<string>());
Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue<string>() == "Create");
var stamp = Authorization(bob, "ReplyAuthorization", reply.ObjectURI, post);
await _harness.Deliver(bob, "/human-centipede", new JsonObject
{
["id"] = NewId(bob, "accepts"), ["type"] = "Accept", ["actor"] = bob.Id,
["object"] = new JsonObject { ["type"] = "ReplyRequest", ["id"] = request["id"]!.GetValue<string>() },
["result"] = IdOf(stamp)
});
var after = await DB.Default.Find<Post>().OneAsync(reply.ID, token);
Assert.Equal((ApprovalState.Accepted, IdOf(stamp)), (after.Approval, after.ApprovalURI));
var create = Assert.Single(await ToShared(fan), a => a["type"]!.GetValue<string>() == "Create");
Assert.Equal(IdOf(stamp), create["object"]!["replyAuthorization"]!.GetValue<string>());
}
[Fact]
public async Task A_rejected_reply_stays_ours_alone_and_a_forged_authorization_changes_nothing()
{
var token = TestContext.Current.CancellationToken;
var (alice, bob, post, fan) = await Posted(new JsonObject
{
["canReply"] = Rule(new[] { "https://dummy.invalid/nobody" }, new[] { "https://www.w3.org/ns/activitystreams#Public" })
});
var reply = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "may I?", InReplyTo = post.ID }, token)).Post;
var request = Assert.Single(await To(bob), a => a["type"]!.GetValue<string>() == "ReplyRequest");
// an authorization naming another post is no authorization
var forged = Authorization(bob, "ReplyAuthorization", reply.ObjectURI, new Post { ObjectURI = "https://elsewhere.invalid/post" });
await _harness.Deliver(bob, "/human-centipede", new JsonObject
{
["id"] = NewId(bob, "accepts"), ["type"] = "Accept", ["actor"] = bob.Id, ["object"] = request["id"]!.GetValue<string>(), ["result"] = IdOf(forged)
});
Assert.Equal(ApprovalState.Pending, (await DB.Default.Find<Post>().OneAsync(reply.ID, token)).Approval);
await _harness.Deliver(bob, "/human-centipede", new JsonObject
{
["id"] = NewId(bob, "rejects"), ["type"] = "Reject", ["actor"] = bob.Id, ["object"] = request["id"]!.GetValue<string>()
});
Assert.Equal(ApprovalState.Rejected, (await DB.Default.Find<Post>().OneAsync(reply.ID, token)).Approval);
Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue<string>() == "Create");
}
[Fact]
public async Task A_like_and_a_boost_its_author_must_approve_are_asked_for_and_a_shut_out_boost_is_refused()
{
var token = TestContext.Current.CancellationToken;
var (alice, bob, post, fan) = await Posted(new JsonObject
{
["canLike"] = Rule(new[] { "https://dummy.invalid/nobody" }, new[] { "https://www.w3.org/ns/activitystreams#Public" }),
["canAnnounce"] = Rule(new[] { "https://dummy.invalid/nobody" }, new[] { "https://www.w3.org/ns/activitystreams#Public" })
});
await _harness.Statuses.Favourite(alice, post.ID, true, token);
var like = await DB.Default.Find<Favourite>().Match(f => f.AccountId == alice.Id && f.PostId == post.ID).ExecuteSingleAsync(token);
Assert.Equal(ApprovalState.Pending, like.Approval);
var likeRequest = Assert.Single(await To(bob), a => a["type"]!.GetValue<string>() == "LikeRequest");
Assert.DoesNotContain(await To(bob), a => a["type"]!.GetValue<string>() == "Like");
var stamp = Authorization(bob, "LikeAuthorization", like.ActivityURI, post);
await _harness.Deliver(bob, "/human-centipede", new JsonObject
{
["id"] = NewId(bob, "accepts"), ["type"] = "Accept", ["actor"] = bob.Id, ["object"] = likeRequest["id"]!.GetValue<string>(), ["result"] = IdOf(stamp)
});
Assert.Equal(ApprovalState.Accepted, (await DB.Default.Find<Favourite>().OneAsync(like.ID, token)).Approval);
Assert.Equal(IdOf(stamp), Assert.Single(await To(bob), a => a["type"]!.GetValue<string>() == "Like")["likeAuthorization"]!.GetValue<string>());
var boost = (await _harness.Statuses.Reblog(alice, post.ID, true, PostVisibility.Public, token)).Post;
Assert.Equal(ApprovalState.Pending, boost.Approval);
var announceRequest = Assert.Single(await _harness.Outgoing(bob.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "AnnounceRequest");
Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue<string>() == "Announce");
await _harness.Deliver(bob, "/human-centipede", new JsonObject
{
["id"] = NewId(bob, "rejects"), ["type"] = "Reject", ["actor"] = bob.Id, ["object"] = announceRequest["id"]!.GetValue<string>()
});
Assert.Equal(ApprovalState.Rejected, (await DB.Default.Find<Post>().OneAsync(boost.ID, token)).Approval);
var (carol, _, shut, _) = await Posted(new JsonObject { ["canAnnounce"] = Rule(new string[0], new string[0]) });
Assert.Equal(422, (await _harness.Statuses.Reblog(carol, shut.ID, true, PostVisibility.Public, token)).Status);
}
// as a third party: a reply bob's policy does not let in at once is shown only with bob's authorization
[Fact]
public async Task A_reply_to_a_post_that_needs_its_authors_approval_is_kept_only_with_that_approval()
{
var token = TestContext.Current.CancellationToken;
var (alice, bob, post, _) = await Posted(new JsonObject
{
["canReply"] = Rule(new[] { "https://dummy.invalid/nobody" }, new[] { "https://www.w3.org/ns/activitystreams#Public" })
});
var carol = new RemoteActor(_harness.Peer, "carol");
await Follows(alice.Id, carol);
var unasked = PublicNote(carol, "<p>barging in</p>");
unasked["inReplyTo"] = post.ObjectURI;
await _harness.Deliver(carol, "/human-centipede", Create(carol, unasked));
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(unasked)).ExecuteAnyAsync(token));
var asked = PublicNote(carol, "<p>with leave</p>");
asked["inReplyTo"] = post.ObjectURI;
asked["replyAuthorization"] = IdOf(Authorization(bob, "ReplyAuthorization", IdOf(asked), post));
await _harness.Deliver(carol, "/human-centipede", Create(carol, asked));
Assert.True(await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(asked)).ExecuteAnyAsync(token));
}
// bob's public post whose replies wait for his approval (FEP-5624's canReply, as PeerTube sets it on a video whose
// comments are moderated), which alice follows bob to see
async Task<(PrivaPub.Federation.Actors.LocalActor Alice, RemoteActor Bob, Post Post, RemoteActor Fan)> Moderated(JsonNode canReply)
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var bob = new RemoteActor(_harness.Peer, "bob");
var fan = new RemoteActor(_harness.Peer, "fan");
await Follows(alice.Id, bob);
await _harness.FollowedBy(alice, fan);
var note = PublicNote(bob, "<p>a moderated post</p>");
note["canReply"] = canReply;
await _harness.Deliver(bob, "/human-centipede", Create(bob, note));
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token);
return (alice, bob, post, fan);
}
Task Answer(RemoteActor from, string type, Post reply, string inReplyTo) => _harness.Deliver(from, "/human-centipede", new JsonObject
{
["id"] = NewId(from, "approvals"), ["type"] = type, ["actor"] = from.Id, ["object"] = reply.ObjectURI, ["inReplyTo"] = inReplyTo
});
[Fact]
public async Task A_reply_its_author_approves_goes_to_that_author_alone_then_out_with_the_approval()
{
var token = TestContext.Current.CancellationToken;
var (alice, bob, post, fan) = await Moderated("https://www.w3.org/ns/activitystreams#Public");
var reply = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "a comment to approve", InReplyTo = post.ID }, token)).Post;
Assert.Equal(ApprovalState.Pending, reply.Approval);
var asked = Assert.Single(await To(bob), a => a["type"]!.GetValue<string>() == "Create");
Assert.Equal(reply.ObjectURI, asked["object"]!["id"]!.GetValue<string>());
Assert.DoesNotContain(await To(bob), a => a["type"]!.GetValue<string>() == "ReplyRequest");
Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue<string>() == "Create");
var approval = NewId(bob, "approve-reply");
await _harness.Deliver(bob, "/human-centipede", new JsonObject
{
["id"] = approval, ["type"] = "ApproveReply", ["actor"] = bob.Id, ["object"] = reply.ObjectURI, ["inReplyTo"] = post.ObjectURI
});
var after = await DB.Default.Find<Post>().OneAsync(reply.ID, token);
Assert.Equal((ApprovalState.Accepted, approval), (after.Approval, after.ReplyApprovalURI));
var create = Assert.Single(await ToShared(fan), a => a["type"]!.GetValue<string>() == "Create");
Assert.Equal(approval, create["object"]!["replyApproval"]!.GetValue<string>());
Assert.Null(create["object"]!["replyAuthorization"]);
}
[Fact]
public async Task A_refused_reply_stays_ours_alone_and_an_approval_from_elsewhere_changes_nothing()
{
var token = TestContext.Current.CancellationToken;
var (alice, bob, post, fan) = await Moderated("https://www.w3.org/ns/activitystreams#Public");
var reply = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "a comment to refuse", InReplyTo = post.ID }, token)).Post;
// an approval by someone else than the post's author, or naming another post, is no approval
await Answer(new RemoteActor(_harness.Peer, "mallory"), "ApproveReply", reply, post.ObjectURI);
await Answer(bob, "ApproveReply", reply, "https://elsewhere.invalid/post");
Assert.Equal(ApprovalState.Pending, (await DB.Default.Find<Post>().OneAsync(reply.ID, token)).Approval);
await Answer(bob, "RejectReply", reply, post.ObjectURI);
Assert.Equal(ApprovalState.Rejected, (await DB.Default.Find<Post>().OneAsync(reply.ID, token)).Approval);
Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue<string>() == "Create");
}
[Fact]
public async Task Only_those_its_canReply_names_or_it_mentions_may_reply()
{
var (alice, _, post, _) = await Moderated(new JsonArray());
var refused = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "nobody asked me", InReplyTo = post.ID },
TestContext.Current.CancellationToken);
Assert.Equal(422, refused.Status);
}
}
}
@@ -3,8 +3,10 @@ using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels.Social;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
@@ -74,6 +76,92 @@ namespace PrivaPub.Tests.Federation
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
}
// Our followers-only posts are addressed to the followers collection, never to each follower, so a follower is found
// among our followers, not in to/cc. The town found every follower's like, reaction and downvote dropped (G-0001).
[Fact]
public async Task A_follower_likes_reacts_to_and_downvotes_a_followers_only_post()
{
var token = TestContext.Current.CancellationToken;
var (_, post) = await LocalPost("followersonly");
var carol = new RemoteActor(_harness.Peer, "carol");
await _harness.FollowedBy(_harness.Local.FromAvatar(await DB.Default.Find<Avatar>().OneAsync(post.GroupUserId, token)), carol);
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(carol)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = carol.Id, ["object"] = post.ObjectURI
});
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(carol)}/reactions/{Guid.NewGuid():N}", ["type"] = "EmojiReact", ["actor"] = carol.Id, ["object"] = post.ObjectURI,
["content"] = "🔥"
});
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(carol)}/dislikes/{Guid.NewGuid():N}", ["type"] = "Dislike", ["actor"] = carol.Id, ["object"] = post.ObjectURI
});
// the downvote counts, and takes the place of carol's like (one vote each, as Lemmy votes)
var stored = await DB.Default.Find<Post>().OneAsync(post.ID, token);
Assert.Equal(0, stored.FavouritesCount);
Assert.Equal(1, stored.DownvotesCount);
Assert.True(await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID && r.ActorURI == carol.Id && r.Emoji == "🔥").ExecuteAnyAsync(token));
}
[Fact]
public async Task A_vote_turned_the_other_way_replaces_the_first_as_lemmy_sends_it()
{
var token = TestContext.Current.CancellationToken;
var (_, post) = await LocalPost();
var voter = new RemoteActor(_harness.Peer, "voter");
var other = new RemoteActor(_harness.Peer, "other");
JsonObject Vote(RemoteActor actor, string type) => new()
{
["id"] = $"{Origin(actor)}/{type.ToLowerInvariant()}s/{Guid.NewGuid():N}", ["type"] = type, ["actor"] = actor.Id, ["object"] = post.ObjectURI
};
await _harness.Deliver(other, "/human-centipede", Vote(other, "Like"));
await _harness.Deliver(voter, "/human-centipede", Vote(voter, "Dislike"));
await _harness.Deliver(voter, "/human-centipede", Vote(voter, "Like"));
var up = await DB.Default.Find<Post>().OneAsync(post.ID, token);
Assert.Equal((2, 0), (up.FavouritesCount, up.DownvotesCount));
await _harness.Deliver(voter, "/human-centipede", Vote(voter, "Dislike"));
var down = await DB.Default.Find<Post>().OneAsync(post.ID, token);
Assert.Equal((1, 1), (down.FavouritesCount, down.DownvotesCount));
Assert.False(await DB.Default.Find<Favourite>().Match(f => f.PostId == post.ID && f.ActorURI == voter.Id).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_circle_member_likes_a_circle_post_and_an_outsider_cannot()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var carol = new RemoteActor(_harness.Peer, "carol");
var mallory = new RemoteActor(_harness.Peer, "mallory");
var circle = new Group
{
UserName = $"circle{Guid.NewGuid():N}"[..20], Name = "a circle", Kind = GroupKind.Circle, OwnerAvatarId = alice.Id,
Members = { new() { AvatarId = alice.Id }, new() { AvatarId = carol.Id, IsForeign = true } }
};
await DB.Default.SaveAsync(circle, token);
var post = new Post
{
GroupUserId = alice.Id, AuthorAccountId = alice.Id, GroupId = circle.ID, Visibility = PostVisibility.Circle, Text = "only us",
ObjectURI = $"{alice.Uri}/scribbles/{Guid.NewGuid():N}"
};
await DB.Default.SaveAsync(post, token);
foreach (var who in new[] { carol, mallory })
await _harness.Deliver(who, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(who)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = who.Id, ["object"] = post.ObjectURI
});
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
Assert.True(await DB.Default.Find<Favourite>().Match(f => f.PostId == post.ID && f.ActorURI == carol.Id).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_boost_of_a_local_post_counts_notifies_and_can_be_undone()
{
+171 -2
View File
@@ -111,6 +111,138 @@ namespace PrivaPub.Tests.Federation
Assert.DoesNotContain(_harness.Peer.Requests, r => r.Path == new Uri(all[2]).AbsolutePath);
}
JsonObject Served(JsonObject document)
{
_harness.Peer.Serve(new Uri(IdOf(document)).AbsolutePath, document.ToJsonString());
return document;
}
static JsonObject Reply(RemoteActor author, string text, JsonObject parent)
{
var note = PublicNote(author, $"<p>{text}</p>");
note["inReplyTo"] = IdOf(parent);
return note;
}
static async Task<Post> Held(JsonObject note)
{
var post = new Post
{
ObjectURI = IdOf(note), ActorURI = note["attributedTo"]!.GetValue<string>(), IsFederatedCopy = true, Visibility = PostVisibility.Public,
ContentHtml = note["content"]!.GetValue<string>()
};
await DB.Default.SaveAsync(post, TestContext.Current.CancellationToken);
return post;
}
// runs the FetchReplies job for the post, and those it queues for this peer's posts, until none is left; how many ran
async Task<int> RunReplies(Post first)
{
var token = TestContext.Current.CancellationToken;
var handler = new RepliesJobHandler(_harness.Db, _harness.Remote, _harness.RemotePosts, _harness.Queue);
var origin = new Uri(first.ObjectURI).GetLeftPart(UriPartial.Authority);
await _harness.Queue.EnqueueMany(new[] { RepliesJobHandler.For(first, 1) }, token);
var ran = 0;
while (true)
{
var mine = new List<Job>();
foreach (var job in await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.FetchReplies && j.State == JobState.Pending).ExecuteAsync(token))
{
var post = await DB.Default.Find<Post>().OneAsync(JsonSerializer.Deserialize<RepliesPayload>(job.Payload)!.PostId, token);
if (post?.ObjectURI.StartsWith(origin) == true)
mine.Add(job);
}
if (mine.Count == 0)
return ran;
foreach (var job in mine)
{
Assert.Equal(JobResult.Done, (await handler.Handle(job, token)).Result);
await DB.Default.Update<Job>().MatchID(job.ID).Modify(j => j.State, JobState.Done).ExecuteAsync(token);
ran++;
}
}
}
static async Task<Dictionary<string, Post>> Stored(params JsonObject[] notes)
{
var uris = notes.Select(IdOf).ToList();
return (await DB.Default.Find<Post>().Match(p => uris.Contains(p.ObjectURI)).ExecuteAsync(TestContext.Current.CancellationToken))
.ToDictionary(p => p.ObjectURI);
}
[Fact]
public async Task A_threads_replies_are_read_from_their_servers_two_levels_down_and_only_public_ones_kept()
{
var poster = new RemoteActor(_harness.Peer, "poster");
var answerer = new RemoteActor(_harness.Peer, "answerer");
var root = PublicNote(poster, "<p>the root</p>");
// what a post carries is as old as the post: the collection is read again by its id
root["replies"] = new JsonObject
{
["id"] = IdOf(root) + "/replies", ["type"] = "Collection", ["first"] = new JsonObject { ["type"] = "CollectionPage", ["items"] = new JsonArray() }
};
Served(root);
var first = Reply(answerer, "first answer", root);
var second = Served(Reply(poster, "second answer", root));
var hidden = Reply(answerer, "for followers", root);
hidden["to"] = new JsonArray(answerer.Id + "/followers");
hidden["cc"] = new JsonArray();
Served(hidden);
var nested = Reply(poster, "nested", first);
var deeper = Served(Reply(answerer, "too deep", nested));
first["replies"] = IdOf(first) + "/replies";
Served(first);
nested["replies"] = IdOf(nested) + "/replies";
Served(nested);
Served(new JsonObject { ["id"] = IdOf(root) + "/replies", ["type"] = "Collection", ["first"] = IdOf(root) + "/replies/1" });
Served(new JsonObject
{
["id"] = IdOf(root) + "/replies/1", ["type"] = "CollectionPage", ["next"] = IdOf(root) + "/replies/2",
["items"] = new JsonArray(first.DeepClone(), IdOf(second))
});
Served(new JsonObject { ["id"] = IdOf(root) + "/replies/2", ["type"] = "CollectionPage", ["items"] = new JsonArray(IdOf(hidden)) });
Served(new JsonObject { ["id"] = IdOf(first) + "/replies", ["type"] = "OrderedCollection", ["orderedItems"] = new JsonArray(IdOf(nested)) });
Served(new JsonObject { ["id"] = IdOf(nested) + "/replies", ["type"] = "OrderedCollection", ["orderedItems"] = new JsonArray(IdOf(deeper)) });
var held = await Held(root);
Assert.Equal(3, await RunReplies(held));
var stored = await Stored(first, second, hidden, nested, deeper);
Assert.Equal(new[] { IdOf(first), IdOf(second), IdOf(nested) }.Order(), stored.Keys.Order());
Assert.Equal(held.ID, stored[IdOf(first)].AnsweringToPostId);
Assert.Equal(held.ID, stored[IdOf(second)].AnsweringToPostId);
Assert.Equal(stored[IdOf(first)].ID, stored[IdOf(nested)].AnsweringToPostId);
Assert.DoesNotContain(_harness.Peer.Requests, r => r.Path == new Uri(IdOf(nested) + "/replies").AbsolutePath);
Assert.All(_harness.Peer.Requests.Where(r => r.Path.EndsWith("/replies")), r => Assert.False(string.IsNullOrEmpty(r.Signature)));
}
[Fact]
public async Task A_threads_context_collection_brings_every_reply_at_once()
{
var poster = new RemoteActor(_harness.Peer, "poster");
var answerer = new RemoteActor(_harness.Peer, "answerer");
var root = PublicNote(poster, "<p>a thread</p>");
var context = NewId(poster, "contexts");
root["context"] = context;
root["replies"] = NewId(poster, "never");
Served(root);
var answer = Served(Reply(answerer, "an answer", root));
var deep = Served(Reply(poster, "an answer to it", answer));
// FEP-f228 lets a context list the activities that made the thread
Served(new JsonObject
{
["id"] = context, ["type"] = "OrderedCollection",
["first"] = new JsonObject { ["type"] = "OrderedCollectionPage", ["orderedItems"] = new JsonArray(IdOf(root), Create(answerer, answer), IdOf(deep)) }
});
var held = await Held(root);
Assert.Equal(1, await RunReplies(held));
var stored = await Stored(answer, deep);
Assert.Equal(held.ID, stored[IdOf(answer)].AnsweringToPostId);
Assert.Equal(stored[IdOf(answer)].ID, stored[IdOf(deep)].AnsweringToPostId);
Assert.DoesNotContain(_harness.Peer.Requests, r => r.Path == new Uri(root["replies"]!.GetValue<string>()).AbsolutePath);
}
async Task<(LocalActor Alice, RemoteActor Mallory, Post Poll)> LocalPoll()
{
var token = TestContext.Current.CancellationToken;
@@ -469,7 +601,7 @@ namespace PrivaPub.Tests.Federation
await DB.Default.DeleteAsync<RemoteInstance>(i => PeerHosts.Contains(i.Host));
}
async Task<JobOutcome> Attempt(LocalActor signer, string inbox)
async Task<JobOutcome> Attempt(LocalActor signer, string inbox, int attempts = 1)
{
var token = TestContext.Current.CancellationToken;
var id = $"{Harness.Base}/a/{Guid.NewGuid():N}";
@@ -479,7 +611,7 @@ namespace PrivaPub.Tests.Federation
["object"] = new JsonObject { ["type"] = "Note", ["content"] = "hi" }
}, token);
var job = await DB.Default.Find<Job>().Match(j => j.DedupeKey == $"{id}|{inbox}").ExecuteSingleAsync(token);
job.Attempts = 1;
job.Attempts = attempts;
var handler = new DeliveryJobHandler(_harness.Local, Peer.Http(), new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions())),
NullLogger<DeliveryJobHandler>.Instance);
return await handler.Handle(job, token);
@@ -533,5 +665,42 @@ namespace PrivaPub.Tests.Federation
Assert.Equal(JobResult.Retry, broken.Result);
Assert.StartsWith("500", broken.Error);
}
// a server that fails on one activity is up: its 500s are that delivery's to retry, and only a gateway's 502, 503 or
// 504 counts towards taking the host out (Smithereen's 500s on documents it could not read once held every delivery
// to it back for an hour)
[Fact]
public async Task Only_a_gateways_failures_take_the_host_out()
{
var (_, alice) = await _harness.Persona("alice");
_harness.Peer.Answer("/broken/inbox", 500);
_harness.Peer.Answer("/gateway/inbox", 502);
for (var i = 0; i < HostCircuitBreaker.Threshold; i++)
await Attempt(alice, _harness.Peer.A + "/broken/inbox");
var stillTried = await Attempt(alice, _harness.Peer.A + "/broken/inbox");
for (var i = 0; i < HostCircuitBreaker.Threshold; i++)
await Attempt(alice, _harness.Peer.A + "/gateway/inbox");
var heldBack = await Attempt(alice, _harness.Peer.A + "/broken/inbox");
Assert.Equal((JobResult.Retry, "500"), (stillTried.Result, stillTried.Error[..3]));
Assert.Equal((JobResult.Defer, "the host is unavailable"), (heldBack.Result, heldBack.Error));
}
// Mastodon's 422 for two first contacts racing to create one account, and its 409 for a held lock, are retried a
// few times; the third refusal is final like any other
[Fact]
public async Task Conflicts_and_unprocessable_answers_are_retried_twice_then_dead()
{
var (_, alice) = await _harness.Persona("alice");
_harness.Peer.Answer("/racing/inbox", 422);
_harness.Peer.Answer("/locked/inbox", 409);
Assert.Equal(JobResult.Retry, (await Attempt(alice, _harness.Peer.A + "/racing/inbox")).Result);
Assert.Equal(JobResult.Retry, (await Attempt(alice, _harness.Peer.A + "/locked/inbox", attempts: 2)).Result);
var final = await Attempt(alice, _harness.Peer.A + "/racing/inbox", attempts: 3);
Assert.Equal(JobResult.Dead, final.Result);
Assert.StartsWith("422", final.Error);
}
}
}
+106
View File
@@ -0,0 +1,106 @@
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
using static PrivaPub.Tests.Support.FederatedSeeds;
namespace PrivaPub.Tests.Federation
{
// An account moving to another (Mastodon's Move), believed as Mastodon believes it: sent by the moving account about
// itself, and the new account naming it among its alsoKnownAs
[Trait("Category", "Integration")]
public sealed class MoveTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
// the new account, serving a document that names the old one (or not)
RemoteActor Target(string name, RemoteActor aliasOf)
{
var target = new RemoteActor(_harness.Peer, name);
var document = target.Document();
if (aliasOf != default)
document["alsoKnownAs"] = new JsonArray(aliasOf.Id);
_harness.Peer.Serve(new Uri(target.Id).AbsolutePath, document.ToJsonString());
return target;
}
Task Move(RemoteActor sender, RemoteActor moved, RemoteActor target) =>
_harness.Deliver(sender, "/human-centipede", new JsonObject
{
["id"] = NewId(sender, "moves"), ["type"] = "Move", ["actor"] = sender.Id, ["object"] = moved.Id, ["target"] = target.Id
});
static Task<ForeignAvatar> Stored(RemoteActor actor) =>
DB.Default.Find<ForeignAvatar>().Match(a => a.ActorURI == actor.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken);
// as Mastodon does it (owner decision 2026-10-05): the follow moves to the new account, in the same lists, and a mute
// or a block of the old account carries over
[Fact]
public async Task A_move_the_new_account_confirms_moves_the_personas_follows_lists_mutes_and_blocks()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var (_, bob) = await _harness.Persona("bob");
var old = new RemoteActor(_harness.Peer, "old");
await _harness.Remote.GetActor(old.Id, refresh: true, token);
await Follows(alice.Id, old);
var oldAccount = await Stored(old);
var list = new PersonaList { AvatarId = alice.Id, Title = "friends" };
await DB.Default.SaveAsync(list, token);
await DB.Default.SaveAsync(new PersonaListMember { ListId = list.ID, AvatarId = alice.Id, AccountId = oldAccount.ID }, token);
await _harness.Relationships.Mute(alice, old.Id, oldAccount.ID, hideNotifications: true, duration: default, token);
await _harness.Relationships.Block(bob, old.Id, oldAccount.ID, token);
var target = Target("new", aliasOf: old);
await Move(old, old, target);
var stored = await Stored(old);
Assert.Equal(target.Id, stored.MovedToURL);
var account = await new MastodonMapper(_harness.Db, _harness.Local).Account(stored.ID, token);
Assert.Equal(target.Name, account.Moved?.Username);
Assert.Contains(_harness.Ledger.Of("in"), e => e.Activity == "Move" && e.Reason == "moved");
var newAccount = await Stored(target);
Assert.True(await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == target.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == old.Id).ExecuteAnyAsync(token));
Assert.Contains(await _harness.Outgoing(target.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Follow");
Assert.Contains(await _harness.Outgoing(old.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Undo");
Assert.True(await DB.Default.Find<PersonaListMember>().Match(m => m.ListId == list.ID && m.AccountId == newAccount.ID).ExecuteAnyAsync(token));
Assert.True(await DB.Default.Find<Mute>().Match(m => m.AvatarId == alice.Id && m.TargetActorURI == target.Id).ExecuteAnyAsync(token));
Assert.True(await DB.Default.Find<Block>().Match(b => b.AvatarId == bob.Id && b.TargetActorURI == target.Id).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_move_the_new_account_does_not_confirm_or_someone_else_sends_changes_nothing()
{
var (_, alice) = await _harness.Persona("alice");
var old = new RemoteActor(_harness.Peer, "old");
var stranger = new RemoteActor(_harness.Peer, "stranger");
await Follows(alice.Id, old);
var unconfirmed = Target("new", aliasOf: default);
var hijack = Target("hijack", aliasOf: stranger);
await Move(old, old, unconfirmed);
await Move(stranger, old, hijack);
Assert.Null((await Stored(old)).MovedToURL);
Assert.Equal(new[] { "not-aliased", "not-self" }, _harness.Ledger.Of("in").Where(e => e.Activity == "Move").Select(e => e.Reason));
}
}
}
@@ -35,6 +35,26 @@ namespace PrivaPub.Tests.Federation
}
""";
// Castopod's episode: its words in description, its sound in audio, the sound's name for a title
[Fact]
public void A_podcast_episode_is_read_with_its_words_sound_and_cover()
{
var episode = NoteParser.Parse(JsonNode.Parse("""
{"id":"https://castopod.test/@pod/episodes/one","type":"PodcastEpisode","published":"2026-10-06T09:08:17+00:00",
"to":["https://www.w3.org/ns/activitystreams#Public"],"attributedTo":"https://castopod.test/@pod",
"description":{"type":"Note","mediaType":"text/markdown","content":"Listen to the **meadow**","contentMap":{"en":"<p>Listen to the meadow</p>"}},
"image":{"type":"Image","mediaType":"image/png","url":"https://castopod.test/media/cover.png"},
"audio":{"id":"https://castopod.test/audio/one.mp3","type":"Audio","name":"Episode one","size":16791,"duration":4.02,
"url":{"href":"https://castopod.test/audio/one.mp3","type":"Link","mediaType":"audio/mpeg"}}}
"""));
Assert.Equal(("PodcastEpisode", "Episode one", "https://castopod.test/media/cover.png"), (episode.Type, episode.Title, episode.CoverURL));
Assert.Contains("<strong>meadow</strong>", episode.ContentHtml);
var sound = Assert.Single(episode.Attachments);
Assert.Equal(("https://castopod.test/audio/one.mp3", "audio/mpeg"), (sound.RemoteURL, sound.ContentType));
Assert.Equal(4.02, episode.Audio.DurationSeconds);
}
[Fact]
public void Reads_a_mastodon_note()
{
@@ -57,6 +77,69 @@ namespace PrivaPub.Tests.Federation
Assert.Null(note.Updated);
}
// GoToSocial writes a lone tag, and lone to and cc, as plain values instead of arrays (captured by the town)
[Fact]
public void Reads_a_gotosocial_reply_whose_only_tag_and_audience_are_single_values()
{
var note = NoteParser.Parse(JsonNode.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "gotosocial",
"followers-only-reply-one-tag.json"))));
var mention = Assert.Single(note.Mentions);
Assert.Equal("https://privapub.test/peasants/flor_pp98", mention.ActorURI);
Assert.Equal(["https://gts.test/users/ines_gt59/followers"], note.To);
Assert.Equal(["https://privapub.test/peasants/flor_pp98"], note.Cc);
}
// PeerTube's video whose comments wait for approval names who may comment (FEP-5624), apart from GoToSocial's policy
[Fact]
public void Reads_who_may_reply_pending_approval()
{
var video = NoteParser.Parse(JsonNode.Parse("""
{"id":"https://peertube.test/videos/watch/1","type":"Video","name":"a video","attributedTo":["https://peertube.test/accounts/pt"],
"canReply":"https://www.w3.org/ns/activitystreams#Public","commentsEnabled":true}
"""));
var open = NoteParser.Parse(JsonNode.Parse("""
{"id":"https://peertube.test/videos/watch/2","type":"Video","name":"an open video","attributedTo":["https://peertube.test/accounts/pt"],
"canReply":null,"commentsPolicy":1}
"""));
var note = NoteParser.Parse(JsonNode.Parse(MastodonNote));
Assert.Equal(["https://www.w3.org/ns/activitystreams#Public"], video.ReplyApprovals.Manual);
Assert.Null(video.ReplyPolicy);
Assert.Null(open.ReplyApprovals);
Assert.Null(note.ReplyApprovals);
}
// Pleroma's edited post carries its earlier versions (formerRepresentations, newest first)
[Fact]
public void Reads_the_earlier_versions_of_a_pleroma_edit()
{
var note = NoteParser.Parse(JsonNode.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "pleroma", "edited.json"))));
var before = Assert.Single(note.FormerRepresentations);
Assert.StartsWith("tomorrow library garden bridge", before.ContentHtml);
Assert.Equal(new DateTime(2026, 10, 5, 0, 49, 58, DateTimeKind.Utc).AddTicks(8_600_710), before.EditedAt);
Assert.Null(before.SpoilerText);
Assert.EndsWith("(edited)", note.ContentHtml);
}
[Fact]
public void Earlier_versions_come_oldest_first_each_read_as_the_post_is()
{
var note = NoteParser.Parse(JsonNode.Parse("""
{"id":"https://pl.example/objects/1","type":"Note","attributedTo":"https://pl.example/users/carol","content":"<p>third</p>",
"published":"2026-10-06T08:00:00Z","updated":"2026-10-06T09:00:00Z",
"formerRepresentations":{"type":"OrderedCollection","totalItems":2,"orderedItems":[
{"type":"Note","content":"<p>second</p>","summary":"careful","published":"2026-10-06T08:00:00Z","updated":"2026-10-06T08:30:00Z"},
{"type":"Note","content":"<p>first<script>x</script></p>","summary":"","published":"2026-10-06T08:00:00Z"}]}}
"""));
Assert.Equal(["<p>first</p>", "<p>second</p>"], note.FormerRepresentations.Select(r => r.ContentHtml));
Assert.Equal([new DateTime(2026, 10, 6, 8, 0, 0, DateTimeKind.Utc), new DateTime(2026, 10, 6, 8, 30, 0, DateTimeKind.Utc)],
note.FormerRepresentations.Select(r => r.EditedAt));
Assert.Equal([(default(string), false), ("careful", true)], note.FormerRepresentations.Select(r => (r.SpoilerText, r.HasContentWarning)));
}
[Fact]
public void Reads_a_lemmy_page_with_a_title()
{
@@ -0,0 +1,100 @@
using MongoDB.Entities;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
using static PrivaPub.Tests.Support.FederatedSeeds;
namespace PrivaPub.Tests.Federation
{
// a persona joins a remote event (owner decision 2026-10-05): Join, answered Accept or Reject; Leave
[Trait("Category", "Integration")]
public sealed class ParticipationTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
async Task<(PrivaPub.Federation.Actors.LocalActor Alice, RemoteActor Group, Post Event)> Announced(string joinMode = "free")
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var group = new RemoteActor(_harness.Peer, "group", type: "Group");
await Follows(alice.Id, group);
var happening = new JsonObject
{
["id"] = NewId(group, "events"), ["type"] = "Event", ["name"] = "A picnic", ["content"] = "<p>bring bread</p>",
["attributedTo"] = group.Id, ["startTime"] = DateTime.UtcNow.AddDays(3).ToString("O"), ["joinMode"] = joinMode,
["to"] = new JsonArray(PrivaPub.Federation.Objects.Addressing.Public), ["cc"] = new JsonArray(group.Id + "/followers")
};
await _harness.Deliver(group, "/human-centipede", Create(group, happening));
return (alice, group, await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(happening)).ExecuteSingleAsync(token));
}
Task<Participation> Of(PrivaPub.Federation.Actors.LocalActor alice, Post happening) =>
DB.Default.Find<Participation>().Match(p => p.AvatarId == alice.Id && p.PostId == happening.ID).ExecuteFirstAsync(TestContext.Current.CancellationToken);
[Fact]
public async Task A_persona_joins_an_event_its_server_accepts_and_leaves_it()
{
var token = TestContext.Current.CancellationToken;
var (alice, group, happening) = await Announced();
Assert.True((await _harness.Participations.Join(alice, happening.ID, token)).Ok);
var join = Assert.Single(await _harness.Outgoing(group.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Join");
Assert.Equal((alice.Uri, happening.ObjectURI), (join["actor"]!.GetValue<string>(), join["object"]!.GetValue<string>()));
Assert.Equal(ParticipationState.Pending, (await Of(alice, happening)).State);
await _harness.Deliver(group, "/human-centipede", new JsonObject
{
["id"] = NewId(group, "accepts"), ["type"] = "Accept", ["actor"] = group.Id, ["object"] = join["id"]!.GetValue<string>()
});
Assert.Equal(ParticipationState.Accepted, (await Of(alice, happening)).State);
Assert.True((await _harness.Participations.Leave(alice, happening.ID, token)).Ok);
Assert.Null(await Of(alice, happening));
var leave = Assert.Single(await _harness.Outgoing(group.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Leave");
Assert.Equal(happening.ObjectURI, leave["object"]!.GetValue<string>());
}
[Fact]
public async Task A_refusal_counts_an_answer_from_another_server_does_not_and_an_invitation_only_event_is_not_joined()
{
var token = TestContext.Current.CancellationToken;
var (alice, group, happening) = await Announced();
await _harness.Participations.Join(alice, happening.ID, token);
var join = Assert.Single(await _harness.Outgoing(group.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Join");
var stranger = new RemoteActor(_harness.Peer, "stranger", _harness.Peer.B);
await _harness.Deliver(stranger, "/human-centipede", new JsonObject
{
["id"] = NewId(stranger, "accepts"), ["type"] = "Accept", ["actor"] = stranger.Id, ["object"] = join["id"]!.GetValue<string>()
});
Assert.Equal(ParticipationState.Pending, (await Of(alice, happening)).State);
await _harness.Deliver(group, "/human-centipede", new JsonObject
{
["id"] = NewId(group, "rejects"), ["type"] = "Reject", ["actor"] = group.Id,
["object"] = new JsonObject { ["id"] = join["id"]!.GetValue<string>(), ["type"] = "Join", ["actor"] = alice.Uri, ["object"] = happening.ObjectURI }
});
Assert.Equal(ParticipationState.Rejected, (await Of(alice, happening)).State);
var (carol, _, invitationOnly) = await Announced("invite");
Assert.Equal(422, (await _harness.Participations.Join(carol, invitationOnly.ID, token)).Status);
}
}
}
@@ -17,6 +17,9 @@ namespace PrivaPub.Tests.Federation
[InlineData(":blobcat@k.example:", "blobcat")]
[InlineData("👍🏽", "👍🏽")]
[InlineData("❤️", "❤️")]
[InlineData("❤", "❤️")]
[InlineData("✌", "✌️")]
[InlineData("🔥\uFE0F", "🔥")]
[InlineData("nope", null)]
[InlineData("👍👍", null)]
[InlineData(":unknown:", null)]
+143
View File
@@ -0,0 +1,143 @@
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
// relays the configuration names: the instance actor follows Public there, and the public posts the relay passes on,
// forwarded or announced, come to the federated timeline as their authors' posts; nothing else is taken from it
[Trait("Category", "Integration")]
[Xunit.Collection(nameof(Exclusive))]
public sealed class RelayTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
{
_harness.RelayOptions.Relays.Clear();
await _harness.Relays.Reconcile(TestContext.Current.CancellationToken);
await _harness.DisposeAsync();
}
}
static CancellationToken Token => TestContext.Current.CancellationToken;
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
string Note(RemoteActor author, string text, string to = "https://www.w3.org/ns/activitystreams#Public")
{
var path = $"/notes/{Guid.NewGuid():N}";
_harness.Peer.Serve(path, new JsonObject
{
["id"] = Origin(author) + path, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"<p>{text}</p>",
["to"] = new JsonArray(to), ["published"] = DateTime.UtcNow.ToString("O")
}.ToJsonString());
return Origin(author) + path;
}
JsonObject Create(RemoteActor author, string noteId) => new()
{
["id"] = noteId + "/activity", ["type"] = "Create", ["actor"] = author.Id,
["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"),
["object"] = new JsonObject { ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = author.Id }
};
// a relay the instance actor subscribes to, which accepts
async Task<RemoteActor> Subscribed()
{
var relay = new RemoteActor(_harness.Peer, "relay", type: "Service");
_harness.RelayOptions.Relays.Add(relay.Id);
await _harness.Relays.Reconcile(Token);
var follow = Assert.Single(await _harness.Outgoing(relay.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Follow");
await _harness.Deliver(relay, "/human-centipede", new JsonObject
{
["id"] = $"{relay.Id}#accept-{Guid.NewGuid():N}", ["type"] = "Accept", ["actor"] = relay.Id, ["object"] = follow.DeepClone()
});
return relay;
}
[Fact]
public async Task The_instance_actor_follows_public_at_a_configured_relay_and_takes_its_answer()
{
var relay = await Subscribed();
var follow = Assert.Single(await _harness.Outgoing(relay.Id + "/inbox"));
Assert.Equal(("Follow", "https://www.w3.org/ns/activitystreams#Public"), (follow["type"]!.GetValue<string>(), follow["object"]!.GetValue<string>()));
Assert.Equal((await _harness.Local.GetInstanceActor(Token)).Uri, follow["actor"]!.GetValue<string>());
Assert.Equal(RelayState.Accepted, (await DB.Default.Find<RelaySubscription>().Match(s => s.ActorURI == relay.Id).ExecuteSingleAsync(Token)).State);
Assert.True(await _harness.Relays.Passes(relay.Id, Token));
_harness.RelayOptions.Relays.Clear();
await _harness.Relays.Reconcile(Token);
Assert.Contains(await _harness.Outgoing(relay.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Undo");
Assert.False(await DB.Default.Find<RelaySubscription>().Match(s => s.ActorURI == relay.Id).ExecuteAnyAsync(Token));
}
[Fact]
public async Task A_public_post_a_subscribed_relay_forwards_is_kept_and_one_from_anyone_else_is_not()
{
var relay = await Subscribed();
var stranger = new RemoteActor(_harness.Peer, "stranger", type: "Service");
var author = new RemoteActor(_harness.Peer, "author", _harness.Peer.B);
var relayed = Note(author, "through the relay");
var forwarded = Note(author, "through a stranger");
var followersOnly = Note(author, "for followers", author.Id + "/followers");
await _harness.Deliver(relay, "/human-centipede", Create(author, relayed));
await _harness.Deliver(stranger, "/human-centipede", Create(author, forwarded));
await _harness.Deliver(relay, "/human-centipede", Create(author, followersOnly));
var kept = await DB.Default.Find<Post>().Match(p => p.ActorURI == author.Id).ExecuteAsync(Token);
Assert.Equal(new[] { relayed }, kept.Select(p => p.ObjectURI));
Assert.Equal(PostVisibility.Public, kept[0].Visibility);
}
// owner decision 2026-10-06: a persona's public post, its edit and its deletion go to the relays too; nothing less
// public does, nor a boost
[Fact]
public async Task A_personas_public_post_goes_to_the_relays_and_nothing_less_public()
{
var relay = await Subscribed();
var (_, alice) = await _harness.Persona("alice");
var token = Token;
var open = await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft { Text = "for everyone", Visibility = PostVisibility.Public }, token);
await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft { Text = "quietly", Visibility = PostVisibility.Unlisted }, token);
await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft { Text = "for followers", Visibility = PostVisibility.FollowersOnly }, token);
var sent = (await _harness.Outgoing(relay.Id + "/inbox")).Where(a => a["type"]!.GetValue<string>() == "Create").ToList();
var create = Assert.Single(sent);
Assert.Equal(alice.PostUri(open.Post.ID), create["object"]!["id"]!.GetValue<string>());
}
[Fact]
public async Task A_post_a_relay_announces_is_its_authors_never_the_relays_boost()
{
var relay = await Subscribed();
var author = new RemoteActor(_harness.Peer, "author", _harness.Peer.B);
var noteId = Note(author, "announced by the relay");
await _harness.Deliver(relay, "/human-centipede", new JsonObject
{
["id"] = $"{relay.Id}/activities/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = relay.Id,
["to"] = new JsonArray(relay.Id + "/followers"), ["object"] = noteId
});
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(Token);
Assert.Equal(author.Id, post.ActorURI);
Assert.False(await DB.Default.Find<Post>().Match(p => p.ReblogOfPostId == post.ID).ExecuteAnyAsync(Token));
}
}
}
@@ -0,0 +1,117 @@
using Microsoft.Extensions.Caching.Memory;
using MongoDB.Entities;
using PrivaPub.Domain.Relationships;
using PrivaPub.Federation.Actors;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
// an account's handle: user@host, or the domain its `webfinger` names (FEP-2c59; Mastodon's LOCAL_DOMAIN apart from
// its WEB_DOMAIN) once that domain's WebFinger says the handle is this account
[Trait("Category", "Integration")]
public sealed class RemoteHandleTests : IAsyncLifetime
{
Harness _harness;
RemoteActorService _remote;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
_remote = new RemoteActorService(Peer.Http(), _harness.Local, new MemoryCache(new MemoryCacheOptions()), _harness.Db,
new StaticOptions<FederationOptions>(new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true }));
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
// the actor at the peer's first address, its handle at the second
RemoteActor Actor(string name) => Serve(new RemoteActor(_harness.Peer, name));
RemoteActor Serve(RemoteActor actor, string preferredUsername = default)
{
var document = actor.Document();
if (preferredUsername != default)
document["preferredUsername"] = preferredUsername;
document["webfinger"] = $"acct:{preferredUsername ?? actor.Name}@{new Uri(_harness.Peer.B).Authority}";
_harness.Peer.Serve(new Uri(actor.Id).AbsolutePath, document.ToJsonString());
return actor;
}
void WebFinger(string self) => _harness.Peer.ServeText("/.well-known/webfinger", new JsonObject
{
["links"] = new JsonArray(new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = self })
}.ToJsonString(), "application/jrd+json");
[Fact]
public async Task A_handle_on_another_domain_is_kept_once_its_webfinger_agrees()
{
var actor = Actor("split");
WebFinger(actor.Id);
var stored = await _remote.GetActor(actor.Id, refresh: true, TestContext.Current.CancellationToken);
Assert.Equal(new Uri(_harness.Peer.B).Authority, stored.Domain);
}
[Fact]
public async Task A_handle_its_webfinger_does_not_confirm_stays_on_the_actors_host()
{
var actor = Actor("claims");
WebFinger("http://localhost/users/someone-else");
var stored = await _remote.GetActor(actor.Id, refresh: true, TestContext.Current.CancellationToken);
Assert.Equal(new Uri(actor.Id).Authority, stored.Domain);
}
[Fact]
public async Task A_renamed_account_is_asked_again()
{
var actor = Actor("renames");
WebFinger(actor.Id);
await _remote.GetActor(actor.Id, refresh: true, TestContext.Current.CancellationToken);
Serve(actor, "renamed");
WebFinger("http://localhost/users/someone-else");
// (another cache: the first fetch holds the actor back for a while)
var later = new RemoteActorService(Peer.Http(), _harness.Local, new MemoryCache(new MemoryCacheOptions()), _harness.Db,
new StaticOptions<FederationOptions>(new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true }));
var stored = await later.GetActor(actor.Id, refresh: true, TestContext.Current.CancellationToken);
Assert.Equal("renamed", stored.UserName);
Assert.Equal(new Uri(actor.Id).Authority, stored.Domain);
}
[Fact]
public async Task A_persona_blocking_the_handles_server_hides_the_account()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var split = Actor("split");
WebFinger(split.Id);
await _remote.GetActor(split.Id, refresh: true, token);
var claims = Actor("claims");
WebFinger("http://localhost/users/someone-else");
await new RemoteActorService(Peer.Http(), _harness.Local, new MemoryCache(new MemoryCacheOptions()), _harness.Db,
new StaticOptions<FederationOptions>(new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true }))
.GetActor(claims.Id, refresh: true, token);
await DB.Default.SaveAsync(new AccountDomainBlock { AvatarId = alice.Id, Domain = new Uri(_harness.Peer.B).Host }, token);
Assert.Equal([split.Id], await Hidden.AuthorsHiddenFrom(alice.Id, new[] { split.Id, claims.Id }, forNotifications: false, token));
Assert.Contains(alice.Id, await Hidden.RecipientsHiding(new[] { alice.Id }, split.Id, token));
Assert.Empty(await Hidden.RecipientsHiding(new[] { alice.Id }, claims.Id, token));
}
}
}
@@ -0,0 +1,114 @@
using MongoDB.Entities;
using PrivaPub.Domain.Statuses;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
using static PrivaPub.Tests.Support.FederatedSeeds;
namespace PrivaPub.Tests.Federation
{
// Inbox forwarding of replies (owner decision 2026-10-05): a reply to a persona's post reaches the persona's followers,
// who are not among those its author's server sends it to
[Trait("Category", "Integration")]
public sealed class ReplyRelayTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
// alice's post; bob replies from his server, fan follows alice from another, pal from bob's
async Task<(PrivaPub.Federation.Actors.LocalActor Alice, Post Post, RemoteActor Bob, RemoteActor Fan, RemoteActor Pal)> Thread()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var bob = new RemoteActor(_harness.Peer, "bob");
var fan = new RemoteActor(_harness.Peer, "fan", _harness.Peer.B);
var pal = new RemoteActor(_harness.Peer, "pal");
await _harness.FollowedBy(alice, fan);
await _harness.FollowedBy(alice, pal);
var post = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "what do you think?" }, token)).Post;
return (alice, post, bob, fan, pal);
}
JsonObject Reply(RemoteActor bob, Post post, string visibility = "public")
{
var reply = PublicNote(bob, "<p>I think so</p>", post.ActorURI);
reply["inReplyTo"] = post.ObjectURI;
if (visibility == "followers")
{
reply["to"] = new JsonArray(bob.Id + "/followers");
reply["cc"] = new JsonArray(post.ActorURI);
}
return reply;
}
Task<List<JsonObject>> At(RemoteActor follower) => _harness.Outgoing(follower.SharedInbox);
[Fact]
public async Task A_public_reply_to_a_personas_post_reaches_its_followers_as_it_came_and_not_its_authors_server()
{
var (_, post, bob, fan, pal) = await Thread();
var create = Create(bob, Reply(bob, post));
create["signature"] = new JsonObject { ["type"] = "RsaSignature2017", ["signatureValue"] = "kept as it came" };
await _harness.Deliver(bob, "/human-centipede", create);
var relayed = Assert.Single(await At(fan), a => a["id"]!.GetValue<string>() == IdOf(create));
Assert.Equal(bob.Id, relayed["actor"]!.GetValue<string>());
Assert.Equal("kept as it came", relayed["signature"]!["signatureValue"]!.GetValue<string>());
Assert.DoesNotContain(await At(pal), a => a["id"]!.GetValue<string>() == IdOf(create));
}
[Fact]
public async Task Its_edit_and_deletion_follow_it_and_a_followers_only_reply_stays_where_it_was_sent()
{
var (_, post, bob, fan, _) = await Thread();
var reply = Reply(bob, post);
await _harness.Deliver(bob, "/human-centipede", Create(bob, reply));
var edited = (JsonObject)reply.DeepClone();
edited["content"] = "<p>I think so, on second thought</p>";
edited["updated"] = DateTime.UtcNow.AddMinutes(1).ToString("O");
var update = Activity(bob, "Update", edited);
await _harness.Deliver(bob, "/human-centipede", update);
var delete = Activity(bob, "Delete", JsonValue.Create(IdOf(reply))!);
await _harness.Deliver(bob, "/human-centipede", delete);
var relayed = (await At(fan)).Select(a => a["id"]!.GetValue<string>()).ToList();
Assert.Contains(IdOf(update), relayed);
Assert.Contains(IdOf(delete), relayed);
var quiet = Create(bob, Reply(bob, post, "followers"));
await _harness.Deliver(bob, "/human-centipede", quiet);
Assert.DoesNotContain(await At(fan), a => a["id"]!.GetValue<string>() == IdOf(quiet));
}
[Fact]
public async Task A_reply_that_reached_us_forwarded_is_not_passed_on_again()
{
var (_, post, bob, fan, _) = await Thread();
var other = new RemoteActor(_harness.Peer, "other", _harness.Peer.B);
var reply = Reply(bob, post);
_harness.Peer.Serve(new Uri(IdOf(reply)).AbsolutePath, reply.ToJsonString());
var create = Create(bob, reply);
var result = await _harness.Deliver(other, "/human-centipede", create);
Assert.Equal("forwarded", result.Reason);
Assert.True(await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(reply)).ExecuteAnyAsync(TestContext.Current.CancellationToken));
Assert.DoesNotContain(await At(fan), a => a["id"]!.GetValue<string>() == IdOf(create));
}
}
}
@@ -0,0 +1,64 @@
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
using static PrivaPub.Tests.Support.FederatedSeeds;
namespace PrivaPub.Tests.Federation
{
// A deleted remote post is never brought back by its own Create, however late it comes; an id its server gives a new
// post afterwards (Gancio numbers an event after the last one it keeps) is that new post
[Trait("Category", "Integration")]
public sealed class ReusedIdTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
[Fact]
public async Task A_deleted_posts_create_stays_dropped_and_a_new_post_under_its_id_is_kept()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var carol = new RemoteActor(_harness.Peer, "carol");
await Follows(alice.Id, carol);
var first = PublicNote(carol, "<p>the first event</p>");
first["published"] = DateTime.UtcNow.AddMinutes(-10).ToString("O");
await _harness.Deliver(carol, "/human-centipede", Create(carol, first));
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = NewId(carol, "deletes"), ["type"] = "Delete", ["actor"] = carol.Id,
["object"] = new JsonObject { ["id"] = IdOf(first), ["type"] = "Tombstone" }
});
Assert.True(await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == IdOf(first)).ExecuteAnyAsync(token));
// the first Create again, late
await _harness.Deliver(carol, "/human-centipede", Create(carol, (JsonObject)first.DeepClone()));
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(first) && !p.DeletedAt.HasValue).ExecuteAnyAsync(token));
var second = PublicNote(carol, "<p>a new event, numbered as the first</p>");
second["id"] = IdOf(first);
second["published"] = DateTime.UtcNow.ToString("O");
await _harness.Deliver(carol, "/human-centipede", Create(carol, second));
var kept = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(first) && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
Assert.Contains("a new event", kept?.ContentHtml);
Assert.False(await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == IdOf(first)).ExecuteAnyAsync(token));
}
}
}
@@ -0,0 +1,252 @@
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" };
// (a test sees only the deliveries queued since it began: the others' share the database)
readonly DateTime _started = DateTime.UtcNow;
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
await DB.Default.DeleteAsync<RemoteInstance>(i => PeerHosts.Contains(i.Host));
}
public async ValueTask DisposeAsync()
{
if (_harness == default)
return;
await DB.Default.DeleteAsync<RemoteInstance>(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<RemoteInstance>(i => i.Host == host);
await DB.Default.SaveAsync(new RemoteInstance { Host = host, Software = software, SoftwareVersion = "1.0.0" }, Token);
}
async Task<ForeignAvatar> Known(RemoteActor actor) => await _harness.Remote.GetActor(actor.Id, refresh: false, Token);
static async Task<Post> 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 = "<p>reported</p>"
};
await DB.Default.SaveAsync(post, Token);
return post;
}
async Task<List<DeliveryPayload>> Queued() =>
(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver && j.CreatedAt >= _started).ExecuteAsync(Token))
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
.ToList();
// a community on a Lemmy, or on a server that takes reports as Lemmy does (the peer as localhost), and one of its posters there
async Task<(RemoteActor Community, RemoteActor Poster)> OnLemmy(string software = "lemmy")
{
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", software);
return (community, poster);
}
// PieFed and Mbin take it too, proven live (2026-10-06); NodeInfo names Lemmy as "lemmy" and kbin's heir as "mbin"
[Theory]
[InlineData("lemmy")]
[InlineData("Lemmy")]
[InlineData("piefed")]
[InlineData("mbin")]
public async Task A_post_in_a_lemmy_community_is_reported_to_its_community_by_the_reporter_and_nowhere_else(string software)
{
var (_, alice) = await _harness.Persona("alice");
var (community, poster) = await OnLemmy(software);
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<string>());
Assert.Equal(reporter.Uri, flag["actor"]!.GetValue<string>());
Assert.EndsWith("/peasants/privapub_reports", reporter.Uri);
Assert.StartsWith(reporter.Uri + "/", flag["id"]!.GetValue<string>());
Assert.Equal(new[] { community.Id }, flag["to"]!.AsArray().Select(t => t!.GetValue<string>()));
Assert.Equal(community.Id, flag["audience"]!.GetValue<string>());
Assert.Equal(post.ObjectURI, flag["object"]!.GetValue<string>());
Assert.Equal("a slur in the title", flag["summary"]!.GetValue<string>());
Assert.Equal("a slur in the title", flag["content"]!.GetValue<string>());
Assert.Empty(await _harness.Outgoing(poster.Id + "/inbox"));
Assert.DoesNotContain(await Queued(), q => q.Inbox != community.SharedInbox && q.Body.Contains(post.ObjectURI));
Assert.DoesNotContain(alice.UserName, flag.ToJsonString());
Assert.DoesNotContain(alice.Id, flag.ToJsonString());
var queued = Assert.Single(await Queued(), q => q.Inbox == community.SharedInbox);
Assert.Equal(LocalActorKind.Reporter, queued.SignerKind);
Assert.Equal(reporter.Id, queued.SignerId);
}
[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<Job>()
.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<DeliveryJobHandler>.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<string>());
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<string>()).Order());
Assert.All(flags, f => Assert.Equal(community.Id, f["to"]![0]!.GetValue<string>()));
Assert.Equal(2, flags.Select(f => f["id"]!.GetValue<string>()).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<string>(), "a spammer", "spam", forward: true, Token);
Assert.False(report.Forwarded);
Assert.False((await DB.Default.Find<PrivaPub.Models.Social.Report>().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<string>());
var toAuthor = Assert.Single(await _harness.Outgoing(mastodonian.Id + "/inbox"));
Assert.Equal(instance.Uri, toAuthor["actor"]!.GetValue<string>());
Assert.Equal(new[] { mastodonian.Id, inCommunity.ObjectURI, elsewhere.ObjectURI }.Order(),
toAuthor["object"]!.AsArray().Select(o => o!.GetValue<string>()).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<string>());
}
[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<Follower>().Match(f => f.ActorURI == follower.Id).ExecuteAnyAsync(Token));
}
}
}
@@ -0,0 +1,50 @@
using PrivaPub.Domain.Statuses;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
// one delivery a server: a reply to a follower's post goes to its server's shared inbox once, not also to the
// follower's own inbox (BookWyrm kept two copies of a post that reached both at once)
[Trait("Category", "Integration")]
public sealed class SharedInboxTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
[Fact]
public async Task A_reply_to_a_followers_post_reaches_its_server_once_through_the_shared_inbox()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var bob = new RemoteActor(_harness.Peer, "bob", sharedInbox: true);
await _harness.FollowedBy(alice, bob);
var path = $"/notes/{Guid.NewGuid():N}";
var noteId = new Uri(bob.Id).GetLeftPart(UriPartial.Authority) + path;
_harness.Peer.Serve(path, new JsonObject
{
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = bob.Id, ["content"] = "<p>a question</p>",
["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"), ["published"] = DateTime.UtcNow.ToString("O")
}.ToJsonString());
var parent = await _harness.RemotePosts.StoreContext(noteId, 0, token);
var reply = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "an answer", InReplyTo = parent.ID }, token);
Assert.True(reply.Ok, reply.Error);
Assert.Single(await _harness.Outgoing(bob.SharedInbox), a => a["type"]!.GetValue<string>() == "Create");
Assert.Empty(await _harness.Outgoing(bob.Id + "/inbox"));
}
}
}
+213
View File
@@ -0,0 +1,213 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Text.Json.Nodes;
using static PrivaPub.Tests.Support.Host.FederationHelpers;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Tests.Federation
{
// walls (FEP-400e, Smithereen's; owner decision 2026-10-06): a persona's followers write on its wall, PrivaPub hosts what
// they write and tells the persona's followers with Add{Note}; the persona takes it off with Remove; an account elsewhere
// that tells of a post on its own wall has it shown to its followers here
[Trait("Category", "Integration")]
public sealed class WallTests : IAsyncLifetime
{
const string Public = "https://www.w3.org/ns/activitystreams#Public";
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static CancellationToken Token => TestContext.Current.CancellationToken;
// a public note by author, written on the wall of owner (whose actor and wall are given), served at its id
string WallNote(RemoteActor author, string ownerUri, string wall, string text, string inReplyTo = default, string to = Public)
{
var path = $"/notes/{Guid.NewGuid():N}";
var origin = new Uri(author.Id).GetLeftPart(UriPartial.Authority);
var note = new JsonObject
{
["id"] = origin + path, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"<p>{text}</p>",
["to"] = new JsonArray(to, ownerUri), ["published"] = DateTime.UtcNow.ToString("O"),
["target"] = new JsonObject { ["type"] = "Collection", ["id"] = wall, ["attributedTo"] = ownerUri }
};
if (inReplyTo != default)
note["inReplyTo"] = inReplyTo;
_harness.Peer.Serve(path, note.ToJsonString());
_notes[origin + path] = note;
return origin + path;
}
readonly Dictionary<string, JsonObject> _notes = new();
// as Smithereen sends it: the whole note, to the wall's owner
JsonObject Create(RemoteActor author, string noteId) => new()
{
["id"] = noteId + "/activityCreate", ["type"] = "Create", ["actor"] = author.Id, ["to"] = _notes[noteId]["to"]!.DeepClone(),
["object"] = _notes[noteId].DeepClone()
};
Task<PostEntity> Post(string objectUri) => DB.Default.Find<PostEntity>().Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(Token);
[Fact]
public void A_persona_names_its_wall_and_lets_its_followers_write_on_it()
{
var actor = ActivityPubRenderer.Actor(new LocalActor { Id = "a", UserName = "alice", BaseAddress = Harness.Base, Kind = PrivaPub.Models.Federation.LocalActorKind.Person });
var circle = ActivityPubRenderer.Actor(new LocalActor { Id = "c", UserName = "circle", BaseAddress = Harness.Base, Kind = PrivaPub.Models.Federation.LocalActorKind.Group, IsCircle = true });
Assert.Equal($"{Harness.Base}/peasants/alice/graffiti", actor["wall"]!.GetValue<string>());
Assert.Equal($"{Harness.Base}/peasants/alice/groupies", actor["privacySettings"]!["wallPosting"]!["allowedTo"]![0]!.GetValue<string>());
Assert.Equal(Public, actor["privacySettings"]!["wallPostVisibility"]!["allowedTo"]![0]!.GetValue<string>());
Assert.Contains(actor["@context"]!.AsArray(), c => c is JsonObject terms && terms["wall"]?["@id"]?.GetValue<string>() == "sm:wall");
Assert.Null(circle["wall"]);
}
[Fact]
public async Task A_followers_post_on_the_wall_is_hosted_shown_and_told_to_the_personas_followers()
{
var (_, alice) = await _harness.Persona("alice");
var smuser = new RemoteActor(_harness.Peer, "smuser", wall: true);
await _harness.FollowedBy(alice, smuser);
var noteId = WallNote(smuser, alice.Uri, alice.Wall, "on alice's wall");
await _harness.Deliver(smuser, "/human-centipede", Create(smuser, noteId));
var post = await Post(noteId);
Assert.NotNull(post);
Assert.Equal((alice.Wall, alice.Uri, alice.Id), (post.WallURI, post.WallOwnerURI, post.WallOwnerAccountId));
Assert.True(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(Token));
Assert.True(await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id && n.PostId == post.ID && n.Type == NotificationType.Mention).ExecuteAnyAsync(Token));
var add = Assert.Single(await _harness.Outgoing(_harness.Peer.A + "/inbox"), a => a["type"]!.GetValue<string>() == "Add");
Assert.Equal((alice.Uri, noteId, alice.Wall), (add["actor"]!.GetValue<string>(), add["object"]!.GetValue<string>(), add["target"]!["id"]!.GetValue<string>()));
Assert.Contains(await _harness.Outgoing(smuser.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Add");
Assert.True(await _harness.Walls.Remove(alice, post, Token));
Assert.Null(await Post(noteId));
var remove = Assert.Single(await _harness.Outgoing(_harness.Peer.A + "/inbox"), a => a["type"]!.GetValue<string>() == "Remove");
Assert.Equal((noteId, alice.Wall), (remove["object"]!.GetValue<string>(), remove["target"]!.GetValue<string>()));
}
[Fact]
public async Task A_wall_post_from_a_stranger_a_reply_or_a_quiet_one_is_refused()
{
var (_, alice) = await _harness.Persona("alice");
var stranger = new RemoteActor(_harness.Peer, "stranger", wall: true);
var follower = new RemoteActor(_harness.Peer, "follower", wall: true);
await _harness.FollowedBy(alice, follower);
var fromStranger = WallNote(stranger, alice.Uri, alice.Wall, "from a stranger");
var reply = WallNote(follower, alice.Uri, alice.Wall, "a reply", inReplyTo: "https://elsewhere.example/notes/1");
var quiet = WallNote(follower, alice.Uri, alice.Wall, "quietly", to: follower.Id + "/followers");
await _harness.Deliver(stranger, "/human-centipede", Create(stranger, fromStranger));
await _harness.Deliver(follower, "/human-centipede", Create(follower, reply));
await _harness.Deliver(follower, "/human-centipede", Create(follower, quiet));
Assert.Null(await Post(fromStranger));
Assert.Null(await Post(reply));
Assert.Null(await Post(quiet));
Assert.DoesNotContain(await _harness.Outgoing(_harness.Peer.A + "/inbox"), a => a["type"]!.GetValue<string>() == "Add");
}
[Fact]
public async Task A_post_on_a_followed_accounts_wall_elsewhere_reaches_its_followers_here_until_it_is_taken_off()
{
var (_, alice) = await _harness.Persona("alice");
var owner = new RemoteActor(_harness.Peer, "owner", wall: true);
var writer = new RemoteActor(_harness.Peer, "writer");
await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = owner.Id, TargetInboxURL = owner.Id + "/inbox", State = FollowState.Accepted }, Token);
var noteId = WallNote(writer, owner.Id, owner.Wall, "on the owner's wall");
// (kept before PrivaPub read walls: its document is read again when it names one)
await DB.Default.SaveAsync(new PrivaPub.Models.User.ForeignAvatar
{
ActorURI = owner.Id, UserName = owner.Name, PublicKeyId = owner.KeyId, PublicKey = owner.PublicKeyPem, InboxURL = owner.Id + "/inbox",
FollowersURL = owner.Id + "/followers", UpdatedAt = DateTime.UtcNow.AddDays(-2)
}, Token);
await _harness.Deliver(owner, "/human-centipede", new JsonObject
{
["id"] = $"{owner.Id}/posts/{Guid.NewGuid():N}/activityAdd", ["type"] = "Add", ["actor"] = owner.Id, ["object"] = noteId,
["target"] = new JsonObject { ["type"] = "Collection", ["id"] = owner.Wall, ["attributedTo"] = owner.Id },
["to"] = new JsonArray(Public, owner.Id + "/followers")
});
var post = await Post(noteId);
Assert.NotNull(post);
Assert.Equal((writer.Id, owner.Id), (post.ActorURI, post.WallOwnerURI));
Assert.True(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(Token));
Assert.False(await DB.Default.Find<RemoteFeatured>().Match(f => f.ObjectURI == noteId).ExecuteAnyAsync(Token));
await _harness.Deliver(owner, "/human-centipede", new JsonObject
{
["id"] = $"{owner.Id}/posts/{Guid.NewGuid():N}/activityRemove", ["type"] = "Remove", ["actor"] = owner.Id, ["object"] = noteId, ["target"] = owner.Wall
});
Assert.Null(await Post(noteId));
}
}
[Trait("Category", "Integration")]
public sealed class WallCollectionTests : IAsyncLifetime
{
PrivaPubHost _host;
HttpClient _client;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
_client = _host.Client();
}
public ValueTask DisposeAsync()
{
_client?.Dispose();
return ValueTask.CompletedTask;
}
[Fact]
public async Task The_wall_lists_the_personas_public_posts_and_what_others_wrote_on_it()
{
var token = TestContext.Current.CancellationToken;
var persona = await _host.Persona(await _host.SignUp(), "wall");
var open = await _host.Publish(persona, "for everyone", PostVisibility.Public);
var quiet = await _host.Publish(persona, "for followers", PostVisibility.FollowersOnly);
var wall = persona.ActorUri() + "/graffiti";
var written = new PostEntity
{
ObjectURI = $"https://elsewhere.example/notes/{Guid.NewGuid():N}", ActorURI = "https://elsewhere.example/users/writer", IsFederatedCopy = true,
Visibility = PostVisibility.Public, WallURI = wall, WallOwnerURI = persona.ActorUri(), WallOwnerAccountId = persona.Id
};
await DB.Default.SaveAsync(written, token);
var collection = await _client.Fetch($"/peasants/{persona.UserName}/graffiti");
var page = await _client.Fetch($"/peasants/{persona.UserName}/graffiti?page=true");
Assert.Equal(HttpStatusCode.OK, collection.Status);
Assert.Equal((wall, 2), (collection.Json["id"]!.GetValue<string>(), collection.Json["totalItems"]!.GetValue<int>()));
var items = page.Json["orderedItems"]!.AsArray().Select(i => i!.GetValue<string>()).ToList();
Assert.Equal(new[] { written.ObjectURI, persona.ActorUri() + "/scribbles/" + open.ID }, items);
Assert.DoesNotContain(items, i => i.EndsWith(quiet.ID));
}
}
}
@@ -0,0 +1 @@
{"attributedTo":"https://gts.test/users/ines_gt59","cc":"https://privapub.test/peasants/flor_pp98","content":"\u003Cp\u003E\u003Cspan class=\u0022h-card\u0022\u003E\u003Ca href=\u0022https://privapub.test/@flor_pp98\u0022 class=\u0022u-url mention\u0022 rel=\u0022nofollow noreferrer noopener\u0022 target=\u0022_blank\u0022\u003E@\u003Cspan\u003Eflor_pp98\u003C/span\u003E\u003C/a\u003E\u003C/span\u003E bicycle the window bicycle tomorrow festival (p111)\u003C/p\u003E","contentMap":{"en":"\u003Cp\u003E\u003Cspan class=\u0022h-card\u0022\u003E\u003Ca href=\u0022https://privapub.test/@flor_pp98\u0022 class=\u0022u-url mention\u0022 rel=\u0022nofollow noreferrer noopener\u0022 target=\u0022_blank\u0022\u003E@\u003Cspan\u003Eflor_pp98\u003C/span\u003E\u003C/a\u003E\u003C/span\u003E bicycle the window bicycle tomorrow festival (p111)\u003C/p\u003E"},"id":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY","inReplyTo":"https://privapub.test/peasants/flor_pp98/scribbles/6ac2beaf092dc0af8ce6035e","interactionPolicy":{"canAnnounce":{"automaticApproval":["https://gts.test/users/ines_gt59"]},"canLike":{"automaticApproval":["https://gts.test/users/ines_gt59","https://gts.test/users/ines_gt59/followers","https://privapub.test/peasants/flor_pp98"]},"canQuote":{"automaticApproval":["https://gts.test/users/ines_gt59"]},"canReply":{"automaticApproval":["https://gts.test/users/ines_gt59","https://gts.test/users/ines_gt59/followers","https://privapub.test/peasants/flor_pp98"]}},"published":"2026-10-04T21:02:31Z","replies":{"first":{"id":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY/replies?page=true","next":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY/replies?page=true\u0026only_other_accounts=false","partOf":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY/replies","type":"CollectionPage"},"id":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY/replies","type":"Collection"},"tag":{"href":"https://privapub.test/peasants/flor_pp98","name":"@flor_pp98@privapub.test","type":"Mention"},"to":"https://gts.test/users/ines_gt59/followers","type":"Note","url":"https://gts.test/@ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY"}
@@ -0,0 +1,71 @@
{
"actor": "https://pleroma.test/users/flor_pl97",
"attachment": [],
"attributedTo": "https://pleroma.test/users/flor_pl97",
"cc": [
"https://pleroma.test/users/flor_pl97/followers"
],
"content": "mountain late window map bright postcard (p10) (edited)",
"context": "https://pleroma.test/contexts/891653c5-91ba-43f3-b3ef-b066caa3fa4d",
"conversation": "https://pleroma.test/contexts/891653c5-91ba-43f3-b3ef-b066caa3fa4d",
"formerRepresentations": {
"orderedItems": [
{
"actor": "https://pleroma.test/users/flor_pl97",
"attachment": [],
"attributedTo": "https://pleroma.test/users/flor_pl97",
"cc": [
"https://pleroma.test/users/flor_pl97/followers"
],
"content": "tomorrow library garden bridge the festival bicycle together garden early quiet morning library the postcard tomorrow mountain the small rain postcard warm garden market map mountain map (p10)",
"contentMap": {
"en": "tomorrow library garden bridge the festival bicycle together garden early quiet morning library the postcard tomorrow mountain the small rain postcard warm garden market map mountain map (p10)"
},
"context": "https://pleroma.test/contexts/891653c5-91ba-43f3-b3ef-b066caa3fa4d",
"conversation": "https://pleroma.test/contexts/891653c5-91ba-43f3-b3ef-b066caa3fa4d",
"published": "2026-10-05T00:49:58.860071Z",
"repliesCount": 2,
"source": {
"content": "tomorrow library garden bridge the festival bicycle together garden early quiet morning library the postcard tomorrow mountain the small rain postcard warm garden market map mountain map (p10)",
"mediaType": "text/plain"
},
"summary": "",
"tag": [],
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"type": "Note"
}
],
"totalItems": 1,
"type": "OrderedCollection"
},
"id": "https://pleroma.test/objects/6101c4fd-ed46-461c-a3fe-9742852f47da",
"published": "2026-10-05T00:49:58.860071Z",
"replies": {
"first": {
"id": "https://pleroma.test/objects/6101c4fd-ed46-461c-a3fe-9742852f47da/replies?page=true",
"orderedItems": [
"https://pleroma.test/objects/b7b7c066-51a8-46d1-8919-07b87f158887",
"https://privapub.test/peasants/vera_pp40/scribbles/6ac2f4520c61eaf3ab241d35"
],
"partOf": "https://pleroma.test/objects/6101c4fd-ed46-461c-a3fe-9742852f47da/replies",
"type": "OrderedCollectionPage"
},
"id": "https://pleroma.test/objects/6101c4fd-ed46-461c-a3fe-9742852f47da/replies",
"totalItems": 2,
"type": "OrderedCollection"
},
"repliesCount": 2,
"source": {
"content": "mountain late window map bright postcard (p10) (edited)",
"mediaType": "text/plain"
},
"summary": "",
"tag": [],
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"type": "Note",
"updated": "2026-10-05T00:52:40.105051Z"
}
@@ -202,6 +202,51 @@ namespace PrivaPub.Tests.Http
Assert.Equal(circle["url"]!.GetValue<string>(), accept["actor"]!.GetValue<string>());
}
// its owner sees who is in the circle and who asks to join, declines one request and takes members out; nobody else
// sees them
[Fact]
public async Task An_owner_sees_the_members_and_requests_declines_one_and_removes_members()
{
var token = TestContext.Current.CancellationToken;
var (owner, circle, member) = await CircleWithRemoteMember();
var groupId = circle["id"]!.GetValue<string>();
var joiner = await NewPersona("joiner");
using (var joining = _host.As(joiner.Root.Jwt))
Assert.True((await joining.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode = circle["invitationCode"]!.GetValue<string>() })).IsSuccessStatusCode);
var asker = new RemoteActor(_peer, "asker");
await _host.Follow(asker, _peer.A, circle["userName"]!.GetValue<string>());
var since = DateTime.UtcNow.AddSeconds(-1);
using var client = _host.As(owner.Root.Jwt);
async Task<List<JsonNode>> Members(HttpClient as_, string avatarId)
{
var response = await as_.GetAsync($"/clientapi/group/members?avatarId={avatarId}&groupId={groupId}", token);
return response.IsSuccessStatusCode ? (await response.JsonItems()).ToList() : null;
}
var listed = await Members(client, owner.Id);
using var joinerClient = _host.As(joiner.Root.Jwt);
var forJoiner = await Members(joinerClient, joiner.Id);
var declined = await client.PostJson("/clientapi/group/reject", new { avatarId = owner.Id, groupId, memberActorURI = asker.Id });
var removedRemote = await client.PostJson("/clientapi/group/remove", new { avatarId = owner.Id, groupId, memberActorURI = member.Id });
var removedLocal = await client.PostJson("/clientapi/group/remove", new { avatarId = owner.Id, groupId, memberActorURI = joiner.ActorUri() });
var removedOwner = await client.PostJson("/clientapi/group/remove", new { avatarId = owner.Id, groupId, memberActorURI = owner.ActorUri() });
Assert.Null(forJoiner);
Assert.Equal(4, listed.Count);
Assert.Contains(listed, m => m!["actorUri"]!.GetValue<string>() == owner.ActorUri() && m["role"]!.GetValue<string>() == "owner" && m["isLocal"]!.GetValue<bool>());
Assert.Contains(listed, m => m!["actorUri"]!.GetValue<string>() == joiner.ActorUri() && !m["isPending"]!.GetValue<bool>());
Assert.Contains(listed, m => m!["actorUri"]!.GetValue<string>() == member.Id && !m["isPending"]!.GetValue<bool>() && !m["isLocal"]!.GetValue<bool>());
Assert.Contains(listed, m => m!["actorUri"]!.GetValue<string>() == asker.Id && m["isPending"]!.GetValue<bool>());
Assert.True(declined.IsSuccessStatusCode);
Assert.True(removedRemote.IsSuccessStatusCode);
Assert.True(removedLocal.IsSuccessStatusCode);
Assert.Equal(HttpStatusCode.BadRequest, removedOwner.StatusCode);
Assert.Equal(new[] { owner.Id }, (await Stored(circle)).Members.Select(m => m.AvatarId));
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == groupId && f.ActorURI != owner.ActorUri()).ExecuteAnyAsync(token));
Assert.Contains(await Jobs.Deliveries(asker.Id + "/inbox", since, token), d => d["type"]!.GetValue<string>() == "Reject");
Assert.Contains(await Jobs.Deliveries(member.Id + "/inbox", since, token), d => d["type"]!.GetValue<string>() == "Reject");
}
[Fact]
public async Task A_circle_is_never_found()
{
@@ -103,7 +103,7 @@ namespace PrivaPub.Tests.Http
var response = await Insert(root, new { userName, name = userName, biography = "testing" });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains("already take", (await response.JsonBody())["errorMessage"]!.GetValue<string>());
Assert.Contains("already taken", (await response.JsonBody())["errorMessage"]!.GetValue<string>());
Assert.Empty(await Listed(root));
}
+54 -4
View File
@@ -101,6 +101,51 @@ namespace PrivaPub.Tests.Http
Assert.Equal("Application", instance.Json["type"]!.GetValue<string>());
}
// PieFed reads the inbox it sends a community's announces to from the Application at a peer's root
[Fact]
public async Task The_instance_actor_answers_at_the_root_for_activitypub_only()
{
var root = await _client.Fetch("/");
var browser = await _client.Fetch("/", Browser);
Assert.Equal(HttpStatusCode.OK, root.Status);
Assert.Equal("Application", root.Json["type"]!.GetValue<string>());
Assert.Equal($"{Base}/peasants/privapub", root.Json["id"]!.GetValue<string>());
Assert.Equal($"{Base}/peasants/privapub/mouth", root.Json["inbox"]!.GetValue<string>());
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<string>());
Assert.Equal("Service", actor["type"]!.GetValue<string>());
Assert.Equal("privapub_reports", actor["preferredUsername"]!.GetValue<string>());
Assert.Equal(id + "/mouth", actor["inbox"]!.GetValue<string>());
Assert.Equal(id + "/anus", actor["outbox"]!.GetValue<string>());
Assert.Equal(id, actor["url"]!.GetValue<string>());
Assert.Equal(id + "#main-key", actor["publicKey"]!["id"]!.GetValue<string>());
Assert.Equal(id, actor["publicKey"]!["owner"]!.GetValue<string>());
Assert.NotEqual(instance.Json["publicKey"]!["publicKeyPem"]!.GetValue<string>(), actor["publicKey"]!["publicKeyPem"]!.GetValue<string>());
Assert.False(actor["discoverable"]!.GetValue<bool>());
Assert.Null(actor["wall"]);
Assert.Null(actor["implements"]);
Assert.Equal(HttpStatusCode.OK, browser.Status);
Assert.Equal("Service", browser.Json["type"]!.GetValue<string>());
Assert.Equal(HttpStatusCode.OK, outbox.Status);
Assert.Equal(0, outbox.Json["totalItems"]!.GetValue<int>());
}
[Fact]
public async Task Users_moves_permanently_to_peasants_and_unknown_actors_are_404()
{
@@ -170,7 +215,7 @@ namespace PrivaPub.Tests.Http
var announce = items[0];
Assert.Equal("Announce", announce["type"]!.GetValue<string>());
Assert.Equal(reblog.ObjectURI, announce["id"]!.GetValue<string>());
Assert.Equal($"{persona.ActorUri()}/grunts/announce-{boosted.ID}", announce["id"]!.GetValue<string>());
Assert.Equal($"{persona.ActorUri()}/grunts/announce-{reblog.ID}", announce["id"]!.GetValue<string>());
Assert.Equal(boosted.ObjectURI, announce["object"]!.GetValue<string>());
Assert.Equal(persona.ActorUri(), announce["actor"]!.GetValue<string>());
@@ -436,7 +481,7 @@ namespace PrivaPub.Tests.Http
string Path(string id) => $"/peasants/{persona.UserName}/grunts/{id}";
var create = await _client.Fetch(Path($"create-{post.ID}"));
var announce = await _client.Fetch(Path($"announce-{original.ID}"));
var announce = await _client.Fetch(Path($"announce-{reblog.ID}"));
Assert.Equal(HttpStatusCode.OK, create.Status);
Assert.Equal(ActivityJson, create.MediaType);
@@ -451,7 +496,8 @@ namespace PrivaPub.Tests.Http
foreach (var unknown in new[] { $"create-{followersOnly.ID}", $"create-{Guid.NewGuid():N}"[..31], $"announce-{post.ID}", $"like-{post.ID}", "x" })
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(Path(unknown))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch($"/peasants/{friend.UserName}/grunts/announce-{original.ID}")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(Path($"announce-{original.ID}"))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch($"/peasants/{friend.UserName}/grunts/announce-{reblog.ID}")).Status);
}
[Fact]
@@ -492,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();
@@ -518,6 +564,10 @@ namespace PrivaPub.Tests.Http
var instance = await client.Fetch("/peasants/privapub");
Assert.Equal(HttpStatusCode.OK, instance.Status);
Assert.Equal("Application", instance.Json["type"]!.GetValue<string>());
var reporter = await client.Fetch("/peasants/privapub_reports");
Assert.Equal(HttpStatusCode.OK, reporter.Status);
Assert.Equal("Service", reporter.Json["type"]!.GetValue<string>());
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");
// a browser is not asked for a signature: it is only sent to the public pages
+46 -2
View File
@@ -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);
@@ -183,6 +184,33 @@ namespace PrivaPub.Tests.Http
Assert.Contains(alice.Id, (await alice.Client.Get($"/api/v1/accounts/search?q={alice.UserName}")).Ok().Ids);
}
// an account elsewhere: what it features, in its order, its public posts only
[Fact]
public async Task A_remote_accounts_pinned_statuses_are_what_it_features()
{
var alice = await _host.Mastodon("alice");
var (bob, bobId) = await Remote();
Post Held(string text, PostVisibility visibility) => new()
{
ObjectURI = $"{bob.Id}/statuses/{Guid.NewGuid():N}", ActorURI = bob.Id, AuthorAccountId = bobId, IsFederatedCopy = true,
Visibility = visibility, ContentHtml = $"<p>{text}</p>", CreationDate = DateTime.UtcNow, UpdateDate = DateTime.UtcNow
};
var first = Held("first pin", PostVisibility.Public);
var second = Held("second pin", PostVisibility.Unlisted);
var hidden = Held("for followers", PostVisibility.FollowersOnly);
await DB.Default.SaveAsync(new[] { first, second, hidden }, Token);
await DB.Default.SaveAsync(new[]
{
new RemoteFeatured { ActorURI = bob.Id, ObjectURI = second.ObjectURI, PostId = second.ID, Position = 1 },
new RemoteFeatured { ActorURI = bob.Id, ObjectURI = hidden.ObjectURI, PostId = hidden.ID, Position = 2 },
new RemoteFeatured { ActorURI = bob.Id, ObjectURI = first.ObjectURI, PostId = first.ID, Position = 0 }
}, Token);
var pinned = (await alice.Client.Get($"/api/v1/accounts/{bobId}/statuses?pinned=true")).Ok();
Assert.Equal(new[] { first.ID, second.ID }, pinned.Ids);
}
[Fact]
public async Task Account_statuses_filter_pins_replies_reblogs_media_and_tags_and_page_both_ways()
{
@@ -493,6 +521,23 @@ namespace PrivaPub.Tests.Http
Assert.All(answer.Array, r => Assert.False(r.Flag("following") || r.Flag("blocking") || r.Flag("muting") || r.Flag("followed_by")));
}
[Fact]
public async Task Following_again_sends_an_unanswered_request_once_more_and_no_more_than_hourly()
{
var since = DateTime.UtcNow.AddSeconds(-1);
var alice = await _host.Mastodon("alice");
var (bob, bobId) = await Remote();
Assert.True((await alice.Client.Post($"/api/v1/accounts/{bobId}/follow")).Ok().Body.Flag("requested"));
var first = Assert.Single(await bob.Delivered(since), d => d.Type() == "Follow");
Assert.True((await alice.Client.Post($"/api/v1/accounts/{bobId}/follow")).Ok().Body.Flag("requested"));
(await alice.Client.Post($"/api/v1/accounts/{bobId}/follow")).Ok();
var follows = (await bob.Delivered(since)).Where(d => d.Type() == "Follow").ToList();
Assert.Equal(2, follows.Count);
Assert.All(follows, f => Assert.Equal(first.Text("id"), f.Text("id")));
}
[Fact]
public async Task A_remote_authors_followers_only_posts_show_only_to_the_personas_that_follow_them()
{
@@ -619,7 +664,6 @@ namespace PrivaPub.Tests.Http
var alice = await _host.Mastodon("alice");
Assert.Empty((await _host.Client().Get($"/api/v1/accounts/{alice.Id}/featured_tags")).Ok().Array);
Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/lists")).Ok().Array);
var familiar = (await alice.Client.Get($"/api/v1/accounts/familiar_followers?id[]={alice.Id}")).Ok();
Assert.Equal(alice.Id, Assert.Single(familiar.Array).Text("id"));
Assert.Empty(familiar.Array[0]!["accounts"]!.AsArray());
+169
View File
@@ -0,0 +1,169 @@
using MongoDB.Entities;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Http
{
// Mastodon's filters: a persona's v2 filters with keywords and statuses, the v1 API over their keywords, and the
// `filtered` each status carries for the filters it matches
[Trait("Category", "Integration")]
public sealed class MastodonFiltersTests : IAsyncLifetime
{
PrivaPubHost _host;
static CancellationToken Token => TestContext.Current.CancellationToken;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
static Task<ApiAnswer> Create(Mastodon owner, string title, string action, params (string Keyword, bool WholeWord)[] keywords) =>
owner.Client.Json(HttpMethod.Post, "/api/v2/filters", new JsonObject
{
["title"] = title,
["context"] = new JsonArray("home", "public", "thread"),
["filter_action"] = action,
["keywords_attributes"] = new JsonArray(keywords.Select(k => (JsonNode)new JsonObject { ["keyword"] = k.Keyword, ["whole_word"] = k.WholeWord }).ToArray())
});
[Fact]
public async Task A_filter_is_made_changed_and_deleted_by_its_owner_only()
{
var alice = await _host.Mastodon("alice");
var mallory = await _host.Mastodon("mallory");
var made = (await Create(alice, "Spoilers", "hide", ("spoiler", true), ("leak", false))).Ok().Object;
var id = made.Text("id");
Assert.Equal("hide", made.Text("filter_action"));
Assert.Equal(new[] { "home", "public", "thread" }, made["context"]!.AsArray().Select(c => c!.GetValue<string>()));
var keywords = made["keywords"]!.AsArray();
Assert.Equal(new[] { "spoiler", "leak" }, keywords.Select(k => k.Text("keyword")));
Assert.Equal(new[] { true, false }, keywords.Select(k => k!.Flag("whole_word")));
Assert.Null(made["expires_at"]);
var changed = (await alice.Client.Json(HttpMethod.Put, $"/api/v2/filters/{id}", new JsonObject
{
["title"] = "Leaks",
["filter_action"] = "warn",
["expires_in"] = 3600,
["keywords_attributes"] = new JsonArray(
new JsonObject { ["id"] = keywords[0].Text("id"), ["_destroy"] = true },
new JsonObject { ["keyword"] = "ending" })
})).Ok().Object;
Assert.Equal("Leaks", changed.Text("title"));
Assert.Equal("warn", changed.Text("filter_action"));
Assert.NotNull(changed.Text("expires_at"));
Assert.Equal(new[] { "leak", "ending" }, changed["keywords"]!.AsArray().Select(k => k.Text("keyword")));
var added = (await alice.Client.Post($"/api/v2/filters/{id}/keywords", ("keyword", "finale"), ("whole_word", "false"))).Ok().Object;
Assert.False(added.Flag("whole_word"));
var keywordId = added.Text("id");
Assert.Equal("finale", (await alice.Client.Get($"/api/v2/filters/keywords/{keywordId}")).Ok().Object.Text("keyword"));
Assert.Equal("last scene", (await alice.Client.Put($"/api/v2/filters/keywords/{keywordId}", ("keyword", "last scene"))).Ok().Object.Text("keyword"));
(await alice.Client.Delete($"/api/v2/filters/keywords/{keywordId}")).Ok();
Assert.Equal(2, (await alice.Client.Get($"/api/v2/filters/{id}/keywords")).Ok().Array.Count);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await Create(alice, " ", "warn", ("x", true))).Status);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await Create(alice, "x", "explode", ("x", true))).Status);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v2/filters", ("title", "x"), ("context[]", "everywhere"))).Status);
Assert.Empty((await mallory.Client.Get("/api/v2/filters")).Ok().Array);
Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Get($"/api/v2/filters/{id}")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Delete($"/api/v2/filters/keywords/{changed["keywords"]![0].Text("id")}")).Status);
Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get("/api/v2/filters")).Status);
(await alice.Client.Delete($"/api/v2/filters/{id}")).Ok();
Assert.Empty((await alice.Client.Get("/api/v2/filters")).Ok().Array);
}
[Fact]
public async Task Statuses_carry_the_filters_they_match_by_word_and_by_id()
{
var alice = await _host.Mastodon("alice");
var bob = await _host.Mastodon("bob");
(await bob.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok();
var filterId = (await Create(alice, "Spoilers", "warn", ("spoiler", true), ("#tv", true))).Ok().Object.Text("id");
var other = (await Create(bob, "Not mine", "hide", ("spoiler", false))).Ok().Object.Text("id");
var whole = (await alice.Status("a big SPOILER ahead")).Text("id");
var partial = (await alice.Status("spoilers galore")).Text("id");
var tagged = (await alice.Status("watching #TV tonight")).Text("id");
var plain = (await alice.Status("nothing to see")).Text("id");
var warned = (await alice.Status("calm text", ("spoiler_text", "spoiler inside"))).Text("id");
var byId = (await alice.Client.Post($"/api/v2/filters/{filterId}/statuses", ("status_id", plain))).Ok().Object;
Assert.Equal(plain, byId.Text("status_id"));
var boost = (await bob.Client.Post($"/api/v1/statuses/{whole}/reblog")).Ok().Object.Text("id");
var home = (await alice.Client.Get("/api/v1/timelines/home?limit=40")).Ok().Array.ToDictionary(s => s.Text("id"), s => s!["filtered"]!.AsArray());
var match = Assert.Single(home[whole]);
Assert.Equal(filterId, match!["filter"].Text("id"));
Assert.Equal("warn", match["filter"].Text("filter_action"));
Assert.Equal(new[] { "spoiler" }, match["keyword_matches"]!.AsArray().Select(k => k!.GetValue<string>()));
Assert.Empty(home[partial]);
Assert.Equal(new[] { "#tv" }, Assert.Single(home[tagged])!["keyword_matches"]!.AsArray().Select(k => k!.GetValue<string>()));
Assert.Equal(new[] { byId.Text("id") }, Assert.Single(home[plain])!["status_matches"]!.AsArray().Select(k => k!.GetValue<string>()));
Assert.Single(home[warned]);
Assert.Single((await alice.Client.Get($"/api/v1/statuses/{boost}")).Ok().Body["filtered"]!.AsArray());
var asBob = (await bob.Client.Get($"/api/v1/statuses/{partial}")).Ok().Body["filtered"]!.AsArray();
Assert.Equal(other, Assert.Single(asBob)!["filter"].Text("id"));
Assert.Empty((await _host.Client().Get($"/api/v1/statuses/{whole}")).Ok().Body["filtered"]!.AsArray());
// an expired filter matches nothing
await DB.Default.Update<PersonaFilter>().MatchID(filterId).Modify(f => f.ExpiresAt, DateTime.UtcNow.AddMinutes(-1)).ExecuteAsync(Token);
Assert.Empty((await alice.Client.Get($"/api/v1/statuses/{whole}")).Ok().Body["filtered"]!.AsArray());
}
[Fact]
public async Task Keywords_come_as_form_fields_listed_or_numbered()
{
var alice = await _host.Mastodon("alice");
var listed = (await alice.Client.Post("/api/v2/filters", ("title", "Listed"), ("context[]", "home"),
("keywords_attributes[][keyword]", "one"), ("keywords_attributes[][whole_word]", "false"),
("keywords_attributes[][keyword]", "two"), ("keywords_attributes[][whole_word]", "true"))).Ok().Object;
Assert.Equal(new[] { ("one", false), ("two", true) },
listed["keywords"]!.AsArray().Select(k => (k.Text("keyword"), k!.Flag("whole_word"))));
var numbered = (await alice.Client.Post("/api/v2/filters", ("title", "Numbered"), ("context[]", "public"),
("keywords_attributes[1][keyword]", "second"), ("keywords_attributes[0][keyword]", "first"),
("keywords_attributes[0][whole_word]", "false"))).Ok().Object;
Assert.Equal(new[] { ("first", false), ("second", true) },
numbered["keywords"]!.AsArray().Select(k => (k.Text("keyword"), k!.Flag("whole_word"))));
}
[Fact]
public async Task A_v1_filter_is_one_keyword_of_a_v2_filter()
{
var alice = await _host.Mastodon("alice");
var made = (await alice.Client.Post("/api/v1/filters", ("phrase", "election"), ("context[]", "home"), ("context[]", "notifications"),
("irreversible", "true"), ("whole_word", "false"))).Ok().Object;
var id = made.Text("id");
Assert.Equal("election", made.Text("phrase"));
Assert.True(made.Flag("irreversible"));
Assert.False(made.Flag("whole_word"));
var v2 = Assert.Single((await alice.Client.Get("/api/v2/filters")).Ok().Array)!;
Assert.Equal("election", v2.Text("title"));
Assert.Equal("hide", v2.Text("filter_action"));
Assert.Equal(id, Assert.Single(v2["keywords"]!.AsArray()).Text("id"));
var changed = (await alice.Client.Put($"/api/v1/filters/{id}", ("phrase", "elections"), ("irreversible", "false"))).Ok().Object;
Assert.Equal("elections", changed.Text("phrase"));
Assert.False(changed.Flag("irreversible"));
Assert.Equal(new[] { id }, (await alice.Client.Get("/api/v1/filters")).Ok().Ids);
Assert.Equal("elections", (await alice.Client.Get($"/api/v1/filters/{id}")).Ok().Object.Text("phrase"));
(await alice.Client.Delete($"/api/v1/filters/{id}")).Ok();
Assert.Empty((await alice.Client.Get("/api/v2/filters")).Ok().Array);
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/filters/{id}")).Status);
}
}
}
+2 -10
View File
@@ -41,7 +41,8 @@ namespace PrivaPub.Tests.Http
var v2 = (await anonymous.Get("/api/v2/instance")).Ok();
Assert.Equal(InstanceController.Version, v2.Body.Text("version"));
Assert.Equal(PrivaPubHost.Host, v2.Body.Text("domain"));
Assert.Null(v2.Body["configuration"]!["urls"]!["streaming"]);//nothing streams yet, so nothing is advertised
Assert.Equal($"wss://{PrivaPubHost.Host}", v2.Body["configuration"]!["urls"]!.Text("streaming"));
Assert.Equal($"wss://{PrivaPubHost.Host}", v1.Body["urls"]!.Text("streaming_api"));
Assert.Equal(PrivaPub.Api.Mastodon.Controllers.StatusesController.MaxPins, v2.Body["configuration"]!["accounts"].Number("max_pinned_statuses"));
Assert.Contains("image/avif", v2.Body["configuration"]!["media_attachments"]!["supported_mime_types"]!.AsArray().Select(t => t!.GetValue<string>()));
Assert.True(v2.Body["registrations"].Flag("enabled"));
@@ -69,25 +70,16 @@ namespace PrivaPub.Tests.Http
[Theory]
[InlineData("/api/v1/custom_emojis")]
[InlineData("/api/v1/announcements")]
[InlineData("/api/v1/trends")]
[InlineData("/api/v1/trends/tags")]
[InlineData("/api/v1/trends/statuses")]
[InlineData("/api/v1/trends/links")]
public async Task Public_stub_routes_answer_empty_lists_to_anyone(string path)
{
Assert.Empty((await _host.Client().Get(path)).Ok().Array);
}
[Theory]
[InlineData("/api/v1/filters")]
[InlineData("/api/v2/filters")]
[InlineData("/api/v1/lists")]
[InlineData("/api/v1/suggestions")]
[InlineData("/api/v2/suggestions")]
[InlineData("/api/v1/followed_tags")]
[InlineData("/api/v1/endorsements")]
[InlineData("/api/v1/featured_tags")]
[InlineData("/api/v1/scheduled_statuses")]
public async Task Signed_in_stub_routes_answer_empty_lists(string path)
{
var reader = await _host.Mastodon("reader");
+179
View File
@@ -0,0 +1,179 @@
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
namespace PrivaPub.Tests.Http
{
// Mastodon's lists: a persona's own groups of accounts it follows, read as timelines, never federated
[Trait("Category", "Integration")]
public sealed class MastodonListsTests : IAsyncLifetime
{
PrivaPubHost _host;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
static async Task Follow(Mastodon follower, Mastodon target) =>
(await follower.Client.Post($"/api/v1/accounts/{target.Id}/follow")).Ok();
static async Task<string> List(Mastodon owner, string title, params (string Key, string Value)[] more) =>
(await owner.Client.Post("/api/v1/lists", more.Prepend(("title", title)).ToArray())).Ok().Object.Text("id");
[Fact]
public async Task A_list_is_created_renamed_and_deleted_by_its_owner_only()
{
var alice = await _host.Mastodon("alice");
var mallory = await _host.Mastodon("mallory");
var created = (await alice.Client.Post("/api/v1/lists", ("title", "Friends"), ("replies_policy", "followed"), ("exclusive", "true"))).Ok().Object;
var id = created.Text("id");
Assert.Equal("Friends", created.Text("title"));
Assert.Equal("followed", created.Text("replies_policy"));
Assert.True(created.Flag("exclusive"));
var other = await List(alice, "Art");
Assert.Equal(new[] { other, id }, (await alice.Client.Get("/api/v1/lists")).Ok().Ids);
var renamed = (await alice.Client.Put($"/api/v1/lists/{id}", ("title", "Close friends"), ("exclusive", "false"))).Ok().Object;
Assert.Equal("Close friends", renamed.Text("title"));
Assert.Equal("followed", renamed.Text("replies_policy"));
Assert.False(renamed.Flag("exclusive"));
Assert.Equal("Close friends", (await alice.Client.Get($"/api/v1/lists/{id}")).Ok().Object.Text("title"));
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/lists", ("title", " "))).Status);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/lists", ("title", "x"), ("replies_policy", "everyone"))).Status);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Put($"/api/v1/lists/{id}", ("title", ""))).Status);
Assert.Empty((await mallory.Client.Get("/api/v1/lists")).Ok().Array);
Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Get($"/api/v1/lists/{id}")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Put($"/api/v1/lists/{id}", ("title", "mine"))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Delete($"/api/v1/lists/{id}")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Get($"/api/v1/timelines/list/{id}")).Status);
Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get("/api/v1/lists")).Status);
(await alice.Client.Delete($"/api/v1/lists/{id}")).Ok();
Assert.Equal(new[] { other }, (await alice.Client.Get("/api/v1/lists")).Ok().Ids);
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/lists/{id}")).Status);
}
[Fact]
public async Task Only_followed_accounts_join_a_list_and_its_timeline_shows_their_posts()
{
var alice = await _host.Mastodon("alice");
var bob = await _host.Mastodon("bob");
var carol = await _host.Mastodon("carol");
var dave = await _host.Mastodon("dave");
await Follow(alice, bob);
await Follow(alice, dave);
var list = await List(alice, "Bob");
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post($"/api/v1/lists/{list}/accounts", ("account_ids[]", carol.Id))).Status);
(await alice.Client.Post($"/api/v1/lists/{list}/accounts", ("account_ids[]", bob.Id))).Ok();
(await alice.Client.Post($"/api/v1/lists/{list}/accounts", ("account_ids[]", bob.Id))).Ok();
Assert.Equal(new[] { bob.Id }, (await alice.Client.Get($"/api/v1/lists/{list}/accounts")).Ok().Ids);
Assert.Equal(new[] { list }, (await alice.Client.Get($"/api/v1/accounts/{bob.Id}/lists")).Ok().Ids);
Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{dave.Id}/lists")).Ok().Array);
var fromBob = (await bob.Status("from bob")).Text("id");
await dave.Status("from dave");
await carol.Status("from carol");
Assert.Equal(new[] { fromBob }, (await alice.Client.Get($"/api/v1/timelines/list/{list}")).Ok().Ids);
(await alice.Client.Delete($"/api/v1/lists/{list}/accounts", ("account_ids[]", bob.Id))).Ok();
Assert.Empty((await alice.Client.Get($"/api/v1/lists/{list}/accounts")).Ok().Array);
Assert.Empty((await alice.Client.Get($"/api/v1/timelines/list/{list}")).Ok().Array);
}
[Theory]
[InlineData("list", true, false, false)]
[InlineData("followed", true, true, false)]
[InlineData("none", true, false, false)]
public async Task A_lists_replies_follow_its_policy(string policy, bool toSelf, bool toFollowed, bool toStranger)
{
var alice = await _host.Mastodon("alice");
var bob = await _host.Mastodon("bob");
var dave = await _host.Mastodon("dave");
var carol = await _host.Mastodon("carol");
await Follow(alice, bob);
await Follow(alice, dave);
var list = await List(alice, "Bob", ("replies_policy", policy));
(await alice.Client.Post($"/api/v1/lists/{list}/accounts", ("account_ids[]", bob.Id))).Ok();
var root = (await bob.Status("a thread")).Text("id");
var mine = (await alice.Status("asking bob")).Text("id");
var ofDave = (await dave.Status("dave's")).Text("id");
var ofCarol = (await carol.Status("carol's")).Text("id");
var self = (await bob.Status("and more", ("in_reply_to_id", root))).Text("id");
var toAlice = (await bob.Status($"@{alice.UserName} answered", ("in_reply_to_id", mine))).Text("id");
var toDave = (await bob.Status($"@{dave.UserName} agreed", ("in_reply_to_id", ofDave))).Text("id");
var toCarol = (await bob.Status($"@{carol.UserName} hm", ("in_reply_to_id", ofCarol))).Text("id");
var shown = (await alice.Client.Get($"/api/v1/timelines/list/{list}")).Ok().Ids.ToList();
Assert.Contains(root, shown);
Assert.Equal(toSelf, shown.Contains(self));
Assert.Contains(toAlice, shown);
Assert.Equal(toFollowed, shown.Contains(toDave));
Assert.Equal(toStranger, shown.Contains(toCarol));
}
[Fact]
public async Task An_exclusive_lists_members_are_read_there_and_not_at_home()
{
var alice = await _host.Mastodon("alice");
var bob = await _host.Mastodon("bob");
var dave = await _host.Mastodon("dave");
await Follow(alice, bob);
await Follow(alice, dave);
var list = await List(alice, "Apart", ("exclusive", "true"));
(await alice.Client.Post($"/api/v1/lists/{list}/accounts", ("account_ids[]", bob.Id))).Ok();
var fromBob = (await bob.Status("from bob")).Text("id");
var fromDave = (await dave.Status("from dave")).Text("id");
var home = (await alice.Client.Get("/api/v1/timelines/home")).Ok().Ids.ToList();
Assert.Contains(fromDave, home);
Assert.DoesNotContain(fromBob, home);
Assert.Equal(new[] { fromBob }, (await alice.Client.Get($"/api/v1/timelines/list/{list}")).Ok().Ids);
(await alice.Client.Put($"/api/v1/lists/{list}", ("exclusive", "false"))).Ok();
Assert.Contains(fromBob, (await alice.Client.Get("/api/v1/timelines/home")).Ok().Ids);
}
[Fact]
public async Task Account_search_with_following_finds_only_followed_accounts()
{
var alice = await _host.Mastodon("alice");
var prefix = $"ls{Guid.NewGuid():N}"[..10];
var followed = await _host.Mastodon(prefix + "a");
var stranger = await _host.Mastodon(prefix + "b");
await Follow(alice, followed);
var found = (await alice.Client.Get($"/api/v1/accounts/search?q={prefix}&following=true")).Ok().Ids.ToList();
Assert.Equal(new[] { followed.Id }, found);
Assert.Contains(stranger.Id, (await alice.Client.Get($"/api/v1/accounts/search?q={prefix}")).Ok().Ids);
Assert.Empty((await stranger.Client.Get($"/api/v1/accounts/search?q={prefix}&following=true")).Ok().Array);
}
[Fact]
public async Task An_unfollowed_account_leaves_the_personas_lists_and_does_not_come_back_with_a_new_follow()
{
var alice = await _host.Mastodon("alice");
var bob = await _host.Mastodon("bob");
await Follow(alice, bob);
var list = await List(alice, "Bob", ("exclusive", "true"));
(await alice.Client.Post($"/api/v1/lists/{list}/accounts", ("account_ids[]", bob.Id))).Ok();
(await alice.Client.Post($"/api/v1/accounts/{bob.Id}/unfollow")).Ok();
Assert.Empty((await alice.Client.Get($"/api/v1/lists/{list}/accounts")).Ok().Array);
Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{bob.Id}/lists")).Ok().Array);
await Follow(alice, bob);
var fromBob = (await bob.Status("back again")).Text("id");
Assert.Contains(fromBob, (await alice.Client.Get("/api/v1/timelines/home")).Ok().Ids);
Assert.Empty((await alice.Client.Get($"/api/v1/lists/{list}/accounts")).Ok().Array);
}
}
}
@@ -0,0 +1,103 @@
using MongoDB.Entities;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Media;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
namespace PrivaPub.Tests.Http
{
// Mastodon's scheduled statuses: a post given a scheduled_at waits as asked, can be moved or dropped, and is
// published by its job when its time comes
[Trait("Category", "Integration")]
public sealed class MastodonScheduledStatusesTests : IAsyncLifetime
{
PrivaPubHost _host;
static CancellationToken Token => TestContext.Current.CancellationToken;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
static string In(TimeSpan from) => DateTime.UtcNow.Add(from).ToString("o");
[Fact]
public async Task A_scheduled_post_waits_is_moved_and_is_dropped_by_its_owner_only()
{
var alice = await _host.Mastodon("alice");
var mallory = await _host.Mastodon("mallory");
var scheduled = (await alice.Client.Post("/api/v1/statuses", ("status", "later, friends"), ("visibility", "unlisted"),
("spoiler_text", "soon"), ("scheduled_at", In(TimeSpan.FromMinutes(30))))).Ok().Object;
var id = scheduled.Text("id");
Assert.Null(scheduled["uri"]);
Assert.Equal("later, friends", scheduled["params"].Text("text"));
Assert.Equal("unlisted", scheduled["params"].Text("visibility"));
Assert.Equal("soon", scheduled["params"].Text("spoiler_text"));
Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/statuses")).Ok().Array);
Assert.Equal(new[] { id }, (await alice.Client.Get("/api/v1/scheduled_statuses")).Ok().Ids);
var later = DateTime.UtcNow.AddHours(2);
var moved = (await alice.Client.Put($"/api/v1/scheduled_statuses/{id}", ("scheduled_at", later.ToString("o")))).Ok().Object;
Assert.Equal(later.ToString("yyyy-MM-ddTHH:mm"), moved.Text("scheduled_at")[..16]);
Assert.Equal(HttpStatusCode.UnprocessableEntity,
(await alice.Client.Put($"/api/v1/scheduled_statuses/{id}", ("scheduled_at", In(TimeSpan.FromMinutes(2))))).Status);
Assert.Equal(HttpStatusCode.UnprocessableEntity,
(await alice.Client.Post("/api/v1/statuses", ("status", "too soon"), ("scheduled_at", In(TimeSpan.FromMinutes(1))))).Status);
Assert.Equal(HttpStatusCode.UnprocessableEntity,
(await alice.Client.Post("/api/v1/statuses", ("status", " "), ("scheduled_at", In(TimeSpan.FromHours(1))))).Status);
Assert.Empty((await mallory.Client.Get("/api/v1/scheduled_statuses")).Ok().Array);
Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Get($"/api/v1/scheduled_statuses/{id}")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Delete($"/api/v1/scheduled_statuses/{id}")).Status);
Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get("/api/v1/scheduled_statuses")).Status);
(await alice.Client.Delete($"/api/v1/scheduled_statuses/{id}")).Ok();
Assert.Empty((await alice.Client.Get("/api/v1/scheduled_statuses")).Ok().Array);
await DB.Default.Update<Job>().Match(j => j.Kind == JobKind.PublishScheduled && j.Payload == id)
.Modify(j => j.RunAt, DateTime.UtcNow).ExecuteAsync(Token);
await _host.Run(j => j.Kind == JobKind.PublishScheduled && j.Payload == id, Token);
Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/statuses")).Ok().Array);
}
[Fact]
public async Task A_scheduled_post_is_published_with_its_media_when_its_time_comes()
{
var alice = await _host.Mastodon("alice");
var upload = MastodonHelpers.Multipart(("file", MastodonHelpers.JpegWithMetadata(16, 16), "image/jpeg", "a.jpg"));
var mediaId = (await alice.Client.Exchange(new HttpRequestMessage(HttpMethod.Post, "/api/v2/media") { Content = upload })).Ok().Body.Text("id");
var id = (await alice.Client.Post("/api/v1/statuses", ("status", "on time"), ("media_ids[]", mediaId), ("poll[options][]", "yes"),
("poll[options][]", "no"), ("poll[expires_in]", "3600"), ("scheduled_at", In(TimeSpan.FromMinutes(10))))).Ok().Object.Text("id");
Assert.Equal(id, (await DB.Default.Find<MediaAttachment>().MatchID(mediaId).ExecuteFirstAsync(Token)).ScheduledStatusId);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", "same media"), ("media_ids[]", mediaId),
("scheduled_at", In(TimeSpan.FromMinutes(20))))).Status);
// not due yet: its job leaves it waiting
Assert.Equal(1, await _host.Run(j => j.Kind == JobKind.PublishScheduled && j.Payload == id, Token));
Assert.Single((await alice.Client.Get("/api/v1/scheduled_statuses")).Ok().Array);
// its time comes (moved into the past, as the clock would have it)
await DB.Default.Update<ScheduledStatus>().MatchID(id).Modify(s => s.ScheduledAt, DateTime.UtcNow.AddSeconds(-1)).ExecuteAsync(Token);
await DB.Default.Update<Job>().Match(j => j.Kind == JobKind.PublishScheduled && j.Payload == id).Modify(j => j.State, JobState.Pending).ExecuteAsync(Token);
await _host.Run(j => j.Kind == JobKind.PublishScheduled && j.Payload == id, Token);
Assert.Empty((await alice.Client.Get("/api/v1/scheduled_statuses")).Ok().Array);
var published = Assert.Single((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/statuses")).Ok().Array)!;
Assert.Contains("on time", published.Text("content"));
Assert.Equal(mediaId, Assert.Single(published["media_attachments"]!.AsArray()).Text("id"));
Assert.Equal(2, published["poll"]!["options"]!.AsArray().Count);
var media = await DB.Default.Find<MediaAttachment>().MatchID(mediaId).ExecuteFirstAsync(Token);
Assert.Equal(published.Text("id"), media.PostId);
Assert.Null(media.ScheduledStatusId);
}
}
}
@@ -1,3 +1,7 @@
using MongoDB.Entities;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
@@ -130,6 +134,87 @@ namespace PrivaPub.Tests.Http
Assert.Equal(1, (await alice.Client.Get($"/api/v1/statuses/{parent.ID}")).Ok().Body.Number("replies_count"));
}
// a remote thread opened by a persona has its replies read from its server (here its FEP-7888 context), at most hourly
[Fact]
public async Task Opening_a_remote_thread_reads_the_replies_that_never_reached_us()
{
var token = TestContext.Current.CancellationToken;
var alice = await _host.Mastodon("alice");
var bob = new RemoteActor(_peer, "bob");
var carol = new RemoteActor(_peer, "carol");
var context = $"{bob.Origin()}/contexts/{Guid.NewGuid():N}";
var post = await _host.PublicPostFrom(bob, alice, shape: note => note["context"] = context);
var answer = carol.Create("<p>said elsewhere</p>", new[] { MastodonHelpers.Public }, new[] { carol.Id + "/followers" },
note => note["inReplyTo"] = post.ObjectURI)["object"]!;
var answerId = answer["id"]!.GetValue<string>();
_peer.Serve(new Uri(answerId).AbsolutePath, answer.ToJsonString());
_peer.Serve(new Uri(context).AbsolutePath, new JsonObject
{
["id"] = context, ["type"] = "OrderedCollection", ["orderedItems"] = new JsonArray(post.ObjectURI, answerId)
}.ToJsonString());
async Task<int> Queued() => (await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.FetchReplies && j.Payload.Contains(post.ID)).ExecuteAsync(token)).Count;
(await _host.Client().Get($"/api/v1/statuses/{post.ID}/context")).Ok();
Assert.Equal(0, await Queued());
Assert.Empty((await alice.Client.Get($"/api/v1/statuses/{post.ID}/context")).Ok().Body["descendants"]!.AsArray());
Assert.Equal(1, await _host.Run(j => j.Kind == JobKind.FetchReplies && j.Payload.Contains(post.ID), token));
var descendants = (await alice.Client.Get($"/api/v1/statuses/{post.ID}/context")).Ok().Body["descendants"]!.AsArray();
Assert.Equal(answerId, Assert.Single(descendants).Text("uri"));
Assert.Equal(1, await Queued());
}
// Akkoma marks a post with sensitive media "sensitive" and leaves its summary empty: the media is hidden, the words are
// not, and no warning is made up for them (found by decePub's e2e tests on the town)
[Fact]
public async Task A_remote_post_sensitive_without_a_summary_hides_its_media_and_not_its_words()
{
var alice = await _host.Mastodon("alice");
var bob = new RemoteActor(_peer, "bob");
_peer.WebFinger(bob);
var post = await _host.PublicPostFrom(bob, alice, shape: note => { note["sensitive"] = true; note["summary"] = ""; });
var status = (await alice.Client.Get($"/api/v1/statuses/{post.ID}")).Ok().Body;
Assert.True(status["sensitive"]!.GetValue<bool>());
Assert.Equal("", status.Text("spoiler_text"));
}
// a status asked for by id is shown even when the viewer blocked or muted its author, as Mastodon shows it: only lists
// leave such posts out (it once answered 200 with null, and decePub's thread page showed nothing)
[Fact]
public async Task A_status_of_an_author_the_viewer_blocked_is_still_shown_by_its_id()
{
var alice = await _host.Mastodon("alice");
var bob = new RemoteActor(_peer, "bob");
_peer.WebFinger(bob);
var post = await _host.PublicPostFrom(bob, alice);
var bobId = (await _host.Known(bob)).ID;
(await alice.Client.Post($"/api/v1/accounts/{bobId}/block")).Ok();
var status = (await alice.Client.Get($"/api/v1/statuses/{post.ID}")).Ok().Body;
Assert.Equal(post.ID, status.Text("id"));
}
// a reader who deletes the @name decePub prefills still answers its author: GoToSocial dropped such a reply, addressed
// to nobody there (found by decePub's e2e tests on the town)
[Fact]
public async Task A_reply_without_the_authors_name_still_names_the_author_in_cc()
{
var since = DateTime.UtcNow.AddSeconds(-1);
var alice = await _host.Mastodon("alice");
var bob = new RemoteActor(_peer, "bob");
_peer.WebFinger(bob);
var parent = await _host.PublicPostFrom(bob, alice);
var reply = await alice.Status("no name in this one", ("in_reply_to_id", parent.ID));
Assert.Empty(reply["mentions"]!.AsArray());
var create = Assert.Single(await bob.Delivered(since), d => d.Type() == "Create");
Assert.Contains(bob.Id, Strings(create["object"]!["cc"]));
}
[Fact]
public async Task A_poll_is_created_validated_and_federated_as_a_question()
{
@@ -303,6 +388,59 @@ namespace PrivaPub.Tests.Http
Assert.True((await alice.Client.Post($"/api/v1/statuses/{quiet.Text("id")}/favourite")).Ok().Body.Flag("favourited"));
}
[Fact]
public async Task A_favourite_or_a_boost_given_again_after_its_undo_is_a_new_activity_that_is_delivered()
{
var since = DateTime.UtcNow.AddSeconds(-1);
var alice = await _host.Mastodon("alice");
var bob = new RemoteActor(_peer, "bob");
var remote = await _host.PublicPostFrom(bob, alice);
var path = $"/api/v1/statuses/{remote.ID}";
foreach (var (on, off) in new[] { ("favourite", "unfavourite"), ("reblog", "unreblog") })
{
(await alice.Client.Post($"{path}/{on}")).Ok();
(await alice.Client.Post($"{path}/{off}")).Ok();
(await alice.Client.Post($"{path}/{on}")).Ok();
}
var sent = await bob.Delivered(since);
foreach (var type in new[] { "Like", "Announce" })
{
var given = sent.Where(d => d.Type() == type).ToList();
Assert.Equal(2, given.Count);
Assert.NotEqual(given[0].Text("id"), given[1].Text("id"));
var undo = Assert.Single(sent, d => d.Type() == "Undo" && d["object"].Text("type") == type);
Assert.Equal(given[0].Text("id"), undo["object"].Text("id"));
}
var status = (await alice.Client.Get(path)).Ok().Body;
Assert.True(status.Flag("favourited"));
Assert.True(status.Flag("reblogged"));
Assert.Equal(1, status.Number("reblogs_count"));
}
[Fact]
public async Task A_boost_reaches_the_authors_server_once_through_its_shared_inbox_and_a_like_its_own_inbox()
{
var since = DateTime.UtcNow.AddSeconds(-1);
var alice = await _host.Mastodon("alice");
var bob = new RemoteActor(_peer, "bob");
var remote = await _host.PublicPostFrom(bob, alice);
await DB.Default.Update<ForeignAvatar>().Match(f => f.ActorURI == bob.Id).Modify(f => f.SharedInboxURL, bob.SharedInbox)
.ExecuteAsync(TestContext.Current.CancellationToken);
(await alice.Client.Post($"/api/v1/statuses/{remote.ID}/reblog")).Ok();
(await alice.Client.Post($"/api/v1/statuses/{remote.ID}/unreblog")).Ok();
(await alice.Client.Post($"/api/v1/statuses/{remote.ID}/favourite")).Ok();
var shared = await Jobs.Deliveries(bob.SharedInbox, since, TestContext.Current.CancellationToken);
Assert.Single(shared, d => d.Type() == "Announce");
Assert.Single(shared, d => d.Type() == "Undo");
var own = await bob.Delivered(since);
Assert.DoesNotContain(own, d => d.Type() is "Announce" or "Undo");
Assert.Single(own, d => d.Type() == "Like");
}
[Fact]
public async Task Bookmarks_are_kept_listed_and_removed()
{
@@ -0,0 +1,165 @@
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Http
{
// Mastodon's streaming API: a WebSocket whose streams are subscribed by message or in the URL, and server-sent events
// per stream; each event mapped for its persona, never what it may not see
[Trait("Category", "Integration")]
public sealed class MastodonStreamingTests : IAsyncLifetime
{
PrivaPubHost _host;
static CancellationToken Token => TestContext.Current.CancellationToken;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
async Task<WebSocket> Connect(Mastodon account, string query = "")
{
var client = _host.Server.CreateWebSocketClient();
var uri = new Uri($"ws://{PrivaPubHost.Host}/api/v1/streaming?access_token={account.Token}{query}");
var socket = await client.ConnectAsync(uri, Token);
// the subscription is made as the socket opens: give the server a moment to hear it
await Task.Delay(200, Token);
return socket;
}
static async Task Say(WebSocket socket, JsonObject message) =>
await socket.SendAsync(Encoding.UTF8.GetBytes(message.ToJsonString()), WebSocketMessageType.Text, true, Token);
// the next message for which `wanted` holds, within a few seconds, or null
static async Task<JsonObject> Next(WebSocket socket, Func<JsonObject, bool> wanted, int seconds = 5)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Token);
timeout.CancelAfter(TimeSpan.FromSeconds(seconds));
var buffer = new byte[65536];
try
{
while (true)
{
var text = new StringBuilder();
WebSocketReceiveResult received;
do
{
received = await socket.ReceiveAsync(buffer, timeout.Token);
text.Append(Encoding.UTF8.GetString(buffer, 0, received.Count));
}
while (!received.EndOfMessage);
if (JsonNode.Parse(text.ToString()) is JsonObject message && wanted(message))
return message;
}
}
catch (OperationCanceledException)
{
return null;
}
}
static string Id(JsonObject message) => JsonNode.Parse(message["payload"]!.GetValue<string>())!["id"]!.GetValue<string>();
[Fact]
public async Task Health_is_answered_and_a_stream_needs_a_persona()
{
var health = await _host.Client().GetAsync("/api/v1/streaming/health", Token);
Assert.Equal("OK", await health.Content.ReadAsStringAsync(Token));
Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().GetAsync("/api/v1/streaming/public", Token)).StatusCode);
var instance = (await _host.Client().Get("/api/v2/instance")).Ok().Body;
Assert.StartsWith("ws", instance["configuration"]!["urls"]!["streaming"]!.GetValue<string>());
}
[Fact]
public async Task The_user_stream_tells_home_posts_notifications_edits_and_deletions()
{
var alice = await _host.Mastodon("alice");
var bob = await _host.Mastodon("bob");
var carol = await _host.Mastodon("carol");
var stranger = await _host.Mastodon("stranger");
(await alice.Client.Post($"/api/v1/accounts/{bob.Id}/follow")).Ok();
using var socket = await Connect(alice, "&stream=user");
using var elsewhere = await Connect(stranger, "&stream=user");
var post = (await bob.Status("streamed to alice")).Text("id");
var update = await Next(socket, m => m.Text("event") == "update");
Assert.NotNull(update);
Assert.Equal(new[] { "user" }, update!["stream"]!.AsArray().Select(s => s!.GetValue<string>()));
Assert.Equal(post, Id(update));
await carol.Status($"@{alice.UserName} hello there", ("visibility", "direct"));
var notification = await Next(socket, m => m.Text("event") == "notification");
Assert.Equal("mention", JsonNode.Parse(notification!["payload"]!.GetValue<string>())!.Text("type"));
(await bob.Client.Put($"/api/v1/statuses/{post}", ("status", "streamed, then edited"))).Ok();
var edited = await Next(socket, m => m.Text("event") == "status.update");
Assert.Contains("then edited", JsonNode.Parse(edited!["payload"]!.GetValue<string>())!.Text("content"));
(await bob.Client.Delete($"/api/v1/statuses/{post}")).Ok();
Assert.NotNull(await Next(socket, m => m.Text("event") == "delete" && m["payload"]!.GetValue<string>() == post));
// a persona whose home never held it hears nothing of it, not even its id
Assert.Null(await Next(elsewhere, m => m.Text("event") is "update" or "delete" && m["payload"]!.GetValue<string>().Contains(post), seconds: 2));
}
[Fact]
public async Task A_hashtag_stream_subscribed_by_message_tells_public_posts_only_and_never_a_blocked_authors()
{
var alice = await _host.Mastodon("alice");
var bob = await _host.Mastodon("bob");
var dave = await _host.Mastodon("dave");
(await alice.Client.Post($"/api/v1/accounts/{dave.Id}/block")).Ok();
var tag = $"st{Guid.NewGuid():N}"[..12];
using var socket = await Connect(alice);
await Say(socket, new JsonObject { ["type"] = "subscribe", ["stream"] = "hashtag", ["tag"] = tag });
await Task.Delay(200, Token);
await dave.Status($"blocked #{tag}");
await bob.Status($"quiet #{tag}", ("visibility", "unlisted"));
var open = (await bob.Status($"open #{tag}")).Text("id");
var told = await Next(socket, m => m.Text("event") == "update");
Assert.Equal(open, Id(told!));
Assert.Equal(new[] { "hashtag", tag }, told["stream"]!.AsArray().Select(s => s!.GetValue<string>()));
Assert.Null(await Next(socket, m => m.Text("event") == "update", seconds: 2));
await Say(socket, new JsonObject { ["type"] = "unsubscribe", ["stream"] = "hashtag", ["tag"] = tag });
await Task.Delay(200, Token);
await bob.Status($"again #{tag}");
Assert.Null(await Next(socket, m => m.Text("event") == "update", seconds: 2));
await Say(socket, new JsonObject { ["type"] = "subscribe", ["stream"] = "list", ["list"] = "000000000000000000000000" });
Assert.Equal("Unknown list", (await Next(socket, m => m["error"] != null))!.Text("error"));
}
[Fact]
public async Task Server_sent_events_tell_a_personas_notifications()
{
var alice = await _host.Mastodon("alice");
var bob = await _host.Mastodon("bob");
using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/streaming/user/notification");
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", alice.Token);
using var response = await _host.Client().SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Token);
Assert.Equal("text/event-stream", response.Content.Headers.ContentType!.MediaType);
using var reader = new StreamReader(await response.Content.ReadAsStreamAsync(Token));
Assert.Equal(":)", await reader.ReadLineAsync(Token));
(await bob.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok();
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Token);
timeout.CancelAfter(TimeSpan.FromSeconds(5));
string line;
while ((line = await reader.ReadLineAsync(timeout.Token)) != null && line != "event: notification")
{
}
Assert.Equal("event: notification", line);
var data = await reader.ReadLineAsync(timeout.Token);
Assert.Equal("follow", JsonNode.Parse(data!["data: ".Length..])!.Text("type"));
}
}
}
+89
View File
@@ -0,0 +1,89 @@
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Http
{
// Hashtags: a tag's week of use in public posts, and followed tags, whose public posts come to the persona's home
[Trait("Category", "Integration")]
public sealed class MastodonTagsTests : IAsyncLifetime
{
PrivaPubHost _host;
Peer _peer;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
_peer = await Peer.Start();
}
public async ValueTask DisposeAsync()
{
if (_peer != default)
await _peer.DisposeAsync();
}
static string NewTag() => $"t{Guid.NewGuid():N}"[..12];
[Fact]
public async Task A_tag_shows_its_week_of_public_use_and_whether_the_persona_follows_it()
{
var alice = await _host.Mastodon("alice");
var bob = await _host.Mastodon("bob");
var tag = NewTag();
await alice.Status($"about #{tag}");
await alice.Status($"more about #{tag.ToUpperInvariant()}");
await bob.Status($"also #{tag}");
await bob.Status($"quietly #{tag}", ("visibility", "unlisted"));
var shown = (await _host.Client().Get($"/api/v1/tags/{tag}")).Ok().Object;
Assert.Equal(tag, shown.Text("name"));
Assert.False(shown.Flag("following"));
var history = shown["history"]!.AsArray();
Assert.Equal(7, history.Count);
Assert.Equal(("3", "2"), (history[0].Text("uses"), history[0].Text("accounts")));
Assert.Equal(new DateTimeOffset(DateTime.UtcNow.Date, TimeSpan.Zero).ToUnixTimeSeconds().ToString(), history[0].Text("day"));
Assert.True((await alice.Client.Post($"/api/v1/tags/{tag}/follow")).Ok().Body.Flag("following"));
Assert.True((await alice.Client.Get($"/api/v1/tags/%23{tag.ToUpperInvariant()}")).Ok().Body.Flag("following"));
Assert.Equal(new[] { tag }, (await alice.Client.Get("/api/v1/followed_tags")).Ok().Array.Select(t => t.Text("name")));
Assert.False((await bob.Client.Get($"/api/v1/tags/{tag}")).Ok().Body.Flag("following"));
Assert.False((await alice.Client.Post($"/api/v1/tags/{tag}/unfollow")).Ok().Body.Flag("following"));
Assert.Empty((await alice.Client.Get("/api/v1/followed_tags")).Ok().Array);
Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Post($"/api/v1/tags/{tag}/follow")).Status);
}
[Fact]
public async Task A_followed_tags_public_posts_come_home_from_here_and_from_other_servers()
{
var alice = await _host.Mastodon("alice");
var bob = await _host.Mastodon("bob");
var carol = await _host.Mastodon("carol");
var remote = new RemoteActor(_peer, "dora");
var tag = NewTag();
(await alice.Client.Post($"/api/v1/tags/{tag}/follow")).Ok();
var open = (await bob.Status($"news about #{tag}")).Text("id");
var quiet = (await bob.Status($"quiet news #{tag}", ("visibility", "unlisted"))).Text("id");
var answer = (await bob.Status($"and #{tag} again", ("in_reply_to_id", open))).Text("id");
var untagged = (await bob.Status("nothing tagged")).Text("id");
var far = await _host.PublicPostFrom(remote, carol, $"<p>far away #{tag}</p>", note => note["tag"]!.AsArray().Add(new JsonObject
{
["type"] = "Hashtag",
["name"] = "#" + tag,
["href"] = $"{remote.Origin()}/tags/{tag}"
}));
var home = (await alice.Client.Get("/api/v1/timelines/home?limit=40")).Ok().Ids.ToList();
Assert.Contains(open, home);
Assert.Contains(far.ID, home);
Assert.DoesNotContain(quiet, home);
Assert.DoesNotContain(answer, home);
Assert.DoesNotContain(untagged, home);
Assert.DoesNotContain(open, (await carol.Client.Get("/api/v1/timelines/home?limit=40")).Ok().Ids);
}
}
}
+37 -8
View File
@@ -105,14 +105,6 @@ namespace PrivaPub.Tests.Http
Assert.Equal(new[] { later, open }, (await alice.Client.Get($"/api/v1/timelines/tag/%23{tag}")).Ok().Ids);
}
[Fact]
public async Task A_list_timeline_is_an_empty_stub()
{
var alice = await _host.Mastodon("alice");
Assert.Empty((await alice.Client.Get("/api/v1/timelines/list/anything")).Ok().Array);
}
[Fact]
public async Task Favourites_list_what_the_persona_liked_newest_first()
{
@@ -154,6 +146,43 @@ namespace PrivaPub.Tests.Http
Assert.DoesNotContain(answer, (await stranger.Client.Get($"/api/v1/accounts/{bob.Id}/statuses")).Ok().Ids);
}
[Fact]
public async Task Conversations_page_by_their_newest_message_and_know_what_is_unread_read_and_removed()
{
var alice = await _host.Mastodon("alice");
var bob = await _host.Mastodon("bob");
var carol = await _host.Mastodon("carol");
await bob.Status($"@{alice.UserName} first", ("visibility", "direct"));
var fromCarol = (await carol.Status($"@{alice.UserName} second", ("visibility", "direct"))).Text("id");
// writing in a conversation reads it
await alice.Status($"@{bob.UserName} third", ("visibility", "direct"));
async Task<JsonArray> List() => (await alice.Client.Get("/api/v1/conversations")).Ok().Array;
var all = await List();
Assert.Equal(2, all.Count);
var (withBob, withCarol) = (all[0].Text("id"), all[1].Text("id"));
Assert.Equal(new[] { bob.Id }, all[0]!["accounts"]!.AsArray().Select(a => a.Text("id")));
Assert.False(all[0].Flag("unread"));
Assert.True(all[1].Flag("unread"));
Assert.Equal(fromCarol, all[1]!["last_status"].Text("id"));
var page = (await alice.Client.Get("/api/v1/conversations?limit=1")).Ok();
Assert.Equal(withBob, Assert.Single(page.Array).Text("id"));
Assert.Equal(withCarol, Assert.Single((await alice.Client.Get(page.Link("next"))).Ok().Array).Text("id"));
Assert.False((await alice.Client.Post($"/api/v1/conversations/{withCarol}/read")).Ok().Body.Flag("unread"));
Assert.False((await List()).Single(c => c.Text("id") == withCarol).Flag("unread"));
// off the list until a newer message, which brings it back unread; carol's own list never changed
(await alice.Client.Delete($"/api/v1/conversations/{withCarol}")).Ok();
Assert.Equal(new[] { withBob }, (await List()).Select(c => c.Text("id")));
Assert.Equal(withCarol, Assert.Single((await carol.Client.Get("/api/v1/conversations")).Ok().Array).Text("id"));
var again = (await carol.Status($"@{alice.UserName} are you there?", ("visibility", "direct"))).Text("id");
var back = (await List())[0];
Assert.Equal((withCarol, again, true), (back.Text("id"), back!["last_status"].Text("id"), back.Flag("unread")));
Assert.Equal(HttpStatusCode.NotFound, (await bob.Client.Delete($"/api/v1/conversations/{withCarol}")).Status);
}
[Fact]
public async Task Markers_are_saved_versioned_and_read_back_per_timeline()
{
+133
View File
@@ -0,0 +1,133 @@
using MongoDB.Entities;
using PrivaPub.Domain.Social;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
namespace PrivaPub.Tests.Http
{
// Trends from what PrivaPub holds (hashtags and links by how many authors use them, posts by their interactions,
// from discoverable authors only) and the directory of accounts that let themselves be discovered
[Trait("Category", "Integration")]
public sealed class MastodonTrendsTests : IAsyncLifetime
{
PrivaPubHost _host;
static CancellationToken Token => TestContext.Current.CancellationToken;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
void Fresh() => _host.Get<Trends>().Forget();
static string NewTag() => $"tr{Guid.NewGuid():N}"[..12];
[Fact]
public async Task A_hashtag_trends_once_two_authors_use_it_today_and_never_from_one_or_behind_a_warning()
{
var alice = await _host.Mastodon("alice");
var bob = await _host.Mastodon("bob");
var carol = await _host.Mastodon("carol");
var shared = NewTag();
var lonely = NewTag();
var warned = NewTag();
await alice.Status($"#{shared} is good");
await bob.Status($"#{shared} indeed");
for (var i = 0; i < 4; i++)
await carol.Status($"#{lonely} again {i}");
await alice.Status($"#{warned}", ("spoiler_text", "careful"));
await bob.Status($"#{warned}", ("spoiler_text", "careful"));
Fresh();
var trends = (await _host.Client().Get("/api/v1/trends/tags?limit=20")).Ok().Array;
var trend = Assert.Single(trends, t => t.Text("name") == shared)!;
Assert.Equal(("2", "2"), (trend["history"]![0].Text("uses"), trend["history"]![0].Text("accounts")));
Assert.DoesNotContain(trends, t => t.Text("name") == lonely);
Assert.DoesNotContain(trends, t => t.Text("name") == warned);
Assert.Contains((await alice.Client.Get("/api/v1/trends?limit=20")).Ok().Array, t => t.Text("name") == shared);
}
[Fact]
public async Task A_post_trends_by_its_interactions_when_its_author_is_discoverable()
{
var alice = await _host.Mastodon("alice");
var hidden = await _host.Mastodon("hidden");
var bob = await _host.Mastodon("bob");
(await hidden.Client.Patch("/api/v1/accounts/update_credentials", ("discoverable", "false"))).Ok();
var liked = (await alice.Status("worth a like")).Text("id");
var quiet = (await hidden.Status("worth a like too")).Text("id");
var answer = (await alice.Status("a reply", ("in_reply_to_id", liked))).Text("id");
foreach (var id in new[] { liked, quiet, answer })
{
(await bob.Client.Post($"/api/v1/statuses/{id}/favourite")).Ok();
(await bob.Client.Post($"/api/v1/statuses/{id}/reblog")).Ok();
}
Fresh();
var trending = (await _host.Client().Get("/api/v1/trends/statuses?limit=40")).Ok().Ids.ToList();
Assert.Contains(liked, trending);
Assert.DoesNotContain(quiet, trending);
Assert.DoesNotContain(answer, trending);
}
[Fact]
public async Task A_link_trends_once_two_authors_share_it()
{
var alice = await _host.Mastodon("alice");
var bob = await _host.Mastodon("bob");
var url = $"https://news.example/{Guid.NewGuid():N}";
foreach (var account in new[] { alice, bob })
{
var id = (await account.Status($"read {url}")).Text("id");
await DB.Default.Update<Post>().MatchID(id).Modify(p => p.Link, new PostLink { Href = url, Title = "The news" }).ExecuteAsync(Token);
}
Fresh();
var link = Assert.Single((await _host.Client().Get("/api/v1/trends/links?limit=20")).Ok().Array, l => l.Text("url") == url)!;
Assert.Equal("The news", link.Text("title"));
Assert.Equal("2", link["history"]![0].Text("accounts"));
}
[Fact]
public async Task The_directory_lists_discoverable_accounts_by_activity_or_by_joining_and_never_by_creation()
{
var alice = await _host.Mastodon("alice");
var hidden = await _host.Mastodon("hidden");
(await hidden.Client.Patch("/api/v1/accounts/update_credentials", ("discoverable", "false"))).Ok();
await hidden.Status("not listed");
await alice.Status("listed");
// by their latest public post, newest first: other tests post too, so the directory is read until alice
var active = new List<string>();
for (var offset = 0; offset < 4000 && !active.Contains(alice.Id); offset += 80)
{
var page = (await _host.Client().Get($"/api/v1/directory?order=active&local=true&limit=80&offset={offset}")).Ok().Ids.ToList();
active.AddRange(page);
if (page.Count < 80)
break;
}
Assert.Contains(alice.Id, active);
Assert.DoesNotContain(hidden.Id, active);
// by the day each says it joined, newest first: a persona's is its published day, set some days back
// (each page in order: other tests make personas between two pages)
var joined = new List<System.Text.Json.Nodes.JsonNode>();
for (var offset = 0; offset < 4000; offset += 80)
{
var page = (await _host.Client().Get($"/api/v1/directory?order=new&local=true&limit=80&offset={offset}")).Ok().Array;
var days = page.Select(a => a.Text("created_at")[..10]).ToList();
Assert.Equal(days.OrderByDescending(d => d, StringComparer.Ordinal), days);
joined.AddRange(page!);
if (page.Count < 80 || joined.Any(a => a.Text("id") == alice.Id))
break;
}
Assert.Contains(joined, a => a.Text("id") == alice.Id);
Assert.DoesNotContain(joined, a => a.Text("id") == hidden.Id);
}
}
}
@@ -0,0 +1,68 @@
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
namespace PrivaPub.Tests.Http
{
// Status search by words, as Mastodon scopes it: the persona's own posts and those it boosted, favourited, bookmarked
// or was named in, and public posts of authors who let themselves be indexed
[Trait("Category", "Integration")]
public sealed class MastodonWordSearchTests : IAsyncLifetime
{
PrivaPubHost _host;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Fact]
public async Task Words_find_what_the_persona_wrote_or_touched_and_what_indexable_authors_made_public()
{
var reader = await _host.Mastodon("reader");
var open = await _host.Mastodon("open");
var shy = await _host.Mastodon("shy");
(await open.Client.Patch("/api/v1/accounts/update_credentials", ("indexable", "true"))).Ok();
var word = $"w{Guid.NewGuid():N}"[..12];
var own = (await reader.Status($"my {word}")).Text("id");
var indexed = (await open.Status($"open {word}")).Text("id");
var quietIndexed = (await open.Status($"followers {word}", ("visibility", "private"))).Text("id");
var unindexed = (await shy.Status($"shy {word}")).Text("id");
var liked = (await shy.Status($"liked {word}")).Text("id");
var named = (await shy.Status($"@{reader.UserName} about {word}")).Text("id");
var warned = (await shy.Status("calm", ("spoiler_text", $"about {word}"))).Text("id");
(await reader.Client.Post($"/api/v1/statuses/{liked}/favourite")).Ok();
(await reader.Client.Post($"/api/v1/statuses/{warned}/bookmark")).Ok();
var found = (await reader.Client.Get($"/api/v2/search?q={word}&type=statuses&limit=40")).Ok().Body["statuses"]!.AsArray()
.Select(s => s.Text("id")).ToList();
Assert.Equal(new[] { warned, named, liked, indexed, own }, found);
Assert.DoesNotContain(unindexed, found);
Assert.DoesNotContain(quietIndexed, found);
Assert.Empty((await _host.Client().Get($"/api/v2/search?q={word}&type=statuses")).Ok().Body["statuses"]!.AsArray());
Assert.Equal(new[] { named },
(await reader.Client.Get($"/api/v2/search?q={word}&type=statuses&limit=1&offset=1")).Ok().Body["statuses"]!.AsArray().Select(s => s.Text("id")));
}
// a handle asks for its account: no posts sharing its words, and no hashtag made of it
[Fact]
public async Task A_handle_finds_its_account_and_neither_posts_nor_a_hashtag()
{
var reader = await _host.Mastodon("reader");
var named = await _host.Mastodon("named");
await reader.Status($"talking of {named.UserName} here");
foreach (var q in new[] { $"@{named.UserName}", $"@{named.UserName}@{PrivaPubHost.Host}", $"{named.UserName}@{PrivaPubHost.Host}" })
{
var body = (await reader.Client.Get($"/api/v2/search?q={Uri.EscapeDataString(q)}")).Ok().Body;
Assert.Contains(body["accounts"]!.AsArray(), a => a.Text("id") == named.Id);
Assert.Empty(body["statuses"]!.AsArray());
Assert.Empty(body["hashtags"]!.AsArray());
}
Assert.Equal("cats", Assert.Single((await reader.Client.Get("/api/v2/search?q=%23Cats")).Ok().Body["hashtags"]!.AsArray()).Text("name"));
}
}
}
@@ -0,0 +1,103 @@
using MongoDB.Entities;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using PrivaPub.Federation.Signing;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Http
{
// RFC 9421 message signatures, as WordPress's ActivityPub plugin, Ghost and Fedify send them first: a delivery so signed
// is taken in one request, and refused when its body or its target is not what was signed
[Trait("Category", "Integration")]
public sealed class MessageSignatureTests : IAsyncLifetime
{
PrivaPubHost _host;
Peer _peer;
static CancellationToken Token => TestContext.Current.CancellationToken;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
_peer = await Peer.Start();
}
public async ValueTask DisposeAsync()
{
if (_peer != default)
await _peer.DisposeAsync();
}
[Fact]
public async Task A_delivery_signed_as_rfc9421_is_taken_and_one_whose_body_or_target_differs_is_refused()
{
var alice = await _host.Mastodon("alice");
var bob = new RemoteActor(_peer, "bob");
var create = bob.Create("<p>signed the new way</p>", new[] { alice.Uri });
var noteId = create["object"]!["id"]!.GetValue<string>();
using var client = _host.Client();
var tampered = bob.MessageSignedPost(alice.Mouth, create);
tampered.Content = new ByteArrayContent(Encoding.UTF8.GetBytes(create.ToJsonString().Replace("new way", "other way")));
tampered.Content.Headers.TryAddWithoutValidation("Content-Type", "application/activity+json");
tampered.Content.Headers.TryAddWithoutValidation("Content-Digest", bob.MessageSignedPost(alice.Mouth, create).Content!.Headers.GetValues("Content-Digest").First());
Assert.Equal(HttpStatusCode.Unauthorized, (await client.SendAsync(tampered, Token)).StatusCode);
var elsewhere = bob.MessageSignedPost(alice.Mouth, create, signedFor: "/human-centipede");
Assert.Equal(HttpStatusCode.Unauthorized, (await client.SendAsync(elsewhere, Token)).StatusCode);
Assert.Equal(HttpStatusCode.Accepted, (await client.SendAsync(bob.MessageSignedPost(alice.Mouth, create), Token)).StatusCode);
Assert.Equal(1, await _host.RunInbox(Id(create), Token));
Assert.Contains("signed the new way", (await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(Token)).ContentHtml);
}
static string Id(JsonObject activity) => activity["id"]!.GetValue<string>();
// a signed fetch (a GET, no body) verifies the same way, and so does RSA-PSS with SHA-512
[Fact]
public void A_signed_fetch_and_an_rsa_pss_signature_verify()
{
using var key = RSA.Create(2048);
var message = new HttpRequestMessage(HttpMethod.Get, "https://privapub.test/peasants/alice/scribbles/1");
MessageSignatures.Sign(message, "https://peer.example/users/bob#main-key", key.ExportPkcs8PrivateKeyPem(), body: null);
var request = Request(message);
var signature = RequestSignature.Of(request);
Assert.Equal("rfc9421:rsa-v1_5-sha256", signature.Scheme);
Assert.Null(signature.Problem(request, body: null));
Assert.True(signature.VerifiedBy(key.ExportSubjectPublicKeyInfoPem(), signature.Signed(request, "https://privapub.test")));
Assert.False(signature.VerifiedBy(key.ExportSubjectPublicKeyInfoPem(), signature.Signed(request, "https://elsewhere.example")));
var created = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var parameters = $"(\"@method\" \"@target-uri\");created={created};keyid=\"bob\";alg=\"rsa-pss-sha512\"";
var signatureBase = $"\"@method\": GET\n\"@target-uri\": https://privapub.test/peasants/alice/scribbles/1\n\"@signature-params\": {parameters}";
var signed = key.SignData(Encoding.UTF8.GetBytes(signatureBase), HashAlgorithmName.SHA512, RSASignaturePadding.Pss);
var pss = new HttpRequestMessage(HttpMethod.Get, "https://privapub.test/peasants/alice/scribbles/1");
pss.Headers.TryAddWithoutValidation("Signature-Input", $"pss={parameters}");
pss.Headers.TryAddWithoutValidation("Signature", $"pss=:{Convert.ToBase64String(signed)}:");
var pssRequest = Request(pss);
var pssSignature = RequestSignature.Of(pssRequest);
Assert.True(pssSignature.VerifiedBy(key.ExportSubjectPublicKeyInfoPem(), pssSignature.Signed(pssRequest, "https://privapub.test")));
}
static HttpRequest Request(HttpRequestMessage message)
{
var context = new DefaultHttpContext();
context.Request.Method = message.Method.Method;
context.Request.Host = new HostString(message.RequestUri!.Host);
context.Request.Path = message.RequestUri.AbsolutePath;
context.Features.Get<IHttpRequestFeature>()!.RawTarget = message.RequestUri.PathAndQuery;
foreach (var (name, values) in message.Headers)
context.Request.Headers[name] = values.ToArray();
return context.Request;
}
}
}
@@ -0,0 +1,119 @@
using MongoDB.Entities;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
namespace PrivaPub.Tests.Http
{
// Mastodon's notification policy and requests (4.3): accepted by default; a policy filters what strangers, new accounts
// or unasked private mentions send into a request per account, which the persona lets in (and that account from then
// on) or dismisses; or drops it
[Trait("Category", "Integration")]
public sealed class NotificationPolicyTests : IAsyncLifetime
{
PrivaPubHost _host;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
static async Task<IEnumerable<string>> Notifiers(Mastodon persona, string query = "") =>
(await persona.Client.Get("/api/v1/notifications" + query)).Ok().Array.Select(n => n!["account"].Text("id"));
[Fact]
public async Task Everything_is_accepted_until_the_persona_chooses()
{
var alice = await _host.Mastodon("alice");
var stranger = await _host.Mastodon("stranger");
var post = (await alice.Status("like me")).Text("id");
var policy = (await alice.Client.Get("/api/v2/notifications/policy")).Ok().Body;
(await stranger.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok();
Assert.Equal(["accept"], new[] { "for_not_following", "for_not_followers", "for_new_accounts", "for_private_mentions", "for_limited_accounts" }
.Select(key => policy.Text(key)).Distinct());
Assert.Contains(stranger.Id, await Notifiers(alice));
Assert.Empty((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array);
}
[Fact]
public async Task A_strangers_notifications_wait_in_a_request_which_lets_them_and_the_stranger_in()
{
var alice = await _host.Mastodon("alice");
var friend = await _host.Mastodon("friend");
var stranger = await _host.Mastodon("stranger");
(await alice.Client.Post($"/api/v1/accounts/{friend.Id}/follow")).Ok();
var post = (await alice.Status("like me")).Text("id");
var policy = (await alice.Client.Patch("/api/v2/notifications/policy", ("for_not_following", "filter"))).Ok().Body;
Assert.Equal("filter", policy.Text("for_not_following"));
(await friend.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok();
(await stranger.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok();
Assert.Equal([friend.Id], await Notifiers(alice));
Assert.Equal(1, (await alice.Client.Get("/api/v1/notifications/unread_count")).Ok().Body["count"]!.GetValue<int>());
Assert.DoesNotContain(stranger.Id, (await alice.Client.Get("/api/v2/notifications")).Ok().Body["accounts"]!.AsArray().Select(a => a.Text("id")));
var request = Assert.Single((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array)!;
Assert.Equal((stranger.Id, "1", post), (request["account"].Text("id"), request.Text("notifications_count"), request["last_status"].Text("id")));
var summary = (await alice.Client.Get("/api/v2/notifications/policy")).Ok().Body["summary"]!;
Assert.Equal((1, 1), (summary["pending_requests_count"]!.GetValue<int>(), summary["pending_notifications_count"]!.GetValue<int>()));
Assert.Equal([stranger.Id], await Notifiers(alice, $"?account_id={stranger.Id}&include_filtered=true"));
(await alice.Client.Post($"/api/v1/notifications/requests/{request.Text("id")}/accept")).Ok();
Assert.Contains(stranger.Id, await Notifiers(alice));
Assert.Empty((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array);
var again = (await alice.Status("like me again")).Text("id");
(await stranger.Client.Post($"/api/v1/statuses/{again}/favourite")).Ok();
Assert.Equal(2, (await Notifiers(alice, "?types[]=favourite")).Count(id => id == stranger.Id));
}
[Fact]
public async Task A_dismissed_request_takes_its_notifications_with_it_and_a_dropped_one_is_never_kept()
{
var alice = await _host.Mastodon("alice");
var stranger = await _host.Mastodon("stranger");
var newcomer = await _host.Mastodon("newcomer");
var post = (await alice.Status("like me")).Text("id");
// (v1 speaks of filtering only)
Assert.True((await alice.Client.Put("/api/v1/notifications/policy", ("filter_not_following", "true"))).Ok().Body.Flag("filter_not_following"));
(await stranger.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok();
var request = Assert.Single((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array)!;
(await alice.Client.Post($"/api/v1/notifications/requests/{request.Text("id")}/dismiss")).Ok();
Assert.Empty((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array);
Assert.Empty(await Notifiers(alice, $"?account_id={stranger.Id}&include_filtered=true"));
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/notifications/requests/{request.Text("id")}")).Status);
(await alice.Client.Patch("/api/v2/notifications/policy", ("for_new_accounts", "drop"))).Ok();
(await newcomer.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok();
Assert.Empty(await Notifiers(alice, $"?account_id={newcomer.Id}&include_filtered=true"));
Assert.Empty((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array);
}
[Fact]
public async Task An_unasked_private_mention_is_filtered_and_one_answering_the_persona_is_not()
{
var alice = await _host.Mastodon("alice");
var stranger = await _host.Mastodon("stranger");
(await alice.Client.Patch("/api/v2/notifications/policy", ("for_private_mentions", "filter"))).Ok();
var asked = (await alice.Status($"@{stranger.UserName} write to me", ("visibility", "direct"))).Text("id");
await stranger.Status($"@{alice.UserName} hello out of nowhere", ("visibility", "direct"));
await stranger.Status($"@{alice.UserName} as you asked", ("visibility", "direct"), ("in_reply_to_id", asked));
var mentions = (await alice.Client.Get("/api/v1/notifications?types[]=mention")).Ok().Array.Select(n => n!["status"].Text("content")).ToList();
Assert.Contains(mentions, c => c.Contains("as you asked"));
Assert.DoesNotContain(mentions, c => c.Contains("out of nowhere"));
Assert.Single((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array);
}
}
}
+7
View File
@@ -167,6 +167,8 @@ namespace PrivaPub.Tests.Http
var key = group.Text("group_key");
Assert.Equal($"grouped-favourite-{id}", key);
// told alone (v1, a stream), each notification names the group it belongs to
Assert.All((await author.Client.Get("/api/v1/notifications?types[]=favourite")).Ok().Array, n => Assert.Equal(key, n.Text("group_key")));
Assert.Equal(2, group.Number("notifications_count"));
Assert.Equal(2, group["sample_account_ids"]!.AsArray().Count);
Assert.Equal(id, group.Text("status_id"));
@@ -174,6 +176,11 @@ namespace PrivaPub.Tests.Http
Assert.True((await author.Client.Get("/api/v2/notifications/unread_count")).Ok().Body.Number("count") >= 1);
Assert.Equal(key, Assert.Single((await author.Client.Get($"/api/v2/notifications/{key}")).Ok().Body["notification_groups"]!.AsArray())!.Text("group_key"));
Assert.Equal(2, (await author.Client.Get($"/api/v2/notifications/{key}/accounts")).Ok().Array.Count);
// a notifications marker past them reads them
var newest = (await author.Client.Get("/api/v1/notifications")).Ok().Ids.First();
(await author.Client.Post("/api/v1/markers", ("notifications[last_read_id]", newest))).Ok();
Assert.Equal(0, (await author.Client.Get("/api/v2/notifications/unread_count")).Ok().Body.Number("count"));
Assert.Equal(0, (await author.Client.Get("/api/v1/notifications/unread_count")).Ok().Body.Number("count"));
Assert.Equal(HttpStatusCode.OK, (await author.Client.Post($"/api/v2/notifications/{key}/dismiss")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await author.Client.Get($"/api/v2/notifications/{key}")).Status);
}
@@ -0,0 +1,63 @@
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Http
{
// a public thread names its replies and its conversation (owner decision 2026-10-06), and they list only what is public
// or unlisted; a followers-only post names neither
[Trait("Category", "Integration")]
public sealed class ThreadCollectionsTests : IAsyncLifetime
{
PrivaPubHost _host;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
static string PathOf(JsonObject status) => new Uri(status.Text("uri")).AbsolutePath;
static string[] Items(JsonNode collection) => collection["orderedItems"]!.AsArray().Select(i => i!.GetValue<string>()).ToArray();
[Fact]
public async Task A_public_post_names_its_replies_and_context_which_list_only_public_answers()
{
var alice = await _host.Mastodon("alice");
var carol = await _host.Mastodon("carol");
var root = await alice.Status("a public question");
var answer = await carol.Status("a public answer", ("in_reply_to_id", root.Text("id")));
var hidden = await carol.Status("for my followers", ("in_reply_to_id", root.Text("id")), ("visibility", "private"));
var deeper = await alice.Status("and a deeper one", ("in_reply_to_id", answer.Text("id")), ("visibility", "unlisted"));
var note = (await _host.ActivityPub(PathOf(root))).Ok().Object;
Assert.Equal(root.Text("uri") + "/replies", note.Text("replies"));
Assert.Equal(root.Text("uri") + "/context", note.Text("context"));
var answerNote = (await _host.ActivityPub(PathOf(deeper))).Ok().Object;
Assert.Equal(root.Text("uri") + "/context", answerNote.Text("context"));
var replies = (await _host.ActivityPub(PathOf(root) + "/replies")).Ok().Object;
Assert.Equal(new[] { answer.Text("uri") }, Items(replies));
var context = (await _host.ActivityPub(PathOf(root) + "/context")).Ok().Object;
Assert.Equal(new[] { root.Text("uri"), answer.Text("uri"), deeper.Text("uri") }, Items(context));
Assert.Equal(alice.Uri, context.Text("attributedTo"));
Assert.DoesNotContain(hidden.Text("uri"), Items(context));
}
[Fact]
public async Task A_followers_only_post_names_neither_and_serves_neither()
{
var alice = await _host.Mastodon("alice");
var quiet = await alice.Status("for my followers", ("visibility", "private"));
Assert.Equal(HttpStatusCode.NotFound, (await _host.ActivityPub(PathOf(quiet) + "/replies")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _host.ActivityPub(PathOf(quiet) + "/context")).Status);
}
}
}
+30 -1
View File
@@ -38,6 +38,31 @@ namespace PrivaPub.Tests.Http
static string Link(JsonObject document, string rel) =>
document["links"]!.AsArray().Single(l => l!["rel"]!.GetValue<string>() == rel)!["href"]!.GetValue<string>();
// FEP-d556, FEP-2677 and FEP-844e: the server's own actor is found from its origin and from NodeInfo, and says what
// the server reads that its documents do not show
[Fact]
public async Task The_server_actor_is_found_from_the_origin_and_nodeinfo_and_tells_what_it_implements()
{
var instance = $"{Base}/peasants/privapub";
foreach (var resource in new[] { Base, Base + "/" })
{
var found = await WebFinger(resource);
Assert.Equal(HttpStatusCode.OK, found.Status);
Assert.Equal(resource, found.Json["subject"]!.GetValue<string>());
Assert.Equal(instance, Link(found.Json, "https://www.w3.org/ns/activitystreams#Service"));
}
var nodeinfo = await _client.Fetch("/.well-known/nodeinfo", "application/json");
Assert.Equal(instance, Link(nodeinfo.Json, "https://www.w3.org/ns/activitystreams#Application"));
var actor = (await _client.Fetch("/peasants/privapub")).Json;
Assert.Contains(actor["implements"]!.AsArray(), i => i!["href"]!.GetValue<string>() == "https://datatracker.ietf.org/doc/html/rfc9421");
var persona = await _host.Persona(await _host.SignUp(), "generated");
var personaActor = (await _client.Fetch($"/peasants/{persona.UserName}")).Json;
Assert.Equal("Application", personaActor["generator"]!["type"]!.GetValue<string>());
Assert.Contains(personaActor["generator"]!["implements"]!.AsArray(), i => i!["href"]!.GetValue<string>() == "https://datatracker.ietf.org/doc/html/rfc9421");
Assert.StartsWith("z6Mk", personaActor["assertionMethod"]![0]!["publicKeyMultibase"]!.GetValue<string>());
}
[Fact]
public async Task WebFinger_finds_a_persona_by_acct_and_by_actor_uri()
{
@@ -83,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);
@@ -95,6 +120,10 @@ namespace PrivaPub.Tests.Http
Assert.Equal($"acct:privapub@{Domain}", instance.Json["subject"]!.GetValue<string>());
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<string>());
Assert.Equal(community.Uri, Link(group.Json, "self"));
@@ -27,6 +27,12 @@ namespace PrivaPub.Tests.Infrastructure
_peer.MapGet("/big", () => Results.Text("{\"a\":\"" + new string('a', FederationHttp.MaxResponseBytes) + "\"}", "application/activity+json"));
_peer.MapGet("/hop/{n:int}", (int n) => Results.Redirect(n == 0 ? "/actor" : $"/hop/{n - 1}"));
_peer.MapGet("/gone", () => Results.StatusCode(410));
// Mobilizon's NodeInfo: a profile URL left unquoted, which .NET cannot parse as a media type
_peer.MapGet("/nodeinfo", (HttpContext context) =>
{
context.Response.Headers.ContentType = "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.1#; charset=utf-8";
return context.Response.WriteAsync("{\"software\":{\"name\":\"Mobilizon\"}}");
});
await _peer.StartAsync();
_base = _peer.Urls.First();
}
@@ -64,6 +70,13 @@ namespace PrivaPub.Tests.Infrastructure
public async Task Refuses_a_fourth_redirect() =>
Assert.Null(await Client().GetJson($"{_base}/hop/3", "application/activity+json", default, TestContext.Current.CancellationToken));
[Fact]
public async Task Reads_json_under_a_content_type_dotnet_cannot_parse()
{
using var fetched = await Client().GetJson($"{_base}/nodeinfo", "application/json", default, TestContext.Current.CancellationToken);
Assert.Equal("Mobilizon", fetched.Root.GetProperty("software").GetProperty("name").GetString());
}
[Fact]
public async Task Refuses_html() =>
Assert.Null(await Client().GetJson($"{_base}/html", "application/activity+json", default, TestContext.Current.CancellationToken));
@@ -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));
}
}
}
@@ -44,6 +44,23 @@ namespace PrivaPub.Tests.Statistics
Assert.Equal(7, facts.LocalComments);
Assert.Equal(new[] { "en", "de" }, facts.Languages);
Assert.Null(InstanceDocuments.NodeInfo(Json("{}"), "2.0").UsersTotal);
// NodeInfo wants the name in lower case, which Mobilizon does not keep
Assert.Equal("mobilizon", InstanceDocuments.NodeInfo(Json("""{"software":{"name":"Mobilizon"}}"""), "2.1").Software);
}
// Castopod publishes NodeInfo2 only
[Fact]
public void NodeInfo2_gives_the_server_and_its_usage()
{
var facts = InstanceDocuments.NodeInfo2(Json("""
{"version":"1.0","server":{"baseUrl":"https://castopod.test/","name":"Castopod","software":"Castopod","version":"1.15.5"},
"protocols":["activitypub"],"openRegistrations":false,"usage":{"users":{"total":1,"activeMonth":1,"activeHalfyear":1},"localPosts":3}}
"""));
Assert.Equal(("castopod", "1.15.5", "Castopod", "nodeinfo2-1.0"), (facts.Software, facts.SoftwareVersion, facts.NodeName, facts.Version));
Assert.Equal(new[] { "activitypub" }, facts.Protocols);
Assert.Equal((1L, 3L), (facts.UsersTotal.Value, facts.LocalPosts.Value));
Assert.Null(InstanceDocuments.NodeInfo2(Json("""{"version":"1.0","server":{}}""")));
}
[Fact]
@@ -57,6 +74,10 @@ namespace PrivaPub.Tests.Statistics
Assert.Equal(("https://a.example/2.2", "2.2"), InstanceDocuments.NodeInfoLink(links));
Assert.Equal(default, InstanceDocuments.NodeInfoLink(Json("[]")));
// Funkwhale 2.0 links its NodeInfo under its swagger schema's URL
Assert.Equal(("https://f.example/api/v2/instance/nodeinfo/2.1", "2.1"), InstanceDocuments.NodeInfoLink(Json("""
{"links":[{"rel":"https://docs.funkwhale.audio/swagger/schema.yml","href":"https://f.example/api/v2/instance/nodeinfo/2.1"}]}
""")));
}
[Fact]
@@ -200,6 +221,30 @@ namespace PrivaPub.Tests.Statistics
Assert.False((await DB.Default.Find<RemoteInstanceSnapshot>().Match(s => s.Host == gone).ExecuteSingleAsync(token)).Reachable);
}
// a server whose NodeInfo failed is asked again on its next arrival, not a week later
[Fact]
public async Task A_failed_description_frees_its_weeks_key_and_a_good_one_keeps_it()
{
var token = TestContext.Current.CancellationToken;
var broken = $"broken{Guid.NewGuid():N}.example";
var fine = $"fine{Guid.NewGuid():N}.example";
ServeServer("/" + fine, "smithereen", v2: false);
var queue = new JobQueue();
async Task Run(string host)
{
var key = InstanceDescriber.DedupeKey(host, DateTime.UtcNow);
Assert.True(await queue.Enqueue(JobKind.DescribeInstance, host, host, key, token));
var job = await DB.Default.Find<Job>().Match(j => j.DedupeKey == key).ExecuteSingleAsync(token);
await new PeerDescriber(Peer.Http(), default, default, _peer.A + "/" + host).Handle(job, token);
}
await Run(broken);
await Run(fine);
Assert.True(await queue.Enqueue(JobKind.DescribeInstance, broken, broken, InstanceDescriber.DedupeKey(broken, DateTime.UtcNow), token));
Assert.False(await queue.Enqueue(JobKind.DescribeInstance, fine, fine, InstanceDescriber.DedupeKey(fine, DateTime.UtcNow), token));
}
[Fact]
public async Task A_crawled_server_never_downgrades_a_touched_one_and_robots_are_obeyed()
{
@@ -49,6 +49,8 @@ namespace PrivaPub.Tests.Statistics
Assert.Equal("-", Interactions.Host("evil host/with path"));
Assert.Equal("-", Interactions.Host(default));
Assert.Equal("social.example", Interactions.HostOf("https://Social.Example:8443/users/x"));
// RFC 9421's algorithm names carry an underscore
Assert.Equal("rfc9421:rsa-v1_5-sha256", Interactions.Sanitize(new InteractionEvent { Signature = "rfc9421:rsa-v1_5-sha256" }).Signature);
}
[Fact]
+38 -14
View File
@@ -14,6 +14,7 @@ using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Relays;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models;
using PrivaPub.Federation.Objects;
@@ -43,41 +44,54 @@ namespace PrivaPub.Tests.Support
Local = new LocalActorService(Db, new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = Base }));
Remote = new RemoteActorService(Peer.Http(cache), Local, cache, Db);
Delivery = new DeliveryService(Db, Queue);
Relays = new Relays(Microsoft.Extensions.Options.Options.Create(RelayOptions), Remote, Local, Delivery);
Fanout = new Fanout(Db);
Groups = new GroupDistributor(Delivery);
Records = new ObjectRecords(Queue);
Polls = new PollService(Db, Local, Delivery, Queue);
Reactions = new Reactions(Db, Delivery);
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue, Records, new NoPreviews());
Outbox = new OutboxPublisher(Db, Local, Delivery);
Outbox = new OutboxPublisher(Db, Local, Delivery, Relays);
Quotes = new QuoteService(Db, Remote, RemotePosts, Local, Delivery, Outbox);
Approvals = new InteractionApprovals(Db, Remote, Local, Delivery, Outbox);
Participations = new Participations(Db, Local, Delivery);
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance, Ledger);
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
Relationships = new RelationshipService(Db, Follows, Delivery);
Featured = new FeaturedPosts(Db, Remote, RemotePosts);
Walls = new Walls(Db, Local, RemotePosts, Delivery, Fanout);
Handlers = new IActivityHandler[]
{
new FollowHandler(Db, Local, Remote, Delivery),
new AcceptHandler(Db, Local, Quotes),
new RejectHandler(Db, Local, Quotes),
new AcceptHandler(Db, Local, Quotes, Approvals, Participations, Relays, Queue),
new RejectHandler(Db, Local, Quotes, Approvals, Participations, Relays),
new ReplyApprovalHandler(Approvals),
new ReplyRejectionHandler(Approvals),
new UndoHandler(Db, Local, Reactions),
new LikeHandler(Db, Reactions),
new EmojiReactHandler(Db, Reactions),
new QuoteRequestHandler(Quotes),
new DislikeHandler(Db),
new JoinHandler(Db, Local, Delivery),
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote, Records, Quotes),
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls, new NoPreviews(), Quotes),
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote, Records, Quotes, relays: Relays),
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls, new NoPreviews(), Quotes, Approvals, Relays, Walls),
new DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes),
new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes),
new FlagHandler(Db, Local)
new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes, Delivery),
new FlagHandler(Db, Local),
new BlockHandler(Db, Local),
new LockHandler(Db),
new MoveHandler(Remote, Db, Local, Follows, Relationships),
new FeaturedHandler(Featured, add: true, Walls, Remote, Queue),
new FeaturedHandler(Featured, add: false, Walls, Remote, Queue)
};
Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance, Ledger);
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
((AnnounceHandler)Handlers.First(h => h is AnnounceHandler)).Relays = Handlers;
Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance, Ledger, Local);
Content = new ContentRenderer(Local, Remote);
Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }),
Local, default, NullLogger<MediaService>.Instance);
Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout, Media, Groups, Polls, new NoPreviews(), Quotes);
Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout, Media, Groups, Polls, new NoPreviews(), Quotes, Approvals);
Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer<GenericRes>(), NullLogger<PostsService>.Instance);
Timelines = new TimelineService(Db, new KeyLocalizer<GenericRes>());
Relationships = new RelationshipService(Db, Follows, Delivery);
Reports = new ReportService(Db, Local, Delivery);
}
@@ -104,8 +118,15 @@ namespace PrivaPub.Tests.Support
public PollService Polls { get; }
public Reactions Reactions { get; }
public QuoteService Quotes { get; }
public InteractionApprovals Approvals { get; }
public Participations Participations { get; }
public TimelineService Timelines { get; }
public RelationshipService Relationships { get; }
public FeaturedPosts Featured { get; }
public Walls Walls { get; }
public Relays Relays { get; }
// the relays the harness's instance actor subscribes to: a test sets them before Relays.Reconcile
public PrivaPub.Infrastructure.Http.FederationOptions RelayOptions { get; } = new();
public ReportService Reports { get; }
public async Task FollowedBy(LocalActor local, RemoteActor follower) =>
@@ -133,9 +154,12 @@ namespace PrivaPub.Tests.Support
public async Task<InboxResult> Deliver(RemoteActor sender, string path, JsonNode activity)
{
var result = await Receiver.Receive(sender.Post(Host, path, activity), default, CancellationToken.None);
var dedupe = "inbox|" + (activity is JsonObject ? activity["id"]?.GetValue<string>() : default);
var job = await DB.Default.Find<Job>().Match(j => j.DedupeKey == dedupe).ExecuteFirstAsync();
if (job != default)
// the job this delivery queued: under its id, or apart (a forwarded copy; an id its sender reused)
var id = activity is JsonObject ? activity["id"]?.GetValue<string>() : default;
var dedupe = (result.Reason == "forwarded" ? "inbox|forwarded|" : "inbox|") + id;
var job = await DB.Default.Find<Job>().Match(j => (j.DedupeKey == dedupe || j.DedupeKey.StartsWith(dedupe + "|")) && j.State == JobState.Pending)
.Sort(j => j.CreatedAt, Order.Descending).ExecuteFirstAsync();
if (job != default && result.Reason != "duplicate")
Assert.Equal(JobResult.Done, (await Processor.Handle(job, CancellationToken.None)).Result);
return result;
}
+2 -1
View File
@@ -23,7 +23,8 @@ namespace PrivaPub.Tests.Support.Host
public const string ClientHeader = "X-Test-Client";
static readonly SemaphoreSlim Boot = new(1, 1);
static readonly Type[] Unwanted = { typeof(JobWorker), typeof(MediaJanitor), typeof(OAuthPruner), typeof(StatisticsSchedule) };
static readonly Type[] Unwanted = { typeof(JobWorker), typeof(MediaJanitor), typeof(OAuthPruner), typeof(StatisticsSchedule),
typeof(PrivaPub.Domain.Social.FollowResender) };
static PrivaPubHost _shared;
readonly string _mediaRoot = Path.Combine(Path.GetTempPath(), $"privapub-tests-{Guid.NewGuid():N}");
+63 -13
View File
@@ -13,12 +13,22 @@ namespace PrivaPub.Tests.Support
public sealed class RemoteActor
{
readonly RSA _key = RSA.Create(2048);
readonly bool _namesSharedInbox;
readonly bool _hasWall;
readonly string _ed25519;
public RemoteActor(Peer peer, string name, string origin = default, string type = "Person")
// sharedInbox: whether its document names its server's shared inbox (endpoints.sharedInbox), as Mastodon's do; wall:
// whether it has a wall (sm:wall, Smithereen's); ed25519: whether it has an Ed25519 key (FEP-521a) to prove what it
// sends (FEP-8b32)
public RemoteActor(Peer peer, string name, string origin = default, string type = "Person", bool sharedInbox = false, bool wall = false,
bool ed25519 = false)
{
_ed25519 = ed25519 ? PrivaPub.Federation.Signing.IntegrityProofs.NewSeed() : default;
Name = $"{name}{Guid.NewGuid():N}"[..20];
Id = $"{origin ?? peer.A}/users/{Name}";
Type = type;
_namesSharedInbox = sharedInbox;
_hasWall = wall;
peer.Serve($"/users/{Name}", Document().ToJsonString());
}
@@ -28,21 +38,45 @@ namespace PrivaPub.Tests.Support
public string Id { get; }
public string KeyId => Id + "#main-key";
public string SharedInbox => Id.Split("/users/")[0] + "/inbox";
public string Wall => Id + "/wall";
public JsonObject Document() => new()
// the activity with its proof by this actor's Ed25519 key
public JsonObject Prove(JsonObject activity)
{
["id"] = Id,
["type"] = Type,
["preferredUsername"] = Name,
["inbox"] = Id + "/inbox",
["followers"] = Id + "/followers",
["publicKey"] = new JsonObject
activity["proof"] = PrivaPub.Federation.Signing.IntegrityProofs.Create(activity, Id + "#ed25519-key", _ed25519, DateTime.UtcNow);
return activity;
}
public JsonObject Document()
{
var document = new JsonObject
{
["id"] = KeyId,
["owner"] = Id,
["publicKeyPem"] = _key.ExportSubjectPublicKeyInfoPem()
}
};
["id"] = Id,
["type"] = Type,
["preferredUsername"] = Name,
["inbox"] = Id + "/inbox",
["followers"] = Id + "/followers",
["outbox"] = Id + "/outbox",
["featured"] = Id + "/featured",
["publicKey"] = new JsonObject
{
["id"] = KeyId,
["owner"] = Id,
["publicKeyPem"] = _key.ExportSubjectPublicKeyInfoPem()
}
};
if (_namesSharedInbox)
document["endpoints"] = new JsonObject { ["sharedInbox"] = SharedInbox };
if (_hasWall)
document["wall"] = Wall;
if (_ed25519 != default)
document["assertionMethod"] = new JsonArray(new JsonObject
{
["id"] = Id + "#ed25519-key", ["type"] = "Multikey", ["controller"] = Id,
["publicKeyMultibase"] = PrivaPub.Federation.Signing.IntegrityProofs.Multikey(PrivaPub.Federation.Signing.IntegrityProofs.PublicKey(_ed25519))
});
return document;
}
public HttpRequestMessage SignedGet(string path, string host = "privapub.test")
{
@@ -68,6 +102,22 @@ namespace PrivaPub.Tests.Support
return request;
}
// the same delivery signed as RFC 9421 does it (WordPress, Ghost, Fedify), for `signedFor` when it is not where the
// request goes
public HttpRequestMessage MessageSignedPost(string path, JsonNode activity, string signedFor = default, string origin = "https://privapub.test")
{
var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
var request = new HttpRequestMessage(HttpMethod.Post, new Uri(origin + (signedFor ?? path))) { Content = new ByteArrayContent(body) };
request.Content.Headers.TryAddWithoutValidation("Content-Type", "application/activity+json");
MessageSignatures.Sign(request, KeyId, _key.ExportPkcs8PrivateKeyPem(), body);
request.RequestUri = new Uri(origin + path);
return request;
}
public string PrivateKeyPem => _key.ExportPkcs8PrivateKeyPem();
public string PublicKeyPem => _key.ExportSubjectPublicKeyInfoPem();
string Signature(string signingString, string headers)
{
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
@@ -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();
@@ -134,7 +134,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/accounts/search"), Scope("read:accounts")]
public async Task<IActionResult> Search([FromServices] AccountSearch search, CancellationToken token) =>
Json(await search.Find(Params.Get("q"), Params.Bool("resolve") == true && MyId != default, Limit(), token));
Json(Params.Bool("following") == true && MyId != default
? await search.Followed(MyId, Params.Get("q"), Limit(), token)
: await search.Find(Params.Get("q"), Params.Bool("resolve") == true && MyId != default, Limit(), token));
[HttpGet("/api/v1/accounts/{id}"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Get(string id, CancellationToken token)
@@ -152,7 +154,12 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (Params.Bool("pinned") == true)
{
if (local == default)
return Json(Array.Empty<Status>());
{
var featured = (await DB.Default.Find<RemoteFeatured>().Match(f => f.ActorURI == remote.ActorURI).Sort(f => f.Position, Order.Ascending)
.Limit(Federation.Actors.FeaturedPosts.MaxFeatured).ExecuteAsync(token)).Select(f => f.PostId).ToList();
var featuredPosts = await _dbEntities.Posts.Match(p => featured.Contains(p.ID) && !p.DeletedAt.HasValue).Match(VisibilityPolicy.IsPublic).ExecuteAsync(token);
return Json(await _mapper.Statuses(featured.Select(id => featuredPosts.FirstOrDefault(p => p.ID == id)).Where(p => p != default).ToList(), MyId, token));
}
var pinIds = (await DB.Default.Find<Pin>().Match(p => p.AvatarId == local.Id).Sort(p => p.ID, Order.Descending).ExecuteAsync(token)).Select(p => p.PostId).ToList();
var pinnedPosts = await _dbEntities.Posts.Match(p => pinIds.Contains(p.ID)).Match(VisibilityPolicy.IsPublic).ExecuteAsync(token);
return Json(await _mapper.Statuses(pinIds.Select(id => pinnedPosts.FirstOrDefault(p => p.ID == id)).Where(p => p != default).ToList(), MyId, token));
@@ -342,9 +349,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/accounts/{id}/featured_tags"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult FeaturedTags(string id) => Json(Array.Empty<object>());
[HttpGet("/api/v1/accounts/{id}/lists"), Scope("read:lists")]
public IActionResult Lists(string id) => Json(Array.Empty<object>());
[HttpGet("/api/v1/accounts/familiar_followers"), Scope("read:follows")]
public IActionResult FamiliarFollowers() => Json(Params.List("id").Select(id => new { id, accounts = Array.Empty<Account>() }).ToList());
@@ -385,8 +389,11 @@ namespace PrivaPub.Api.Mastodon.Controllers
var followedBy = uri == default ? default : await _dbEntities.Followers.Match(f => f.LocalActorId == MyId && f.ActorURI == uri).ExecuteFirstAsync(token);
var blocking = uri != default && await DB.Default.Find<Block>().Match(b => b.AvatarId == MyId && b.TargetActorURI == uri).ExecuteAnyAsync(token);
var mute = uri == default ? default : await DB.Default.Find<Mute>().Match(m => m.AvatarId == MyId && m.TargetActorURI == uri).ExecuteFirstAsync(token);
var blockedBy = local != default && await DB.Default.Find<Block>().Match(b => b.AvatarId == local.Id && b.TargetActorURI == Me.Uri).ExecuteAnyAsync(token);
var domainBlocked = remote != default && await DB.Default.Find<AccountDomainBlock>().Match(b => b.AvatarId == MyId && b.Domain == remote.Domain).ExecuteAnyAsync(token);
var blockedBy = local != default
? await DB.Default.Find<Block>().Match(b => b.AvatarId == local.Id && b.TargetActorURI == Me.Uri).ExecuteAnyAsync(token)
: remote != default && await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == MyId && b.ActorURI == remote.ActorURI).ExecuteAnyAsync(token);
var domainBlocked = remote != default && (await DB.Default.Find<AccountDomainBlock>().Match(b => b.AvatarId == MyId).ExecuteAsync(token))
.Any(b => Hidden.Blocks(b.Domain, remote.ActorURI, remote.Domain));
return new Relationship
{
Id = id,
@@ -0,0 +1,362 @@
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Bson;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Domain.Social;
using PrivaPub.Models.Social;
using PrivaPub.StaticServices;
namespace PrivaPub.Api.Mastodon.Controllers
{
// Mastodon's filters (owner decision 2026-10-04, back from the cut list): v2 filters with their keywords and statuses,
// and the v1 API, whose filter is one keyword of a v2 filter. Statuses carry what they match in `filtered`; clients
// apply the context and the action. Nothing here leaves the server.
public class FiltersController : MastodonController
{
const int MaxFilters = 100;
const int MaxEntries = 100;
const int MaxKeywordLength = 100;
static readonly Regex Attribute = new(@"^keywords_attributes\[(\d*)\]\[(\w+)\]$", RegexOptions.Compiled);
readonly DbEntities _dbEntities;
public FiltersController(DbEntities dbEntities)
{
_dbEntities = dbEntities;
}
static string NewId() => ObjectId.GenerateNewId().ToString();
Task<PersonaFilter> Mine(string id, CancellationToken token) =>
DB.Default.Find<PersonaFilter>().Match(f => f.ID == id && f.AvatarId == MyId).ExecuteFirstAsync(token);
Task<PersonaFilter> WithKeyword(string keywordId, CancellationToken token) =>
DB.Default.Find<PersonaFilter>().Match(f => f.AvatarId == MyId && f.Keywords.Any(k => k.Id == keywordId)).ExecuteFirstAsync(token);
Task<PersonaFilter> WithStatus(string statusFilterId, CancellationToken token) =>
DB.Default.Find<PersonaFilter>().Match(f => f.AvatarId == MyId && f.Statuses.Any(s => s.Id == statusFilterId)).ExecuteFirstAsync(token);
static JsonResult Invalid(string message) => Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: " + message);
// title, context[], filter_action, expires_in: what a create or an update sets; an error message when one is wrong
string Apply(PersonaFilter filter, bool creating)
{
var title = Params.Get("title")?.Trim();
if (title != default || creating)
{
if (string.IsNullOrEmpty(title) || title.Length > 200)
return "Title can't be blank";
filter.Title = title;
}
if (Params.Has("context") || creating)
{
var context = Params.List("context").Distinct().ToList();
if (context.Count == 0 || context.Any(c => !FilterContexts.IsValid(c)))
return "Context can't be blank";
filter.Context = context;
}
if (Params.Get("filter_action") is { } action)
{
if (!FilterActions.IsValid(action))
return "Filter action is not included in the list";
filter.Action = action;
}
if (Params.Has("expires_in"))
filter.ExpiresAt = Params.Int("expires_in") is > 0 and var seconds ? DateTime.UtcNow.AddSeconds(seconds) : default;
return default;
}
// keywords_attributes, as Rails takes them: a JSON array of objects, or form fields [][keyword], [][whole_word], [][id],
// [][_destroy], or numbered [0][keyword]
List<Dictionary<string, string>> KeywordAttributes()
{
if (Params.Json is { ValueKind: System.Text.Json.JsonValueKind.Object } body
&& body.TryGetProperty("keywords_attributes", out var array) && array.ValueKind == System.Text.Json.JsonValueKind.Array)
return array.EnumerateArray().Where(o => o.ValueKind == System.Text.Json.JsonValueKind.Object)
.Select(o => o.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.ValueKind switch
{
System.Text.Json.JsonValueKind.String => p.Value.GetString(),
System.Text.Json.JsonValueKind.True => "true",
System.Text.Json.JsonValueKind.False => "false",
_ => p.Value.ToString()
}))
.ToList();
var numbered = new SortedDictionary<int, Dictionary<string, string>>();
var listed = new Dictionary<string, IReadOnlyList<string>>();
foreach (var name in Params.Names)
{
var match = Attribute.Match(name);
if (!match.Success)
continue;
if (match.Groups[1].Value.Length == 0)
listed[match.Groups[2].Value] = Params.List(name);
else
{
var index = int.Parse(match.Groups[1].Value);
if (!numbered.TryGetValue(index, out var item))
numbered[index] = item = new Dictionary<string, string>();
item[match.Groups[2].Value] = Params.Get(name);
}
}
var items = numbered.Values.ToList();
var count = listed.Count == 0 ? 0 : listed.Values.Max(v => v.Count);
for (var i = 0; i < count; i++)
items.Add(listed.Where(kv => i < kv.Value.Count).ToDictionary(kv => kv.Key, kv => kv.Value[i]));
return items;
}
static bool IsTrue(string value) => value?.ToLowerInvariant() is "true" or "1" or "on" or "yes";
// adds, changes or removes keywords; an error message when one is wrong
static string ApplyKeywords(PersonaFilter filter, List<Dictionary<string, string>> items)
{
foreach (var item in items)
{
var id = item.GetValueOrDefault("id");
var existing = id == default ? default : filter.Keywords.FirstOrDefault(k => k.Id == id);
if (existing != default && IsTrue(item.GetValueOrDefault("_destroy")))
{
filter.Keywords.Remove(existing);
continue;
}
var keyword = item.GetValueOrDefault("keyword")?.Trim();
if (existing == default && string.IsNullOrEmpty(keyword))
return "Keyword can't be blank";
if (keyword?.Length > MaxKeywordLength)
return "Keyword is too long";
var target = existing ?? new FilterKeyword { Id = NewId() };
if (!string.IsNullOrEmpty(keyword))
target.Keyword = keyword;
if (item.ContainsKey("whole_word"))
target.WholeWord = IsTrue(item["whole_word"]);
else if (existing == default)
target.WholeWord = true;
if (existing == default)
filter.Keywords.Add(target);
}
return filter.Keywords.Count > MaxEntries ? "Too many keywords" : default;
}
// -- v2
[HttpGet("/api/v2/filters"), Scope("read:filters")]
public async Task<IActionResult> All(CancellationToken token) =>
Json((await DB.Default.Find<PersonaFilter>().Match(f => f.AvatarId == MyId).Sort(f => f.ID, Order.Ascending).ExecuteAsync(token))
.Select(Filters.View).ToList());
[HttpGet("/api/v2/filters/{id}"), Scope("read:filters")]
public async Task<IActionResult> One(string id, CancellationToken token) =>
await Mine(id, token) is { } filter ? Json(Filters.View(filter)) : NotFoundError();
[HttpPost("/api/v2/filters"), Scope("write:filters")]
public async Task<IActionResult> Create(CancellationToken token)
{
if (await DB.Default.CountAsync<PersonaFilter>(f => f.AvatarId == MyId, token) >= MaxFilters)
return Invalid("Too many filters");
var filter = new PersonaFilter { AvatarId = MyId };
if ((Apply(filter, creating: true) ?? ApplyKeywords(filter, KeywordAttributes())) is { } problem)
return Invalid(problem);
await DB.Default.SaveAsync(filter, token);
return Json(Filters.View(filter));
}
[HttpPut("/api/v2/filters/{id}"), Scope("write:filters")]
public async Task<IActionResult> Update(string id, CancellationToken token)
{
if (await Mine(id, token) is not { } filter)
return NotFoundError();
if ((Apply(filter, creating: false) ?? ApplyKeywords(filter, KeywordAttributes())) is { } problem)
return Invalid(problem);
await DB.Default.SaveAsync(filter, token);
return Json(Filters.View(filter));
}
[HttpDelete("/api/v2/filters/{id}"), Scope("write:filters")]
public async Task<IActionResult> Delete(string id, CancellationToken token)
{
if (await Mine(id, token) is not { } filter)
return NotFoundError();
await DB.Default.DeleteAsync<PersonaFilter>(filter.ID);
return Json(new { });
}
[HttpGet("/api/v2/filters/{id}/keywords"), Scope("read:filters")]
public async Task<IActionResult> Keywords(string id, CancellationToken token) =>
await Mine(id, token) is { } filter ? Json(filter.Keywords.Select(Filters.Keyword).ToList()) : NotFoundError();
[HttpPost("/api/v2/filters/{id}/keywords"), Scope("write:filters")]
public async Task<IActionResult> AddKeyword(string id, CancellationToken token)
{
if (await Mine(id, token) is not { } filter)
return NotFoundError();
var item = new Dictionary<string, string> { ["keyword"] = Params.Get("keyword") };
if (Params.Has("whole_word"))
item["whole_word"] = Params.Get("whole_word");
if (ApplyKeywords(filter, new List<Dictionary<string, string>> { item }) is { } problem)
return Invalid(problem);
await DB.Default.SaveAsync(filter, token);
return Json(Filters.Keyword(filter.Keywords[^1]));
}
[HttpGet("/api/v2/filters/keywords/{id}"), Scope("read:filters")]
public async Task<IActionResult> Keyword(string id, CancellationToken token) =>
await WithKeyword(id, token) is { } filter ? Json(Filters.Keyword(filter.Keywords.First(k => k.Id == id))) : NotFoundError();
[HttpPut("/api/v2/filters/keywords/{id}"), Scope("write:filters")]
public async Task<IActionResult> UpdateKeyword(string id, CancellationToken token)
{
if (await WithKeyword(id, token) is not { } filter)
return NotFoundError();
var item = new Dictionary<string, string> { ["id"] = id, ["keyword"] = Params.Get("keyword") };
if (Params.Has("whole_word"))
item["whole_word"] = Params.Get("whole_word");
if (ApplyKeywords(filter, new List<Dictionary<string, string>> { item }) is { } problem)
return Invalid(problem);
await DB.Default.SaveAsync(filter, token);
return Json(Filters.Keyword(filter.Keywords.First(k => k.Id == id)));
}
[HttpDelete("/api/v2/filters/keywords/{id}"), Scope("write:filters")]
public async Task<IActionResult> DeleteKeyword(string id, CancellationToken token)
{
if (await WithKeyword(id, token) is not { } filter)
return NotFoundError();
filter.Keywords.RemoveAll(k => k.Id == id);
await DB.Default.SaveAsync(filter, token);
return Json(new { });
}
[HttpGet("/api/v2/filters/{id}/statuses"), Scope("read:filters")]
public async Task<IActionResult> Statuses(string id, CancellationToken token) =>
await Mine(id, token) is { } filter ? Json(filter.Statuses.Select(Filters.Status).ToList()) : NotFoundError();
// a status the persona can see, once per filter
[HttpPost("/api/v2/filters/{id}/statuses"), Scope("write:filters")]
public async Task<IActionResult> AddStatus(string id, CancellationToken token)
{
if (await Mine(id, token) is not { } filter)
return NotFoundError();
var statusId = Params.Get("status_id");
var post = string.IsNullOrEmpty(statusId) ? default : await _dbEntities.Posts.Match(p => p.ID == statusId).ExecuteFirstAsync(token);
if (post == default || !await Domain.Privacy.VisibilityPolicy.CanSee(post, MyId, token))
return NotFoundError();
if (filter.Statuses.FirstOrDefault(s => s.PostId == statusId) is { } present)
return Json(Filters.Status(present));
if (filter.Statuses.Count >= MaxEntries)
return Invalid("Too many statuses");
var added = new FilterStatus { Id = NewId(), PostId = statusId };
filter.Statuses.Add(added);
await DB.Default.SaveAsync(filter, token);
return Json(Filters.Status(added));
}
[HttpGet("/api/v2/filters/statuses/{id}"), Scope("read:filters")]
public async Task<IActionResult> Status(string id, CancellationToken token) =>
await WithStatus(id, token) is { } filter ? Json(Filters.Status(filter.Statuses.First(s => s.Id == id))) : NotFoundError();
[HttpDelete("/api/v2/filters/statuses/{id}"), Scope("write:filters")]
public async Task<IActionResult> DeleteStatus(string id, CancellationToken token)
{
if (await WithStatus(id, token) is not { } filter)
return NotFoundError();
filter.Statuses.RemoveAll(s => s.Id == id);
await DB.Default.SaveAsync(filter, token);
return Json(new { });
}
// -- v1: each keyword is a filter of its own, with its parent's context, action and expiry
static FilterV1Entity V1(PersonaFilter filter, FilterKeyword keyword) => new()
{
Id = keyword.Id,
Phrase = keyword.Keyword,
Context = filter.Context,
WholeWord = keyword.WholeWord,
ExpiresAt = filter.ExpiresAt.HasValue ? MastodonJson.Time(filter.ExpiresAt.Value) : default,
Irreversible = filter.Action == FilterActions.Hide
};
[HttpGet("/api/v1/filters"), Scope("read:filters")]
public async Task<IActionResult> AllV1(CancellationToken token) =>
Json((await DB.Default.Find<PersonaFilter>().Match(f => f.AvatarId == MyId).Sort(f => f.ID, Order.Ascending).ExecuteAsync(token))
.SelectMany(f => f.Keywords.Select(k => V1(f, k))).ToList());
[HttpGet("/api/v1/filters/{id}"), Scope("read:filters")]
public async Task<IActionResult> OneV1(string id, CancellationToken token) =>
await WithKeyword(id, token) is { } filter ? Json(V1(filter, filter.Keywords.First(k => k.Id == id))) : NotFoundError();
// phrase, context[], irreversible, whole_word, expires_in
string ApplyV1(PersonaFilter filter, FilterKeyword keyword, bool creating)
{
var phrase = Params.Get("phrase")?.Trim();
if (phrase != default || creating)
{
if (string.IsNullOrEmpty(phrase))
return "Phrase can't be blank";
if (phrase.Length > MaxKeywordLength)
return "Phrase is too long";
keyword.Keyword = phrase;
if (creating)
filter.Title = phrase;
}
if (Params.Has("context") || creating)
{
var context = Params.List("context").Distinct().ToList();
if (context.Count == 0 || context.Any(c => !FilterContexts.IsValid(c)))
return "Context can't be blank";
filter.Context = context;
}
if (Params.Bool("whole_word") is { } wholeWord)
keyword.WholeWord = wholeWord;
if (Params.Bool("irreversible") is { } irreversible)
filter.Action = irreversible ? FilterActions.Hide : FilterActions.Warn;
if (Params.Has("expires_in"))
filter.ExpiresAt = Params.Int("expires_in") is > 0 and var seconds ? DateTime.UtcNow.AddSeconds(seconds) : default;
return default;
}
[HttpPost("/api/v1/filters"), Scope("write:filters")]
public async Task<IActionResult> CreateV1(CancellationToken token)
{
if (await DB.Default.CountAsync<PersonaFilter>(f => f.AvatarId == MyId, token) >= MaxFilters)
return Invalid("Too many filters");
var keyword = new FilterKeyword { Id = NewId(), WholeWord = true };
var filter = new PersonaFilter { AvatarId = MyId, Keywords = new List<FilterKeyword> { keyword } };
if (ApplyV1(filter, keyword, creating: true) is { } problem)
return Invalid(problem);
await DB.Default.SaveAsync(filter, token);
return Json(V1(filter, keyword));
}
[HttpPut("/api/v1/filters/{id}"), Scope("write:filters")]
public async Task<IActionResult> UpdateV1(string id, CancellationToken token)
{
if (await WithKeyword(id, token) is not { } filter)
return NotFoundError();
var keyword = filter.Keywords.First(k => k.Id == id);
if (ApplyV1(filter, keyword, creating: false) is { } problem)
return Invalid(problem);
await DB.Default.SaveAsync(filter, token);
return Json(V1(filter, keyword));
}
// the keyword goes, and its filter with it when nothing else is left in it
[HttpDelete("/api/v1/filters/{id}"), Scope("write:filters")]
public async Task<IActionResult> DeleteV1(string id, CancellationToken token)
{
if (await WithKeyword(id, token) is not { } filter)
return NotFoundError();
filter.Keywords.RemoveAll(k => k.Id == id);
if (filter.Keywords.Count == 0 && filter.Statuses.Count == 0)
await DB.Default.DeleteAsync<PersonaFilter>(filter.ID);
else
await DB.Default.SaveAsync(filter, token);
return Json(new { });
}
}
}
@@ -40,6 +40,10 @@ namespace PrivaPub.Api.Mastodon.Controllers
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 };
@@ -82,7 +86,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
description = Description,
email = string.Empty,
version = Version,
urls = new { },//no streaming API yet, so none is advertised
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),
@@ -115,7 +119,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
languages = LanguageCodes(HttpContext.RequestServices),
configuration = new
{
urls = new { },//no streaming API yet, so none is advertised
urls = new { streaming = Streaming },
accounts = new { max_featured_tags = 0, max_pinned_statuses = StatusesController.MaxPins },
statuses = Statuses,
media_attachments = MediaAttachments,
@@ -0,0 +1,189 @@
using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Privacy;
using PrivaPub.Models.Social;
using PrivaPub.StaticServices;
namespace PrivaPub.Api.Mastodon.Controllers
{
// Mastodon's lists (owner decision 2026-10-04, back from the cut list): a persona groups accounts it follows, reads them
// as a timeline, and may keep an exclusive list's members out of its home. Nothing here leaves the server.
public class ListsController : MastodonController
{
const int MaxLists = 50;
const int MaxMembers = 500;
readonly DbEntities _dbEntities;
readonly MastodonMapper _mapper;
public ListsController(DbEntities dbEntities, MastodonMapper mapper)
{
_dbEntities = dbEntities;
_mapper = mapper;
}
static object View(PersonaList list) => new { id = list.ID, title = list.Title, replies_policy = list.RepliesPolicy, exclusive = list.Exclusive };
Task<PersonaList> Mine(string id, CancellationToken token) =>
DB.Default.Find<PersonaList>().Match(l => l.ID == id && l.AvatarId == MyId).ExecuteFirstAsync(token);
// a list holds only accounts the persona still follows: whatever ended the follow (an unfollow, a Reject, a Block, the
// account's deletion), its membership goes with it, pruned here on the next read
async Task<List<string>> Members(PersonaList list, CancellationToken token)
{
var members = (await DB.Default.Find<PersonaListMember>().Match(m => m.ListId == list.ID).ExecuteAsync(token)).Select(m => m.AccountId).ToList();
if (members.Count == 0)
return members;
var followed = (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.State == FollowState.Accepted && members.Contains(f.TargetAccountId))
.ExecuteAsync(token)).Select(f => f.TargetAccountId).ToHashSet();
var gone = members.Where(m => !followed.Contains(m)).ToList();
if (gone.Count > 0)
await DB.Default.DeleteAsync<PersonaListMember>(m => m.ListId == list.ID && gone.Contains(m.AccountId));
return members.Where(followed.Contains).ToList();
}
[HttpGet("/api/v1/lists"), Scope("read:lists")]
public async Task<IActionResult> All(CancellationToken token) =>
Json((await DB.Default.Find<PersonaList>().Match(l => l.AvatarId == MyId).Sort(l => l.Title, MongoDB.Entities.Order.Ascending)
.ExecuteAsync(token)).Select(View).ToList());
[HttpGet("/api/v1/lists/{id}"), Scope("read:lists")]
public async Task<IActionResult> One(string id, CancellationToken token) =>
await Mine(id, token) is { } list ? Json(View(list)) : NotFoundError();
[HttpPost("/api/v1/lists"), Scope("write:lists")]
public async Task<IActionResult> Create(CancellationToken token)
{
var title = Params.Get("title")?.Trim();
var policy = Params.Get("replies_policy") ?? ListRepliesPolicy.List;
if (string.IsNullOrEmpty(title) || title.Length > 200)
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Title can't be blank");
if (!ListRepliesPolicy.IsValid(policy))
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Replies policy is not included in the list");
if (await DB.Default.CountAsync<PersonaList>(l => l.AvatarId == MyId, token) >= MaxLists)
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Too many lists");
var list = new PersonaList { AvatarId = MyId, Title = title, RepliesPolicy = policy, Exclusive = Params.Bool("exclusive") == true };
await DB.Default.SaveAsync(list, token);
return Json(View(list));
}
[HttpPut("/api/v1/lists/{id}"), Scope("write:lists")]
public async Task<IActionResult> Update(string id, CancellationToken token)
{
if (await Mine(id, token) is not { } list)
return NotFoundError();
var title = Params.Get("title")?.Trim();
var policy = Params.Get("replies_policy");
if (title != default)
{
if (title.Length == 0 || title.Length > 200)
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Title can't be blank");
list.Title = title;
}
if (policy != default)
{
if (!ListRepliesPolicy.IsValid(policy))
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Replies policy is not included in the list");
list.RepliesPolicy = policy;
}
if (Params.Bool("exclusive") is { } exclusive)
list.Exclusive = exclusive;
await DB.Default.SaveAsync(list, token);
return Json(View(list));
}
[HttpDelete("/api/v1/lists/{id}"), Scope("write:lists")]
public async Task<IActionResult> Delete(string id, CancellationToken token)
{
if (await Mine(id, token) is not { } list)
return NotFoundError();
await DB.Default.DeleteAsync<PersonaListMember>(m => m.ListId == list.ID);
await DB.Default.DeleteAsync<PersonaList>(list.ID);
return Json(new { });
}
[HttpGet("/api/v1/lists/{id}/accounts"), Scope("read:lists")]
public async Task<IActionResult> Accounts(string id, CancellationToken token)
{
if (await Mine(id, token) is not { } list)
return NotFoundError();
await Members(list, token);
var members = await Page.From(Params, Limit(40, 80)).Fetch(DB.Default.Find<PersonaListMember>().Match(m => m.ListId == list.ID), m => m.ID, token);
var accounts = await _mapper.Accounts(members.Select(m => m.AccountId), token);
Link($"/api/v1/lists/{id}/accounts", members.LastOrDefault()?.ID, members.FirstOrDefault()?.ID);
return Json(members.Where(m => accounts.ContainsKey(m.AccountId)).Select(m => accounts[m.AccountId]).ToList());
}
// only accounts the persona follows may join its list, as on Mastodon
[HttpPost("/api/v1/lists/{id}/accounts"), Scope("write:lists")]
public async Task<IActionResult> Add(string id, CancellationToken token)
{
if (await Mine(id, token) is not { } list)
return NotFoundError();
var ids = Params.List("account_ids").Distinct().ToList();
if (ids.Count == 0)
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Account ids can't be blank");
var followed = (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.State == FollowState.Accepted && ids.Contains(f.TargetAccountId))
.ExecuteAsync(token)).Select(f => f.TargetAccountId).ToHashSet();
if (ids.Any(a => !followed.Contains(a)))
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: You must follow this account to add it to a list");
var present = (await DB.Default.Find<PersonaListMember>().Match(m => m.ListId == list.ID && ids.Contains(m.AccountId)).ExecuteAsync(token))
.Select(m => m.AccountId).ToHashSet();
if (await DB.Default.CountAsync<PersonaListMember>(m => m.ListId == list.ID, token) + ids.Count(a => !present.Contains(a)) > MaxMembers)
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Too many accounts in the list");
foreach (var account in ids.Where(a => !present.Contains(a)))
await DB.Default.SaveAsync(new PersonaListMember { ListId = list.ID, AvatarId = MyId, AccountId = account }, token);
return Json(new { });
}
[HttpDelete("/api/v1/lists/{id}/accounts"), Scope("write:lists")]
public async Task<IActionResult> Remove(string id, CancellationToken token)
{
if (await Mine(id, token) is not { } list)
return NotFoundError();
var ids = Params.List("account_ids");
await DB.Default.DeleteAsync<PersonaListMember>(m => m.ListId == list.ID && ids.Contains(m.AccountId));
return Json(new { });
}
[HttpGet("/api/v1/accounts/{id}/lists"), Scope("read:lists")]
public async Task<IActionResult> Containing(string id, CancellationToken token)
{
var listIds = (await DB.Default.Find<PersonaListMember>().Match(m => m.AvatarId == MyId && m.AccountId == id).ExecuteAsync(token))
.Select(m => m.ListId).ToList();
return Json((await DB.Default.Find<PersonaList>().Match(l => l.AvatarId == MyId && listIds.Contains(l.ID)).ExecuteAsync(token))
.Select(View).ToList());
}
// the list's timeline: the persona's home entries by its members. Replies are kept as Mastodon keeps them: a member's
// replies to itself and to the persona always, to others as the policy says (anyone followed, the list's members, no one)
[HttpGet("/api/v1/timelines/list/{id}"), Scope("read:lists")]
public async Task<IActionResult> Timeline(string id, CancellationToken token)
{
if (await Mine(id, token) is not { } list)
return NotFoundError();
var members = await Members(list, token);
var entries = await Page.From(Params, Limit()).Fetch(
_dbEntities.TimelineEntries.Match(e => e.AvatarId == MyId && members.Contains(e.AuthorAccountId)), e => e.PostId, token);
var ids = entries.Select(e => e.PostId).ToList();
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token)).ToDictionary(p => p.ID);
var answered = list.RepliesPolicy switch
{
ListRepliesPolicy.Followed => (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.State == FollowState.Accepted).ExecuteAsync(token))
.Select(f => f.TargetAccountId).Append(MyId).ToHashSet(),
ListRepliesPolicy.List => members.Append(MyId).ToHashSet(),
_ => new HashSet<string> { MyId }
};
var shown = ids.Where(posts.ContainsKey).Select(i => posts[i])
.Where(p => p.ReblogOfPostId != default || string.IsNullOrEmpty(p.AnsweringToPostId) && string.IsNullOrEmpty(p.InReplyToURI)
|| p.InReplyToAccountId != default && (p.InReplyToAccountId == p.AuthorAccountId || answered.Contains(p.InReplyToAccountId)))
.ToList();
Link($"/api/v1/timelines/list/{id}", entries.LastOrDefault()?.PostId, entries.FirstOrDefault()?.PostId);
return Json(await _mapper.Statuses(shown, MyId, token));
}
}
}
@@ -101,12 +101,14 @@ namespace PrivaPub.Api.Mastodon.Controllers
return new EmptyResult();
}
object View(MediaAttachment attachment) => new
object View(MediaAttachment attachment) => View(_media, attachment);
internal static object View(IMediaService media, MediaAttachment attachment) => new
{
id = attachment.ID,
type = attachment.Kind,
url = _media.Url(attachment.FilePath),
preview_url = _media.Url(attachment.PreviewPath ?? attachment.FilePath),
url = media.Url(attachment.FilePath),
preview_url = media.Url(attachment.PreviewPath ?? attachment.FilePath),
remote_url = default(string),
text_url = default(string),
meta = new
@@ -0,0 +1,178 @@
using Microsoft.AspNetCore.Mvc;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Social;
using PrivaPub.Models.Social;
using PrivaPub.StaticServices;
namespace PrivaPub.Api.Mastodon.Controllers
{
// Mastodon's notification policy and requests (4.3): what the persona's notifications from strangers, new accounts,
// unasked private mentions and silenced accounts become, and the requests that gather the filtered ones, one per
// account, let in or dismissed
public class NotificationRequestsController : MastodonController
{
readonly MastodonMapper _mapper;
readonly DbEntities _dbEntities;
public NotificationRequestsController(MastodonMapper mapper, DbEntities dbEntities)
{
_mapper = mapper;
_dbEntities = dbEntities;
}
static string Name(NotificationAction action) => action switch
{
NotificationAction.Filter => "filter",
NotificationAction.Drop => "drop",
_ => "accept"
};
static NotificationAction? Parse(string value) => value?.ToLowerInvariant() switch
{
"accept" => NotificationAction.Accept,
"filter" => NotificationAction.Filter,
"drop" => NotificationAction.Drop,
_ => null
};
async Task<object> Summary(CancellationToken token) => new
{
pending_requests_count = await DB.Default.CountAsync<NotificationRequest>(r => r.AvatarId == MyId, token),
pending_notifications_count = await DB.Default.CountAsync<Notification>(n => n.AvatarId == MyId && n.Filtered, token)
};
async Task<object> V2(NotificationPolicy policy, CancellationToken token) => new
{
for_not_following = Name(policy.ForNotFollowing),
for_not_followers = Name(policy.ForNotFollowers),
for_new_accounts = Name(policy.ForNewAccounts),
for_private_mentions = Name(policy.ForPrivateMentions),
for_limited_accounts = Name(policy.ForLimitedAccounts),
summary = await Summary(token)
};
// the first version spoke of filtering only: dropped is filtered for it
async Task<object> V1(NotificationPolicy policy, CancellationToken token) => new
{
filter_not_following = policy.ForNotFollowing != NotificationAction.Accept,
filter_not_followers = policy.ForNotFollowers != NotificationAction.Accept,
filter_new_accounts = policy.ForNewAccounts != NotificationAction.Accept,
filter_private_mentions = policy.ForPrivateMentions != NotificationAction.Accept,
summary = await Summary(token)
};
[HttpGet("/api/v2/notifications/policy"), Scope("read:notifications")]
public async Task<IActionResult> Policy(CancellationToken token) => Json(await V2(await NotificationPolicies.Of(MyId, token), token));
[HttpPatch("/api/v2/notifications/policy"), Scope("write:notifications")]
public async Task<IActionResult> SetPolicy(CancellationToken token)
{
var policy = await NotificationPolicies.Of(MyId, token);
policy.ForNotFollowing = Parse(Params.Get("for_not_following")) ?? policy.ForNotFollowing;
policy.ForNotFollowers = Parse(Params.Get("for_not_followers")) ?? policy.ForNotFollowers;
policy.ForNewAccounts = Parse(Params.Get("for_new_accounts")) ?? policy.ForNewAccounts;
policy.ForPrivateMentions = Parse(Params.Get("for_private_mentions")) ?? policy.ForPrivateMentions;
policy.ForLimitedAccounts = Parse(Params.Get("for_limited_accounts")) ?? policy.ForLimitedAccounts;
await NotificationPolicies.Save(policy, token);
return Json(await V2(policy, token));
}
[HttpGet("/api/v1/notifications/policy"), Scope("read:notifications")]
public async Task<IActionResult> PolicyV1(CancellationToken token) => Json(await V1(await NotificationPolicies.Of(MyId, token), token));
[HttpPut("/api/v1/notifications/policy"), HttpPatch("/api/v1/notifications/policy"), Scope("write:notifications")]
public async Task<IActionResult> SetPolicyV1(CancellationToken token)
{
var policy = await NotificationPolicies.Of(MyId, token);
static NotificationAction Filter(bool? filter, NotificationAction current) =>
filter switch { true => current == NotificationAction.Drop ? NotificationAction.Drop : NotificationAction.Filter, false => NotificationAction.Accept, _ => current };
policy.ForNotFollowing = Filter(Params.Bool("filter_not_following"), policy.ForNotFollowing);
policy.ForNotFollowers = Filter(Params.Bool("filter_not_followers"), policy.ForNotFollowers);
policy.ForNewAccounts = Filter(Params.Bool("filter_new_accounts"), policy.ForNewAccounts);
policy.ForPrivateMentions = Filter(Params.Bool("filter_private_mentions"), policy.ForPrivateMentions);
await NotificationPolicies.Save(policy, token);
return Json(await V1(policy, token));
}
async Task<object> Map(NotificationRequest request, CancellationToken token)
{
var account = await _mapper.Account(request.FromAccountId, token);
if (account == default)
return default;
var post = string.IsNullOrEmpty(request.LastPostId) ? default
: await _dbEntities.Posts.Match(p => p.ID == request.LastPostId && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
return new
{
id = request.ID,
created_at = MastodonJson.Time(request.CreatedAt),
updated_at = MastodonJson.Time(request.UpdatedAt),
notifications_count = request.NotificationsCount.ToString(System.Globalization.CultureInfo.InvariantCulture),
account,
last_status = post == default ? default : await _mapper.Status(post, MyId, token)
};
}
[HttpGet("/api/v1/notifications/requests"), Scope("read:notifications")]
public async Task<IActionResult> Requests(CancellationToken token)
{
var requests = await Page.From(Params, Limit(40, 80)).Fetch(DB.Default.Find<NotificationRequest>().Match(r => r.AvatarId == MyId), r => r.ID, token);
Link("/api/v1/notifications/requests", requests.LastOrDefault()?.ID, requests.FirstOrDefault()?.ID);
var mapped = new List<object>();
foreach (var request in requests)
if (await Map(request, token) is { } one)
mapped.Add(one);
return Json(mapped);
}
Task<NotificationRequest> Mine(string id, CancellationToken token) =>
DB.Default.Find<NotificationRequest>().Match(r => r.ID == id && r.AvatarId == MyId).ExecuteFirstAsync(token);
[HttpGet("/api/v1/notifications/requests/merged"), Scope("read:notifications")]
public IActionResult Merged() => Json(new { merged = true });
[HttpGet("/api/v1/notifications/requests/{id}"), Scope("read:notifications")]
public async Task<IActionResult> Request(string id, CancellationToken token) =>
await Mine(id, token) is { } request && await Map(request, token) is { } mapped ? Json(mapped) : NotFoundError();
[HttpPost("/api/v1/notifications/requests/{id}/accept"), Scope("write:notifications")]
public async Task<IActionResult> Accept(string id, CancellationToken token)
{
if (await Mine(id, token) is not { } request)
return NotFoundError();
await NotificationPolicies.Accept(request, token);
return Json(new { });
}
[HttpPost("/api/v1/notifications/requests/{id}/dismiss"), Scope("write:notifications")]
public async Task<IActionResult> Dismiss(string id, CancellationToken token)
{
if (await Mine(id, token) is not { } request)
return NotFoundError();
await NotificationPolicies.Dismiss(request, token);
return Json(new { });
}
[HttpPost("/api/v1/notifications/requests/accept"), Scope("write:notifications")]
public async Task<IActionResult> AcceptMany(CancellationToken token)
{
foreach (var id in Params.List("id").Take(100))
if (await Mine(id, token) is { } request)
await NotificationPolicies.Accept(request, token);
return Json(new { });
}
[HttpPost("/api/v1/notifications/requests/dismiss"), Scope("write:notifications")]
public async Task<IActionResult> DismissMany(CancellationToken token)
{
foreach (var id in Params.List("id").Take(100))
if (await Mine(id, token) is { } request)
await NotificationPolicies.Dismiss(request, token);
return Json(new { });
}
}
}
@@ -0,0 +1,103 @@
using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Domain.Media;
using PrivaPub.Domain.Statuses;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Media;
using PrivaPub.Models.Social;
namespace PrivaPub.Api.Mastodon.Controllers
{
// Mastodon's scheduled statuses (owner decision 2026-10-04, back from the cut list): the persona's posts waiting for
// their time, moved to another time or dropped. POST /api/v1/statuses with scheduled_at makes them.
public class ScheduledStatusesController : MastodonController
{
readonly IMediaService _media;
readonly IJobQueue _jobs;
public ScheduledStatusesController(IMediaService media, IJobQueue jobs)
{
_media = media;
_jobs = jobs;
}
public static async Task<object> View(ScheduledStatus scheduled, IMediaService media, CancellationToken token)
{
var p = scheduled.Params;
var ids = p.MediaIds;
var attachments = ids.Count == 0
? new List<MediaAttachment>()
: await DB.Default.Find<MediaAttachment>().Match(m => ids.Contains(m.ID)).ExecuteAsync(token);
return new
{
id = scheduled.ID,
scheduled_at = MastodonJson.Time(scheduled.ScheduledAt),
@params = new
{
text = p.Text,
poll = p.Poll == default ? default : new { options = p.Poll.Options, expires_in = p.Poll.ExpiresIn, multiple = p.Poll.Multiple, hide_totals = p.Poll.HideTotals },
media_ids = ids.Count == 0 ? default : ids,
sensitive = p.Sensitive,
spoiler_text = p.SpoilerText,
visibility = p.Visibility,
in_reply_to_id = p.InReplyToId,
language = p.Language,
application_id = default(string),
scheduled_at = default(string),
idempotency = p.Idempotency,
with_rate_limit = false,
quoted_status_id = p.QuotedStatusId
},
media_attachments = ids.Select(id => attachments.FirstOrDefault(m => m.ID == id)).Where(m => m != default)
.Select(m => MediaController.View(media, m)).ToList()
};
}
Task<ScheduledStatus> Mine(string id, CancellationToken token) =>
DB.Default.Find<ScheduledStatus>().Match(s => s.ID == id && s.AvatarId == MyId).ExecuteFirstAsync(token);
[HttpGet("/api/v1/scheduled_statuses"), Scope("read:statuses")]
public async Task<IActionResult> All(CancellationToken token)
{
var page = await Page.From(Params, Limit(20, 40)).Fetch(DB.Default.Find<ScheduledStatus>().Match(s => s.AvatarId == MyId), s => s.ID, token);
Link("/api/v1/scheduled_statuses", page.LastOrDefault()?.ID, page.FirstOrDefault()?.ID);
var views = new List<object>();
foreach (var scheduled in page)
views.Add(await View(scheduled, _media, token));
return Json(views);
}
[HttpGet("/api/v1/scheduled_statuses/{id}"), Scope("read:statuses")]
public async Task<IActionResult> One(string id, CancellationToken token) =>
await Mine(id, token) is { } scheduled ? Json(await View(scheduled, _media, token)) : NotFoundError();
[HttpPut("/api/v1/scheduled_statuses/{id}"), Scope("write:statuses")]
public async Task<IActionResult> Move(string id, CancellationToken token)
{
if (await Mine(id, token) is not { } scheduled)
return NotFoundError();
if (!DateTime.TryParse(Params.Get("scheduled_at"), System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var at))
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Scheduled at is invalid");
if (await ScheduledStatuses.Refusal(MyId, at, scheduled.ID, token) is { } refusal)
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: " + refusal);
scheduled.ScheduledAt = at;
await DB.Default.Update<ScheduledStatus>().MatchID(scheduled.ID).Modify(s => s.ScheduledAt, at).ExecuteAsync(token);
await _jobs.EnqueueMany(new[] { ScheduledStatuses.JobFor(scheduled, new Uri(Me.BaseAddress).Host) }, token);
return Json(await View(scheduled, _media, token));
}
[HttpDelete("/api/v1/scheduled_statuses/{id}"), Scope("write:statuses")]
public async Task<IActionResult> Drop(string id, CancellationToken token)
{
if (await Mine(id, token) is not { } scheduled)
return NotFoundError();
await DB.Default.DeleteAsync<ScheduledStatus>(scheduled.ID);
await ScheduledStatuses.Release(scheduled.ID, token);
return Json(new { });
}
}
}
@@ -15,7 +15,7 @@ using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Api.Mastodon.Controllers
{
public class SearchController : MastodonController
public partial class SearchController : MastodonController
{
readonly MastodonMapper _mapper;
readonly DbEntities _dbEntities;
@@ -55,7 +55,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (post == default && resolve && type is null or "statuses")
post = await _remotePosts.StoreContext(q, 0, token);
if (post != default && type is null or "statuses" && await VisibilityPolicy.CanSee(post, MyId, token))
results.Statuses.Add(await _mapper.Status(post, MyId, token));
if (await _mapper.Status(post, MyId, token) is { } status)
results.Statuses.Add(status);
if (results.Statuses.Count == 0 && type is null or "accounts")
{
var local = await _localActors.FindByUri(q, token);
@@ -72,59 +73,76 @@ namespace PrivaPub.Api.Mastodon.Controllers
{
results.Accounts = await _search.Find(q, resolve, Limit(), Math.Max(0, offset), token);
}
if (type is null or "hashtags" && q.TrimStart('#') is { Length: > 0 } tag)
results.Hashtags.Add(new { name = tag.ToLowerInvariant(), url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag.ToLowerInvariant())}", history = Array.Empty<object>() });
// an account's handle asks for the account, never for posts that happen to share its words
if (type is null or "statuses" && MyId != default && !q.StartsWith('#') && !Handle().IsMatch(q))
results.Statuses = await Words(q, Math.Max(0, offset), Limit(), token);
if (type is null or "hashtags" && TagsController.Normalise(q) is var tag && TagsController.IsHashtag(tag))
results.Hashtags.Add(new { name = tag, url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag)}", history = Array.Empty<object>() });
return Json(results);
}
}
public partial class SearchController
{
const int WordCandidates = 400;
[System.Text.RegularExpressions.GeneratedRegex(@"^@?[^\s@]+@[^\s@]+$|^@[^\s@]+$")]
private static partial System.Text.RegularExpressions.Regex Handle();
// Posts by their words, as Mastodon searches them: those the persona wrote, boosted, favourited, bookmarked or was
// named in, and public posts of authors who let themselves be indexed; newest first, what the persona may see only
async Task<List<Status>> Words(string q, int offset, int limit, CancellationToken token)
{
var candidates = await _dbEntities.Posts.Match(f => f.Text(q)).Match(p => !p.DeletedAt.HasValue && p.ReblogOfPostId == null && !p.AuthorGone)
.Sort(p => p.ID, MongoDB.Entities.Order.Descending).Limit(WordCandidates).ExecuteAsync(token);
if (candidates.Count == 0)
return new List<Status>();
var ids = candidates.Select(p => p.ID).ToList();
var mine = new HashSet<string>();
mine.UnionWith((await _dbEntities.Favourites.Match(f => f.AccountId == MyId && ids.Contains(f.PostId)).ExecuteAsync(token)).Select(f => f.PostId));
mine.UnionWith((await MongoDB.Entities.DB.Default.Find<Models.Social.Bookmark>().Match(b => b.AvatarId == MyId && ids.Contains(b.PostId)).ExecuteAsync(token))
.Select(b => b.PostId));
mine.UnionWith((await _dbEntities.Posts.Match(p => p.AuthorAccountId == MyId && ids.Contains(p.ReblogOfPostId) && !p.DeletedAt.HasValue).ExecuteAsync(token))
.Select(p => p.ReblogOfPostId));
var authors = candidates.Select(p => p.AuthorAccountId ?? p.GroupUserId).Where(a => a != default).Distinct().ToList();
var indexable = (await _dbEntities.Avatars.Match(a => authors.Contains(a.ID) && a.Settings.IsIndexable).ExecuteAsync(token)).Select(a => a.ID)
.Concat((await _dbEntities.ForeignAvatars.Match(f => authors.Contains(f.ID) && f.IsIndexable).ExecuteAsync(token)).Select(f => f.ID))
.ToHashSet();
var found = new List<PostEntity>();
foreach (var post in candidates)
{
var author = post.AuthorAccountId ?? post.GroupUserId;
var reachable = author == MyId && !post.IsFederatedCopy || mine.Contains(post.ID) || post.Mentions.Any(m => m.AccountId == MyId)
|| post.Visibility == PostVisibility.Public && indexable.Contains(author);
if (reachable && await VisibilityPolicy.CanSee(post, MyId, token))
found.Add(post);
if (found.Count >= offset + limit)
break;
}
return await _mapper.Statuses(found.Skip(offset).Take(limit).ToList(), MyId, token);
}
}
public class StubsController : MastodonController
{
[HttpGet("/api/v1/custom_emojis"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult CustomEmojis() => Json(Array.Empty<object>());
[HttpGet("/api/v1/filters"), Scope("read:filters")]
public IActionResult FiltersV1() => Json(Array.Empty<object>());
[HttpGet("/api/v2/filters"), Scope("read:filters")]
public IActionResult FiltersV2() => Json(Array.Empty<object>());
[HttpGet("/api/v1/lists"), Scope("read:lists")]
public IActionResult Lists() => Json(Array.Empty<object>());
[HttpGet("/api/v1/announcements"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult Announcements() => Json(Array.Empty<object>());
[HttpGet("/api/v1/trends"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult Trends() => Json(Array.Empty<object>());
[HttpGet("/api/v1/trends/tags"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult TrendTags() => Json(Array.Empty<object>());
[HttpGet("/api/v1/trends/statuses"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult TrendStatuses() => Json(Array.Empty<object>());
[HttpGet("/api/v1/trends/links"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult TrendLinks() => Json(Array.Empty<object>());
[HttpGet("/api/v1/suggestions"), Scope("read")]
public IActionResult SuggestionsV1() => Json(Array.Empty<object>());
[HttpGet("/api/v2/suggestions"), Scope("read")]
public IActionResult SuggestionsV2() => Json(Array.Empty<object>());
[HttpGet("/api/v1/followed_tags"), Scope("read:follows")]
public IActionResult FollowedTags() => Json(Array.Empty<object>());
[HttpGet("/api/v1/endorsements"), Scope("read:accounts")]
public IActionResult Endorsements() => Json(Array.Empty<object>());
[HttpGet("/api/v1/featured_tags"), Scope("read:accounts")]
public IActionResult FeaturedTags() => Json(Array.Empty<object>());
[HttpGet("/api/v1/scheduled_statuses"), Scope("read:statuses")]
public IActionResult ScheduledStatuses() => Json(Array.Empty<object>());
[HttpGet("/api/v1/preferences"), Scope("read:accounts")]
public IActionResult Preferences() => Json(new Dictionary<string, object>
{
@@ -140,23 +158,12 @@ namespace PrivaPub.Api.Mastodon.Controllers
// Routes Mastodon answers that we have nothing behind: an empty or default answer lets clients degrade, where a
// 404 breaks some of them (Mastodon invariant 5)
[HttpGet("/api/v1/directory"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult Directory() => Json(Array.Empty<object>());
[HttpGet("/api/v1/timelines/link"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult LinkTimeline() => Json(Array.Empty<object>());
[HttpGet("/api/v1/accounts/{id}/identity_proofs"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult IdentityProofs(string id) => Json(Array.Empty<object>());
[HttpGet("/api/v1/tags/{name}"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult Tag(string name)
{
var tag = name?.TrimStart('#').ToLowerInvariant() ?? string.Empty;
var baseAddress = HttpContext.RequestServices.GetRequiredService<Federation.Actors.ILocalActorService>().BaseAddress;
return Json(new { name = tag, url = $"{baseAddress}/tags/{Uri.EscapeDataString(tag)}", history = Array.Empty<object>(), following = false });
}
[HttpGet("/api/v1/instance/languages"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult Languages() => Json(InstanceController.LanguageCodes(HttpContext.RequestServices)
.Select(code => new { code, name = System.Globalization.CultureInfo.GetCultureInfo(code).EnglishName }));
@@ -175,29 +182,5 @@ namespace PrivaPub.Api.Mastodon.Controllers
+ "together. What a persona posts is shared as its visibility says; a located post never leaves this server. "
+ "Uploads lose their metadata. Statistics name servers, never people: see /stargazing.</p>"
});
[HttpGet("/api/v1/notifications/policy"), Scope("read:notifications")]
public IActionResult NotificationPolicyV1() => Json(new
{
filter_not_following = false,
filter_not_followers = false,
filter_new_accounts = false,
filter_private_mentions = false,
summary = new { pending_requests_count = 0, pending_notifications_count = 0 }
});
[HttpGet("/api/v2/notifications/policy"), Scope("read:notifications")]
public IActionResult NotificationPolicyV2() => Json(new
{
for_not_following = "accept",
for_not_followers = "accept",
for_new_accounts = "accept",
for_private_mentions = "accept",
for_limited_accounts = "accept",
summary = new { pending_requests_count = 0, pending_notifications_count = 0 }
});
[HttpGet("/api/v1/notifications/requests"), Scope("read:notifications")]
public IActionResult NotificationRequests() => Json(Array.Empty<object>());
}
}
@@ -9,6 +9,7 @@ using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Privacy;
using PrivaPub.Domain.Statuses;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Outbox;
@@ -29,10 +30,20 @@ namespace PrivaPub.Api.Mastodon.Controllers
readonly IMemoryCache _cache;
readonly IQuoteService _quotes;
readonly IOutboxPublisher _outbox;
readonly PrivaPub.Infrastructure.Jobs.IJobQueue _jobs;
readonly Domain.Media.IMediaService _media;
readonly IParticipations _participations;
readonly Federation.Actors.IWalls _walls;
public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache, IQuoteService quotes,
IOutboxPublisher outbox)
IOutboxPublisher outbox, PrivaPub.Infrastructure.Jobs.IJobQueue jobs, Domain.Media.IMediaService media, IParticipations participations = default,
Federation.Actors.IWalls walls = default)
{
_walls = walls;
_participations = participations;
_jobs = jobs;
_media = media;
_quotes = quotes;
_outbox = outbox;
_statuses = statuses;
@@ -52,23 +63,31 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (existing != default)
return Json(await _mapper.Status(existing, MyId, token));
}
var outcome = await _statuses.Publish(Me, new StatusDraft
var asked = new ScheduledParams
{
Text = Params.Get("status"),
PlainText = true,
SpoilerText = Params.Get("spoiler_text"),
Sensitive = Params.Bool("sensitive") ?? Me.Settings.DefaultSensitive,
Visibility = Visibility(Params.Get("visibility") ?? Me.Settings.DefaultVisibility),
InReplyTo = Params.Get("in_reply_to_id"),
Visibility = Params.Get("visibility") ?? Me.Settings.DefaultVisibility,
InReplyToId = Params.Get("in_reply_to_id"),
Language = Params.Get("language") ?? Me.Settings.DefaultLanguage,
MediaIds = Params.List("media_ids"),
MediaIds = Params.List("media_ids").ToList(),
QuotedStatusId = Params.Get("quoted_status_id"),
QuotePolicy = Params.Get("quote_approval_policy"),
Idempotency = string.IsNullOrEmpty(idempotency) ? default : idempotency,
Poll = Params.Has("poll[options]")
? new PollDraft(Params.List("poll[options]"), Params.Int("poll[expires_in]") ?? 0, Params.Bool("poll[multiple]") == true,
Params.Bool("poll[hide_totals]") == true)
? new ScheduledPoll
{
Options = Params.List("poll[options]").ToList(),
ExpiresIn = Params.Int("poll[expires_in]") ?? 0,
Multiple = Params.Bool("poll[multiple]") == true,
HideTotals = Params.Bool("poll[hide_totals]") == true
}
: default
}, token);
};
if (Params.Get("scheduled_at") is { Length: > 0 } scheduledAt)
return await Schedule(asked, scheduledAt, cacheKey, token);
var outcome = await _statuses.Publish(Me, ScheduledStatuses.Draft(asked), token);
if (!outcome.Ok)
return Error(outcome.Status, outcome.Error);
if (cacheKey != default)
@@ -76,6 +95,35 @@ namespace PrivaPub.Api.Mastodon.Controllers
return Json(await _mapper.Status(outcome.Post, MyId, token));
}
// scheduled_at: the post waits, as asked, for a PublishScheduled job at that time; what it would need then (words,
// media of the persona's own, not yet posted) is checked now
async Task<IActionResult> Schedule(ScheduledParams asked, string scheduledAt, string cacheKey, CancellationToken token)
{
if (cacheKey != default && _cache.TryGetValue(cacheKey + ":scheduled", out string earlier)
&& await DB.Default.Find<ScheduledStatus>().MatchID(earlier).ExecuteFirstAsync(token) is { } waiting)
return Json(await ScheduledStatusesController.View(waiting, _media, token));
if (!DateTime.TryParse(scheduledAt, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var at))
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Scheduled at is invalid");
if (await ScheduledStatuses.Refusal(MyId, at, default, token) is { } refusal)
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: " + refusal);
if (string.IsNullOrWhiteSpace(asked.Text) && asked.MediaIds.Count == 0 && asked.Poll == default)
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Text can't be blank");
var mediaIds = asked.MediaIds.Distinct().ToList();
if (mediaIds.Count > 4 || mediaIds.Count > 0 && await DB.Default.CountAsync<Models.Media.MediaAttachment>(
m => mediaIds.Contains(m.ID) && m.OwnerAvatarId == MyId && m.PostId == null && m.ScheduledStatusId == null, token) != mediaIds.Count)
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not yours, already posted or too many");
var scheduled = new ScheduledStatus { AvatarId = MyId, ScheduledAt = at, Params = asked };
await DB.Default.SaveAsync(scheduled, token);
if (mediaIds.Count > 0)
await DB.Default.Update<Models.Media.MediaAttachment>().Match(m => mediaIds.Contains(m.ID)).Modify(m => m.ScheduledStatusId, scheduled.ID).ExecuteAsync(token);
await _jobs.EnqueueMany(new[] { ScheduledStatuses.JobFor(scheduled, new Uri(Me.BaseAddress).Host) }, token);
if (cacheKey != default)
_cache.Set(cacheKey + ":scheduled", scheduled.ID, TimeSpan.FromHours(1));
return Json(await ScheduledStatusesController.View(scheduled, _media, token));
}
[HttpGet("/api/v1/statuses"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Many(CancellationToken token)
{
@@ -92,7 +140,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
public async Task<IActionResult> Get(string id, CancellationToken token)
{
var post = await Visible(id, token);
return post == default ? NotFoundError() : Json(await _mapper.Status(post, MyId, token));
var status = post == default ? default : await _mapper.Status(post, MyId, token);
return status == default ? NotFoundError() : Json(status);
}
[HttpPut("/api/v1/statuses/{id}"), Scope("write:statuses")]
@@ -115,7 +164,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
{
var before = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
if (before == default)
return NotFoundError();
return await OffWall(id, token);
var status = await _mapper.Status(before, MyId, token);
var outcome = await _statuses.Remove(Me, id, token);
if (!outcome.Ok)
@@ -128,6 +177,20 @@ namespace PrivaPub.Api.Mastodon.Controllers
return Json(status);
}
// someone's post on the persona's wall (FEP-400e), taken off it: as on Smithereen, the owner of a wall deletes what is on it
async Task<IActionResult> OffWall(string id, CancellationToken token)
{
var walled = _walls == default
? default
: await _dbEntities.Posts.Match(p => p.ID == id && p.WallOwnerAccountId == MyId && p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
if (walled == default)
return NotFoundError();
var status = await _mapper.Status(walled, MyId, token);
if (!await _walls.Remove(Me, walled, token))
return NotFoundError();
return Json(status);
}
[HttpGet("/api/v1/statuses/{id}/context"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Context(string id, CancellationToken token)
{
@@ -160,6 +223,11 @@ namespace PrivaPub.Api.Mastodon.Controllers
frontier.Add(child.ID);
}
}
// a persona opening a public remote thread has its replies read from their servers, for the next look (once an hour)
if (MyId != default)
await _jobs.EnqueueMany(new[] { post, ancestors.FirstOrDefault() }
.Where(p => p is { IsFederatedCopy: true, Visibility: PostVisibility.Public or PostVisibility.Unlisted }).DistinctBy(p => p.ID)
.Select(p => Federation.Inbox.RepliesJobHandler.For(p, 1)), token);
return Json(new Context
{
Ancestors = await _mapper.Statuses(ancestors, MyId, token),
@@ -200,6 +268,22 @@ namespace PrivaPub.Api.Mastodon.Controllers
return post == default ? NotFoundError() : Json(new StatusSource { Id = post.ID, Text = post.Text ?? string.Empty, SpoilerText = post.SpoilerText ?? string.Empty });
}
// a persona joins or leaves a remote event (owner decision 2026-10-05); the status says how it stands
// (privapub.event.participation)
[HttpPost("/api/privapub/v1/statuses/{id}/join"), Scope("write:statuses")]
public async Task<IActionResult> Join(string id, CancellationToken token)
{
var outcome = await _participations.Join(Me, id, token);
return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error);
}
[HttpPost("/api/privapub/v1/statuses/{id}/leave"), Scope("write:statuses")]
public async Task<IActionResult> Leave(string id, CancellationToken token)
{
var outcome = await _participations.Leave(Me, id, token);
return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error);
}
[HttpPost("/api/v1/statuses/{id}/favourite"), Scope("write:favourites")]
public Task<IActionResult> Favourite(string id, CancellationToken token) => Toggle(_statuses.Favourite(Me, id, true, token), id, token);
@@ -338,6 +422,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
try
{
await DB.Default.SaveAsync(new Models.Social.Pin { AvatarId = MyId, PostId = post.ID }, token);
await _outbox.PublishFeatured(MyId, post, featured: true, token);
}
catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey)
{
@@ -348,7 +433,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpPost("/api/v1/statuses/{id}/unpin"), Scope("write:accounts")]
public async Task<IActionResult> Unpin(string id, CancellationToken token)
{
await DB.Default.DeleteAsync<Models.Social.Pin>(p => p.AvatarId == MyId && p.PostId == id);
var unpinned = await DB.Default.DeleteAsync<Models.Social.Pin>(p => p.AvatarId == MyId && p.PostId == id);
if (unpinned is { IsAcknowledged: true, DeletedCount: > 0 } && await _dbEntities.Posts.MatchID(id).ExecuteFirstAsync(token) is { } post)
await _outbox.PublishFeatured(MyId, post, featured: false, token);
return await Unchanged(id, token);
}
@@ -364,7 +451,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
async Task<IActionResult> Unchanged(string id, CancellationToken token)
{
var post = await Visible(id, token);
return post == default ? NotFoundError() : Json(await _mapper.Status(post, MyId, token));
var status = post == default ? default : await _mapper.Status(post, MyId, token);
return status == default ? NotFoundError() : Json(status);
}
async Task<PostEntity> Visible(string id, CancellationToken token)
@@ -373,12 +461,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
return post != default && post.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(post, MyId, token) ? post : default;
}
static PostVisibility Visibility(string value) => value?.ToLowerInvariant() switch
{
"unlisted" => PostVisibility.Unlisted,
"private" => PostVisibility.FollowersOnly,
"direct" => PostVisibility.Direct,
_ => PostVisibility.Public
};
static PostVisibility Visibility(string value) => ScheduledStatuses.Visibility(value);
}
}
@@ -0,0 +1,223 @@
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Privacy;
using PrivaPub.Domain.Timelines;
using PrivaPub.Models.Social;
using PrivaPub.StaticServices;
namespace PrivaPub.Api.Mastodon.Controllers
{
// Mastodon's streaming API: one WebSocket on /api/v1/streaming that subscribes to streams by message, or one stream
// per request as server-sent events on /api/v1/streaming/{stream}. Every stream needs a signed-in persona (the token
// comes as access_token, in the Authorization header, or as the WebSocket's protocol). What a stream tells is mapped
// for that persona as it is sent: a post it may not see, or whose author it blocked or muted, is never sent.
public class StreamingController : MastodonController
{
static readonly TimeSpan KeepAlive = TimeSpan.FromSeconds(15);
readonly MastodonMapper _mapper;
readonly DbEntities _dbEntities;
public StreamingController(MastodonMapper mapper, DbEntities dbEntities)
{
_mapper = mapper;
_dbEntities = dbEntities;
}
[HttpGet("/api/v1/streaming/health"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult Health() => Content("OK", "text/plain");
// the stream a name and its parameter make, or why not: a hashtag needs its tag, a list must be the persona's own
async Task<(Streams.Key Key, string Error)> Resolve(string name, string tag, string list, CancellationToken token)
{
switch (name)
{
case Streams.User or Streams.UserNotification or Streams.Public or Streams.PublicLocal or Streams.PublicRemote:
return (new Streams.Key(name), default);
case Streams.Hashtag or Streams.HashtagLocal:
var normal = TagsController.Normalise(tag);
return normal.Length == 0 ? (default, "Missing tag name parameter") : (new Streams.Key(name, normal), default);
case Streams.List:
return !string.IsNullOrEmpty(list) && await DB.Default.Find<PersonaList>().Match(l => l.ID == list && l.AvatarId == MyId).ExecuteAnyAsync(token)
? (new Streams.Key(name, list), default)
: (default, "Unknown list");
default:
return (default, "Unknown stream type");
}
}
// what the event says, for this persona: a status or a notification as JSON, a deleted id as itself; null when it
// is not the persona's to see
async Task<string> Payload(Streams.Event e, CancellationToken token)
{
if (e.Name == "delete")
return e.Id;
if (e.Name == "notification")
{
var notification = await _dbEntities.Notifications.Match(n => n.ID == e.Id && n.AvatarId == MyId).ExecuteFirstAsync(token);
var mapped = notification == default ? default : (await _mapper.Notifications(new List<Notification> { notification }, MyId, token)).FirstOrDefault();
return mapped == default ? default : JsonSerializer.Serialize(mapped, MastodonJson.Options);
}
var post = await _dbEntities.Posts.MatchID(e.Id).ExecuteFirstAsync(token);
if (post == default || !await VisibilityPolicy.CanSee(post, MyId, token))
return default;
var status = (await _mapper.Statuses(new[] { post }, MyId, token)).FirstOrDefault();
return status == default ? default : JsonSerializer.Serialize(status, MastodonJson.Options);
}
[HttpGet("/api/v1/streaming"), Scope("read")]
public async Task<IActionResult> Socket(CancellationToken token)
{
if (!HttpContext.WebSockets.IsWebSocketRequest)
return Error(StatusCodes.Status400BadRequest, "This is a WebSocket endpoint; server-sent events are at /api/v1/streaming/{stream}");
var protocol = HttpContext.WebSockets.WebSocketRequestedProtocols.FirstOrDefault();
using var socket = await HttpContext.WebSockets.AcceptWebSocketAsync(protocol);
var listener = Streams.Open(MyId);
var sending = new SemaphoreSlim(1, 1);
async Task Send(JsonObject message)
{
var bytes = Encoding.UTF8.GetBytes(message.ToJsonString());
await sending.WaitAsync(token);
try
{
await socket.SendAsync(bytes, WebSocketMessageType.Text, true, token);
}
finally
{
sending.Release();
}
}
async Task Subscribe(string type, string name, string tag, string list)
{
var (key, error) = await Resolve(name, tag, list, token);
if (error != default)
{
await Send(new JsonObject { ["error"] = error, ["status"] = 400 });
return;
}
if (type == "unsubscribe")
listener.Stop(key);
else
listener.Listen(key);
}
try
{
if (Params.Get("stream") is { Length: > 0 } first)
await Subscribe("subscribe", first, Params.Get("tag"), Params.Get("list"));
using var done = CancellationTokenSource.CreateLinkedTokenSource(token);
var reading = Task.Run(async () =>
{
var buffer = new byte[4096];
while (socket.State == WebSocketState.Open && !done.IsCancellationRequested)
{
var text = new StringBuilder();
WebSocketReceiveResult received;
do
{
received = await socket.ReceiveAsync(buffer, done.Token);
if (received.MessageType == WebSocketMessageType.Close)
{
done.Cancel();
return;
}
text.Append(Encoding.UTF8.GetString(buffer, 0, received.Count));
}
while (!received.EndOfMessage && text.Length < 16384);
JsonNode message;
try
{
message = JsonNode.Parse(text.ToString());
}
catch (JsonException)
{
continue;
}
if (message?["type"]?.GetValue<string>() is "subscribe" or "unsubscribe" && message["stream"]?.GetValue<string>() is { } stream)
await Subscribe(message["type"]!.GetValue<string>(), stream, message["tag"]?.GetValue<string>(), message["list"]?.GetValue<string>());
}
}, done.Token);
await foreach (var (key, e) in listener.Queue.Reader.ReadAllAsync(done.Token))
{
if (!listener.Hears(key) || await Payload(e, done.Token) is not { } payload)
continue;
await Send(new JsonObject { ["stream"] = new JsonArray(key.Wire.Select(w => (JsonNode)w).ToArray()), ["event"] = e.Name, ["payload"] = payload });
}
await reading;
}
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException)
{
}
finally
{
Streams.Close(listener);
if (socket.State == WebSocketState.Open)
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, default, CancellationToken.None);
}
return new EmptyResult();
}
[HttpGet("/api/v1/streaming/{*stream}"), Scope("read")]
public async Task<IActionResult> Events(string stream, CancellationToken token)
{
var name = stream.Replace('/', ':');
var (key, error) = await Resolve(name, Params.Get("tag"), Params.Get("list"), token);
if (error != default)
return Error(StatusCodes.Status400BadRequest, error);
Response.ContentType = "text/event-stream";
Response.Headers.CacheControl = "no-cache";
Response.Headers["X-Accel-Buffering"] = "no";
var listener = Streams.Open(MyId);
listener.Listen(key);
var writing = new SemaphoreSlim(1, 1);
async Task Write(string text)
{
await writing.WaitAsync(token);
try
{
await Response.WriteAsync(text, token);
await Response.Body.FlushAsync(token);
}
finally
{
writing.Release();
}
}
try
{
await Write(":)\n\n");
var beating = Task.Run(async () =>
{
while (!token.IsCancellationRequested)
{
await Task.Delay(KeepAlive, token);
await Write(":thump\n\n");
}
}, token);
await foreach (var (_, e) in listener.Queue.Reader.ReadAllAsync(token))
{
if (await Payload(e, token) is not { } payload)
continue;
await Write($"event: {e.Name}\ndata: {payload}\n\n");
}
await beating;
}
catch (OperationCanceledException)
{
}
finally
{
Streams.Close(listener);
}
return new EmptyResult();
}
}
}
@@ -0,0 +1,116 @@
using System.Text;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.StaticServices;
namespace PrivaPub.Api.Mastodon.Controllers
{
// Hashtags as Mastodon shows them (owner decision 2026-10-04, followed tags back from the cut list): a tag with the
// last week of its use in public posts PrivaPub holds, and whether the persona follows it; following one brings its
// public posts to the persona's home as they arrive (Fanout).
public class TagsController : MastodonController
{
const int MaxFollowed = 500;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
public TagsController(DbEntities dbEntities, ILocalActorService localActors)
{
_dbEntities = dbEntities;
_localActors = localActors;
}
public static string Normalise(string name) => name?.Trim().TrimStart('#').Normalize(NormalizationForm.FormKC).ToLowerInvariant() ?? string.Empty;
// what a hashtag can be, as Mastodon has it: letters, marks, digits and underscores, with a letter or underscore
public static bool IsHashtag(string normal) =>
normal.Length is > 0 and <= 100 && normal.All(c => char.IsLetterOrDigit(c) || c == '_' || char.GetUnicodeCategory(c) is
System.Globalization.UnicodeCategory.NonSpacingMark or System.Globalization.UnicodeCategory.SpacingCombiningMark)
&& normal.Any(c => char.IsLetter(c) || c == '_');
// a day's uses and authors, newest first, for the last week, as Mastodon gives them (strings, days as unix time)
public static async Task<List<object>> History(DbEntities dbEntities, string tag, CancellationToken token)
{
var today = DateTime.UtcNow.Date;
var since = today.AddDays(-6);
var posts = await dbEntities.Posts
.Match(p => p.Tags.Contains(tag) && p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && p.CreationDate >= since)
.Project(p => p.Include(x => x.CreationDate).Include(x => x.AuthorAccountId).Include(x => x.GroupUserId))
.ExecuteAsync(token);
return Enumerable.Range(0, 7).Select(back => today.AddDays(-back)).Select(day =>
{
var that = posts.Where(p => p.CreationDate >= day && p.CreationDate < day.AddDays(1)).ToList();
return (object)new
{
day = new DateTimeOffset(day, TimeSpan.Zero).ToUnixTimeSeconds().ToString(),
uses = that.Count.ToString(),
accounts = that.Select(p => p.AuthorAccountId ?? p.GroupUserId).Distinct().Count().ToString()
};
}).ToList();
}
async Task<object> View(string tag, bool? following, CancellationToken token) => new
{
name = tag,
url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag)}",
history = await History(_dbEntities, tag, token),
following = following ?? (MyId != default && await DB.Default.Find<FollowedTag>().Match(t => t.AvatarId == MyId && t.Name == tag).ExecuteAnyAsync(token))
};
[HttpGet("/api/v1/tags/{name}"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Tag(string name, CancellationToken token)
{
var tag = Normalise(name);
return tag.Length == 0 ? NotFoundError() : Json(await View(tag, default, token));
}
[HttpPost("/api/v1/tags/{name}/follow"), Scope("write:follows")]
public async Task<IActionResult> Follow(string name, CancellationToken token)
{
var tag = Normalise(name);
if (tag.Length == 0 || tag.Length > 100)
return NotFoundError();
if (!await DB.Default.Find<FollowedTag>().Match(t => t.AvatarId == MyId && t.Name == tag).ExecuteAnyAsync(token))
{
if (await DB.Default.CountAsync<FollowedTag>(t => t.AvatarId == MyId, token) >= MaxFollowed)
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Too many followed hashtags");
try
{
await DB.Default.SaveAsync(new FollowedTag { AvatarId = MyId, Name = tag }, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
}
}
return Json(await View(tag, true, token));
}
[HttpPost("/api/v1/tags/{name}/unfollow"), Scope("write:follows")]
public async Task<IActionResult> Unfollow(string name, CancellationToken token)
{
var tag = Normalise(name);
await DB.Default.DeleteAsync<FollowedTag>(t => t.AvatarId == MyId && t.Name == tag);
return Json(await View(tag, false, token));
}
[HttpGet("/api/v1/followed_tags"), Scope("read:follows")]
public async Task<IActionResult> Followed(CancellationToken token)
{
var page = await Page.From(Params, Limit(100, 200)).Fetch(DB.Default.Find<FollowedTag>().Match(t => t.AvatarId == MyId), t => t.ID, token);
Link("/api/v1/followed_tags", page.LastOrDefault()?.ID, page.FirstOrDefault()?.ID);
var views = new List<object>();
foreach (var followed in page)
views.Add(await View(followed.Name, true, token));
return Json(views);
}
}
}
@@ -1,11 +1,14 @@
using Microsoft.AspNetCore.Mvc;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Privacy;
using PrivaPub.Domain.Statuses;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.StaticServices;
@@ -30,9 +33,19 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/timelines/home"), Scope("read:statuses")]
public async Task<IActionResult> Home(CancellationToken token)
{
var entries = await Page.From(Params, Limit()).Fetch(_dbEntities.TimelineEntries.Match(e => e.AvatarId == MyId), e => e.PostId, token);
//an exclusive list's members are read in that list, not at home
var exclusive = (await DB.Default.Find<PersonaList>().Match(l => l.AvatarId == MyId && l.Exclusive).ExecuteAsync(token)).Select(l => l.ID).ToList();
var apart = exclusive.Count == 0
? new List<string>()
: (await DB.Default.Find<PersonaListMember>().Match(m => exclusive.Contains(m.ListId)).ExecuteAsync(token)).Select(m => m.AccountId).Distinct().ToList();
var home = _dbEntities.TimelineEntries.Match(e => e.AvatarId == MyId);
if (apart.Count > 0)
home.Match(e => !apart.Contains(e.AuthorAccountId));
var entries = await Page.From(Params, Limit()).Fetch(home, e => e.PostId, token);
var ids = entries.Select(e => e.PostId).ToList();
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token)).ToDictionary(p => p.ID);
// (located posts once reached their author's home: the entries left from then are passed over)
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && p.Visibility != PostVisibility.LocalGeo).Match(VisibilityPolicy.IsShown)
.ExecuteAsync(token)).ToDictionary(p => p.ID);
var statuses = await _mapper.Statuses(ids.Where(posts.ContainsKey).Select(id => posts[id]).ToList(), MyId, token);
Link("/api/v1/timelines/home", entries.LastOrDefault()?.PostId, entries.FirstOrDefault()?.PostId);
return Json(statuses);
@@ -61,9 +74,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
return await Respond(query, $"/api/v1/timelines/tag/{hashtag}", token);
}
[HttpGet("/api/v1/timelines/list/{id}"), Scope("read:lists")]
public IActionResult List(string id) => Json(Array.Empty<Status>());
[HttpGet("/api/v1/favourites"), Scope("read:favourites")]
public async Task<IActionResult> Favourites(CancellationToken token)
{
@@ -78,41 +88,79 @@ namespace PrivaPub.Api.Mastodon.Controllers
return Json(await _mapper.Statuses(visible, MyId, token));
}
// the persona's direct conversations, by their newest post (paged like Mastodon's by that post's id), each with its
// other members and whether its newest post, someone else's, is newer than what the persona read; the ones it took
// off its list stay off until a newer message
[HttpGet("/api/v1/conversations"), Scope("read:statuses")]
public async Task<IActionResult> Conversations(CancellationToken token)
{
var groups = await _dbEntities.DmGroups.Match(g => !g.DeletionAt.HasValue && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId))
.Sort(g => g.UpdatedAt, Order.Descending).Limit(Limit()).ExecuteAsync(token);
var conversations = new List<Conversation>();
foreach (var group in groups)
{
var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token);
if (last == default)
continue;
var others = new List<string>();
foreach (var member in group.Members.Where(m => !(m.AvatarId == MyId && !m.IsForeign)))
others.Add(member.IsForeign
? (await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == member.AvatarId).ExecuteFirstAsync(token))?.ID
: member.AvatarId);
var accounts = await _mapper.Accounts(others, token);
conversations.Add(new Conversation
{
Id = group.ID,
Accounts = accounts.Values.ToList(),
LastStatus = await _mapper.Status(last, MyId, token)
});
}
return Json(conversations);
var groups = await Page.From(Params, Limit(20, 40)).Fetch(
_dbEntities.DmGroups.Match(g => !g.DeletionAt.HasValue && g.LastPostId != null && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId)),
g => g.LastPostId, token);
Link("/api/v1/conversations", groups.LastOrDefault()?.LastPostId, groups.FirstOrDefault()?.LastPostId);
return Json(await ConversationsOf(groups, token));
}
async Task<List<Conversation>> ConversationsOf(List<DmGroup> groups, CancellationToken token)
{
var ids = groups.Select(g => g.ID).ToList();
var states = (await DB.Default.Find<ConversationState>().Match(s => s.AvatarId == MyId && ids.Contains(s.ConversationId)).ExecuteAsync(token))
.ToDictionary(s => s.ConversationId);
groups = groups.Where(g => !ConversationStates.IsHidden(states.GetValueOrDefault(g.ID), g.LastPostId)).ToList();
// the newest post each conversation still shows: usually its LastPostId, else (deleted) the one before
var newestIds = groups.Select(g => g.LastPostId).ToList();
var newest = (await _dbEntities.Posts.Match(p => newestIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token))
.ToDictionary(p => p.ConversationId);
foreach (var group in groups.Where(g => !newest.ContainsKey(g.ID)))
if (await _dbEntities.Posts.Match(p => p.ConversationId == group.ID).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending)
.ExecuteFirstAsync(token) is { } shown)
newest[group.ID] = shown;
var foreignUris = groups.SelectMany(g => g.Members).Where(m => m.IsForeign).Select(m => m.AvatarId).Distinct().ToList();
var foreignIds = (await _dbEntities.ForeignAvatars.Match(f => foreignUris.Contains(f.ActorURI)).ExecuteAsync(token))
.GroupBy(f => f.ActorURI).ToDictionary(g => g.Key, g => g.First().ID);
List<string> Others(DmGroup group) => group.Members.Where(m => !(m.AvatarId == MyId && !m.IsForeign))
.Select(m => m.IsForeign ? foreignIds.GetValueOrDefault(m.AvatarId) : m.AvatarId).Where(id => id != default).ToList();
var accounts = await _mapper.Accounts(groups.SelectMany(Others).Distinct(), token);
var statuses = (await _mapper.Statuses(newest.Values.ToList(), MyId, token)).ToDictionary(s => s.Id);
return groups.Where(g => newest.ContainsKey(g.ID) && statuses.ContainsKey(newest[g.ID].ID))
.Select(g => new Conversation
{
Id = g.ID,
Unread = ConversationStates.IsUnread(states.GetValueOrDefault(g.ID), newest[g.ID].ID, newest[g.ID].AuthorAccountId, MyId),
Accounts = Others(g).Select(id => accounts.GetValueOrDefault(id)).Where(a => a != default).ToList(),
LastStatus = statuses[newest[g.ID].ID]
})
.ToList();
}
async Task<DmGroup> MyConversation(string id, CancellationToken token) =>
await _dbEntities.DmGroups.Match(g => g.ID == id && !g.DeletionAt.HasValue && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId))
.ExecuteFirstAsync(token);
[HttpPost("/api/v1/conversations/{id}/read"), Scope("write:conversations")]
public async Task<IActionResult> ReadConversation(string id, CancellationToken token)
{
var group = await _dbEntities.DmGroups.Match(g => g.ID == id && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId)).ExecuteFirstAsync(token);
var group = await MyConversation(id, token);
if (group == default)
return NotFoundError();
var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token);
return Json(new Conversation { Id = group.ID, LastStatus = last == default ? default : await _mapper.Status(last, MyId, token) });
await ConversationStates.Read(MyId, group.ID, group.LastPostId, token);
var conversation = (await ConversationsOf(new List<DmGroup> { group }, token)).FirstOrDefault();
return Json(conversation ?? new Conversation { Id = group.ID });
}
// off the persona's list until a newer message; the messages themselves stay
[HttpDelete("/api/v1/conversations/{id}"), Scope("write:conversations")]
public async Task<IActionResult> RemoveConversation(string id, CancellationToken token)
{
var group = await MyConversation(id, token);
if (group == default)
return NotFoundError();
await ConversationStates.Hide(MyId, group.ID, group.LastPostId, token);
await ConversationStates.Read(MyId, group.ID, group.LastPostId, token);
return Json(new { });
}
[HttpGet("/api/v1/markers"), Scope("read:statuses")]
@@ -141,6 +189,10 @@ namespace PrivaPub.Api.Mastodon.Controllers
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
result[timeline] = new Entities.Marker { LastReadId = marker.LastReadId, Version = marker.Version, UpdatedAt = MastodonJson.Time(marker.UpdatedAt) };
// what the marker passes is read: the unread count (v1 and v2) counts only what is newer
if (timeline == "notifications")
await DB.Default.Update<NotificationEntity>().Match(n => n.AvatarId == MyId && !n.IsRead)
.Match(f => f.Lte(n => n.ID, lastRead)).Modify(n => n.IsRead, true).ExecuteAsync(token);
}
return Json(result);
}
@@ -155,7 +207,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
public class NotificationsController : MastodonController
{
static readonly Dictionary<NotificationType, string> Names = new()
internal static readonly Dictionary<NotificationType, string> Names = new()
{
[NotificationType.Mention] = "mention",
[NotificationType.Follow] = "follow",
@@ -182,6 +234,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
var types = Params.List("types").Select(Parse).Where(t => t.HasValue).Select(t => t.Value).ToList();
var excluded = Params.List("exclude_types").Select(Parse).Where(t => t.HasValue).Select(t => t.Value).ToList();
var query = _dbEntities.Notifications.Match(n => n.AvatarId == MyId);
// what the persona's notification policy filtered stays in its requests, unless asked for (a request's own page)
if (Params.Bool("include_filtered") != true)
query.Match(NotFiltered);
if (types.Count > 0)
query.Match(n => types.Contains(n.Type));
if (excluded.Count > 0)
@@ -191,6 +246,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
return query;
}
// (notifications made before the policy have no Filtered at all)
static readonly FilterDefinition<NotificationEntity> NotFiltered = Builders<NotificationEntity>.Filter.Ne(n => n.Filtered, true);
[HttpGet("/api/v1/notifications"), Scope("read:notifications")]
public async Task<IActionResult> List(CancellationToken token)
{
@@ -201,11 +259,11 @@ namespace PrivaPub.Api.Mastodon.Controllers
// Grouped notifications (Mastodon 4.3): api_versions.mastodon says we speak that API, so clients call it. Likes and
// boosts of one post group together, as do follows within an hour; everything else stands alone.
static readonly HashSet<string> DefaultGrouped = new() { "favourite", "reblog", "follow" };
internal static readonly HashSet<string> DefaultGrouped = new() { "favourite", "reblog", "follow" };
HashSet<string> Grouped() => Params.List("grouped_types") is { Count: > 0 } chosen ? chosen.ToHashSet() : DefaultGrouped;
static string GroupKey(NotificationEntity n, ISet<string> grouped) =>
internal static string GroupKey(NotificationEntity n, ISet<string> grouped) =>
!grouped.Contains(Names[n.Type]) ? $"ungrouped-{n.ID}"
: n.Type == NotificationType.Follow ? $"grouped-follow-{n.CreatedAt:yyyyMMddHH}"
: string.IsNullOrEmpty(n.PostId) ? $"ungrouped-{n.ID}"
@@ -234,7 +292,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
async Task<List<NotificationEntity>> Members(string key, CancellationToken token) =>
InGroup(key) is { } filter
? await _dbEntities.Notifications.Match(filter).Sort(n => n.ID, Order.Descending).Limit(80).ExecuteAsync(token)
? await _dbEntities.Notifications.Match(filter).Match(NotFiltered).Sort(n => n.ID, Order.Descending).Limit(80).ExecuteAsync(token)
: new List<NotificationEntity>();
async Task<object> Groups(List<NotificationEntity> page, ISet<string> grouped, CancellationToken token)
@@ -245,7 +303,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
foreach (var group in shown.GroupBy(n => GroupKey(n, grouped)))
{
var newest = mapped[group.First().ID];
var total = group.Key.StartsWith("ungrouped-", StringComparison.Ordinal) ? 1 : await DB.Default.CountAsync(InGroup(group.Key), token);
var total = group.Key.StartsWith("ungrouped-", StringComparison.Ordinal) ? 1
: await DB.Default.CountAsync(Builders<NotificationEntity>.Filter.And(Builders<NotificationEntity>.Filter.Where(InGroup(group.Key)), NotFiltered), token);
groups.Add(new
{
group_key = group.Key,
@@ -341,34 +400,13 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/notifications/unread_count"), Scope("read:notifications")]
public async Task<IActionResult> UnreadCount(CancellationToken token)
{
var unread = await DB.Default.Find<NotificationEntity>().Match(n => n.AvatarId == MyId && !n.IsRead)
var unread = await DB.Default.Find<NotificationEntity>().Match(n => n.AvatarId == MyId && !n.IsRead).Match(NotFiltered)
.Sort(n => n.ID, Order.Descending).Limit(Math.Clamp(Params.Int("limit") ?? 100, 1, 1000)).ExecuteAsync(token);
return Json(new { count = (await Map(unread, token)).Count });
}
async Task<List<Entities.Notification>> Map(List<NotificationEntity> notifications, CancellationToken token)
{
var accounts = await _mapper.Accounts(notifications.Select(n => n.FromAccountId), token);
var postIds = notifications.Where(n => n.PostId != default).Select(n => n.PostId).Distinct().ToList();
var posts = postIds.Count == 0
? new List<PostEntity>()
: await _dbEntities.Posts.Match(p => postIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token);
var statuses = (await _mapper.Statuses(posts, MyId, token)).ToDictionary(s => s.Id);
return notifications
.Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty) && (n.PostId == default || statuses.ContainsKey(n.PostId)))
.Select(n => new Entities.Notification
{
Id = n.ID,
Type = Names[n.Type],
CreatedAt = MastodonJson.Time(n.CreatedAt),
GroupKey = $"ungrouped-{n.ID}",
Account = accounts[n.FromAccountId],
Status = n.PostId == default ? default : statuses[n.PostId],
Emoji = n.Emoji == default ? default : n.EmojiURL == default ? n.Emoji : $":{n.Emoji}:",
EmojiUrl = _mapper.ProxiedUrl(n.EmojiURL)
})
.ToList();
}
Task<List<Entities.Notification>> Map(List<NotificationEntity> notifications, CancellationToken token) =>
_mapper.Notifications(notifications, MyId, token);
static NotificationType? Parse(string name) => Names.FirstOrDefault(n => n.Value == name) is { Value: not null } pair ? pair.Key : (NotificationType?)null;
}
@@ -0,0 +1,160 @@
using Microsoft.AspNetCore.Mvc;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Privacy;
using PrivaPub.Domain.Social;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
namespace PrivaPub.Api.Mastodon.Controllers
{
// Trends and the directory (owner decision 2026-10-04, back from the cut list), from what PrivaPub already holds
// (Trends). The directory lists only accounts that let themselves be discovered.
public class TrendsController : MastodonController
{
readonly Trends _trends;
readonly MastodonMapper _mapper;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
public TrendsController(Trends trends, MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors)
{
_trends = trends;
_mapper = mapper;
_dbEntities = dbEntities;
_localActors = localActors;
}
static List<object> History(Trends.Trend trend) => trend.History.Select(h => (object)new
{
day = new DateTimeOffset(h.Day, TimeSpan.Zero).ToUnixTimeSeconds().ToString(),
uses = h.Uses.ToString(),
accounts = h.Accounts.ToString()
}).ToList();
IEnumerable<T> Window<T>(IEnumerable<T> items, int fallback, int max) =>
items.Skip(Math.Max(0, Params.Int("offset") ?? 0)).Take(Limit(fallback, max));
[HttpGet("/api/v1/trends"), HttpGet("/api/v1/trends/tags"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Tags(CancellationToken token)
{
var followed = MyId == default
? new HashSet<string>()
: (await DB.Default.Find<Models.Social.FollowedTag>().Match(t => t.AvatarId == MyId).ExecuteAsync(token)).Select(t => t.Name).ToHashSet();
return Json(Window(await _trends.Tags(token), 10, 20).Select(t => new
{
name = t.Key,
url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(t.Key)}",
history = History(t),
following = MyId == default ? default(bool?) : followed.Contains(t.Key)
}).ToList());
}
[HttpGet("/api/v1/trends/statuses"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Statuses(CancellationToken token)
{
var ids = Window(await _trends.Statuses(token), 20, 40).ToList();
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token)).ToDictionary(p => p.ID);
return Json(await _mapper.Statuses(ids.Where(posts.ContainsKey).Select(id => posts[id]).ToList(), MyId, token));
}
[HttpGet("/api/v1/trends/links"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Links(CancellationToken token)
{
var trends = Window(await _trends.Links(token), 10, 20).ToList();
var sampleIds = trends.Select(t => t.SamplePostId).ToList();
var samples = (await _dbEntities.Posts.Match(p => sampleIds.Contains(p.ID)).ExecuteAsync(token)).ToDictionary(p => p.ID);
return Json(trends.Where(t => samples.ContainsKey(t.SamplePostId)).Select(t =>
{
var link = samples[t.SamplePostId].Link;
var card = _mapper.LinkCard(t.Key, link.Title, link.Description, link.ImageURL);
return card == default ? default : (object)new
{
card.Url, card.Title, card.Description, card.Type, card.AuthorName, card.AuthorUrl, card.ProviderName, card.ProviderUrl,
card.Html, card.Width, card.Height, card.Image, card.ImageDescription, card.EmbedUrl, card.Blurhash, card.Language,
card.PublishedAt, card.Authors, History = History(t)
};
}).Where(c => c != default).ToList());
}
// order=active: by their latest public post PrivaPub holds; order=new: by when they say they joined, which for a
// persona is its published day (never its creation, so personas made together are not told apart by their order)
[HttpGet("/api/v1/directory"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Directory(CancellationToken token)
{
var offset = Math.Max(0, Params.Int("offset") ?? 0);
var limit = Limit(40, 80);
var localOnly = Params.Bool("local") == true;
var barred = await Counted.BarredPersonas(token);
var wanted = offset + limit;
var ordered = new List<string>();
if (Params.Get("order") == "new")
{
// many share a day: the first `wanted` are read with every other of the last one's day, so each page is cut the same way
var personas = await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID))
.Sort(a => a.PublishedOn, Order.Descending).Limit(wanted).ExecuteAsync(token);
if (personas.Count == wanted)
{
var edge = personas[^1].PublishedOn;
var known = personas.Select(a => a.ID).ToList();
personas.AddRange(await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID)
&& a.PublishedOn == edge && !known.Contains(a.ID)).ExecuteAsync(token));
}
var remote = localOnly
? new List<ForeignAvatar>()
: await _dbEntities.ForeignAvatars.Match(f => f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue && f.Published != null)
.Sort(f => f.Published, Order.Descending).Limit(wanted).ExecuteAsync(token);
if (remote.Count == wanted)
{
var edge = remote[^1].Published;
var known = remote.Select(f => f.ID).ToList();
remote.AddRange(await _dbEntities.ForeignAvatars.Match(f => f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue
&& f.Published == edge && !known.Contains(f.ID)).ExecuteAsync(token));
}
ordered = personas.Select(a => (a.ID, Joined: a.PublishedOn, Tie: Tie(a.UserName)))
.Concat(remote.Select(f => (f.ID, Joined: f.Published.Value, Tie: Tie(f.ActorURI))))
.OrderByDescending(a => a.Joined).ThenBy(a => a.Tie, StringComparer.Ordinal)
.Select(a => a.ID).Skip(offset).Take(limit).ToList();
}
else
{
var discoverableLocal = (await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID))
.Project(a => a.Include(x => x.ID)).ExecuteAsync(token)).Select(a => a.ID).ToHashSet();
var seen = new HashSet<string>();
var maxId = default(string);
for (var round = 0; round < 20 && ordered.Count < wanted; round++)
{
var page = _dbEntities.Posts.Match(p => p.Visibility == Models.Post.PostVisibility.Public && !p.DeletedAt.HasValue && p.ReblogOfPostId == null);
if (maxId != default)
page.Match(f => f.Lt(p => p.ID, maxId));
if (localOnly)
page.Match(p => !p.IsFederatedCopy);
var batch = await page.Sort(p => p.ID, Order.Descending).Limit(500)
.Project(p => p.Include(x => x.ID).Include(x => x.AuthorAccountId).Include(x => x.GroupUserId).Include(x => x.IsFederatedCopy))
.ExecuteAsync(token);
if (batch.Count == 0)
break;
maxId = batch[^1].ID;
var fresh = batch.Select(p => (Id: p.AuthorAccountId ?? p.GroupUserId, p.IsFederatedCopy)).Where(a => a.Id != default && seen.Add(a.Id)).ToList();
var remoteIds = fresh.Where(a => a.IsFederatedCopy).Select(a => a.Id).ToList();
var discoverableRemote = remoteIds.Count == 0
? new HashSet<string>()
: (await _dbEntities.ForeignAvatars.Match(f => remoteIds.Contains(f.ID) && f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue)
.Project(f => f.Include(x => x.ID)).ExecuteAsync(token)).Select(f => f.ID).ToHashSet();
ordered.AddRange(fresh.Where(a => a.IsFederatedCopy ? discoverableRemote.Contains(a.Id) : discoverableLocal.Contains(a.Id)).Select(a => a.Id));
}
ordered = ordered.Skip(offset).Take(limit).ToList();
}
var accounts = await _mapper.Accounts(ordered, token);
return Json(ordered.Where(accounts.ContainsKey).Select(id => accounts[id]).ToList());
}
static string Tie(string name) =>
Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(name ?? string.Empty)));
}
}
+63 -1
View File
@@ -32,6 +32,7 @@
public string HeaderDescription { get; set; } = string.Empty;
public List<Field> Fields { get; set; } = new();
public Source Source { get; set; }
public Account Moved { get; set; }//the account it moved to, when that account names it among its aliases
}
public class CustomEmojiEntity
@@ -65,6 +66,8 @@
public class PrivaPubStatus
{
public string ObjectType { get; set; }
public bool Locked { get; set; }//its community's moderators locked it: a reply is refused
public string Approval { get; set; }//our reply or boost its target's author must approve: "pending", "rejected"
public string Title { get; set; }
public string Excerpt { get; set; }
public string Cover { get; set; }
@@ -74,6 +77,7 @@
public PrivaPubAudio Audio { get; set; }
public PrivaPubEvent Event { get; set; }
public PrivaPubPlace Place { get; set; }//a remote post's own place (Pixelfed), shown only
public Account Wall { get; set; }//whose wall it was written on (FEP-400e), when not its author's
public PrivaPubVotes Votes { get; set; } = new();
}
@@ -146,6 +150,7 @@
public int? Capacity { get; set; }
public string Status { get; set; }
public string Category { get; set; }
public string Participation { get; set; }//the viewer's: "pending", "accepted" or "rejected"
}
public class PrivaPubPlace
@@ -178,6 +183,48 @@
public string CurrentUser { get; set; } = "denied";
}
public class FilterEntity
{
public string Id { get; set; }
public string Title { get; set; }
public List<string> Context { get; set; } = new();
public string ExpiresAt { get; set; }
public string FilterAction { get; set; }
public List<FilterKeywordEntity> Keywords { get; set; } = new();
public List<FilterStatusEntity> Statuses { get; set; } = new();
}
public class FilterKeywordEntity
{
public string Id { get; set; }
public string Keyword { get; set; }
public bool WholeWord { get; set; }
}
public class FilterStatusEntity
{
public string Id { get; set; }
public string StatusId { get; set; }
}
public class FilterResultEntity
{
public FilterEntity Filter { get; set; }
public List<string> KeywordMatches { get; set; }
public List<string> StatusMatches { get; set; }
}
// the v1 filter, which is one keyword of a v2 filter
public class FilterV1Entity
{
public string Id { get; set; }
public string Phrase { get; set; }
public List<string> Context { get; set; } = new();
public bool WholeWord { get; set; }
public string ExpiresAt { get; set; }
public bool Irreversible { get; set; }
}
public class EmojiReactionEntity
{
public string Name { get; set; }
@@ -266,13 +313,28 @@
public Application Application { get; set; }
public string Language { get; set; }
public string Text { get; set; }
public List<object> Filtered { get; set; } = new();
public List<FilterResultEntity> Filtered { get; set; } = new();
public PrivaPubStatus Privapub { get; set; }
public List<EmojiReactionEntity> EmojiReactions { get; set; } = new();
public PleromaStatus Pleroma { get; set; }
public QuoteEntity Quote { get; set; }
public int QuotesCount { get; set; }
public QuoteApprovalEntity QuoteApproval { get; set; }
public InteractionPolicyEntity InteractionPolicy { get; set; }//GoToSocial's: who may like, reply to and boost a remote post
}
// GoToSocial's interaction_policy: "public", "followers", "following", "author", "me", or an account's URI
public class InteractionPolicyEntity
{
public InteractionRuleEntity CanFavourite { get; set; }
public InteractionRuleEntity CanReply { get; set; }
public InteractionRuleEntity CanReblog { get; set; }
}
public class InteractionRuleEntity
{
public List<string> AutomaticApproval { get; set; } = new();
public List<string> ManualApproval { get; set; } = new();
}
public class MediaAttachment
@@ -8,14 +8,19 @@ namespace PrivaPub.Api.Mastodon.Infrastructure
{
readonly Dictionary<string, List<string>> _values;
MastodonParams(Dictionary<string, List<string>> values)
MastodonParams(Dictionary<string, List<string>> values, JsonElement? json = default)
{
_values = values;
Json = json;
}
// a JSON body as it came, for what flattening loses: which fields belong to the same object of an array
public JsonElement? Json { get; }
public static async Task<MastodonParams> Read(HttpRequest request, CancellationToken token)
{
var values = new Dictionary<string, List<string>>(StringComparer.Ordinal);
JsonElement? json = default;
foreach (var pair in request.Query)
foreach (var value in pair.Value)
Add(values, pair.Key, value);
@@ -33,12 +38,13 @@ namespace PrivaPub.Api.Mastodon.Infrastructure
{
using var document = await JsonDocument.ParseAsync(request.Body, cancellationToken: token);
Flatten(values, default, document.RootElement);
json = document.RootElement.Clone();
}
catch (JsonException)
{
}
}
return new MastodonParams(values);
return new MastodonParams(values, json);
}
public static MastodonParams From(IEnumerable<KeyValuePair<string, string>> pairs)
@@ -51,6 +57,8 @@ namespace PrivaPub.Api.Mastodon.Infrastructure
public bool Has(string name) => _values.ContainsKey(name);
public IEnumerable<string> Names => _values.Keys;
public string Get(string name) => _values.TryGetValue(name, out var list) && list.Count > 0 ? list[^1] : default;
public IReadOnlyList<string> List(string name) => _values.TryGetValue(name, out var list) ? list : (IReadOnlyList<string>)Array.Empty<string>();
@@ -21,6 +21,30 @@ namespace PrivaPub.Api.Mastodon.Mappers
public async Task<List<Account>> Find(string q, bool resolve, int limit, CancellationToken token) => await Find(q, resolve, limit, 0, token);
// following=true: only accounts the persona follows, matched by the start of their name, display name or server
// (what a client offers when adding to a list), read from what PrivaPub holds and never resolved
public async Task<List<Account>> Followed(string viewerId, string q, int limit, CancellationToken token)
{
q = q?.Trim().TrimStart('@') ?? string.Empty;
var ids = (await _dbEntities.Followings.Match(f => f.AvatarId == viewerId && f.State == Models.Social.FollowState.Accepted).ExecuteAsync(token))
.Select(f => f.TargetAccountId).Where(id => !string.IsNullOrEmpty(id)).ToList();
if (ids.Count == 0)
return new List<Account>();
var parts = q.Split('@');
var localDomain = new Uri(_localActors.BaseAddress).Authority;
bool Matches(string userName, string name, string domain) =>
userName?.StartsWith(parts[0], StringComparison.OrdinalIgnoreCase) == true
&& (parts.Length < 2 || domain?.StartsWith(parts[1], StringComparison.OrdinalIgnoreCase) == true)
|| parts.Length == 1 && name?.Contains(q, StringComparison.OrdinalIgnoreCase) == true;
var found = (await _dbEntities.Avatars.Match(a => ids.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token))
.Where(a => Matches(a.UserName, a.Name, localDomain)).Select(a => (a.UserName, a.ID))
.Concat((await _dbEntities.ForeignAvatars.Match(f => ids.Contains(f.ID) && !f.DeletionAt.HasValue).ExecuteAsync(token))
.Where(f => Matches(f.UserName, f.Name, f.Domain)).Select(f => (f.UserName, f.ID)))
.OrderBy(a => a.UserName, StringComparer.OrdinalIgnoreCase).Take(limit).Select(a => a.ID).ToList();
var accounts = await _mapper.Accounts(found, token);
return found.Where(accounts.ContainsKey).Select(id => accounts[id]).ToList();
}
// gone accounts (remote ones that deleted themselves, personas of banned or deleted roots) are never found
public async Task<List<Account>> Find(string q, bool resolve, int limit, int offset, CancellationToken token)
{
+85 -10
View File
@@ -40,6 +40,32 @@ namespace PrivaPub.Api.Mastodon.Mappers
public string ProxiedUrl(string url) => Proxied(url);
// notifications as the persona sees them: one whose account or post it can no longer see is left out
public async Task<List<Entities.Notification>> Notifications(List<Models.Social.Notification> notifications, string viewerId, CancellationToken token)
{
var accounts = await Accounts(notifications.Select(n => n.FromAccountId), token);
var postIds = notifications.Where(n => n.PostId != default).Select(n => n.PostId).Distinct().ToList();
var posts = postIds.Count == 0
? new List<PostEntity>()
: await _dbEntities.Posts.Match(p => postIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token);
var statuses = (await Statuses(posts, viewerId, token)).ToDictionary(s => s.Id);
return notifications
.Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty) && (n.PostId == default || statuses.ContainsKey(n.PostId)))
.Select(n => new Entities.Notification
{
Id = n.ID,
Type = Controllers.NotificationsController.Names[n.Type],
CreatedAt = MastodonJson.Time(n.CreatedAt),
//the group it belongs to by default, so a notification told alone (v1, a stream) joins its group in a client
GroupKey = Controllers.NotificationsController.GroupKey(n, Controllers.NotificationsController.DefaultGrouped),
Account = accounts[n.FromAccountId],
Status = n.PostId == default ? default : statuses[n.PostId],
Emoji = n.Emoji == default ? default : n.EmojiURL == default ? n.Emoji : $":{n.Emoji}:",
EmojiUrl = Proxied(n.EmojiURL)
})
.ToList();
}
string MissingAvatar => $"{_localActors.BaseAddress}/media/missing-avatar.png";
string MissingHeader => $"{_localActors.BaseAddress}/media/missing-header.png";
@@ -140,18 +166,32 @@ namespace PrivaPub.Api.Mastodon.Mappers
accounts[avatar.ID] = await Local(_localActors.FromAvatar(avatar), false, token);
foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue && g.Kind == GroupKind.Community).ExecuteAsync(token))
accounts[group.ID] = await Local(_localActors.FromGroup(group), false, token);
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID) && !f.DeletionAt.HasValue).ExecuteAsync(token))
var foreigns = await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID) && !f.DeletionAt.HasValue).ExecuteAsync(token);
foreach (var foreign in foreigns)
accounts[foreign.ID] = Foreign(foreign);
// an account that moved shows where to, once the new account names it as one of its own (as a Move is believed)
var movedTo = foreigns.Where(f => !string.IsNullOrEmpty(f.MovedToURL)).Select(f => f.MovedToURL).Distinct().ToList();
if (movedTo.Count > 0)
{
var targets = (await _dbEntities.ForeignAvatars.Match(f => movedTo.Contains(f.ActorURI) && !f.DeletionAt.HasValue).ExecuteAsync(token))
.ToDictionary(f => f.ActorURI);
foreach (var foreign in foreigns)
if (foreign.MovedToURL != default && targets.TryGetValue(foreign.MovedToURL, out var target) && target.AlsoKnownAs.Contains(foreign.ActorURI))
accounts[foreign.ID].Moved = Foreign(target);
}
return accounts;
}
public async Task<Status> Status(PostEntity post, string viewerId, CancellationToken token) =>
(await Statuses(new[] { post }, viewerId, token)).FirstOrDefault();
(await Statuses(new[] { post }, viewerId, nested: false, token, hideFromViewer: false)).FirstOrDefault();
public Task<List<Status>> Statuses(IReadOnlyCollection<PostEntity> posts, string viewerId, CancellationToken token) =>
Statuses(posts, viewerId, nested: false, token);
async Task<List<Status>> Statuses(IReadOnlyCollection<PostEntity> posts, string viewerId, bool nested, CancellationToken token)
// Lists leave out what the viewer blocked or muted; a status asked for by its id is still shown, as Mastodon shows it
// (only its author's block of the viewer hides it, and that is decided before mapping)
async Task<List<Status>> Statuses(IReadOnlyCollection<PostEntity> posts, string viewerId, bool nested, CancellationToken token,
bool hideFromViewer = true)
{
var originalIds = posts.Where(p => p.ReblogOfPostId != default).Select(p => p.ReblogOfPostId).Distinct().ToList();
var originals = originalIds.Count == 0
@@ -163,7 +203,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
var mentionAccounts = mentionUris.Count == 0
? new Dictionary<string, ForeignAvatar>()
: (await _dbEntities.ForeignAvatars.Match(f => mentionUris.Contains(f.ActorURI)).ExecuteAsync(token)).ToDictionary(f => f.ActorURI);
var accounts = await Accounts(all.Select(AuthorOf), token);
var accounts = await Accounts(all.Select(AuthorOf).Concat(all.Select(p => p.WallOwnerAccountId).Where(w => w != default)), token);
var ids = all.Select(p => p.ID).ToList();
var favourited = viewerId == default
@@ -186,14 +226,20 @@ namespace PrivaPub.Api.Mastodon.Mappers
})
.OrderByDescending(e => e.Count)
.ToList());
var eventIds = all.Where(p => p.Event != default && p.IsFederatedCopy).Select(p => p.ID).ToList();
var participations = viewerId == default || eventIds.Count == 0
? new Dictionary<string, Models.Social.ParticipationState>()
: (await DB.Default.Find<Models.Social.Participation>().Match(p => p.AvatarId == viewerId && eventIds.Contains(p.PostId)).ExecuteAsync(token))
.ToDictionary(p => p.PostId, p => p.State);
var pollIds = all.Where(p => p.Poll != default).Select(p => p.ID).ToList();
var ownVotes = viewerId == default || pollIds.Count == 0
? new Dictionary<string, List<int>>()
: (await DB.Default.Find<PollVote>().Match(v => pollIds.Contains(v.PostId) && v.VoterAccountId == viewerId && v.IsLocalVoter).ExecuteAsync(token))
.GroupBy(v => v.PostId).ToDictionary(g => g.Key, g => g.Select(v => v.Choice).ToList());
var hidden = viewerId == default
var hidden = viewerId == default || !hideFromViewer
? new HashSet<string>()
: await Domain.Relationships.Hidden.AuthorsHiddenFrom(viewerId, all.Select(p => p.ActorURI), forNotifications: false, token);
var filters = await Domain.Social.Filters.Of(viewerId, token);
var reblogged = viewerId == default
? new HashSet<string>()
: (await _dbEntities.Posts.Match(p => p.AuthorAccountId == viewerId && ids.Contains(p.ReblogOfPostId) && !p.DeletedAt.HasValue).ExecuteAsync(token))
@@ -222,18 +268,21 @@ namespace PrivaPub.Api.Mastodon.Mappers
Bookmarked = bookmarked.Contains(post.ID),
Pinned = pinned.Contains(post.ID),
Sensitive = post.HasContentWarning,
SpoilerText = post.SpoilerText ?? (post.HasContentWarning ? post.Title ?? ActivityPubRenderer.ContentWarning : string.Empty),
//a remote post keeps its own summary: sensitive with none (Akkoma's sensitive media, Lemmy's NSFW) hides the
//media, never the words behind an invented warning; a local post warned without words gets the default one
SpoilerText = post.SpoilerText ?? (post.HasContentWarning && !post.IsFederatedCopy ? post.Title ?? ActivityPubRenderer.ContentWarning : string.Empty),
Visibility = Visibility(post.Visibility),
Language = post.Language,
MediaAttachments = post.Media.Count > 0 ? post.Media.Select(Media).ToList() : Playable(post),
Card = Card(post),
Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)),
Emojis = Emojis(post.Emojis),
Privapub = Extension(post),
Privapub = Extension(post, accounts),
EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new(),
Pleroma = new PleromaStatus { EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new() },
Mentions = post.Mentions.Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList()
Mentions = post.Mentions.Where(m => !m.Silent).Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList(),
Filtered = filters.Of(post)
};
return status;
}
@@ -255,6 +304,9 @@ namespace PrivaPub.Api.Mastodon.Mappers
{
status.QuotesCount = post.QuotesCount;
status.QuoteApproval = post.IsFederatedCopy ? Approval(post, viewerId) : LocalApproval(post, viewerId, followsAuthor.Contains(post.GroupUserId));
status.InteractionPolicy = Policies(post);
if (status.Privapub?.Event != default && participations.TryGetValue(post.ID, out var participation))
status.Privapub.Event.Participation = participation.ToString().ToLowerInvariant();
if (post.QuoteState == QuoteState.None)
return;
var state = post.QuoteState.ToString().ToLowerInvariant();
@@ -282,6 +334,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
continue;
Quote(inner, original);
status.Reblog = inner;
status.Filtered = inner.Filtered;//a boost is filtered as what it boosts
status.Url = inner.Url;//a boost has no page of its own; /grunts would answer JSON to a browser
status.Content = string.Empty;
status.Reblogged = reblogged.Contains(original.ID);
@@ -326,6 +379,23 @@ namespace PrivaPub.Api.Mastodon.Mappers
return new QuoteApprovalEntity { Automatic = everyone, CurrentUser = current };
}
// a remote post's canLike, canReply and canAnnounce, in GoToSocial's client terms; null when it states none
static InteractionPolicyEntity Policies(PostEntity post)
{
if (!post.IsFederatedCopy || post.LikePolicy == default && post.ReplyPolicy == default && post.AnnouncePolicy == default)
return default;
var collections = post.ActorURI?.TrimEnd('/');
string Named(string who) => Addressing.IsPublic(who) ? "public"
: who == post.ActorURI ? "author"
: who.EndsWith("/followers", StringComparison.Ordinal) && who.StartsWith(collections, StringComparison.Ordinal) ? "followers"
: who.EndsWith("/following", StringComparison.Ordinal) && who.StartsWith(collections, StringComparison.Ordinal) ? "following"
: who;
InteractionRuleEntity Rule(InteractionRule rule) => rule == default
? new InteractionRuleEntity { AutomaticApproval = new List<string> { "public" } }
: new InteractionRuleEntity { AutomaticApproval = rule.Automatic.Select(Named).Distinct().ToList(), ManualApproval = rule.Manual.Select(Named).Distinct().ToList() };
return new InteractionPolicyEntity { CanFavourite = Rule(post.LikePolicy), CanReply = Rule(post.ReplyPolicy), CanReblog = Rule(post.AnnouncePolicy) };
}
static QuoteApprovalEntity Approval(PostEntity post, string viewerId)
{
if (!post.IsFederatedCopy || post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
@@ -410,9 +480,12 @@ namespace PrivaPub.Api.Mastodon.Mappers
List<CustomEmojiEntity> Emojis(IEnumerable<CustomEmoji> emojis) =>
emojis?.Select(e => new CustomEmojiEntity { Shortcode = e.Shortcode, Url = Proxied(e.URL), StaticUrl = Proxied(e.URL) }).ToList() ?? new();
PrivaPubStatus Extension(PostEntity post) => new()
PrivaPubStatus Extension(PostEntity post, Dictionary<string, Account> accounts) => new()
{
Wall = post.WallOwnerAccountId == default || post.WallOwnerAccountId == AuthorOf(post) ? default : accounts.GetValueOrDefault(post.WallOwnerAccountId),
ObjectType = post.ObjectType,
Locked = post.LockedAt.HasValue,
Approval = post.Approval is ApprovalState.Pending or ApprovalState.Rejected ? post.Approval.ToString().ToLowerInvariant() : default,
Title = post.IsFederatedCopy ? post.Title : default,
Excerpt = post.Excerpt,
Cover = Proxied(post.CoverURL),
@@ -515,6 +588,8 @@ namespace PrivaPub.Api.Mastodon.Mappers
return default;
}
public PreviewCard LinkCard(string url, string title, string description, string image) => Card(url, title, description, image);
PreviewCard Card(string url, string title, string description, string image)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
@@ -48,6 +48,18 @@ namespace PrivaPub.Controllers.ClientToServer
public async Task<IActionResult> Approve(GroupMembershipForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.ApproveMember(User.GetUserId(), form, token));
[HttpGet, Route("/clientapi/group/members")]
public async Task<IActionResult> Members([FromQuery] string avatarId, [FromQuery] string groupId, CancellationToken token) =>
Answer(await _groupUsersService.GetMembers(User.GetUserId(), avatarId, groupId, token));
[HttpPost, Route("/clientapi/group/reject")]
public async Task<IActionResult> Reject(GroupMembershipForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.RejectMember(User.GetUserId(), form, token));
[HttpPost, Route("/clientapi/group/remove")]
public async Task<IActionResult> Remove(GroupMembershipForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.RemoveMember(User.GetUserId(), form, token));
IActionResult Invalid() => BadRequest(new WebResult().Invalidate(_localizer["Invalid model."]));
IActionResult Answer(WebResult result) => result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Admin;
using PrivaPub.Extensions;
using PrivaPub.Models.Social;
@@ -17,20 +18,20 @@ namespace PrivaPub.Controllers.ClientToServer
[HttpGet, Route("/clientapi/moderator/reports")]
public async Task<IActionResult> Reports([FromQuery] bool resolved, CancellationToken token) =>
Ok((await DB.Default.Find<Report>().Match(r => r.IsResolved == resolved).Sort(r => r.ID, Order.Descending).Limit(100).ExecuteAsync(token))
.Select(r => new
.Select(r => new ViewReport
{
r.ID,
ID = r.ID,
Reporter = r.ReporterAvatarId != default ? "local" : r.ReporterActorURI,
r.TargetAccountId,
r.TargetActorURI,
r.PostIds,
r.ObjectURIs,
r.Category,
r.Comment,
r.Forwarded,
r.IsResolved,
r.CreatedAt,
r.ResolvedAt
TargetAccountId = r.TargetAccountId,
TargetActorURI = r.TargetActorURI,
PostIds = r.PostIds,
ObjectURIs = r.ObjectURIs,
Category = r.Category,
Comment = r.Comment,
Forwarded = r.Forwarded,
IsResolved = r.IsResolved,
CreatedAt = r.CreatedAt,
ResolvedAt = r.ResolvedAt
}));
[HttpPost, Route("/clientapi/moderator/reports/{id}/resolve")]
@@ -227,7 +227,7 @@ namespace PrivaPub.Controllers.ClientToServer
if (string.Equals(avatarUserName, rootUserName?.Trim(), StringComparison.OrdinalIgnoreCase))
return Localizer["Your persona's username must differ from your login."];
if (await LocalActors.IsUserNameTaken(avatarUserName, token))
return Localizer["The username '{0}' is already take.", avatarUserName];
return Localizer["The username '{0}' is already taken.", avatarUserName];
return default;
}
+1 -1
View File
@@ -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;
}
+2 -2
View File
@@ -157,11 +157,11 @@ namespace PrivaPub.Domain.Media
}
}
//one pass: uploads left unattached for a day go, then the proxy cache is trimmed to its size, oldest first
//one pass: uploads left unattached for a day go (unless a scheduled post waits for them), then the proxy cache is trimmed to its size, oldest first
public async Task Sweep(CancellationToken token)
{
var cutoff = DateTime.UtcNow - UnattachedLifetime;
foreach (var stale in await DB.Default.Find<MediaAttachment>().Match(m => m.PostId == null && m.CreatedAt < cutoff).Limit(500).ExecuteAsync(token))
foreach (var stale in await DB.Default.Find<MediaAttachment>().Match(m => m.PostId == null && m.ScheduledStatusId == null && m.CreatedAt < cutoff).Limit(500).ExecuteAsync(token))
await _media.Delete(stale);
TrimProxyCache();
}
@@ -1,8 +1,10 @@
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using System.Linq.Expressions;
@@ -47,6 +49,31 @@ namespace PrivaPub.Domain.Privacy
};
}
// A remote account may see a post, and so like, react to, vote on or downvote it, when the post was for it: anyone for
// public and unlisted, an addressed account for a DM, a follower or an addressed account for followers-only (our own
// followers-only posts are addressed to the followers collection, never to each follower), a member for a circle.
// Accounts only: the instance-actor alias of SignedFetchAuthorizer.MayRead is for refetches, never for interactions.
public static async Task<bool> RemoteCanSee(Post post, ForeignAvatar actor, CancellationToken token)
{
if (!Shown(post) || actor == default || string.IsNullOrEmpty(actor.ActorURI))
return false;
if (IsPublicCompiled(post))
return true;
var addressed = post.To.Contains(actor.ActorURI) || post.Cc.Contains(actor.ActorURI) || post.Mentions.Any(m => m.ActorURI == actor.ActorURI);
return post.Visibility switch
{
PostVisibility.Direct => addressed,
//a remote author's followers live on other servers too; we know only who it addressed
PostVisibility.FollowersOnly => addressed || !post.IsFederatedCopy && !string.IsNullOrEmpty(post.GroupUserId) && await DB.Default.Find<Follower>()
.Match(f => f.LocalActorId == post.GroupUserId && f.LocalActorKind == LocalActorKind.Person && f.IsAccepted && f.ActorURI == actor.ActorURI)
.ExecuteAnyAsync(token),
PostVisibility.Circle => !string.IsNullOrEmpty(post.GroupId) && await DB.Default.Find<GroupEntity>()
.Match(g => g.ID == post.GroupId && g.Members.Any(m => m.IsForeign && m.AvatarId == actor.ActorURI))
.ExecuteAnyAsync(token),
_ => false
};
}
static async Task<bool> FollowsAuthor(string viewerAvatarId, Post post, CancellationToken token)
{
if (!string.IsNullOrEmpty(post.ActorURI))
@@ -7,6 +7,7 @@ using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
@@ -141,11 +142,14 @@ namespace PrivaPub.Domain.Relationships
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
}
foreach (var following in await _dbEntities.Followings.Match(f => f.AvatarId == me.Id && !f.TargetIsLocal).ExecuteAsync(token))
if (Hidden.HostMatches(following.TargetActorURI, domain))
var followings = await _dbEntities.Followings.Match(f => f.AvatarId == me.Id && !f.TargetIsLocal).ExecuteAsync(token);
var followers = await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id).ExecuteAsync(token);
var handles = await Hidden.HandleDomains(followings.Select(f => f.TargetActorURI).Concat(followers.Select(f => f.ActorURI)).ToList(), token);
foreach (var following in followings)
if (Hidden.Blocks(domain, following.TargetActorURI, handles.GetValueOrDefault(following.TargetActorURI)))
await _follows.UnfollowAs(me, following.TargetActorURI, token);
foreach (var follower in await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id).ExecuteAsync(token))
if (Hidden.HostMatches(follower.ActorURI, domain))
foreach (var follower in followers)
if (Hidden.Blocks(domain, follower.ActorURI, handles.GetValueOrDefault(follower.ActorURI)))
await DB.Default.DeleteAsync<Follower>(follower.ID);
}
@@ -167,8 +171,23 @@ namespace PrivaPub.Domain.Relationships
public static class Hidden
{
public static bool HostMatches(string actorUri, string domain) =>
Uri.TryCreate(actorUri, UriKind.Absolute, out var uri)
&& (uri.Host.Equals(domain, StringComparison.OrdinalIgnoreCase) || uri.Host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase));
Uri.TryCreate(actorUri, UriKind.Absolute, out var uri) && IsOn(uri.Host, domain);
static bool IsOn(string host, string domain) =>
host.Equals(domain, StringComparison.OrdinalIgnoreCase) || host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase);
// a server blocked by a persona hides an account whose actor is on it, or whose handle is (its domain apart from its
// actor's host, FEP-2c59: the servers Mastodon lists as blocked are its handles' domains)
public static bool Blocks(string domain, string actorUri, string handleDomain) =>
HostMatches(actorUri, domain) || (!string.IsNullOrEmpty(handleDomain) && IsOn(handleDomain.Split(':')[0], domain));
// the domains of these accounts' handles, for those known
public static async Task<Dictionary<string, string>> HandleDomains(List<string> actorUris, CancellationToken token) =>
actorUris.Count == 0
? []
: (await DB.Default.Find<ForeignAvatar>().Match(a => actorUris.Contains(a.ActorURI))
.Project(a => new ForeignAvatar { ActorURI = a.ActorURI, Domain = a.Domain }).ExecuteAsync(token))
.GroupBy(a => a.ActorURI).ToDictionary(g => g.Key, g => g.First().Domain);
public static async Task<HashSet<string>> AuthorsHiddenFrom(string avatarId, IEnumerable<string> actorUris, bool forNotifications, CancellationToken token)
{
@@ -179,12 +198,18 @@ namespace PrivaPub.Domain.Relationships
var now = DateTime.UtcNow;
foreach (var block in await DB.Default.Find<Block>().Match(b => b.AvatarId == avatarId && uris.Contains(b.TargetActorURI)).ExecuteAsync(token))
hidden.Add(block.TargetActorURI);
//an account that blocked the persona is hidden from it too, as Mastodon hides it from the blocked account's home
foreach (var blocker in await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == avatarId && uris.Contains(b.ActorURI)).ExecuteAsync(token))
hidden.Add(blocker.ActorURI);
foreach (var mute in await DB.Default.Find<Mute>().Match(m => m.AvatarId == avatarId && uris.Contains(m.TargetActorURI)).ExecuteAsync(token))
if ((mute.ExpiresAt == null || mute.ExpiresAt > now) && (!forNotifications || mute.HideNotifications))
hidden.Add(mute.TargetActorURI);
var domains = await DB.Default.Find<AccountDomainBlock>().Match(b => b.AvatarId == avatarId).ExecuteAsync(token);
if (domains.Count == 0)
return hidden;
var handles = await HandleDomains(uris, token);
foreach (var uri in uris)
if (domains.Any(d => HostMatches(uri, d.Domain)))
if (domains.Any(d => Blocks(d.Domain, uri, handles.GetValueOrDefault(uri))))
hidden.Add(uri);
return hidden;
}
@@ -198,11 +223,17 @@ namespace PrivaPub.Domain.Relationships
var now = DateTime.UtcNow;
foreach (var block in await DB.Default.Find<Block>().Match(b => ids.Contains(b.AvatarId) && b.TargetActorURI == actorUri).ExecuteAsync(token))
hiding.Add(block.AvatarId);
foreach (var blocked in await DB.Default.Find<BlockedBy>().Match(b => ids.Contains(b.AvatarId) && b.ActorURI == actorUri).ExecuteAsync(token))
hiding.Add(blocked.AvatarId);
foreach (var mute in await DB.Default.Find<Mute>().Match(m => ids.Contains(m.AvatarId) && m.TargetActorURI == actorUri).ExecuteAsync(token))
if (mute.ExpiresAt == null || mute.ExpiresAt > now)
hiding.Add(mute.AvatarId);
foreach (var block in await DB.Default.Find<AccountDomainBlock>().Match(b => ids.Contains(b.AvatarId)).ExecuteAsync(token))
if (HostMatches(actorUri, block.Domain))
var domains = await DB.Default.Find<AccountDomainBlock>().Match(b => ids.Contains(b.AvatarId)).ExecuteAsync(token);
if (domains.Count == 0)
return hiding;
var handle = (await HandleDomains([actorUri], token)).GetValueOrDefault(actorUri);
foreach (var block in domains)
if (Blocks(block.Domain, actorUri, handle))
hiding.Add(block.AvatarId);
return hiding;
}
+55 -3
View File
@@ -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,30 @@ 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<Report>().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. PieFed (which drops a report from an Application) and Mbin (which reads the reason from summary alone) did on
// 2026-10-06. NodeInfo names them: they speak Lemmy's shapes, and nothing else tells them apart.
static readonly HashSet<string> ServiceReportTakers = new(StringComparer.Ordinal) { "lemmy", "piefed", "mbin" };
static async Task<bool> 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<Models.Jobs.RemoteInstance>().Match(i => i.Host == host).ExecuteFirstAsync(token);
return instance?.Software is { } software && ServiceReportTakers.Contains(software.ToLowerInvariant());
}
}
}
+115
View File
@@ -0,0 +1,115 @@
using System.Text.RegularExpressions;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Entities;
using PrivaPub.Models.Social;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Domain.Social
{
// A viewer's active filters, matched against posts as Mastodon matches them: a keyword anywhere in the post's words
// (its warning, title, text, poll options and media descriptions), case-insensitive, whole words when asked (a word
// boundary only where the keyword starts or ends with a word character); a filtered status by its id.
public sealed class Filters
{
static readonly AngleSharp.Html.Parser.HtmlParser Html = new();
readonly List<(PersonaFilter Filter, FilterEntity View, List<(string Keyword, Regex Pattern)> Keywords, HashSet<string> PostIds)> _active;
Filters(List<PersonaFilter> filters)
{
var now = DateTime.UtcNow;
_active = filters.Where(f => f.IsActive(now))
.Select(f => (f, View(f), f.Keywords.Select(k => (k.Keyword, Pattern(k))).ToList(), f.Statuses.Select(s => s.PostId).ToHashSet()))
.ToList();
}
public static readonly Filters None = new(new List<PersonaFilter>());
public static async Task<Filters> Of(string viewerId, CancellationToken token) =>
viewerId == default ? None : new Filters(await DB.Default.Find<PersonaFilter>().Match(f => f.AvatarId == viewerId).ExecuteAsync(token));
public bool IsEmpty => _active.Count == 0;
public static Regex Pattern(FilterKeyword keyword)
{
var escaped = Regex.Escape(keyword.Keyword ?? string.Empty);
if (keyword.WholeWord)
{
var start = Regex.IsMatch(keyword.Keyword ?? string.Empty, @"^\w") ? @"\b" : string.Empty;
var end = Regex.IsMatch(keyword.Keyword ?? string.Empty, @"\w$") ? @"\b" : string.Empty;
escaped = start + escaped + end;
}
return new Regex(escaped, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(50));
}
public List<FilterResultEntity> Of(PostEntity post)
{
if (_active.Count == 0 || post == default)
return new List<FilterResultEntity>();
string words = default;
var results = new List<FilterResultEntity>();
foreach (var (filter, view, keywords, postIds) in _active)
{
var keywordMatches = new List<string>();
if (keywords.Count > 0)
{
words ??= Words(post);
foreach (var (keyword, pattern) in keywords)
if (Matches(pattern, words))
keywordMatches.Add(keyword);
}
var statusMatches = postIds.Contains(post.ID) ? new List<string> { filter.Statuses.First(s => s.PostId == post.ID).Id } : new List<string>();
if (keywordMatches.Count > 0 || statusMatches.Count > 0)
results.Add(new FilterResultEntity
{
Filter = view,
KeywordMatches = keywordMatches.Count > 0 ? keywordMatches : default,
StatusMatches = statusMatches.Count > 0 ? statusMatches : default
});
}
return results;
}
static bool Matches(Regex pattern, string words)
{
try
{
return pattern.IsMatch(words);
}
catch (RegexMatchTimeoutException)
{
return false;
}
}
static string Words(PostEntity post)
{
var text = !string.IsNullOrEmpty(post.Text) && post.IsFederatedCopy == false
? post.Text
: string.IsNullOrEmpty(post.ContentHtml) ? post.Text : Html.ParseDocument($"<body>{post.ContentHtml}</body>").Body!.TextContent;
return string.Join("\n", new[] { post.SpoilerText, post.Title, text }
.Concat(post.Poll?.Options.Select(o => o.Title) ?? Enumerable.Empty<string>())
.Concat(post.Media.Select(m => m.Description))
.Where(s => !string.IsNullOrEmpty(s)));
}
public static FilterEntity View(PersonaFilter filter) => new()
{
Id = filter.ID,
Title = filter.Title,
Context = filter.Context,
ExpiresAt = filter.ExpiresAt.HasValue ? Api.Mastodon.Infrastructure.MastodonJson.Time(filter.ExpiresAt.Value) : default,
FilterAction = filter.Action,
Keywords = filter.Keywords.Select(Keyword).ToList(),
Statuses = filter.Statuses.Select(Status).ToList()
};
public static FilterKeywordEntity Keyword(FilterKeyword keyword) =>
new() { Id = keyword.Id, Keyword = keyword.Keyword, WholeWord = keyword.WholeWord };
public static FilterStatusEntity Status(FilterStatus status) => new() { Id = status.Id, StatusId = status.PostId };
}
}
+27
View File
@@ -0,0 +1,27 @@
namespace PrivaPub.Domain.Social
{
// sends again, now and then, the follow requests still unanswered (FollowService.ResendPending)
public sealed class FollowResender(IServiceScopeFactory scopes, ILogger<FollowResender> logger) : BackgroundService
{
static readonly TimeSpan Every = TimeSpan.FromMinutes(15);
protected override async Task ExecuteAsync(CancellationToken token)
{
using var timer = new PeriodicTimer(Every);
while (await timer.WaitForNextTickAsync(token))
{
try
{
using var scope = scopes.CreateScope();
var sent = await scope.ServiceProvider.GetRequiredService<IFollowService>().ResendPending(DateTime.UtcNow, token);
if (sent > 0)
logger.LogInformation("Sent {Count} unanswered follow requests again", sent);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Unanswered follow requests could not be sent again");
}
}
}
}
}
+49 -1
View File
@@ -29,6 +29,7 @@ namespace PrivaPub.Domain.Social
Task<Following> FollowAs(LocalActor follower, string target, bool showReblogs, CancellationToken token);
Task UnfollowAs(LocalActor follower, string target, CancellationToken token);
Task<bool> Decide(LocalActor me, string followerAccountId, bool accept, CancellationToken token);
Task<int> ResendPending(DateTime now, CancellationToken token);
}
public class FollowService : IFollowService
@@ -95,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;
@@ -105,6 +106,11 @@ namespace PrivaPub.Domain.Social
if (existing.ShowReblogs != showReblogs)
await DB.Default.Update<Following>().MatchID(existing.ID).Modify(f => f.ShowReblogs, showReblogs).ExecuteAsync(token);
existing.ShowReblogs = showReblogs;
// a request still unanswered is sent again, at most once an hour: a server can take a Follow (202) and
// drop it later, as one that cannot yet read our actor does; one that holds it already ignores the copy
if (existing.State == FollowState.Requested && remote != default)
await _delivery.Enqueue(follower, new[] { remote.InboxURL }, FollowActivity(follower, existing), token,
again: "again-" + DateTime.UtcNow.ToString("yyyyMMddHH", System.Globalization.CultureInfo.InvariantCulture));
return existing;
}
@@ -128,6 +134,8 @@ namespace PrivaPub.Domain.Social
{
return await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token);
}
//a new follow starts in no list, whatever lists the account was in before a follow that ended without passing here
await DB.Default.DeleteAsync<PersonaListMember>(m => m.AvatarId == follower.Id && m.AccountId == following.TargetAccountId);
if (local != default)
await FollowLocally(follower, local, following, token);
@@ -145,6 +153,7 @@ namespace PrivaPub.Domain.Social
return;
await DB.Default.DeleteAsync<Following>(following.ID);
await DB.Default.DeleteAsync<PersonaListMember>(m => m.AvatarId == follower.Id && m.AccountId == following.TargetAccountId);
if (following.TargetIsLocal)
{
await DB.Default.DeleteAsync<Follower>(f => f.LocalActorId == following.TargetAccountId && f.ActorURI == follower.Uri);
@@ -268,6 +277,45 @@ namespace PrivaPub.Domain.Social
return (default, actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token));
}
// how long after it was made, or last sent again, a request still unanswered is sent again: a server can take a Follow and
// lose its answer (Lemmy 1.0 sends nothing it queued for a server before it started sending there), and one that
// already holds the follow answers the copy again
public static readonly TimeSpan[] ResendAfter =
{
TimeSpan.FromMinutes(15), TimeSpan.FromHours(1), TimeSpan.FromHours(6), TimeSpan.FromDays(1), TimeSpan.FromDays(2), TimeSpan.FromDays(4)
};
// the requests to remote accounts due to be sent again; how many were
public async Task<int> ResendPending(DateTime now, CancellationToken token)
{
var sent = 0;
// (a request older than the count has no Resent yet: "not at least" matches it, "less than" would not)
var pending = await _dbEntities.Followings.Match(f => f.State == FollowState.Requested && !f.TargetIsLocal && !(f.Resent >= ResendAfter.Length))
.ExecuteAsync(token);
foreach (var following in pending.Where(f => (f.ResentAt ?? f.CreatedAt) + ResendAfter[f.Resent] <= now))
{
var follower = await _localActors.FindById(LocalActorKind.Person, following.AvatarId, token);
var inbox = following.TargetInboxURL ?? (await _remoteActors.GetActor(following.TargetActorURI, refresh: false, token))?.InboxURL;
if (follower != default && !string.IsNullOrEmpty(inbox))
{
// under a new id each time: Lemmy keeps the ids it received and never answers one again
var again = FollowActivity(follower, following);
again["id"] = AgainId(following.FollowActivityURI, following.Resent + 1);
await _delivery.Enqueue(follower, new[] { inbox }, again, token,
again: "resend-" + now.ToString("yyyyMMddHHmm", System.Globalization.CultureInfo.InvariantCulture));
sent++;
}
await DB.Default.Update<Following>().MatchID(following.ID).Modify(b => b.Inc(f => f.Resent, 1)).Modify(f => f.ResentAt, now)
.ExecuteAsync(token);
}
return sent;
}
// a follow sent again is the same follow under a new id, which an answer may name (AcceptHandler.FindFollowing)
public const string Again = "-again-";
public static string AgainId(string followActivityUri, int resend) => $"{followActivityUri}{Again}{resend}";
static JsonObject FollowActivity(LocalActor follower, Following following) => new()
{
["@context"] = ActivityPubRenderer.ActivityStreams,
@@ -0,0 +1,137 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Domain.Social
{
// A persona's notification policy (Mastodon 4.3), applied as each notification is made: accepted, filtered into a
// request per account, or dropped. Polls ending and edits of posts the persona answered are never filtered.
public static class NotificationPolicies
{
static readonly TimeSpan NewAccount = TimeSpan.FromDays(30);
// someone who followed the persona lately is not yet a follower for the policy: following, then writing, is how
// a newcomer gets past it
static readonly TimeSpan NewFollower = TimeSpan.FromDays(3);
public static async Task<NotificationPolicy> Of(string avatarId, CancellationToken token) =>
await DB.Default.Find<NotificationPolicy>().Match(p => p.AvatarId == avatarId).ExecuteFirstAsync(token)
?? new NotificationPolicy { AvatarId = avatarId };
public static async Task Save(NotificationPolicy policy, CancellationToken token) =>
await DB.Default.Update<NotificationPolicy>().Match(p => p.AvatarId == policy.AvatarId)
.Modify(p => p.ForNotFollowing, policy.ForNotFollowing)
.Modify(p => p.ForNotFollowers, policy.ForNotFollowers)
.Modify(p => p.ForNewAccounts, policy.ForNewAccounts)
.Modify(p => p.ForPrivateMentions, policy.ForPrivateMentions)
.Modify(p => p.ForLimitedAccounts, policy.ForLimitedAccounts)
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
public static async Task<NotificationAction> Judge(string avatarId, NotificationType type, string fromAccountId, string fromActorUri, string postId,
CancellationToken token)
{
if (type is NotificationType.Poll or NotificationType.Update || string.IsNullOrEmpty(fromActorUri))
return NotificationAction.Accept;
var policy = await DB.Default.Find<NotificationPolicy>().Match(p => p.AvatarId == avatarId).ExecuteFirstAsync(token);
if (policy is null or { ForNotFollowing: NotificationAction.Accept, ForNotFollowers: NotificationAction.Accept, ForNewAccounts: NotificationAction.Accept,
ForPrivateMentions: NotificationAction.Accept, ForLimitedAccounts: NotificationAction.Accept })
return NotificationAction.Accept;
if (await DB.Default.Find<NotificationPermission>().Match(p => p.AvatarId == avatarId && p.FromActorURI == fromActorUri).ExecuteAnyAsync(token))
return NotificationAction.Accept;
var actions = new List<NotificationAction>();
var following = await DB.Default.Find<Following>()
.Match(f => f.AvatarId == avatarId && f.TargetActorURI == fromActorUri && f.State == FollowState.Accepted).ExecuteAnyAsync(token);
if (!following)
actions.Add(policy.ForNotFollowing);
if (policy.ForNotFollowers != NotificationAction.Accept)
{
var since = DateTime.UtcNow - NewFollower;
if (!await DB.Default.Find<Follower>()
.Match(f => f.LocalActorId == avatarId && f.ActorURI == fromActorUri && f.IsAccepted && f.CreationDate <= since).ExecuteAnyAsync(token))
actions.Add(policy.ForNotFollowers);
}
if (policy.ForNewAccounts != NotificationAction.Accept || policy.ForLimitedAccounts != NotificationAction.Accept)
{
var (since, silenced) = await Sender(fromAccountId, fromActorUri, token);
if (since > DateTime.UtcNow - NewAccount)
actions.Add(policy.ForNewAccounts);
if (silenced)
actions.Add(policy.ForLimitedAccounts);
}
// a private mention it did not ask for: from someone it does not follow, answering nothing of the persona's
if (policy.ForPrivateMentions != NotificationAction.Accept && type == NotificationType.Mention && !following && !string.IsNullOrEmpty(postId)
&& await DB.Default.Find<PostEntity>().Match(p => p.ID == postId && p.Visibility == PostVisibility.Direct && p.InReplyToAccountId != avatarId)
.ExecuteAnyAsync(token))
actions.Add(policy.ForPrivateMentions);
return actions.Contains(NotificationAction.Drop) ? NotificationAction.Drop
: actions.Contains(NotificationAction.Filter) ? NotificationAction.Filter
: NotificationAction.Accept;
}
// since when the sender has been on the fediverse (its actor's published, else when this server first met it), and
// whether it is silenced here, itself or its server
static async Task<(DateTime Since, bool Silenced)> Sender(string accountId, string actorUri, CancellationToken token)
{
var host = Uri.TryCreate(actorUri, UriKind.Absolute, out var uri) ? uri.Host : default;
var silencedHere = host != default && (await DB.Default.Find<DomainBlock>().Match(b => b.Severity == DomainBlockSeverity.Silence).ExecuteAsync(token))
.Any(b => host.Equals(b.Domain, StringComparison.OrdinalIgnoreCase) || host.EndsWith("." + b.Domain, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrEmpty(accountId) && await DB.Default.Find<Avatar>().Match(a => a.ID == accountId).ExecuteFirstAsync(token) is { } local)
return (local.CreatedAt, local.SilencedAt.HasValue);
var foreign = await DB.Default.Find<ForeignAvatar>().Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token);
return foreign == default ? (DateTime.UtcNow, silencedHere) : (foreign.Published ?? foreign.CreatedAt, silencedHere || foreign.SilencedAt.HasValue);
}
// a notification filtered from an account: its request gathers it
public static async Task Requested(Notification notification, CancellationToken token)
{
var now = DateTime.UtcNow;
var update = DB.Default.Update<NotificationRequest>()
.Match(r => r.AvatarId == notification.AvatarId && r.FromActorURI == notification.FromActorURI)
.Modify(b => b.Inc(r => r.NotificationsCount, 1))
.Modify(r => r.FromAccountId, notification.FromAccountId)
.Modify(r => r.UpdatedAt, now)
.Modify(b => b.SetOnInsert(r => r.CreatedAt, now))
.Option(o => o.IsUpsert = true);
if (!string.IsNullOrEmpty(notification.PostId))
update.Modify(r => r.LastPostId, notification.PostId);
try
{
await update.ExecuteAsync(token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
// (two filtered at once from one account: the other made the request)
await update.Option(o => o.IsUpsert = false).ExecuteAsync(token);
}
}
// the persona lets an account's notifications in, now and from then on
public static async Task Accept(NotificationRequest request, CancellationToken token)
{
try
{
await DB.Default.SaveAsync(new NotificationPermission { AvatarId = request.AvatarId, FromActorURI = request.FromActorURI }, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
}
await DB.Default.Update<Notification>().Match(n => n.AvatarId == request.AvatarId && n.FromActorURI == request.FromActorURI && n.Filtered)
.Modify(n => n.Filtered, false).ExecuteAsync(token);
await DB.Default.DeleteAsync<NotificationRequest>(request.ID);
}
// or dismisses them
public static async Task Dismiss(NotificationRequest request, CancellationToken token)
{
await DB.Default.DeleteAsync<Notification>(n => n.AvatarId == request.AvatarId && n.FromActorURI == request.FromActorURI && n.Filtered);
await DB.Default.DeleteAsync<NotificationRequest>(request.ID);
}
}
}
+17 -3
View File
@@ -14,9 +14,12 @@ namespace PrivaPub.Domain.Social
return;
if ((await Relationships.Hidden.AuthorsHiddenFrom(avatarId, new[] { fromActorUri }, forNotifications: true, token)).Count > 0)
return;
var action = await NotificationPolicies.Judge(avatarId, type, fromAccountId, fromActorUri, postId, token);
if (action == NotificationAction.Drop)
return;
try
{
await DB.Default.SaveAsync(new Notification
var notification = new Notification
{
AvatarId = avatarId,
Type = type,
@@ -25,8 +28,19 @@ namespace PrivaPub.Domain.Social
PostId = postId,
Emoji = emoji,
EmojiURL = emojiUrl,
DedupeKey = emoji == default ? $"{type}|{avatarId}|{fromActorUri}|{postId}" : $"{type}|{avatarId}|{fromActorUri}|{postId}|{emoji}"
}, token);
DedupeKey = emoji == default ? $"{type}|{avatarId}|{fromActorUri}|{postId}" : $"{type}|{avatarId}|{fromActorUri}|{postId}|{emoji}",
Filtered = action == NotificationAction.Filter
};
await DB.Default.SaveAsync(notification, token);
// filtered: in its account's request, told to no stream
if (notification.Filtered)
{
await NotificationPolicies.Requested(notification, token);
return;
}
var told = new Timelines.Streams.Event("notification", notification.ID);
Timelines.Streams.ToPersona(avatarId, new Timelines.Streams.Key(Timelines.Streams.User), told);
Timelines.Streams.ToPersona(avatarId, new Timelines.Streams.Key(Timelines.Streams.UserNotification), told);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
+9 -3
View File
@@ -1,9 +1,9 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
@@ -43,9 +43,10 @@ namespace PrivaPub.Domain.Social
public async Task Receive(PostEntity post, ForeignAvatar actor, string content, JsonNode tags, string activityId, CancellationToken token)
{
if (!LikeHandler.MaySee(post, actor) || Normalise(content, ObjectShapes.Emojis(tags)) is not { } emoji)
var visible = await VisibilityPolicy.RemoteCanSee(post, actor, token);
if (!visible || Normalise(content, ObjectShapes.Emojis(tags)) is not { } emoji)
{
Arrival.Drop(LikeHandler.MaySee(post, actor) ? "unparseable" : "not-visible");
Arrival.Drop(visible ? "unparseable" : "not-visible");
return;
}
try
@@ -177,6 +178,11 @@ namespace PrivaPub.Domain.Social
return requireKnownCustom ? default : new Picked(bare, default);
return new Picked(bare, emoji.URL);
}
// one emoji, one name: ❤ and ❤️ (with the emoji variation selector) were two reactions, counted apart. The selector
// is dropped, then put back on a symbol that shows as text without it (U+2000 to U+2BFF: ❤, ☺, ✌...)
text = text.Replace("\uFE0F", "");
if (text.Length == 1 && text[0] is >= '\u2000' and <= '\u2BFF')
text += "\uFE0F";
if (text.Any(c => c < 128 || char.IsWhiteSpace(c)) || new StringInfo(text).LengthInTextElements != 1)
return default;
return new Picked(text, default);
+126
View File
@@ -0,0 +1,126 @@
using Microsoft.Extensions.Caching.Memory;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
namespace PrivaPub.Domain.Social
{
// What is being talked about here (owner decision 2026-10-04: trends count only public posts PrivaPub already holds,
// and fetch nothing). Hashtags and links rise with the number of different authors using them today against the
// week before; posts with their favourites, boosts and replies, halving every twelve hours. A post trends only if its
// author is discoverable, and nothing warned about (content warning) trends. Computed at most every ten minutes.
public sealed class Trends
{
public static readonly TimeSpan Fresh = TimeSpan.FromMinutes(10);
const int MinAuthors = 2;//one account alone never makes a trend
const int Kept = 40;
readonly DbEntities _dbEntities;
readonly IMemoryCache _cache;
public Trends(DbEntities dbEntities, IMemoryCache cache)
{
_dbEntities = dbEntities;
_cache = cache;
}
// what is computed is forgotten: the next read computes it again
public void Forget()
{
foreach (var key in new[] { "trends:tags", "trends:links", "trends:statuses" })
_cache.Remove(key);
}
public sealed record DayUse(DateTime Day, int Uses, int Accounts);
public sealed record Trend(string Key, List<DayUse> History, string SamplePostId);
public Task<List<Trend>> Tags(CancellationToken token) =>
_cache.GetOrCreateAsync("trends:tags", async entry =>
{
entry.AbsoluteExpirationRelativeToNow = Fresh;
var posts = await Week(p => p.Tags.Count > 0, token);
return Rank(posts.SelectMany(p => p.Tags.Distinct().Select(t => (Key: t, Post: p))));
});
public Task<List<Trend>> Links(CancellationToken token) =>
_cache.GetOrCreateAsync("trends:links", async entry =>
{
entry.AbsoluteExpirationRelativeToNow = Fresh;
var posts = await Week(p => p.Link != null, token);
return Rank(posts.Where(p => Uri.TryCreate(p.Link.Href, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http")
.Select(p => (Key: Normal(p.Link.Href), Post: p)));
});
// the ids of the trending posts, best first
public Task<List<string>> Statuses(CancellationToken token) =>
_cache.GetOrCreateAsync("trends:statuses", async entry =>
{
entry.AbsoluteExpirationRelativeToNow = Fresh;
var now = DateTime.UtcNow;
var since = now.AddHours(-48);
var posts = await _dbEntities.Posts
.Match(p => p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && !p.AuthorGone && !p.HasContentWarning && !p.IsLocalOnly
&& p.ReblogOfPostId == null && p.InReplyToURI == null && p.AnsweringToPostId == null && p.CreationDate >= since
&& p.FavouritesCount + p.ReblogsCount + p.RepliesCount > 0)
.ExecuteAsync(token);
var discoverable = await Discoverable(posts.Select(p => p.AuthorAccountId ?? p.GroupUserId), token);
return posts.Where(p => discoverable.Contains(p.AuthorAccountId ?? p.GroupUserId))
.Select(p => (p.ID, Score: (p.FavouritesCount + 2.0 * p.ReblogsCount + p.RepliesCount) * Math.Pow(0.5, (now - p.CreationDate).TotalHours / 12)))
.OrderByDescending(p => p.Score).Take(Kept).Select(p => p.ID).ToList();
});
Task<List<Post>> Week(System.Linq.Expressions.Expression<Func<Post, bool>> with, CancellationToken token)
{
var since = DateTime.UtcNow.Date.AddDays(-6);
return _dbEntities.Posts
.Match(p => p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && !p.AuthorGone && !p.HasContentWarning && !p.IsLocalOnly
&& p.ReblogOfPostId == null && p.CreationDate >= since)
.Match(with)
.Project(p => p.Include(x => x.ID).Include(x => x.Tags).Include(x => x.Link).Include(x => x.CreationDate)
.Include(x => x.AuthorAccountId).Include(x => x.GroupUserId))
.ExecuteAsync(token);
}
// rising: today's authors squared over the week's daily average before today, so a new subject beats a steady one
static List<Trend> Rank(IEnumerable<(string Key, Post Post)> uses)
{
var today = DateTime.UtcNow.Date;
return uses.GroupBy(u => u.Key)
.Select(g =>
{
var history = Enumerable.Range(0, 7).Select(back => today.AddDays(-back)).Select(day =>
{
var that = g.Where(u => u.Post.CreationDate >= day && u.Post.CreationDate < day.AddDays(1)).ToList();
return new DayUse(day, that.Count, that.Select(u => u.Post.AuthorAccountId ?? u.Post.GroupUserId).Distinct().Count());
}).ToList();
var recentAuthors = g.Where(u => u.Post.CreationDate >= today.AddDays(-1)).Select(u => u.Post.AuthorAccountId ?? u.Post.GroupUserId).Distinct().Count();
var before = history.Skip(1).Average(h => h.Accounts);
return (Trend: new Trend(g.Key, history, g.OrderByDescending(u => u.Post.CreationDate).First().Post.ID),
Recent: recentAuthors, Score: recentAuthors * recentAuthors / (before + 1));
})
.Where(t => t.Recent >= MinAuthors)
.OrderByDescending(t => t.Score).ThenByDescending(t => t.Recent).ThenBy(t => t.Trend.Key, StringComparer.Ordinal)
.Take(Kept).Select(t => t.Trend).ToList();
}
static string Normal(string href) =>
Uri.TryCreate(href, UriKind.Absolute, out var uri) ? uri.GetLeftPart(UriPartial.Query).TrimEnd('/') : href;
// the accounts among these whose owners let them be discovered: personas by their setting, remote accounts by theirs
static async Task<HashSet<string>> Discoverable(IEnumerable<string> accountIds, CancellationToken token)
{
var ids = accountIds.Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList();
var barred = await Privacy.Counted.BarredPersonas(token);
var local = await DB.Default.Find<Avatar>().Match(a => ids.Contains(a.ID) && a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID))
.Project(a => a.Include(x => x.ID)).ExecuteAsync(token);
var remote = await DB.Default.Find<ForeignAvatar>().Match(f => ids.Contains(f.ID) && f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue)
.Project(f => f.Include(x => x.ID)).ExecuteAsync(token);
return local.Select(a => a.ID).Concat(remote.Select(f => f.ID)).ToHashSet();
}
}
}
+23 -14
View File
@@ -1,5 +1,6 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Admin;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Statistics;
@@ -46,7 +47,7 @@ namespace PrivaPub.Domain.Statistics
return result.OrderBy(d => d.Day).ThenBy(d => d.Host, StringComparer.Ordinal).ToList();
}
public async Task<object> Overview(int days, CancellationToken token)
public async Task<ViewStatisticsOverview> Overview(int days, CancellationToken token)
{
var hostDays = await Days(days, default, token);
var counters = Sum(hostDays.Select(d => d.Counters));
@@ -54,21 +55,21 @@ namespace PrivaPub.Domain.Statistics
var server = await DB.Default.Find<ServerDay>().Match(d => d.Day >= DateTime.UtcNow.Date.AddDays(1 - Math.Clamp(days, 1, 3660))).ExecuteAsync(token);
var delivered = Outcomes(counters, Interactions.Out);
var attempts = delivered.Where(o => o.Key != Interactions.Deferred).Sum(o => o.Value);
return new
return new ViewStatisticsOverview
{
Days = days,
Channels = Channels.ToDictionary(c => c, c => counters.Where(k => k.Key.StartsWith(c + ":", StringComparison.Ordinal)).Sum(k => k.Value)),
Inbound = Outcomes(counters, Interactions.In),
Outbound = delivered,
DeliverySuccess = attempts == 0 ? (double?)null : Math.Round(delivered.GetValueOrDefault(Interactions.Ok) / (double)attempts, 4),
DeliveryLatencyMs = new { P50 = Percentile(latency, Interactions.Out, 0.5), P95 = Percentile(latency, Interactions.Out, 0.95) },
Hosts = new
DeliveryLatencyMs = new ViewLatency { P50 = Percentile(latency, Interactions.Out, 0.5), P95 = Percentile(latency, Interactions.Out, 0.95) },
Hosts = new ViewHostCounts
{
Active = hostDays.Where(d => d.Host != Interactions.Unknown && d.Counters.Count > 0).Select(d => d.Host).Distinct().Count(),
Known = await DB.Default.CountAsync<RemoteInstance>(cancellation: token)
},
Accounts = server.Sum(d => d.Accounts) + hostDays.Where(d => d.Live).Sum(d => d.Accounts),
Ledger = new
Ledger = new ViewLedgerCounts
{
Written = server.Sum(d => d.LedgerWritten),
Dropped = server.Sum(d => d.LedgerDropped),
@@ -77,7 +78,7 @@ namespace PrivaPub.Domain.Statistics
};
}
public async Task<object> Hosts(int days, string sort, string software, int page, int limit, CancellationToken token)
public async Task<ViewHostsPage> Hosts(int days, string sort, string software, int page, int limit, CancellationToken token)
{
var hostDays = await Days(days, default, token);
var instances = (await DB.Default.Find<RemoteInstance>().ExecuteAsync(token)).ToDictionary(i => i.Host, StringComparer.Ordinal);
@@ -87,10 +88,10 @@ namespace PrivaPub.Domain.Statistics
var counters = Sum(g.Select(d => d.Counters));
var outbound = Outcomes(counters, Interactions.Out);
var instance = instances.GetValueOrDefault(g.Key);
return new
return new ViewHostRow
{
Host = g.Key,
instance?.Software,
Software = instance?.Software,
Version = instance?.SoftwareVersion,
In = counters.Where(k => k.Key.StartsWith("in:", StringComparison.Ordinal)).Sum(k => k.Value),
Out = counters.Where(k => k.Key.StartsWith("out:", StringComparison.Ordinal)).Sum(k => k.Value),
@@ -98,8 +99,12 @@ namespace PrivaPub.Domain.Statistics
Failures = outbound.Where(o => o.Key is Interactions.Retry or Interactions.Dead or Interactions.Failed).Sum(o => o.Value),
LatencyP50 = Percentile(Sum(g.Select(d => d.Latency)), Interactions.Out, 0.5),
Accounts = g.Sum(d => d.Accounts),
instance?.DescribedAt,
Delivery = instance == default ? default : new { instance.ConsecutiveFailures, instance.UnavailableUntil, instance.LastSuccessAt, instance.LastFailureAt }
DescribedAt = instance?.DescribedAt,
Delivery = instance == default ? default : new ViewHostDelivery
{
ConsecutiveFailures = instance.ConsecutiveFailures, UnavailableUntil = instance.UnavailableUntil, LastSuccessAt = instance.LastSuccessAt,
LastFailureAt = instance.LastFailureAt
}
};
})
.Where(r => software == default || string.Equals(r.Software, software, StringComparison.OrdinalIgnoreCase));
@@ -112,7 +117,7 @@ namespace PrivaPub.Domain.Statistics
};
var list = rows.ToList();
limit = Math.Clamp(limit, 1, 200);
return new { Total = list.Count, Page = Math.Max(page, 1), Hosts = list.Skip((Math.Max(page, 1) - 1) * limit).Take(limit), Attribution };
return new ViewHostsPage { Total = list.Count, Page = Math.Max(page, 1), Hosts = list.Skip((Math.Max(page, 1) - 1) * limit).Take(limit).ToList(), Attribution = Attribution };
}
public async Task<object> Host(string host, int days, CancellationToken token)
@@ -147,7 +152,7 @@ namespace PrivaPub.Domain.Statistics
.ExecuteAsync(token);
}
public async Task<object> Crawler(bool enabled, int revisitDays, CancellationToken token)
public async Task<ViewCrawler> Crawler(bool enabled, int revisitDays, CancellationToken token)
{
var due = DateTime.UtcNow.AddDays(-Math.Max(revisitDays, 1));
var recent = await DB.Default.Find<RemoteInstance>()
@@ -155,14 +160,18 @@ namespace PrivaPub.Domain.Statistics
.Sort(i => i.LastCrawledAt, Order.Descending)
.Limit(20)
.ExecuteAsync(token);
return new
return new ViewCrawler
{
Enabled = enabled,
Touched = await DB.Default.CountAsync<RemoteInstance>(i => i.Seen == "touched", token),
Crawled = await DB.Default.CountAsync<RemoteInstance>(i => i.Seen == "crawled", token),
Due = await DB.Default.CountAsync<RemoteInstance>(i => i.LastCrawledAt == null || i.LastCrawledAt < due, token),
RobotsDisallowed = await DB.Default.CountAsync<RemoteInstance>(i => i.RobotsDisallowed == true, token),
Recent = recent.Select(i => new { i.Host, i.Seen, i.LastCrawledAt, i.PeersCount, i.RobotsDisallowed, i.CrawlError, i.Software })
Recent = recent.Select(i => new ViewCrawledHost
{
Host = i.Host, Seen = i.Seen, LastCrawledAt = i.LastCrawledAt, PeersCount = i.PeersCount, RobotsDisallowed = i.RobotsDisallowed,
CrawlError = i.CrawlError, Software = i.Software
}).ToList()
};
}
@@ -0,0 +1,59 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Models.Group;
using PrivaPub.Models.Social;
namespace PrivaPub.Domain.Statuses
{
// A direct conversation's newest post (DmGroup.LastPostId, which pages the conversations list) and each persona's
// read and removed marks on it (ConversationState). A conversation is unread for a persona when its newest post is
// someone else's and newer than what the persona read; writing in it reads it.
public static class ConversationStates
{
public static async Task Posted(string conversationId, string postId, string authorAvatarId, CancellationToken token)
{
await DB.Default.Update<DmGroup>().MatchID(conversationId)
.Modify(g => g.UpdatedAt, DateTime.UtcNow)
.Modify(g => g.LastPostId, postId)
.ExecuteAsync(token);
if (authorAvatarId != default)
await Read(authorAvatarId, conversationId, postId, token);
}
public static Task Read(string avatarId, string conversationId, string postId, CancellationToken token) =>
Mark(avatarId, conversationId, s => s.LastReadPostId, postId, token);
public static Task Hide(string avatarId, string conversationId, string postId, CancellationToken token) =>
Mark(avatarId, conversationId, s => s.HiddenUpTo, postId, token);
public static bool IsUnread(ConversationState state, string lastPostId, string lastAuthorId, string avatarId) =>
lastPostId != default && lastAuthorId != avatarId && string.CompareOrdinal(lastPostId, state?.LastReadPostId) > 0;
public static bool IsHidden(ConversationState state, string lastPostId) =>
state?.HiddenUpTo != default && string.CompareOrdinal(lastPostId, state.HiddenUpTo) <= 0;
// the mark only moves forward; the first mark makes the state (two at once: the unique index keeps one, and the
// loser marks again)
static async Task Mark(string avatarId, string conversationId, System.Linq.Expressions.Expression<Func<ConversationState, string>> field,
string postId, CancellationToken token)
{
if (postId == default)
return;
Task Write() => DB.Default.Update<ConversationState>()
.Match(s => s.AvatarId == avatarId && s.ConversationId == conversationId)
.Modify(b => b.Max(field, postId))
.Modify(s => s.UpdatedAt, DateTime.UtcNow)
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
try
{
await Write();
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
await Write();
}
}
}
}
Loaded 100 of 328 files, more files were not shown because too many files have changed in this diff. Show more