Compare commits

..
124 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
thepraandClaude Opus 5.5 436f7da464 CDNs found by themselves, and servers followed through time
Build / Build (push) Successful in 5m11s
Deploy / privapub.thepra.dev (push) Successful in 5m48s
PrivaPub now finds CDNs three ways, best first: the address ranges the
CDNs publish (Cloudflare, Fastly, Amazon CloudFront, Bunny, Gcore,
Imperva), downloaded daily by CdnUpdater and kept in CdnRangeSet; the
CDN's fingerprint in the responses it already gets from a server
(EdgeHintsHandler on the federation client); and the networks that carry
only a CDN. The fixed ASN list is gone; ASNs shared with plain hosting
(AWS, DataPacket) no longer hide a server. A server's Geo records the
CDN, its domain and how it was found, and weekly snapshots now keep the
city and coordinates too.

Servers through time (ServerPlaces): /instances/:host/history lists a
server's weekly snapshots, a CDN-fronted server's geo names the CDN's
domain and where the server was before it (before_cdn), and
/api/privapub/v1/cdns and /cdns/:domain group servers by CDN with week
by week who joined and who left. Owner decisions recorded in ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 11:33:39 +02:00
thepraandClaude Opus 5.5 5d7edc4a4c ROADMAP: no size or bucket restrictions on the public statistics
The plan for the public /stargazing statistics showed per-server
activity only as order-of-magnitude buckets and only for servers
reporting at least 10 users. The owner never intended either
restriction; both are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 11:20:44 +02:00
thepraandClaude Opus 5.5 eb7552e8f3 Server locations: no user threshold
The public projection of a server's place showed only the country for
servers reporting fewer than 10 users. The owner never decided that
threshold: every located server now shows its city, coordinates (0.1°)
and network, and a CDN-fronted one still shows only its CDN, since the
address reached is the CDN's edge. Statistics:PublicCityMinUsers is
gone; the ROADMAP decision on server locations, CLAUDE.md and the
/stargazing explainer are corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 11:12:02 +02:00
thepraandClaude Opus 5.5 31d1d21d1e Pixelfed places: a remote post's own place, shown and never federated again
A Note's `location` that is a Place with coordinates (Pixelfed sends
them as strings) is kept in the new Post.Place and shown as
Status.privapub.place {name, latitude, longitude, country}; an edit
replaces it. An Event's location stays its own `places`, and nothing
renders a place back out. Closes the INTEROP P2 Pixelfed location gap
and the ROADMAP long-tail item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 10:07:06 +02:00
thepraandClaude Opus 5.5 d9fb5c582f Where a server is, on the public instance API
/api/privapub/v1/instances/:host gains `geo`, the public projection of a
server's place already decided for public server locations
(PublicGeo.Project): city, coordinates and network for servers reporting
at least 10 users and not behind a CDN, the country otherwise, the CDN's
name for a CDN-fronted one, with DB-IP's attribution. `?host[]=`
answers up to 40 servers at once, and this server describes itself:
its host's address located once a day (SelfLocation), or
Statistics:Geo:Self when the owner sets it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 10:03:59 +02:00
thepraandClaude Opus 5.5 2cd75176a4 One sign-in for decePubClient: the root JWT exchanged for a persona's token
The first-party client signs in on /clientapi and exchanges that JWT on
/oauth/token (RFC 8693, subject_token_type jwt, avatar_id) for one
persona's Mastodon token. Only the seeded public application `decepub`
holds the grant; RootJwtSubjectToken validates the JWT through RootJwt,
which JwtBearer now shares (signature, lifetime, ban, deletion, session
stamp). The issued token names the avatar, never the root.

Owner decision recorded in ROADMAP; it supersedes "moving decePubClient
onto the Mastodon API is out of scope".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 10:03:46 +02:00
thepraandClaude Opus 5.5 c7e8760ffe docs: v1.20.0 deployed and verified; everything on in production
Build / Build (push) Successful in 4m34s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-04 04:27:40 +02:00
thepraandClaude Opus 5.5 ad8d353f9a docs: FEDERATION.md tested against six peers with signed fetches, the shipped FEPs; v1.19.1 and v1.20.0 in ROADMAP
Build / Build (push) Successful in 5m14s
Deploy / privapub.thepra.dev (push) Successful in 5m25s
- FEDERATION.md:
  - It said only GoToSocial was tested live. It now lists all six pasture peers, run with signed fetches required as
    production runs, and what is checked both ways.
  - FEP-044f, FEP-9967, FEP-c0e0 and FEP-5feb moved from planned to supported.
  - indexable and discoverable are described as the settings they are.
- ROADMAP: v1.19.1 deployed and verified, v1.20.0 (phase 4).
- Pasture, Akkoma: its like and boost count gets two minutes. In the final clean pass, all six peers with SecureMode on
  gave 245 passed, 1 failed, 4 expected. The failure was this count, which Akkoma's job queue was slow to update
  under the load of six peers; two reruns gave 44/44.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-04 04:16:16 +02:00
thepraandClaude Opus 5.5 e2f61ede56 Everything on, phase 4b: ids that resolve, a hashtag page, grouped notifications, remote accounts' real counts
- A community's announces resolve, as FEDERATION.md and ROADMAP always said Announce ids do. GroupDistributor keeps
  each one it sends (GroupAnnouncement, unique by id). /grunts serves it while the post it carries is shown and 410
  after, and also serves the announce-{postId} ids the group outbox lists, which now keep a stable `published`
  instead of the time of the fetch.
- A persona's boost points at the boosted post: its `url` in the Mastodon API is the boosted post's page, and a browser
  following the boost's id is redirected there instead of getting JSON.
- /tags/{tag}, where every Hashtag link we send points, is now a public page of this server's public posts with that
  tag, with the same strict CSP and noindex as the profile pages.
- Grouped notifications (/api/v2/notifications, its unread count, a group, its accounts and dismiss). We advertise
  api_versions.mastodon = 7 so clients show quotes, and clients that trust it call these; they answered 404. Likes and
  boosts of one post group together, as do follows within an hour. The version string stays 4.2.0 until streaming
  and Web Push exist.
- A remote account's follower, following and post counts are what its server publishes. AccountCountsJob reads its
  collections' totalItems from its own origin, signed by the instance actor, at most daily and only after the account
  was fetched, never when someone looks. They used to be 0.
- The other ids that do not resolve are documented as such: Update, Delete, EmojiReact, QuoteRequest and its answers,
  Flag, Ignore and poll votes.

676 tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-04 03:56:18 +02:00
thepraandClaude Opus 5.5 8f25bf056d Everything on, phase 4a: one answer everywhere for counts, search, collections and the instance API
Owner decision 2026-10-04: fix the mismatches and every other mismatch of the same kind.

- One counting rule (Domain/Privacy/Counted), Mastodon's. It is used for a persona's statuses_count, its outbox
  totalItems, NodeInfo localPosts and the instance status_count, which used to count four different things. It
  counts every post that is neither deleted nor a DM, boosts included, and circle and located posts too (owner
  decision). A group's count includes its remote members' posts.
- Users. Personas of banned or deleted roots no longer count, and are not found in search. NodeInfo now gives
  activeMonth and activeHalfyear, and the v2 instance gives active_month instead of a constant 0.
- replies_count counts only public and unlisted replies, so it no longer tells anyone that a private reply exists.
  Migration _012 recounts it.
- A remote account that deletes itself takes everything out of every count (GoneActors): its likes, downvotes,
  reactions and poll votes go and their counters come back, as do its boosts', replies' and quotes' counts, and its
  notifications. Lookups, account lists, search and favourited_by no longer show it. Migration _012 applies this to
  accounts already gone.
- Deleting a post also deletes its pins and the local boosts of it.
- /stalking gives the same total as following_count. Members are still never listed, and hide_collections is now
  always true, since the setting never did anything.
- Joining a community by invitation is following it, so /flock and /groupies agree; leaving unfollows.
- Search. Anyone may search, as on Mastodon; resolve and offset need a sign-in, offset pages, and deleted accounts
  are never found.
- notifications/unread_count counts what the list shows, and the owner's follower and following lists page with
  Link.
- The instance API advertises what is enforced:
  - max_characters, now enforced with a 422;
  - max_pinned_statuses = MaxPins;
  - the media types and limits MediaService and MediaOptions accept;
  - PollService's limits;
  - the configured languages;
  - no streaming URL until streaming exists.

  domain_count counts the servers we have exchanged with; which ones stays unpublished (peers is empty).
- Routes Mastodon answers now answer instead of 404:
  - directory, tags/{name}, timelines/link and identity_proofs;
  - instance/languages, translation_languages, domain_blocks and privacy_policy;
  - the v1 and v2 notification policy, and notification requests.

Also, from phase 3: a recovered password ends /clientapi sessions through a per-root SessionStamp claim instead of
comparing the JWT's whole-second nbf with the change time. That comparison let a token issued in the same second
survive, which made a test flaky.

671 tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-04 03:48:40 +02:00
thepraandClaude Opus 5.5 123ba39520 A post whose author cannot be shown is left out of a list, never a 500
Build / Build (push) Successful in 4m19s
Deploy / privapub.thepra.dev (push) Successful in 4m50s
The v1.19.0 deploy's tests failed where build.yml's passed. A test stored a public remote post with no author id, and
when the public timeline test ran after it, MastodonMapper.Statuses looked that null up in its accounts and answered
500. The mapper now skips such a post, and the test stores a real author.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-04 03:46:48 +02:00
thepraandClaude Opus 5.5 f6dbf71964 Everything on, phase 2 completed: SecureMode on in production, checked by the deploy
Build / Build (push) Successful in 5m5s
Deploy / privapub.thepra.dev (push) Failing after 4m39s
Owner decision 2026-10-04: SecureMode on once the pasture passes with it.

- Both clean pasture passes were run over all six peers:
  - normally: 246 passed, 0 failed;
  - with Federation__SecureMode=true: every federation check passed. The only failures were four checks expecting an
    unsigned GET to get 404 or 410 where SecureMode answers 401. Those checks now go through `unserved` and
    `gone_unsigned` (lib/interop.sh), which expect 401 when SecureMode is on.
- Circle posts now reach their member on GoToSocial and Mastodon, and survive Mastodon's signed refetch, as does a
  followers-only post. The GoToSocial expected failure is gone.
- appsettings.Production.json turns SecureMode on.
- The deploy now checks that an unsigned GET of @thepra answers 401 and that a browser is redirected. It reads
  @thepra's discoverability through the Mastodon API, since the actor is no longer readable unsigned.
- docs/INTEROP.md (Mastodon, GoToSocial), CLAUDE.md and ROADMAP updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-04 03:34:48 +02:00
thepraandClaude Opus 5.5 8c2eba6cbb Everything on, phase 3: sign-in and recovery tell nothing, recovered passwords end sessions, deleted roots are gone everywhere
Owner decision 2026-10-04: fix the account privacy findings.

- Sign-in. Every failure answers "That username and password do not match." after the same work: an unknown login
  is hashed against a decoy, and the comparison is constant-time. "Banned" is told only to someone who gave the right
  password. This covers /clientapi/user/login, /invitation/login and /oauth/login.
- Recovery.
  - Every request answers the same sentence and queues a SendRecovery job, whether or not the account exists or has an
    email. The lookup, the code and SMTP move to RecoveryJob, so neither the answer nor its timing says anything.
  - Codes are kept only as a SHA-256 hash, for one hour. Migration _011 drops the plaintext ones, which never expired.
  - A recovered password ends every session of the root. RootSessions sets CredentialsChangedAt, which JwtEvents
    checks against the JWT's issue time, now stamped as nbf, and revokes each persona's OAuth tokens and authorizations.
- Deleting a root (RootRemoval: the admin route, or the restored self-delete at /clientapi/user/delete, which asks for
  the password).
  - Its sessions end.
  - Each persona and each group it owns sends Delete{Actor} to its followers, its members and the accounts it follows.
  - The personas' posts are emptied.
  - /peasants/{name} answers 410 with a Tombstone (formerType Person or Group), as do its inbox and WebFinger, through
    LocalActorService.Gone. The names stay reserved.
  - The root keeps only a unique `deleted-{id}` name; the second deletion on an instance used to collide on
    "Deleted user".

Also, from phase 2's pasture: GoToSocial files a circle post like a DM and shows it only to accounts it mentions. Each
member's copy, and a member's refetch, now also mentions that member silently. The GoToSocial scenario checks circle
posts in conversations, like DMs, and they pass there now, as on Mastodon.

657 tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-04 03:16:59 +02:00
thepraandClaude Opus 5.5 fcd35f5043 Everything on, phase 2: circle posts for everyone, private posts on signed refetch, browsers past SecureMode
Circles (owner decision 2026-10-04: fix them for compatibility):
- Mastodon 4.7 and GoToSocial drop a post that names none of their accounts, and a circle post named only the circle
  and its /flock. OutboxPublisher.Publish now sends each member a copy that also names that member in `cc`, on the
  activity and on the object, and names no other member. The Create, every Update (edit, poll, quote approval, policy,
  through the new PublishUpdate) and the Delete (StatusService.Remove now uses Publish) all go that way.
- UpdateOf renders with the post's group, so an Update keeps a circle post's `audience` and a community post's `Page`
  and title.
- A reply to a circle post stays in the circle, whichever client wrote it.
- A circle post can no longer quote a post that needs permission: asking would show the circle post to its author.

Posts that are not public, on refetch (SignedFetchAuthorizer.MayRead):
- Followers-only, direct and circle posts are served to a signed request from someone they were for, or from the
  instance actor of a server where one of them lives. That is a follower or an addressed account, an addressed
  account, or a member. Everyone else still gets 404.
- Once deleted they answer those readers 410. Mastodon deletes its copy when a refetch answers 404.
- A circle refetch names the requesting member, or the members on the requesting server, as the delivered copy did.
- /grunts/create-{id} serves the same.
- /peasants/{name}/whispers/{id}, a DM's `context`, was never routed. It is now the conversation's posts, for its
  participants only.

SecureMode lets browsers through to the redirect to the public page, instead of answering them 401.

653 tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-04 03:01:14 +02:00
375 changed files with 28968 additions and 1101 deletions

No files matched your search

+6 -1
View File
@@ -95,6 +95,11 @@ jobs:
[ "$served" = "$BUILD_COMMIT" ] || { echo "::error::served build is '$served', expected '$BUILD_COMMIT'"; exit 1; } [ "$served" = "$BUILD_COMMIT" ] || { echo "::error::served build is '$served', expected '$BUILD_COMMIT'"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$PUBLIC_URL/peasants/privapub") code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$PUBLIC_URL/peasants/privapub")
[ "$code" = "200" ] || { echo "::error::the instance actor answered $code"; exit 1; } [ "$code" = "200" ] || { echo "::error::the instance actor answered $code"; exit 1; }
# SecureMode (owner decision 2026-10-04): an unsigned reader gets 401 from a persona, a browser the public page
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$PUBLIC_URL/peasants/thepra")
[ "$code" = "401" ] || { echo "::error::an unsigned GET of a persona answered $code, not 401: SecureMode is off"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: text/html' "$PUBLIC_URL/peasants/thepra")
[ "$code" = "302" ] || { echo "::error::a browser asking for a persona got $code, not a redirect"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/.well-known/nodeinfo") code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/.well-known/nodeinfo")
[ "$code" = "200" ] || { echo "::error::nodeinfo answered $code"; exit 1; } [ "$code" = "200" ] || { echo "::error::nodeinfo answered $code"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/swagger/index.html") code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/swagger/index.html")
@@ -123,7 +128,7 @@ jobs:
read -r token cid cs <<< "$(tools/smoke/oauth.sh "$PUBLIC_URL" "$login" "$password" thepra read)" read -r token cid cs <<< "$(tools/smoke/oauth.sh "$PUBLIC_URL" "$login" "$password" thepra read)"
echo "::add-mask::$token" echo "::add-mask::$token"
tools/smoke/mastodon-api.sh "$PUBLIC_URL" "$token" tools/smoke/mastodon-api.sh "$PUBLIC_URL" "$token"
discoverable=$(curl -fsS -H 'Accept: application/activity+json' "$PUBLIC_URL/peasants/thepra" | python3 -c "import json,sys; print(json.load(sys.stdin).get('discoverable'))") discoverable=$(curl -fsS -H "Authorization: Bearer $token" "$PUBLIC_URL/api/v1/accounts/verify_credentials" | python3 -c "import json,sys; print(json.load(sys.stdin).get('discoverable'))")
[ "$discoverable" = "False" ] || { echo "::error::@thepra is discoverable"; exit 1; } [ "$discoverable" = "False" ] || { echo "::error::@thepra is discoverable"; exit 1; }
curl -fsS -o /dev/null -X POST "$PUBLIC_URL/oauth/revoke" --data-urlencode "token=$token" \ curl -fsS -o /dev/null -X POST "$PUBLIC_URL/oauth/revoke" --data-urlencode "token=$token" \
--data-urlencode "client_id=$cid" --data-urlencode "client_secret=$cs" --data-urlencode "client_id=$cid" --data-urlencode "client_secret=$cs"
+2
View File
@@ -402,6 +402,8 @@ FodyWeavers.xsd
PrivaPub/media-store/ PrivaPub/media-store/
PrivaPub/media-store-proxy/ PrivaPub/media-store-proxy/
tools/pasture/.publish/ tools/pasture/.publish/
tools/pasture/.flood/
.claude/worktrees/ .claude/worktrees/
tools/pasture/.ca/ tools/pasture/.ca/
tools/pasture/.state/ tools/pasture/.state/
tools/pasture/out/
+352 -25
View File
@@ -1,4 +1,4 @@
# CLAUDE.md # CLAUDE.md
Guidance for working in this repository. `docs/ROADMAP.md` holds the owner's decisions and the phased plan to full Guidance for working in this repository. `docs/ROADMAP.md` holds the owner's decisions and the phased plan to full
ActivityPub interop; read it before changing anything federation-, privacy- or API-shaped. `docs/INTEROP.md` is the ActivityPub interop; read it before changing anything federation-, privacy- or API-shaped. `docs/INTEROP.md` is the
@@ -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**. becoming two kinds: a public **community** (FEP-1b12, Lemmy-compatible) and a private, invitation-only **circle**.
- **`DmGroup` is a direct-message conversation.** - **`DmGroup` is a direct-message conversation.**
- **`privapub` is the instance actor** (type Application). It signs fetches no persona should be tied to. - **`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: Privacy features in the model:
- location-ranged posts (`Post.Location`, `RangeKm`), to become local-only and never federated; - location-ranged posts (`Post.Location`, `RangeKm`), to become local-only and never federated;
@@ -29,8 +32,8 @@ Privacy features in the model:
The intended client is the Mastodon client API (Tusky, Elk, Phanpy, Ivory), where each avatar logs in as its own The intended client is the Mastodon client API (Tusky, Elk, Phanpy, Ivory), where each avatar logs in as its own
account. The private `/clientapi` covers what Mastodon can't express. `thepra/decePubClient` account. The private `/clientapi` covers what Mastodon can't express. `thepra/decePubClient`
(https://decepub.thepra.dev) is the owner's own Pleroma-FE-like PWA. It still shows mock data and is not wired to the (https://decepub.thepra.dev) is the owner's own Pleroma-FE-like PWA. It signs in once on `/clientapi` and exchanges that
server. JWT for each persona's Mastodon token (`PersonaExchange`), then uses both APIs.
## Route names are deliberate ## Route names are deliberate
@@ -83,9 +86,14 @@ PrivaPub/ ASP.NET Core Web API, net10.0
(authoritative fetch, key verification, WebFinger), ActorDocument (parser) (authoritative fetch, key verification, WebFinger), ActorDocument (parser)
Objects/ Origin, ActivityJson, NoteParser, Addressing, ContentSanitizer Objects/ Origin, ActivityJson, NoteParser, Addressing, ContentSanitizer
Moderation/ DomainBlocks (suspend / silence / reject media) 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, 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 Outbox/ OutboxPublisher (who a post goes to), DeliveryService (queues jobs) + DeliveryJobHandler
Rendering/ ActivityPubRenderer (Mastodon @context, actors, notes, collections) Rendering/ ActivityPubRenderer (Mastodon @context, actors, notes, collections)
Domain/ Domain/
@@ -95,7 +103,9 @@ PrivaPub/ ASP.NET Core Web API, net10.0
Privacy/ VisibilityPolicy (IsPublic expression, CanSee) Privacy/ VisibilityPolicy (IsPublic expression, CanSee)
Relationships/ RelationshipService (blocks, mutes, account domain blocks; Hidden), ReportService Relationships/ RelationshipService (blocks, mutes, account domain blocks; Hidden), ReportService
Media/ MediaService (libvips, ffmpeg remux, blurhash), MediaProxy, MediaJanitor 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/ Api/Mastodon/
Auth/ OpenIddict setup (keys in Mongo), MastodonScopes, TokenController, OAuthPruner Auth/ OpenIddict setup (keys in Mongo), MastodonScopes, TokenController, OAuthPruner
Infrastructure/ MastodonController (avatar context, scopes, errors, Link), MastodonParams, MastodonJson, Page 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 `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). (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 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 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 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. the actor lists it, its `owner` is the actor and it shares the actor's origin.
4. **Inbound inboxes verify everything before acting:** 4. **Inbound inboxes verify everything before acting:**
- the signature covers `(request-target)`, `host`, `digest` and `date` or `(created)`; - 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 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 - 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:** 5. **Status codes:**
- bad or missing signature: **401**; - bad or missing signature: **401**;
- malformed or forged body: **400**; - malformed or forged body: **400**;
@@ -182,29 +204,69 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
`ContentFormat` says what `Text` holds. Remote names are plain text. `ContentFormat` says what `Text` holds. Remote names are plain text.
8. **Circles federate to members only.** A circle is an undiscoverable Group actor that takes follow requests (the owner 8. **Circles federate to members only.** A circle is an undiscoverable Group actor that takes follow requests (the owner
approves); its posts are addressed to the circle and its `/flock`, delivered to members' personal inboxes, never approves); its posts are addressed to the circle and its `/flock`, delivered to members' personal inboxes, never
announced, and served only to a signed request from a member or a member's instance actor announced. Each member's copy also names that member in `cc` (`OutboxPublisher.Naming`, owner decision
(`SignedFetchAuthorizer`), 404 otherwise. Circles never appear in search, lookups, mentions or profile pages. 2026-10-04), because Mastodon and GoToSocial keep a post only when it names one of their accounts; Create, every
Update and the Delete all go through `OutboxPublisher.Publish` for that. A reply to a circle post stays in the circle,
and a circle post never asks a non-member for a quote. Circles never appear in search, lookups, mentions or profile
pages.
**A post that is not public is served only to a signed request from someone it was for** (`SignedFetchAuthorizer.MayRead`):
a follower or an addressed account for followers-only, an addressed account for a DM, a member for a circle, or the
instance actor of a server where one of them lives; 404 to anyone else, 410 to them once it is deleted. A circle
refetch names the requesting member, or the members on the requesting server. A DM's `context`
(`/peasants/{name}/whispers/{id}`) lists the conversation's posts for its participants. Mastodon deletes its copy
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, **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`. 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 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). `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`): 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 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`). 202 once it has verified and queued; a handler must be idempotent (unique `ObjectURI`, job `DedupeKey`).
11. **Every "may anyone see this" goes through `VisibilityPolicy.IsPublic`;** a persona-specific read uses `CanSee`, and 11. **Every number about posts and users goes through `Domain/Privacy/Counted`** (owner decision 2026-10-04: one
answer everywhere): a persona's `statuses_count`, its outbox `totalItems`, NodeInfo `localPosts` and the instance
`status_count` all count Mastodon's way (not deleted, not a DM, boosts included, circle and located posts too);
users exclude personas of banned or deleted roots; `replies_count` counts only public and unlisted replies; a
remote account that deletes itself takes its likes, votes, reactions, boosts and replies out of every count
(`GoneActors`). Follower and following counts are public, their members never are (`hide_collections` is always
true).
12. **Every "may anyone see this" goes through `VisibilityPolicy.IsPublic`;** a persona-specific read uses `CanSee`, and
any other read of stored posts filters by `IsShown`. All three hide deleted posts and the posts of a remote account any other read of stored posts filters by `IsShown`. All three hide deleted posts and the posts of a remote account
that deleted itself (`Post.AuthorGone`: kept, hidden everywhere, owner decision). that deleted itself (`Post.AuthorGone`: kept, hidden everywhere, owner decision).
12. **Remote content is stored only when someone here asked for it:** a persona follows the author, is addressed or 13. **Remote content is stored only when someone here asked for it:** a persona follows the author, is addressed or
mentioned, it replies to a local post, or it is addressed to a community the author follows; a public parent is mentioned, it replies to a local post, or it is addressed to a community the author follows; a public parent is
fetched as context. Followers-only is detected by the author's stored `followers` URL. fetched as context. Followers-only is detected by the author's stored `followers` URL.
13. **Home timelines are written, not computed:** every stored or created post goes through `Fanout.Distribute`, and 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). 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 ## Mastodon client API invariants
1. **A token is one persona.** Its subject is the avatar id; the root id lives only in the fifteen-minute `/oauth` 1. **A token is one persona.** Its subject is the avatar id; the root id lives only in the fifteen-minute `/oauth`
cookie used while choosing the persona, and never in a token, an authorization or a response. cookie used while choosing the persona, and never in a token, an authorization or a response.
- **The first-party client exchanges the root JWT for one persona's token** (RFC 8693 on `/oauth/token`,
`subject_token_type` `urn:ietf:params:oauth:token-type:jwt`, `avatar_id`; `Api/Mastodon/Auth/PersonaExchange.cs`).
Only the seeded public application (`ClientApi:FirstPartyClientId`, default `decepub`) holds the grant.
`RootJwtSubjectToken` validates the JWT through `RootJwt`, the same rules as JwtBearer (signature, lifetime, ban,
deletion, session stamp). The issued token is the same kind as the authorization code flow's, so revocation and
`RootSessions` cover it.
2. `/api/*` authenticates with OpenIddict validation, everything else with the old JWT (`PrivaPub` policy scheme). 2. `/api/*` authenticates with OpenIddict validation, everything else with the old JWT (`PrivaPub` policy scheme).
Every `/api` request re-checks that the persona's root is neither banned nor deleted (`MastodonController`). Every `/api` request re-checks that the persona's root is neither banned nor deleted (`MastodonController`).
3. Read parameters through `Params` (query, form and JSON merged Rails-style), never MVC binding. A value type read 3. Read parameters through `Params` (query, form and JSON merged Rails-style), never MVC binding. A value type read
@@ -214,9 +276,17 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
error that leaves without a body (a challenge, a 404 from routing, a 429) gets Mastodon's `{"error": ...}` from error that leaves without a body (a challenge, a 404 from routing, a 429) gets Mastodon's `{"error": ...}` from
`UseMastodonErrorBodies`. `NeverFiveHundredTests` walks every route with junk ids, anonymously, as a persona and `UseMastodonErrorBodies`. `NeverFiveHundredTests` walks every route with junk ids, anonymously, as a persona and
with a junk token. with a junk token.
6. Advertise `4.2.0 (compatible; PrivaPub)` until grouped notifications exist. 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, 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, and up/down votes. Every media URL in it goes 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
through the proxy; only page links (`link.url`, an event's online link) point at the remote site, because following through the proxy; only page links (`link.url`, an event's online link) point at the remote site, because following
one is the reader's choice. one is the reader's choice.
@@ -243,6 +313,16 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
- **No root id in federation output, NodeInfo or logs, no IP next to an identity in logs, and no `ex.Message` to a - **No root id in federation output, NodeInfo or logs, no IP next to an identity in logs, and no `ex.Message` to a
client** ("Something went wrong." instead). client** ("Something went wrong." instead).
- **Sign-in and recovery never tell which logins or addresses exist** (owner decision 2026-10-04): every failed sign-in
gets "That username and password do not match." after the same hashing (`RootUsersService.Decoy`, a constant-time
comparison), and only a correct password learns of a ban. Every recovery request answers the same sentence and
queues a `SendRecovery` job, found or not; `RecoveryJob` sends the email and keeps only a SHA-256 of the code, for an
hour. A recovered password ends every session of the root (`RootSessions`: a new `SessionStamp`, which every `/clientapi`
JWT carries, and OpenIddict revocation for each persona).
- **Deleting a root deletes everything public it had** (`RootRemoval`, from the admin route or `/clientapi/user/delete`
with the password): its sessions end, each persona and each group it owns sends `Delete{Actor}` to followers, members
and the accounts it follows, the personas' posts are emptied, and `/peasants/{name}`, its inbox and WebFinger answer
410 (`LocalActorService.Gone`) while the names stay reserved. The root keeps only a `deleted-{id}` name.
- **Never derive a public name from the root username.** Invitation sign-up takes `AvatarUserName` and refuses one - **Never derive a public name from the root username.** Invitation sign-up takes `AvatarUserName` and refuses one
equal to the login. equal to the login.
- **One username space:** personas, groups and the instance reserve their name in `ReservedName` (unique index) before - **One username space:** personas, groups and the instance reserve their name in `ReservedName` (unique index) before
@@ -254,7 +334,8 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
see ids. Never emit `CreatedAt`. see ids. Never emit `CreatedAt`.
- **Per-avatar state stays per avatar:** blocks, mutes, notifications, follows. Nothing may relate sibling avatars. - **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 - **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 - **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 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. something about an avatar gets the same treatment: ask, then record it there.
@@ -285,7 +366,19 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
revision. Delivery details reach them through `Arrival.Current`, which `InboxProcessor` sets for the handler's revision. Delivery details reach them through `Arrival.Current`, which `InboxProcessor` sets for the handler's
duration. A fetched record has no signature, key or `@context` of the activity that caused the fetch (its activity duration. A fetched record has no signature, key or `@context` of the activity that caused the fetch (its activity
fields name that trigger), and every record stores its `Extensions` and `ContextNamespaces` for statistics. The raw form lives outside `Post` so timelines never load it. Read through fields name that trigger), and every record stores its `Extensions` and `ContextNamespaces` for statistics. The raw form lives outside `Post` so timelines never load it. Read through
`/api/privapub/v1/statuses/:id/provenance` and `/api/privapub/v1/instances/:host`. `/api/privapub/v1/statuses/:id/provenance` and `/api/privapub/v1/instances/:host` (or `?host[]=`, up to 40; this
server answers too).
- **A server's place leaves only through `PublicGeo.Project`:** the city, coordinates (0.1°) and network it was located
to, whatever its size (there is no user threshold), and only the CDN's name for a CDN-fronted one. This server's own
place is its host's address located once a day (`SelfLocation`), or `Statistics:Geo:Self` when the owner sets it.
- **CDNs are found three ways, best first** (`Infrastructure/Geo/CdnCatalog.cs`): the ranges a CDN publishes
(`CdnUpdater` downloads them daily into `CdnRangeSet`, keeping the old set when a download or parse fails), the CDN's
fingerprint in the responses PrivaPub already gets (`EdgeHintsHandler` on the federation client, memory only), and
a network that carries nothing but a CDN. Never list an ASN shared with plain hosting (AWS, Google, Microsoft,
DataPacket). A server's `Geo` records the CDN, its domain and how it was found.
- **Servers through time** (`Domain/Statistics/ServerPlaces.cs`, from the weekly `RemoteInstanceSnapshot`s):
`/api/privapub/v1/instances/:host/history`, a CDN-fronted server's `geo.before_cdn`, and the CDNs grouped by their
own domain (`/api/privapub/v1/cdns`, `/cdns/:domain` with weekly joined/left).
- **Remote objects are parsed for every shape in `ObjectShapes`:** `url`/`icon`/`image` as a value, an object or an - **Remote objects are parsed for every shape in `ObjectShapes`:** `url`/`icon`/`image` as a value, an object or an
array; Markdown `content`; missing `mediaType`s inferred; thumbnails from any of their five places; and the typed array; Markdown `content`; missing `mediaType`s inferred; thumbnails from any of their five places; and the typed
`Link`, `Video`, `Audio` and `Event` details. A Mastodon API card is built from those, never by fetching the linked page `Link`, `Video`, `Audio` and `Event` details. A Mastodon API card is built from those, never by fetching the linked page
@@ -315,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 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. 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 - **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 - **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, 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 and only backfill keeps a `published`-derived id. With `published` ids a reply arriving in the same second could sort
@@ -390,6 +484,10 @@ tools/pasture/interop.sh [gts mastodon ...] # each peer
tools/pasture/run.sh down # removes every pasture container and volume 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` - **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 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 a later phase, `privapub_token <persona>`, `stats_check <host> <software>`), `scenarios/<name>.sh`. Each peer talks to
@@ -412,14 +510,19 @@ tools/pasture/run.sh down # removes e
delete is checked as a 404 on `/api/v1/statuses/{id}`. 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 - 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. also checks our pending (`requested`) state and the manual Accept.
- 37 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; DMs both ways and off public timelines; polls both ways; quote policy; link cards; edits and deletes both
ways; 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. - **Mastodon (4.7.3):** web and sidekiq on the shared Postgres and Redis, `ALLOWED_PRIVATE_ADDRESSES` for the network.
Its token comes from `rails runner` (no password grant). Without Elasticsearch its status search finds nothing, so Its token comes from `rails runner` (no password grant). Without Elasticsearch its status search finds nothing, so
deliveries are checked through `/api/v1/accounts/:id/statuses` of the sender as Mastodon knows them, or 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 `Status.exists?` through `rails runner`. Its actors are numbered (`/ap/users/<id>`), so look URIs up rather than
build them. 49 checks; circle posts are an expected failure (see `docs/INTEROP.md`, Mastodon). build them. Circle posts and a followers-only post survive its signed refetch (`ActivityPub::FetchRemoteStatusService`
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 - **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>` (`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 with the token as `i` (`mk` in the scenario); `users/relation` answers a list, `users/notes` leaves replies out unless
@@ -432,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 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 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 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 - **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 `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 `DANGER_FEDERATION_ALLOW_LOCAL_IP=1`; 0.19 cannot join (its rustls trusts only its bundled roots). Its API is
@@ -440,11 +543,235 @@ 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 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 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 `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.
- **Crawler:** `PRIVAPUB_ENV="Statistics__Crawler__Enabled=true Statistics__Crawler__Seeds__0=mastodon.test" - **Crawler:** `PRIVAPUB_ENV="Statistics__Crawler__Enabled=true Statistics__Crawler__Seeds__0=mastodon.test"
run.sh up mastodon`, then `interop.sh crawler`. `PRIVAPUB_ENV` passes any setting to the PrivaPub container. 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 - `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. `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 After the pasture, verify.funfedi.dev and the owner's GoToSocial at social.arasaka.software. **Ask before acting from
the owner's GoToSocial account.** the owner's GoToSocial account.**
+270 -37
View File
@@ -8,33 +8,100 @@ PrivaPub is an ActivityPub server written in C#. This document follows
- [ActivityPub](https://www.w3.org/TR/activitypub/) (server-to-server) - [ActivityPub](https://www.w3.org/TR/activitypub/) (server-to-server)
- [WebFinger](https://webfinger.net/) - [WebFinger](https://webfinger.net/)
- [HTTP Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures), `rsa-sha256` / `hs2019` with RSA keys - [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 - [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 ## Tested against
- **GoToSocial 0.22.1, end to end.** It runs in a private network on the workstation (`tools/pasture/`) and is driven End to end, in a private network on the workstation (`tools/pasture/`), each peer driven through its own client API
through its own client API. Checked both ways: and every run starting clean, with signed fetches required (as privapub.thepra.dev runs):
- follows, including to a locked account; - **GoToSocial 0.22.1**
- public posts with a content warning; - **Mastodon 4.7.3**
- replies with notifications; - **Misskey 2026.10**
- likes and boosts; - **Sharkey 2025.4.7**
- direct messages; - **Akkoma 3.20.1**
- edits and deletes; - **Lemmy 1.0.0-beta.2**, and **Lemmy 0.19.20** (built with the platform's roots, so it trusts the pasture's CA)
- unfollow. - **PeerTube 8.3.1**
- **Mastodon, Misskey, Lemmy and PeerTube, by unit tests only.** The tests feed the parser documents written in each - **Pixelfed 0.14.4**
server's shape. No live exchange with any of them has run yet. - **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;
- every visibility, content warnings, replies and their notifications;
- likes, boosts and emoji reactions with their undos;
- direct messages;
- polls;
- quotes;
- images with alt text;
- edits with history and deletes;
- communities and circles;
- blocks and unfollows.
`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 ## Supported FEPs
- [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md) - [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-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-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)
- [FEP-9967: Polls](https://codeberg.org/fediverse/fep/src/branch/main/fep/9967/fep-9967.md)
- [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 P5 to P8, and the per-platform notes in `docs/INTEROP.md`): FEP-044f (quotes), Planned (see `docs/ROADMAP.md`, phases P7 and P8, and the per-platform notes in `docs/INTEROP.md`): FEP-9098 (custom
FEP-9967 (polls), FEP-c0e0 (emoji reactions), FEP-9098 (custom emoji), FEP-7888 and FEP-f228 (threads), FEP-7628 (Move), emoji), FEP-7888 and FEP-f228 (threads), FEP-7628 (Move), FEP-8967 (link
FEP-8fcf (followers synchronisation), FEP-5feb (`indexable`), FEP-8967 (link attachments), FEP-521a and FEP-8b32 (keys attachments), FEP-ae0c (relays).
and integrity proofs), FEP-ae0c (relays).
## Actors ## Actors
@@ -52,15 +119,23 @@ actor document, a collection, NodeInfo or a delivery relates two avatars of the
| Activities | `/peasants/{name}/grunts/{id}` | | Activities | `/peasants/{name}/grunts/{id}` |
| Quote permissions (`QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{id}` | | Quote permissions (`QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{id}` |
| Direct-message context | `/peasants/{name}/whispers/{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}` | | Profile and post pages | `/@{name}`, `/@{name}/{id}` |
The names are the project's own and are stable; resolve actors through WebFinger, not by guessing a path. The names are the project's own and are stable; resolve actors through WebFinger, not by guessing a path.
- The key is `{actor}#main-key`, RSA 2048, served as SPKI PEM with `owner` set to the actor. - The key is `{actor}#main-key`, RSA 2048, served as SPKI PEM with `owner` set to the actor.
- `published` on an actor is a whole day, chosen at random up to two weeks before the account was made, so two - `published` on an actor is a whole day, chosen at random up to two weeks before the account was made, so two
personas made on the same day do not share a date. `indexable` is `false`. 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 - 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 ## Groups
@@ -68,16 +143,31 @@ 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 - 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 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 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 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. the actor's `attributedTo` points to, with `postingRestrictedToMods` as Lemmy expects. A mention of a community posts into it.
- A **circle** is private. Its actor is not discoverable and every follow is a request. Its posts are addressed to the - A **circle** is private. Its actor is not discoverable and every follow is a request. Its posts are addressed to the
circle and its members collection, delivered to each member's own inbox and never announced. They are served only circle and its members collection, delivered to each member's own inbox and never announced. Each member's copy also
to a signed request from a member, or from the instance actor of a member's server; anyone else gets 404. A names that member in `cc`, so servers that keep only posts naming one of their accounts (Mastodon, GoToSocial) keep
Mastodon member's replies reach only the people they mention. 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. 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 - 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 ## Activities
@@ -86,28 +176,87 @@ Received:
| Activity | Effect | | Activity | Effect |
|---|---| |---|---|
| `Follow` | follows an avatar or community; `Accept` is sent unless the community approves members by hand | | `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 | | `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{Note}` | replaces the content; the previous version is kept |
| `Update{Person}` | refetches the actor | | `Update{Person}` | refetches the actor |
| `Like` | counted and notified, on posts the liker could see | | `Like` | counted and notified, on posts the liker could see |
| `Dislike` | counted as a downvote (Lemmy, PieFed, Mbin, Friendica); `Undo` takes it back | | `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 | | `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 | | `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 | | `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}`, 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`. - **Attachments** are `Document`s with `mediaType`, `name` (alt text), `blurhash`, `focalPoint`, `width` and `height`.
Uploaded files have all metadata removed. 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 - **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it
followed); an unblock sends `Undo{Block}`. 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` A `Create`'s `Note` carries Mastodon's `content`, `contentMap`, `summary` and `sensitive`, plus `Mention` and `Hashtag`
tags. tags.
@@ -117,6 +266,8 @@ tags.
content, one per choice, counted once per voter. Counts are refreshed with an `Update{Question}` at most every three 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`. 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. 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): **Quotes** (FEP-044f, with the older keys):
- **Received.** A quote is read from `quote`, `quoteUrl`, `quoteUri`, `_misskey_quote` or a FEP-e232 `Link` tag. - **Received.** A quote is read from `quote`, `quoteUrl`, `quoteUri`, `_misskey_quote` or a FEP-e232 `Link` tag.
@@ -139,6 +290,28 @@ An incoming `Update` without a newer `updated` only refreshes counts and details
`Delete{stamp}`. `Delete{stamp}`.
- Followers-only posts and DMs can never be quoted. - 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. **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`, **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 `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
@@ -162,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. 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. `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 ## Server descriptions and the crawler
PrivaPub keeps statistics about servers, never about accounts (see `/stargazing` on the server). PrivaPub keeps statistics about servers, never about accounts (see `/stargazing` on the server).
@@ -190,21 +376,68 @@ 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 - **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 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. 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 - **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. 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 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
they answer those same readers 410. A direct message's `context`, `/peasants/{name}/whispers/{id}`, is an
`OrderedCollection` of the conversation's posts for its participants.
- **HTML.** Received HTML is sanitised to Mastodon's allowlist. - **HTML.** Received HTML is sanitised to Mastodon's allowlist.
- **Keys we cannot fetch for now.** When a sender's key cannot be fetched because its server timed out or answered - **Keys we cannot fetch for now.** When a sender's key cannot be fetched because its server timed out or answered
5xx, the inbox answers 503 with `Retry-After: 300` rather than 401. 5xx, the inbox answers 503 with `Retry-After: 300` rather than 401.
- **Activity ids.** `Create` and `Announce` ids dereference. `Follow`, `Like`, `Block` and the `Accept`, `Reject` and - **Activity ids.** `Create` and `Announce` ids dereference, a community's announces included, until the post they
`Undo` that answer them do not, because serving them would reveal who follows, likes and blocks whom; they are always carry is deleted (then 410); a persona's boost id sends a browser to the boosted post. `Follow`, `Like`, `Block` and
sent with their object embedded. the `Accept`, `Reject` and `Undo` that answer them do not, because serving them would reveal who follows, likes and
- **Delivery.** Failed deliveries are retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused, blocks whom. Neither do `Update`, `Delete`, `EmojiReact`, `QuoteRequest` and its answers, `Flag`, `Ignore` or poll
starting at an hour and growing to a week. 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.** 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 ## Known limitations
- Our own posts carry no custom emoji: emoji are received and shown, not offered. - Our own posts carry no custom emoji: emoji are received and shown, not offered.
- Remote media is fetched through this server's proxy when a local client displays it. Video and audio are streamed - Remote media is fetched through this server's proxy when a local client displays it. Video and audio are streamed
through it with byte ranges passed on, so a PeerTube file is never fetched whole for one viewer. through it with byte ranges passed on, so a PeerTube file is never fetched whole for one viewer.
- Collections expose counts, not members. - Collections expose counts, not members: `/groupies` and `/stalking` give the same totals as the Mastodon API's
- Only `rsa-sha256`-style keys are verified. RFC 9421 signatures are planned. 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.
- 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; }
}
}
@@ -0,0 +1,14 @@
using PrivaPub.ClientModels.Resources;
using System.ComponentModel.DataAnnotations;
namespace PrivaPub.ClientModels.User
{
// Deleting one's own login, and with it every persona and group it owns, asks for the password again.
public class RemoveSelfForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
public string Password { get; set; }
}
}
@@ -60,6 +60,7 @@ namespace PrivaPub.Tests.Domain
{ {
public string BaseAddress => Base; public string BaseAddress => Base;
public Task<GoneActor> Gone(string userName, CancellationToken token) => Task.FromResult<GoneActor>(default);
public Task<LocalActor> FindByUserName(string userName, CancellationToken token) => public Task<LocalActor> FindByUserName(string userName, CancellationToken token) =>
Task.FromResult(userName.Equals("alice", StringComparison.OrdinalIgnoreCase) Task.FromResult(userName.Equals("alice", StringComparison.OrdinalIgnoreCase)
? new LocalActor { Id = "a1", UserName = "alice", BaseAddress = Base, Kind = LocalActorKind.Person } ? new LocalActor { Id = "a1", UserName = "alice", BaseAddress = Base, Kind = LocalActorKind.Person }
@@ -67,7 +68,9 @@ namespace PrivaPub.Tests.Domain
public Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token) => throw new NotSupportedException(); 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> 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> 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> IsUserNameTaken(string userName, CancellationToken token) => throw new NotSupportedException();
public Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, 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(); 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)) .Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
.Where(p => p.Body.Contains(post.ID)); .Where(p => p.Body.Contains(post.ID));
Assert.Empty(outgoing); 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] [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); 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 token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice"); 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"); var poster = new RemoteActor(_harness.Peer, "poster");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token); 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); 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)); 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] [Fact]
public async Task A_fetched_object_does_not_wear_the_signature_of_the_activity_that_caused_the_fetch() 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));
}
}
}
+104 -1
View File
@@ -9,6 +9,7 @@ using PrivaPub.Models.Federation;
using PrivaPub.Models.Group; using PrivaPub.Models.Group;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Models.Social; using PrivaPub.Models.Social;
using PrivaPub.StaticServices;
using PrivaPub.Tests.Support; using PrivaPub.Tests.Support;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
@@ -89,6 +90,28 @@ namespace PrivaPub.Tests.Federation
Assert.Contains(announces, a => a["object"] is JsonValue); 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] [Fact]
public async Task An_open_community_takes_posts_from_anyone() public async Task An_open_community_takes_posts_from_anyone()
{ {
@@ -102,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)); 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] [Fact]
public async Task Mentioning_a_community_posts_into_it_as_a_titled_page() public async Task Mentioning_a_community_posts_into_it_as_a_titled_page()
{ {
@@ -138,21 +176,86 @@ namespace PrivaPub.Tests.Federation
Assert.Equal(PostVisibility.Circle, outcome.Post.Visibility); Assert.Equal(PostVisibility.Circle, outcome.Post.Visibility);
var create = Assert.Single(await _harness.Outgoing(member.Id + "/inbox")); var create = Assert.Single(await _harness.Outgoing(member.Id + "/inbox"));
Assert.Equal(new[] { circle.Uri, circle.Flock }, create["object"]!["to"]!.AsArray().Select(t => t!.GetValue<string>())); Assert.Equal(new[] { circle.Uri, circle.Flock }, create["object"]!["to"]!.AsArray().Select(t => t!.GetValue<string>()));
// the member's copy names the member, so Mastodon and GoToSocial keep it, and names nobody else
Assert.Equal(new[] { member.Id }, create["cc"]!.AsArray().Select(t => t!.GetValue<string>()));
Assert.Equal(new[] { member.Id }, create["object"]!["cc"]!.AsArray().Select(t => t!.GetValue<string>()));
Assert.Contains(create["object"]!["tag"]!.AsArray(), t => t!["type"]!.GetValue<string>() == "Mention" && t["href"]!.GetValue<string>() == member.Id);
Assert.DoesNotContain(Addressing.Public, create.ToJsonString()); Assert.DoesNotContain(Addressing.Public, create.ToJsonString());
Assert.Empty(await _harness.Outgoing(follower.SharedInbox)); Assert.Empty(await _harness.Outgoing(follower.SharedInbox));
Assert.Empty((await _harness.Outgoing(member.SharedInbox)).Where(a => a["type"]!.GetValue<string>() == "Announce")); Assert.Empty((await _harness.Outgoing(member.SharedInbox)).Where(a => a["type"]!.GetValue<string>() == "Announce"));
var authorizer = new SignedFetchAuthorizer(_harness.Remote); var authorizer = new SignedFetchAuthorizer(_harness.Remote, new DbEntities());
var path = new Uri(outcome.Post.ObjectURI).AbsolutePath; var path = new Uri(outcome.Post.ObjectURI).AbsolutePath;
var asMember = await authorizer.Requester(member.Get(Harness.Host, path), token); var asMember = await authorizer.Requester(member.Get(Harness.Host, path), token);
var asOutsider = await authorizer.Requester(outsider.Get(Harness.Host, path), token); var asOutsider = await authorizer.Requester(outsider.Get(Harness.Host, path), token);
Assert.True(SignedFetchAuthorizer.MayReadCircle(circleEntity, asMember)); Assert.True(SignedFetchAuthorizer.MayReadCircle(circleEntity, asMember));
Assert.False(SignedFetchAuthorizer.MayReadCircle(circleEntity, asOutsider)); Assert.False(SignedFetchAuthorizer.MayReadCircle(circleEntity, asOutsider));
Assert.False(SignedFetchAuthorizer.MayReadCircle(circleEntity, default)); Assert.False(SignedFetchAuthorizer.MayReadCircle(circleEntity, default));
Assert.True(await authorizer.MayRead(outcome.Post, asMember, token));
Assert.False(await authorizer.MayRead(outcome.Post, asOutsider, token));
Assert.Equal(new[] { member.Id }, SignedFetchAuthorizer.CircleReaders(circleEntity, asMember));
Assert.True(circle.IsCircle); Assert.True(circle.IsCircle);
Assert.False(circle.Discoverable); Assert.False(circle.Discoverable);
} }
[Fact]
public async Task A_circle_posts_edit_and_delete_reach_each_member_naming_only_that_member()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var first = new RemoteActor(_harness.Peer, "first");
var second = new RemoteActor(_harness.Peer, "second", _harness.Peer.B);
var (_, circle) = await Group(GroupKind.Circle, PostingPolicy.Followers, alice.Id, first.Id, second.Id);
await _harness.Remote.GetActor(first.Id, refresh: false, token);
await _harness.Remote.GetActor(second.Id, refresh: false, token);
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "just us", GroupId = circle.Id }, token);
await _harness.Statuses.Edit(alice, outcome.Post.ID, new StatusDraft { Text = "just us, edited" }, token);
await _harness.Statuses.Remove(alice, outcome.Post.ID, token);
foreach (var (member, other) in new[] { (first, second), (second, first) })
{
var sent = await _harness.Outgoing(member.Id + "/inbox");
Assert.Equal(new[] { "Create", "Update", "Delete" }, sent.Select(a => a["type"]!.GetValue<string>()));
Assert.All(sent, a => Assert.Contains(member.Id, a["cc"]!.AsArray().Select(c => c!.GetValue<string>())));
Assert.All(sent, a => Assert.DoesNotContain(other.Id, a.ToJsonString()));
}
}
[Fact]
public async Task A_reply_to_a_circle_post_stays_in_it_and_a_circle_post_asks_nobody_outside_for_a_quote()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var (_, bob) = await _harness.Persona("bob");
var (entity, circle) = await Group(GroupKind.Circle, PostingPolicy.Followers, alice.Id);
entity.Members.Add(new GroupMember { AvatarId = bob.Id });
await DB.Default.SaveAsync(entity, token);
var outsider = new RemoteActor(_harness.Peer, "asked");
var outsiderAccount = await _harness.Remote.GetActor(outsider.Id, refresh: false, token);
var asksFirst = new Post
{
ObjectURI = $"{Origin(outsider)}/notes/{Guid.NewGuid():N}",
ActorURI = outsider.Id,
GroupUserId = outsiderAccount.ID,
AuthorAccountId = outsiderAccount.ID,
IsFederatedCopy = true,
Visibility = PostVisibility.Public,
ContentHtml = "<p>ask me first</p>",
QuotePolicy = new InteractionRule { Manual = new() { Addressing.Public } }
};
await DB.Default.SaveAsync(asksFirst, token);
var root = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "just us", GroupId = circle.Id }, token);
var reply = await _harness.Statuses.Publish(bob, new StatusDraft { Text = "still just us", InReplyTo = root.Post.ID }, token);
var quote = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "look", GroupId = circle.Id, QuotedStatusId = asksFirst.ID }, token);
Assert.Equal(PostVisibility.Circle, reply.Post.Visibility);
Assert.Equal(circle.Id, reply.Post.GroupId);
Assert.Equal(422, quote.Status);
Assert.Empty(await _harness.Outgoing(outsider.Id + "/inbox"));
}
[Fact] [Fact]
public async Task Only_circle_members_can_post_into_a_circle() public async Task Only_circle_members_can_post_into_a_circle()
{ {
@@ -101,6 +101,33 @@ namespace PrivaPub.Tests.Federation
Assert.Equal("the signature has expired", Assert.Equal("the signature has expired",
Check(MastodonRequest(extra: $",expires=\"{Now.AddMinutes(-20).ToUnixTimeSeconds()}\""))); 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] [Fact]
public void Signs_with_the_raw_request_target() 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.ClientModels.Post;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Tests.Support; using PrivaPub.Tests.Support;
@@ -106,6 +107,75 @@ namespace PrivaPub.Tests.Federation
Assert.NotNull(message.ConversationId); 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] [Fact]
public async Task An_unreachable_key_asks_the_sender_to_retry_instead_of_refusing() 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); 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] [Fact]
public async Task A_join_of_a_local_post_is_answered_with_ignore() 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); 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] [Fact]
public async Task An_actor_update_refreshes_the_account_from_its_origin_even_when_its_updated_is_older() 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] [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 token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice"); 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)); await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", like));
var liked = Processed("Announce"); 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))); await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(voter, "Undo", like)));
var unliked = Processed("Announce"); var unliked = Processed("Announce");
await _harness.Deliver(stranger, "/human-centipede", Activity(stranger, "Announce", like)); await _harness.Deliver(stranger, "/human-centipede", Activity(stranger, "Announce", like));
var unfollowed = Processed("Announce"); var unfollowed = Processed("Announce");
//votes relayed by a community are a documented gap (docs/INTEROP.md, Lemmy: "Announces of activities other than Create") //Lemmy relays its members' votes inside the community's Announce (G-0003): a voter on the community's own server
Assert.Equal(("dropped", "unsupported", "Like"), (liked.Outcome, liked.Reason, liked.Object)); //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(("dropped", "unsupported", "Undo"), (unliked.Outcome, unliked.Reason, unliked.Object)); Assert.Equal("accepted", liked.Outcome);
Assert.Equal("accepted", unliked.Outcome);
Assert.Equal(("dropped", "not-followed"), (unfollowed.Outcome, unfollowed.Reason)); Assert.Equal(("dropped", "not-followed"), (unfollowed.Outcome, unfollowed.Reason));
var after = await DB.Default.Find<Post>().OneAsync(post.ID, token); var after = await DB.Default.Find<Post>().OneAsync(post.ID, token);
Assert.Equal(0, after.FavouritesCount); Assert.Equal(0, after.FavouritesCount);
Assert.False(await DB.Default.Find<Favourite>().Match(f => f.PostId == post.ID).ExecuteAnyAsync(token)); 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] [Fact]
public async Task A_locked_persona_holds_a_follow_until_it_decides_and_answers_with_the_original_follow() 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 token = TestContext.Current.CancellationToken;
var alice = await LocalAvatar("alice"); var alice = await LocalAvatar("alice");
var mallory = new RemoteActor(_peer, "mallory"); 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)); var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, attributedTo: victim.Id));
Assert.Equal(400, result.StatusCode); Assert.Equal(400, result.StatusCode);
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == victim.Id).ExecuteAnyAsync(token)); 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] [Fact]
@@ -263,6 +270,28 @@ namespace PrivaPub.Tests.Federation
Assert.Equal(alice.Id, mention.AccountId); 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] [Fact]
public async Task A_suspended_domain_is_dropped_before_its_key_is_fetched() 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.Post;
using PrivaPub.ClientModels.Social; using PrivaPub.ClientModels.Social;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Models.Social; using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support; using PrivaPub.Tests.Support;
using System.Text.Json.Nodes; 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); 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] [Fact]
public async Task A_boost_of_a_local_post_counts_notifies_and_can_be_undone() 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); 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() async Task<(LocalActor Alice, RemoteActor Mallory, Post Poll)> LocalPoll()
{ {
var token = TestContext.Current.CancellationToken; var token = TestContext.Current.CancellationToken;
@@ -469,7 +601,7 @@ namespace PrivaPub.Tests.Federation
await DB.Default.DeleteAsync<RemoteInstance>(i => PeerHosts.Contains(i.Host)); 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 token = TestContext.Current.CancellationToken;
var id = $"{Harness.Base}/a/{Guid.NewGuid():N}"; var id = $"{Harness.Base}/a/{Guid.NewGuid():N}";
@@ -479,7 +611,7 @@ namespace PrivaPub.Tests.Federation
["object"] = new JsonObject { ["type"] = "Note", ["content"] = "hi" } ["object"] = new JsonObject { ["type"] = "Note", ["content"] = "hi" }
}, token); }, token);
var job = await DB.Default.Find<Job>().Match(j => j.DedupeKey == $"{id}|{inbox}").ExecuteSingleAsync(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())), var handler = new DeliveryJobHandler(_harness.Local, Peer.Http(), new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions())),
NullLogger<DeliveryJobHandler>.Instance); NullLogger<DeliveryJobHandler>.Instance);
return await handler.Handle(job, token); return await handler.Handle(job, token);
@@ -533,5 +665,42 @@ namespace PrivaPub.Tests.Federation
Assert.Equal(JobResult.Retry, broken.Result); Assert.Equal(JobResult.Retry, broken.Result);
Assert.StartsWith("500", broken.Error); 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] [Fact]
public void Reads_a_mastodon_note() public void Reads_a_mastodon_note()
{ {
@@ -57,6 +77,69 @@ namespace PrivaPub.Tests.Federation
Assert.Null(note.Updated); 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] [Fact]
public void Reads_a_lemmy_page_with_a_title() public void Reads_a_lemmy_page_with_a_title()
{ {
+46 -1
View File
@@ -1,4 +1,4 @@
using PrivaPub.Api.Mastodon.Mappers; using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
@@ -79,6 +79,51 @@ namespace PrivaPub.Tests.Federation
Assert.Equal("2026-10-03 10:00 – 13:00 (Europe/Rome) · Biblioteca", MastodonMapper.EventLine(note.Event)); Assert.Equal("2026-10-03 10:00 – 13:00 (Europe/Rome) · Biblioteca", MastodonMapper.EventLine(note.Event));
} }
[Fact]
public void Keeps_a_pixelfed_place_whose_coordinates_are_strings()
{
var note = Parse("""
{
"id": "https://pixel.example/p/ann/1", "type": "Note", "attributedTo": "https://pixel.example/users/ann", "content": "<p>view</p>",
"location": { "type": "Place", "name": "Tokyo", "longitude": "139.6917", "latitude": "35.6895", "country": "Japan" },
"commentsEnabled": true
}
""");
Assert.Equal(("Tokyo", 35.6895, 139.6917, "Japan"), (note.Place.Name, note.Place.Latitude, note.Place.Longitude, note.Place.Country));
Assert.Null(note.Event);
}
[Theory]
[InlineData("null")]
[InlineData("""{ "type": "Place", "name": "Nowhere" }""")]
[InlineData("""{ "type": "Place", "name": "Off the map", "latitude": 123, "longitude": 9 }""")]
[InlineData("""{ "type": "Place", "latitude": "north", "longitude": "9.19" }""")]
[InlineData("""{ "type": "VirtualLocation", "url": "https://meet.example/a", "latitude": 45, "longitude": 9 }""")]
public void Has_no_place_without_a_place_with_coordinates(string location)
{
var note = Parse($$"""
{ "id": "https://pixel.example/p/ann/2", "type": "Note", "attributedTo": "https://pixel.example/users/ann", "content": "<p>x</p>", "location": {{location}} }
""");
Assert.Null(note.Place);
}
[Fact]
public void An_events_location_stays_the_events_own()
{
var note = Parse("""
{
"id": "https://events.example/events/10", "type": "Event", "name": "Picnic", "startTime": "2026-10-03T10:00:00+02:00",
"location": { "type": "Place", "name": "Park", "latitude": 45.07, "longitude": 7.68 },
"attributedTo": "https://events.example/@club"
}
""");
Assert.Null(note.Place);
Assert.Equal("Park", Assert.Single(note.Event.Places).Name);
}
[Fact] [Fact]
public void Keeps_a_lemmy_link_post_as_a_link_with_its_thumbnail() public void Keeps_a_lemmy_link_post_as_a_link_with_its_thumbnail()
{ {
@@ -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(":blobcat@k.example:", "blobcat")]
[InlineData("👍🏽", "👍🏽")] [InlineData("👍🏽", "👍🏽")]
[InlineData("❤️", "❤️")] [InlineData("❤️", "❤️")]
[InlineData("❤", "❤️")]
[InlineData("✌", "✌️")]
[InlineData("🔥\uFE0F", "🔥")]
[InlineData("nope", null)] [InlineData("nope", null)]
[InlineData("👍👍", null)] [InlineData("👍👍", null)]
[InlineData(":unknown:", 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"
}
+70 -23
View File
@@ -6,6 +6,8 @@ using MongoDB.Entities;
using PrivaPub.ClientModels; using PrivaPub.ClientModels;
using PrivaPub.Models.Group; using PrivaPub.Models.Group;
using PrivaPub.Models.User; using PrivaPub.Models.User;
using PrivaPub.Services;
using PrivaPub.Models.Jobs;
using PrivaPub.Tests.Support; using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host; using PrivaPub.Tests.Support.Host;
@@ -136,9 +138,11 @@ namespace PrivaPub.Tests.Http
Assert.Equal(HttpStatusCode.OK, ok.StatusCode); Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
Assert.Equal(root.Id, (await ok.JsonBody())["userId"]!.GetValue<string>()); Assert.Equal(root.Id, (await ok.JsonBody())["userId"]!.GetValue<string>());
// a wrong password and an unknown login get the same answer, so sign-in tells nobody which logins exist
Assert.Equal(HttpStatusCode.BadRequest, wrong.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, wrong.StatusCode);
Assert.Equal("Wrong password.", await Message(wrong)); Assert.Equal(RootUsersService.NoMatch, await Message(wrong));
Assert.Equal(HttpStatusCode.BadRequest, unknown.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, unknown.StatusCode);
Assert.Equal(await Message(wrong), await Message(unknown));
Assert.Equal(HttpStatusCode.OK, logout.StatusCode); Assert.Equal(HttpStatusCode.OK, logout.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, anonymousLogout.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, anonymousLogout.StatusCode);
} }
@@ -290,42 +294,59 @@ namespace PrivaPub.Tests.Http
} }
[Fact] [Fact]
public async Task Recovery_without_an_email_is_refused() public async Task Recovery_answers_the_same_whoever_asks_and_queues_the_work()
{ {
var root = await _host.SignUp("noemail"); var token = TestContext.Current.CancellationToken;
var since = DateTime.UtcNow.AddSeconds(-1);
var withEmail = await _host.SignUp("withemail");
var withoutEmail = await _host.SignUp("noemail");
var email = $"{Guid.NewGuid():N}@example.test";
using (var signedIn = _host.As(withEmail.Jwt))
Assert.Equal(HttpStatusCode.OK, (await signedIn.PostJson("/clientapi/user/update", new { email })).StatusCode);
using var client = _host.Client(); using var client = _host.Client();
var response = await client.PostJson("/clientapi/user/recover/password", new { userName = root.UserName }); var answers = new[]
var unknown = await client.PostJson("/clientapi/user/recover/password", new { userName = Name("nobody") }); {
var neither = await client.PostJson("/clientapi/user/recover/password", new { }); await client.PostJson("/clientapi/user/recover/password", new { userName = withEmail.UserName }),
await client.PostJson("/clientapi/user/recover/password", new { email }),
await client.PostJson("/clientapi/user/recover/password", new { userName = withoutEmail.UserName }),
await client.PostJson("/clientapi/user/recover/password", new { userName = Name("nobody") }),
await client.PostJson("/clientapi/user/recover/password", new { email = $"{Guid.NewGuid():N}@example.test" })
};
var bodies = new List<string>();
foreach (var answer in answers)
{
Assert.Equal(HttpStatusCode.OK, answer.StatusCode);
bodies.Add(await answer.Content.ReadAsStringAsync(token));
}
Assert.Equal(HttpStatusCode.Locked, response.StatusCode); Assert.Single(bodies.Distinct());
Assert.Contains("doesn't have an email", await Message(response)); Assert.Contains("recovery link is on its way", bodies[0]);
Assert.False(await DB.Default.Find<EmailRecovery>().Match(r => r.RootUserId == root.Id).ExecuteAnyAsync(TestContext.Current.CancellationToken)); var queued = await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.SendRecovery && j.CreatedAt >= since).ExecuteAsync(token);
Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode); Assert.True(queued.Count >= answers.Length);
Assert.Equal(HttpStatusCode.BadRequest, neither.StatusCode); Assert.Contains(queued, j => j.Payload.Contains(withEmail.Id));
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostJson("/clientapi/user/recover/password", new { })).StatusCode);
} }
[Fact] [Fact]
public async Task Recovery_through_an_unreachable_mail_server_says_so_and_nothing_more() public async Task The_recovery_job_keeps_only_a_hash_and_an_unreachable_mail_server_says_nothing_to_anyone()
{ {
var token = TestContext.Current.CancellationToken;
var root = await _host.SignUp("smtp"); var root = await _host.SignUp("smtp");
var email = $"{Guid.NewGuid():N}@example.test"; var email = $"{Guid.NewGuid():N}@example.test";
using (var signedIn = _host.As(root.Jwt)) using (var signedIn = _host.As(root.Jwt))
Assert.Equal(HttpStatusCode.OK, (await signedIn.PostJson("/clientapi/user/update", new { email })).StatusCode); Assert.Equal(HttpStatusCode.OK, (await signedIn.PostJson("/clientapi/user/update", new { email })).StatusCode);
using var client = _host.Client(); using var client = _host.Client();
var byName = await client.PostJson("/clientapi/user/recover/password", new { userName = root.UserName }); var answer = await client.PostJson("/clientapi/user/recover/password", new { userName = root.UserName });
var byEmail = await client.PostJson("/clientapi/user/recover/password", new { email }); await _host.Run(j => j.Kind == JobKind.SendRecovery && j.Payload.Contains(root.Id), token);
foreach (var response in new[] { byName, byEmail }) Assert.Equal(HttpStatusCode.OK, answer.StatusCode);
{ var recovery = await DB.Default.Find<EmailRecovery>().Match(r => r.RootUserId == root.Id).ExecuteFirstAsync(token);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); Assert.NotNull(recovery);
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); Assert.Null(recovery.RecoveryCode);
Assert.Equal("Failed to send email.", JsonNode.Parse(body)!["errorMessage"]!.GetValue<string>()); Assert.Equal(64, recovery.CodeHash.Length);
foreach (var leak in new[] { "xception", "efused", "ocket", "127.0.0.1", "smtp", "SMTP", " at " }) Assert.InRange(recovery.ExpiresAt, DateTime.UtcNow.AddMinutes(50), DateTime.UtcNow.AddMinutes(61));
Assert.DoesNotContain(leak, body);
}
} }
[Fact] [Fact]
@@ -348,7 +369,10 @@ namespace PrivaPub.Tests.Http
{ {
var root = await _host.SignUp("recover"); var root = await _host.SignUp("recover");
var code = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N"); var code = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N");
await DB.Default.SaveAsync(new EmailRecovery { RootUserId = root.Id, RecoveryCode = code }, TestContext.Current.CancellationToken); await DB.Default.SaveAsync(new EmailRecovery { RootUserId = root.Id, CodeHash = RecoveryJob.Hash(code), ExpiresAt = DateTime.UtcNow.AddHours(1) },
TestContext.Current.CancellationToken);
var persona = await _host.Persona(root, "recovered");
var apiToken = await _host.MastodonToken(persona);
using var client = _host.Client(); using var client = _host.Client();
var valid = await client.PostAsync("/clientapi/user/recover/valid", new StringContent($"\"{code}\"", Encoding.UTF8, "application/json"), TestContext.Current.CancellationToken); var valid = await client.PostAsync("/clientapi/user/recover/valid", new StringContent($"\"{code}\"", Encoding.UTF8, "application/json"), TestContext.Current.CancellationToken);
@@ -361,6 +385,29 @@ namespace PrivaPub.Tests.Http
Assert.Equal(HttpStatusCode.BadRequest, (await LogIn(root.UserName, root.Password)).StatusCode); Assert.Equal(HttpStatusCode.BadRequest, (await LogIn(root.UserName, root.Password)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await LogIn(root.UserName, NewPassword)).StatusCode); Assert.Equal(HttpStatusCode.OK, (await LogIn(root.UserName, NewPassword)).StatusCode);
Assert.False(await DB.Default.Find<EmailRecovery>().Match(r => r.RootUserId == root.Id).ExecuteAnyAsync(TestContext.Current.CancellationToken)); Assert.False(await DB.Default.Find<EmailRecovery>().Match(r => r.RootUserId == root.Id).ExecuteAnyAsync(TestContext.Current.CancellationToken));
// whoever held the old sessions may be why the password was recovered: they end
using (var oldSession = _host.As(root.Jwt))
Assert.Equal(HttpStatusCode.Unauthorized, (await oldSession.GetAsync("/clientapi/user/sniff/again", TestContext.Current.CancellationToken)).StatusCode);
using (var oldApp = _host.Client())
{
oldApp.DefaultRequestHeaders.Authorization = new("Bearer", apiToken);
Assert.Equal(HttpStatusCode.Unauthorized, (await oldApp.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode);
}
}
[Fact]
public async Task An_expired_recovery_code_changes_nothing()
{
var root = await _host.SignUp("expired");
var code = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N");
await DB.Default.SaveAsync(new EmailRecovery { RootUserId = root.Id, CodeHash = RecoveryJob.Hash(code), ExpiresAt = DateTime.UtcNow.AddMinutes(-1) },
TestContext.Current.CancellationToken);
using var client = _host.Client();
var change = await client.PostJson("/clientapi/user/recover/update/password", new { newPassword = NewPassword, repeatedPassword = NewPassword, recoveryCode = code });
Assert.Equal(HttpStatusCode.NotFound, change.StatusCode);
Assert.Equal(HttpStatusCode.OK, (await LogIn(root.UserName, root.Password)).StatusCode);
} }
[Fact] [Fact]
@@ -202,6 +202,51 @@ namespace PrivaPub.Tests.Http
Assert.Equal(circle["url"]!.GetValue<string>(), accept["actor"]!.GetValue<string>()); 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] [Fact]
public async Task A_circle_is_never_found() 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" }); var response = await Insert(root, new { userName, name = userName, biography = "testing" });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); 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)); Assert.Empty(await Listed(root));
} }
+118 -7
View File
@@ -101,6 +101,51 @@ namespace PrivaPub.Tests.Http
Assert.Equal("Application", instance.Json["type"]!.GetValue<string>()); 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] [Fact]
public async Task Users_moves_permanently_to_peasants_and_unknown_actors_are_404() public async Task Users_moves_permanently_to_peasants_and_unknown_actors_are_404()
{ {
@@ -142,7 +187,9 @@ namespace PrivaPub.Tests.Http
Assert.Equal(ActivityJson, outbox.MediaType); Assert.Equal(ActivityJson, outbox.MediaType);
Assert.Equal("OrderedCollection", outbox.Json["type"]!.GetValue<string>()); Assert.Equal("OrderedCollection", outbox.Json["type"]!.GetValue<string>());
Assert.Equal(persona.ActorUri() + "/anus", outbox.Json["id"]!.GetValue<string>()); Assert.Equal(persona.ActorUri() + "/anus", outbox.Json["id"]!.GetValue<string>());
Assert.Equal(22, outbox.Json["totalItems"]!.GetValue<int>()); // statuses_count, as Mastodon counts it: everything but the DM, the deleted post and the copy of a remote post,
// the followers-only and located posts and the boost included; only the public posts are listed
Assert.Equal(24, outbox.Json["totalItems"]!.GetValue<int>());
Assert.Null(outbox.Json["orderedItems"]); Assert.Null(outbox.Json["orderedItems"]);
var first = outbox.Json["first"]!.GetValue<string>(); var first = outbox.Json["first"]!.GetValue<string>();
Assert.Equal(persona.ActorUri() + "/anus?page=true", first); Assert.Equal(persona.ActorUri() + "/anus?page=true", first);
@@ -168,7 +215,7 @@ namespace PrivaPub.Tests.Http
var announce = items[0]; var announce = items[0];
Assert.Equal("Announce", announce["type"]!.GetValue<string>()); Assert.Equal("Announce", announce["type"]!.GetValue<string>());
Assert.Equal(reblog.ObjectURI, announce["id"]!.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(boosted.ObjectURI, announce["object"]!.GetValue<string>());
Assert.Equal(persona.ActorUri(), announce["actor"]!.GetValue<string>()); Assert.Equal(persona.ActorUri(), announce["actor"]!.GetValue<string>());
@@ -221,7 +268,7 @@ namespace PrivaPub.Tests.Http
Assert.DoesNotContain(pending, followers.Text); Assert.DoesNotContain(pending, followers.Text);
Assert.Equal(HttpStatusCode.OK, following.Status); Assert.Equal(HttpStatusCode.OK, following.Status);
Assert.Equal(persona.ActorUri() + "/stalking", following.Json["id"]!.GetValue<string>()); Assert.Equal(persona.ActorUri() + "/stalking", following.Json["id"]!.GetValue<string>());
Assert.Equal(0, following.Json["totalItems"]!.GetValue<int>()); Assert.Equal(1, following.Json["totalItems"]!.GetValue<int>());//counted, as following_count says, never listed
Assert.DoesNotContain(friend.UserName, following.Text); Assert.DoesNotContain(friend.UserName, following.Text);
} }
@@ -322,11 +369,66 @@ namespace PrivaPub.Tests.Http
Assert.Equal(new[] { circle.Uri, circle.Uri + "/flock" }, Strings(asMember.Json["to"])); Assert.Equal(new[] { circle.Uri, circle.Uri + "/flock" }, Strings(asMember.Json["to"]));
Assert.DoesNotContain(ActivityPubRenderer.Public, asMember.Text); Assert.DoesNotContain(ActivityPubRenderer.Public, asMember.Text);
Assert.Equal(HttpStatusCode.OK, asMemberServer.Status); Assert.Equal(HttpStatusCode.OK, asMemberServer.Status);
// a refetch names the member, as the member's copy did; the instance actor sees the members on its server only
Assert.Equal(new[] { member.Id }, Strings(asMember.Json["cc"]));
Assert.Equal(new[] { member.Id }, Strings(asMemberServer.Json["cc"]));
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(path)).Status); Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(path)).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(path, Browser)).Status); Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(path, Browser)).Status);
foreach (var outsider in new[] { neighbour, stranger, strangerServer }) foreach (var outsider in new[] { neighbour, stranger, strangerServer })
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(outsider.SignedGet(path))).Status); Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(outsider.SignedGet(path))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch($"/peasants/{owner.UserName}/grunts/create-{post.ID}")).Status); var create = $"/peasants/{owner.UserName}/grunts/create-{post.ID}";
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(create)).Status);
var createAsMember = await _client.Fetch(member.SignedGet(create));
Assert.Equal(HttpStatusCode.OK, createAsMember.Status);
Assert.Equal(post.ObjectURI, createAsMember.Json["object"]!["id"]!.GetValue<string>());
Assert.Equal(new[] { member.Id }, Strings(createAsMember.Json["object"]!["cc"]));
}
[Fact]
public async Task Followers_only_and_direct_posts_are_served_to_signed_fetches_from_those_they_were_for()
{
var token = TestContext.Current.CancellationToken;
var owner = await _host.Persona(await _host.SignUp(), "private");
var follower = new RemoteActor(_peer, "follower");
var followerServer = new RemoteActor(_peer, "instance", type: "Application");
var recipient = new RemoteActor(_peer, "recipient");
var stranger = new RemoteActor(_peer, "stranger", _peer.B);
_peer.WebFinger(recipient);
await DB.Default.SaveAsync(new Follower
{
LocalActorId = owner.Id,
LocalActorKind = LocalActorKind.Person,
ActorURI = follower.Id,
InboxURL = follower.Id + "/inbox"
}, token);
var quiet = await _host.Publish(owner, "for followers", PostVisibility.FollowersOnly);
var direct = await _host.Publish(owner, new StatusDraft { Text = $"@{recipient.Handle()} just you", PlainText = true, Visibility = PostVisibility.Direct });
var quietPath = $"/peasants/{owner.UserName}/scribbles/{quiet.ID}";
var directPath = $"/peasants/{owner.UserName}/scribbles/{direct.ID}";
Assert.Equal(HttpStatusCode.OK, (await _client.Fetch(follower.SignedGet(quietPath))).Status);
Assert.Equal(HttpStatusCode.OK, (await _client.Fetch(followerServer.SignedGet(quietPath))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(stranger.SignedGet(quietPath))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(quietPath)).Status);
var asRecipient = await _client.Fetch(recipient.SignedGet(directPath));
Assert.Equal(HttpStatusCode.OK, asRecipient.Status);
Assert.Contains(recipient.Id, Strings(asRecipient.Json["to"]));
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(follower.SignedGet(directPath))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(directPath)).Status);
// the DM's context is the conversation, for its participants only
var context = PathOf(asRecipient.Json["context"]!.GetValue<string>());
var conversation = await _client.Fetch(recipient.SignedGet(context));
Assert.Equal(HttpStatusCode.OK, conversation.Status);
Assert.Equal(new[] { direct.ObjectURI }, Strings(conversation.Json["orderedItems"]));
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(stranger.SignedGet(context))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(context)).Status);
// once deleted, those it was for learn it is gone; everyone else still learns nothing
await _host.Remove(owner, quiet);
Assert.Equal(HttpStatusCode.Gone, (await _client.Fetch(follower.SignedGet(quietPath))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(stranger.SignedGet(quietPath))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(quietPath)).Status);
} }
[Fact] [Fact]
@@ -379,7 +481,7 @@ namespace PrivaPub.Tests.Http
string Path(string id) => $"/peasants/{persona.UserName}/grunts/{id}"; string Path(string id) => $"/peasants/{persona.UserName}/grunts/{id}";
var create = await _client.Fetch(Path($"create-{post.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(HttpStatusCode.OK, create.Status);
Assert.Equal(ActivityJson, create.MediaType); Assert.Equal(ActivityJson, create.MediaType);
@@ -394,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" }) 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(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] [Fact]
@@ -435,7 +538,7 @@ namespace PrivaPub.Tests.Http
} }
[Fact] [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(); var secure = await SecureModeHost.Shared();
using var client = secure.Client(); using var client = secure.Client();
@@ -461,8 +564,16 @@ namespace PrivaPub.Tests.Http
var instance = await client.Fetch("/peasants/privapub"); var instance = await client.Fetch("/peasants/privapub");
Assert.Equal(HttpStatusCode.OK, instance.Status); Assert.Equal(HttpStatusCode.OK, instance.Status);
Assert.Equal("Application", instance.Json["type"]!.GetValue<string>()); 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]) foreach (var path in paths[..^1])
Assert.True((await client.Fetch(reader.SignedGet(path))).Status == HttpStatusCode.OK, $"{path} refused a signed GET"); 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
var browser = await client.Fetch($"/peasants/{persona.UserName}", Browser);
Assert.Equal(HttpStatusCode.Redirect, browser.Status);
Assert.Equal(HttpStatusCode.Redirect, (await client.Fetch($"/peasants/{persona.UserName}/scribbles/{post.ID}", Browser)).Status);
} }
} }
} }
@@ -0,0 +1,291 @@
using MongoDB.Entities;
using PrivaPub.Models.Jobs;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Http
{
// Where a server is, on the public instance API: where it was located (city, coordinates and network, whatever its size),
// only the CDN's name for a CDN-fronted one, the batch form, and this server itself.
[Trait("Category", "Integration")]
public sealed class InstanceLocationTests : IAsyncLifetime
{
PrivaPubHost _host;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
static CancellationToken Token => TestContext.Current.CancellationToken;
static async Task<string> Instance(int users, InstanceGeo geo, string name = "located")
{
var host = $"{name}{Guid.NewGuid():N}.example";
await DB.Default.SaveAsync(new RemoteInstance { Host = host, Software = "mastodon", UsersTotal = users, Geo = geo }, Token);
return host;
}
static InstanceGeo Milan(string cdn = default) => new()
{
Country = "IT",
City = "Milan",
Latitude = 45.5,
Longitude = 9.2,
Asn = 12345,
AsnOrg = "Example Hosting",
Cdn = cdn,
Ipv6 = true,
Source = "DB-IP Lite 2026-10",
LocatedAt = DateTime.UtcNow.AddDays(-2)
};
async Task<JsonNode> Geo(string host) => (await _host.Client().Get($"/api/privapub/v1/instances/{host}")).Ok().Body["geo"];
[Fact]
public async Task A_located_server_shows_its_city_coordinates_and_network()
{
var geo = await Geo(await Instance(50, Milan()));
Assert.Equal("city", geo.Text("precision"));
Assert.Equal(("IT", "Milan", "Example Hosting"), (geo.Text("country"), geo.Text("city"), geo.Text("network")));
Assert.Equal(45.5, geo["latitude"]!.GetValue<double>());
Assert.Equal(9.2, geo["longitude"]!.GetValue<double>());
Assert.Equal("DB-IP Lite 2026-10", geo.Text("source"));
Assert.NotNull(geo.Text("located_at"));
Assert.Null(geo["asn"]);
Assert.Null(geo["ipv6"]);
}
[Fact]
public async Task A_one_user_server_shows_its_city_too()
{
var geo = await Geo(await Instance(1, Milan()));
Assert.Equal("city", geo.Text("precision"));
Assert.Equal(("IT", "Milan", "Example Hosting"), (geo.Text("country"), geo.Text("city"), geo.Text("network")));
Assert.Equal(45.5, geo["latitude"]!.GetValue<double>());
}
[Fact]
public async Task A_server_located_only_to_its_country_shows_the_country()
{
var geo = await Geo(await Instance(50, new InstanceGeo { Country = "IT", Source = "DB-IP Lite 2026-10", LocatedAt = DateTime.UtcNow }));
Assert.Equal("country", geo.Text("precision"));
Assert.Equal("IT", geo.Text("country"));
Assert.Null(geo["city"]);
Assert.Null(geo["latitude"]);
}
[Fact]
public async Task A_cdn_fronted_server_shows_only_its_cdn()
{
var geo = await Geo(await Instance(5000, Milan(cdn: "Cloudflare")));
Assert.Equal("cdn", geo.Text("precision"));
Assert.Equal("Cloudflare", geo.Text("cdn"));
Assert.Null(geo["country"]);
Assert.Null(geo["latitude"]);
Assert.Null(geo["network"]);
}
[Fact]
public async Task An_unlocated_server_has_no_place()
{
Assert.Null(await Geo(await Instance(50, default)));
}
[Fact]
public async Task The_provenance_of_a_post_carries_the_same_projection()
{
var host = await Instance(3, Milan());
var described = (await _host.Client().Get($"/api/privapub/v1/instances/{host}")).Ok().Body;
Assert.Equal("city", described["geo"].Text("precision"));
Assert.Equal(described["geo"]!.ToJsonString(), (await Geo(host))!.ToJsonString());
}
[Fact]
public async Task Several_servers_answer_at_once_in_the_order_asked_without_the_unknown_ones()
{
var big = await Instance(50, Milan(), "big");
var small = await Instance(3, Milan(), "small");
var unknown = $"unknown{Guid.NewGuid():N}.example";
var answer = (await _host.Client().Get($"/api/privapub/v1/instances?host[]={small}&host[]={unknown}&host[]={big.ToUpperInvariant()}&host[]={small}")).Ok();
var hosts = answer.Body!.AsArray().Select(i => i.Text("host")).ToList();
Assert.Equal(new[] { small, big }, hosts);
Assert.Equal("city", answer.Body![0]["geo"].Text("precision"));
Assert.Equal("city", answer.Body![1]["geo"].Text("precision"));
Assert.Empty((await _host.Client().Get("/api/privapub/v1/instances")).Ok().Body!.AsArray());
}
[Fact]
public async Task At_most_forty_servers_answer_at_once()
{
var hosts = new List<string>();
for (var i = 0; i < 42; i++)
hosts.Add(await Instance(1, default, "many"));
var answer = (await _host.Client().Get("/api/privapub/v1/instances?" + string.Join("&", hosts.Select(h => "host[]=" + h)))).Ok();
Assert.Equal(hosts.Take(40), answer.Body!.AsArray().Select(i => i.Text("host")));
}
[Fact]
public async Task This_server_describes_itself()
{
var self = (await _host.Client().Get($"/api/privapub/v1/instances/{PrivaPubHost.Host}")).Ok().Body;
Assert.Equal(PrivaPubHost.Host, self.Text("host"));
Assert.Equal("privapub", self.Text("software"));
Assert.Equal("activitypub", Assert.Single(self["protocols"]!.AsArray())!.GetValue<string>());
Assert.Null(self["geo"]);
var batch = (await _host.Client().Get($"/api/privapub/v1/instances?host[]={PrivaPubHost.Host}")).Ok().Body!.AsArray();
Assert.Equal("privapub", Assert.Single(batch).Text("software"));
}
[Fact]
public async Task The_owner_may_place_this_server()
{
var placed = await SelfPlacedHost.Shared();
var geo = (await placed.Client().Get($"/api/privapub/v1/instances/{PrivaPubHost.Host}")).Ok().Body["geo"];
Assert.Equal("city", geo.Text("precision"));
Assert.Equal(("IT", "Milan", "configured"), (geo.Text("country"), geo.Text("city"), geo.Text("source")));
Assert.Equal(45.5, geo["latitude"]!.GetValue<double>());
Assert.Equal(9.2, geo["longitude"]!.GetValue<double>());
}
static string WeeksAgo(int weeks) => PrivaPub.Federation.Objects.InstanceDescriber.Week(DateTime.UtcNow.AddDays(-7 * weeks));
static Task Snapshot(string host, int weeksAgo, string cdn = default, string cdnDomain = default, string city = default, double? latitude = default,
double? longitude = default, string country = default) =>
DB.Default.SaveAsync(new RemoteInstanceSnapshot
{
Host = host, Week = WeeksAgo(weeksAgo), TakenAt = DateTime.UtcNow.AddDays(-7 * weeksAgo), Reachable = true, Software = "mastodon", UsersTotal = 3,
Cdn = cdn, CdnDomain = cdnDomain, CdnSource = cdn == default ? default : "ranges", Country = country, City = city, Latitude = latitude, Longitude = longitude
}, Token);
[Fact]
public async Task A_cdn_fronted_server_names_its_cdn_and_where_it_was_before_it()
{
var host = await Instance(3, new InstanceGeo
{
Country = "US", City = "San Francisco", Latitude = 37.8, Longitude = -122.4, Cdn = "Cloudflare", CdnDomain = "cloudflare.com", CdnSource = "ranges",
Source = "DB-IP Lite 2026-10", LocatedAt = DateTime.UtcNow
}, "fronted");
await Snapshot(host, 3, city: "Berlin", latitude: 52.5, longitude: 13.4, country: "DE");
await Snapshot(host, 2, city: "Hamburg", latitude: 53.6, longitude: 10.0, country: "DE");
await Snapshot(host, 1, cdn: "Cloudflare", cdnDomain: "cloudflare.com", city: "San Francisco", latitude: 37.8, longitude: -122.4, country: "US");
var geo = await Geo(host);
Assert.Equal(("cdn", "Cloudflare", "cloudflare.com", "ranges"), (geo.Text("precision"), geo.Text("cdn"), geo.Text("cdn_domain"), geo.Text("cdn_source")));
Assert.Null(geo["latitude"]);
Assert.Equal((WeeksAgo(2), "Hamburg", "DE"), (geo["before_cdn"].Text("week"), geo["before_cdn"].Text("city"), geo["before_cdn"].Text("country")));
Assert.Equal(53.6, geo["before_cdn"]!["latitude"]!.GetValue<double>());
}
[Fact]
public async Task A_server_history_is_weekly_newest_first_and_a_cdn_hides_its_place()
{
var host = await Instance(3, Milan(), "historic");
await Snapshot(host, 2, city: "Milan", latitude: 45.5, longitude: 9.2, country: "IT");
await Snapshot(host, 1, cdn: "Fastly", cdnDomain: "fastly.com", city: "Frankfurt", latitude: 50.1, longitude: 8.7, country: "DE");
var weeks = (await _host.Client().Get($"/api/privapub/v1/instances/{host}/history")).Ok().Body!.AsArray();
Assert.Equal(new[] { WeeksAgo(1), WeeksAgo(2) }, weeks.Select(w => w.Text("week")));
Assert.Equal(("cdn", "fastly.com"), (weeks[0]!["geo"].Text("precision"), weeks[0]!["geo"].Text("cdn_domain")));
Assert.Null(weeks[0]!["geo"]!["city"]);
Assert.Equal(("city", "Milan"), (weeks[1]!["geo"].Text("precision"), weeks[1]!["geo"].Text("city")));
Assert.Equal(3, weeks[1]!["users_total"]!.GetValue<long>());
Assert.Single((await _host.Client().Get($"/api/privapub/v1/instances/{host}/history?weeks=1")).Ok().Body!.AsArray());
Assert.Equal(HttpStatusCode.NotFound, (await _host.Client().Get($"/api/privapub/v1/instances/unknown{Guid.NewGuid():N}.example/history")).Status);
}
[Fact]
public async Task Servers_are_grouped_by_their_cdn_week_by_week()
{
var cdn = $"Edge{Guid.NewGuid():N}"[..20];
var key = cdn.ToLowerInvariant();
var stayed = await Instance(3, new InstanceGeo { Country = "FR", Cdn = cdn, CdnSource = "headers" }, "stayed");
var moved = await Instance(3, Milan(), "moved");
await Snapshot(moved, 3, cdn: cdn);
await Snapshot(moved, 2, cdn: cdn);
await Snapshot(stayed, 2, cdn: cdn);
await Snapshot(moved, 1, city: "Milan", latitude: 45.5, longitude: 9.2, country: "IT");
await Snapshot(stayed, 1, cdn: cdn);
var list = (await _host.Client().Get("/api/privapub/v1/cdns")).Ok().Body!.AsArray();
var group = list.Single(g => g.Text("key") == key);
Assert.Equal((cdn, 1), (group.Text("name"), group["servers"]!.GetValue<int>()));
var cloudflare = list.Single(g => g.Text("key") == "cloudflare.com");
Assert.Equal(new[] { "ranges", "headers", "asn" }, cloudflare["detected_by"]!.AsArray().Select(v => v!.GetValue<string>()));
var detail = (await _host.Client().Get($"/api/privapub/v1/cdns/{key}")).Ok().Body;
Assert.Equal(new[] { stayed }, detail["hosts"]!.AsArray().Select(h => h!.GetValue<string>()));
var weeks = detail["weeks"]!.AsArray();
Assert.Equal(new[] { WeeksAgo(1), WeeksAgo(2), WeeksAgo(3) }, weeks.Select(w => w.Text("week")));
Assert.Equal(new[] { 1, 2, 1 }, weeks.Select(w => w!["servers"]!.GetValue<int>()));
Assert.Equal(new[] { moved }, weeks[0]!["left"]!.AsArray().Select(h => h!.GetValue<string>()));
Assert.Equal(new[] { stayed }, weeks[1]!["joined"]!.AsArray().Select(h => h!.GetValue<string>()));
Assert.Equal(new[] { moved }, weeks[2]!["joined"]!.AsArray().Select(h => h!.GetValue<string>()));
Assert.Equal("Cloudflare", (await _host.Client().Get("/api/privapub/v1/cdns/cloudflare.com")).Ok().Body.Text("name"));
Assert.Equal(HttpStatusCode.NotFound, (await _host.Client().Get($"/api/privapub/v1/cdns/none{Guid.NewGuid():N}")).Status);
}
[Fact]
public async Task Junk_hosts_are_not_an_error()
{
Assert.Equal(HttpStatusCode.NotFound, (await _host.Client().Get($"/api/privapub/v1/instances/{new string('a', 300)}")).Status);
Assert.Empty((await _host.Client().Get($"/api/privapub/v1/instances?host[]=&host[]={new string('a', 300)}")).Ok().Body!.AsArray());
}
}
public sealed class SelfPlacedHost : PrivaPubHost
{
static readonly SemaphoreSlim Boot = new(1, 1);
static SelfPlacedHost _shared;
public static new async Task<SelfPlacedHost> Shared()
{
await PrivaPubHost.Shared();
await Boot.WaitAsync();
try
{
if (_shared == default)
{
var host = new SelfPlacedHost();
_ = host.Services;
_shared = host;
}
return _shared;
}
finally
{
Boot.Release();
}
}
protected override IEnumerable<KeyValuePair<string, string>> Settings() => base.Settings().Concat(new Dictionary<string, string>
{
["Statistics:Geo:Self:Latitude"] = "45.4642",
["Statistics:Geo:Self:Longitude"] = "9.19",
["Statistics:Geo:Self:City"] = "Milan",
["Statistics:Geo:Self:Country"] = "IT"
});
}
}
+46 -2
View File
@@ -127,7 +127,7 @@ namespace PrivaPub.Tests.Http
} }
[Fact] [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 alice = await _host.Mastodon("alice");
var (bob, bobId) = await Remote(); 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 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 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")).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=nobody{Guid.NewGuid():N}")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get("/api/v1/accounts/lookup?acct=a@b@c")).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); 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] [Fact]
public async Task Account_statuses_filter_pins_replies_reblogs_media_and_tags_and_page_both_ways() 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"))); 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] [Fact]
public async Task A_remote_authors_followers_only_posts_show_only_to_the_personas_that_follow_them() 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"); var alice = await _host.Mastodon("alice");
Assert.Empty((await _host.Client().Get($"/api/v1/accounts/{alice.Id}/featured_tags")).Ok().Array); 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(); 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.Equal(alice.Id, Assert.Single(familiar.Array).Text("id"));
Assert.Empty(familiar.Array[0]!["accounts"]!.AsArray()); 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);
}
}
}
+4 -10
View File
@@ -41,7 +41,10 @@ namespace PrivaPub.Tests.Http
var v2 = (await anonymous.Get("/api/v2/instance")).Ok(); var v2 = (await anonymous.Get("/api/v2/instance")).Ok();
Assert.Equal(InstanceController.Version, v2.Body.Text("version")); Assert.Equal(InstanceController.Version, v2.Body.Text("version"));
Assert.Equal(PrivaPubHost.Host, v2.Body.Text("domain")); Assert.Equal(PrivaPubHost.Host, v2.Body.Text("domain"));
Assert.Equal($"wss://{PrivaPubHost.Host}", v2.Body["configuration"]!["urls"].Text("streaming")); 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")); Assert.True(v2.Body["registrations"].Flag("enabled"));
Assert.True((await anonymous.Get("/nodeinfo/2.1")).Ok().Body.Flag("openRegistrations")); Assert.True((await anonymous.Get("/nodeinfo/2.1")).Ok().Body.Flag("openRegistrations"));
Assert.False(v2.Body["configuration"]!["translation"].Flag("enabled")); Assert.False(v2.Body["configuration"]!["translation"].Flag("enabled"));
@@ -67,25 +70,16 @@ namespace PrivaPub.Tests.Http
[Theory] [Theory]
[InlineData("/api/v1/custom_emojis")] [InlineData("/api/v1/custom_emojis")]
[InlineData("/api/v1/announcements")] [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) public async Task Public_stub_routes_answer_empty_lists_to_anyone(string path)
{ {
Assert.Empty((await _host.Client().Get(path)).Ok().Array); Assert.Empty((await _host.Client().Get(path)).Ok().Array);
} }
[Theory] [Theory]
[InlineData("/api/v1/filters")]
[InlineData("/api/v2/filters")]
[InlineData("/api/v1/lists")]
[InlineData("/api/v1/suggestions")] [InlineData("/api/v1/suggestions")]
[InlineData("/api/v2/suggestions")] [InlineData("/api/v2/suggestions")]
[InlineData("/api/v1/followed_tags")]
[InlineData("/api/v1/endorsements")] [InlineData("/api/v1/endorsements")]
[InlineData("/api/v1/featured_tags")] [InlineData("/api/v1/featured_tags")]
[InlineData("/api/v1/scheduled_statuses")]
public async Task Signed_in_stub_routes_answer_empty_lists(string path) public async Task Signed_in_stub_routes_answer_empty_lists(string path)
{ {
var reader = await _host.Mastodon("reader"); 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;
using PrivaPub.Tests.Support.Host; 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")); 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] [Fact]
public async Task A_poll_is_created_validated_and_federated_as_a_question() 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")); 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] [Fact]
public async Task Bookmarks_are_kept_listed_and_removed() 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); 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] [Fact]
public async Task Favourites_list_what_the_persona_liked_newest_first() 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); 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] [Fact]
public async Task Markers_are_saved_versioned_and_read_back_per_timeline() 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);
}
}
}
+270
View File
@@ -0,0 +1,270 @@
using MongoDB.Entities;
using PrivaPub.Models.Federation;
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.Http
{
// Two views of the same fact give one answer (owner decision 2026-10-04: fix the mismatches).
[Trait("Category", "Integration")]
public sealed class OneAnswerTests : 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();
}
[Fact]
public async Task Anyone_may_search_and_only_a_signed_in_reader_resolves_or_pages()
{
var reader = await _host.Mastodon("searcher");
Assert.Equal(HttpStatusCode.OK, (await _client.Get("/api/v2/search?q=somebody")).Status);
Assert.Equal(HttpStatusCode.Unauthorized, (await _client.Get("/api/v2/search?q=somebody&resolve=true")).Status);
Assert.Equal(HttpStatusCode.Unauthorized, (await _client.Get("/api/v2/search?q=somebody&offset=5")).Status);
Assert.Equal(HttpStatusCode.OK, (await reader.Client.Get("/api/v2/search?q=somebody&offset=5")).Status);
}
[Fact]
public async Task A_remote_account_that_deletes_itself_leaves_nothing_in_the_counts()
{
var token = TestContext.Current.CancellationToken;
var author = await _host.Mastodon("liked");
var post = await author.Status("like me");
var fan = new RemoteActor(_peer, "fickle");
var like = new JsonObject
{
["id"] = $"{fan.Id}#likes/{Guid.NewGuid():N}",
["type"] = "Like",
["actor"] = fan.Id,
["object"] = post.Text("uri")
};
Assert.Equal(HttpStatusCode.Accepted, (await _client.SendAsync(fan.SignedPost("/human-centipede", like), token)).StatusCode);
await _host.RunInbox(like["id"]!.GetValue<string>(), token);
Assert.Equal(1, (await author.Client.Get($"/api/v1/statuses/{post.Text("id")}")).Ok().Body.Number("favourites_count"));
var delete = new JsonObject
{
["id"] = $"{fan.Id}#delete",
["type"] = "Delete",
["actor"] = fan.Id,
["object"] = fan.Id,
["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public")
};
Assert.Equal(HttpStatusCode.Accepted, (await _client.SendAsync(fan.SignedPost("/human-centipede", delete), token)).StatusCode);
await _host.RunInbox(delete["id"]!.GetValue<string>(), token);
Assert.Equal(0, (await author.Client.Get($"/api/v1/statuses/{post.Text("id")}")).Ok().Body.Number("favourites_count"));
Assert.Empty((await author.Client.Get($"/api/v1/statuses/{post.Text("id")}/favourited_by")).Ok().Array);
Assert.False(await DB.Default.Find<Favourite>().Match(f => f.ActorURI == fan.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Notification>().Match(n => n.FromActorURI == fan.Id).ExecuteAnyAsync(token));
Assert.Equal(0, (await author.Client.Get("/api/v1/notifications/unread_count")).Ok().Body.Number("count"));
}
[Fact]
public async Task Only_public_and_unlisted_replies_are_counted()
{
var author = await _host.Mastodon("threadstart");
var replier = await _host.Mastodon("replier");
var root = await author.Status("say something");
var id = root.Text("id");
await replier.Status("in public", ("in_reply_to_id", id));
await replier.Status("quietly", ("in_reply_to_id", id), ("visibility", "unlisted"));
await replier.Status("for my followers", ("in_reply_to_id", id), ("visibility", "private"));
await replier.Status($"@{author.UserName} just you", ("in_reply_to_id", id), ("visibility", "direct"));
Assert.Equal(2, (await author.Client.Get($"/api/v1/statuses/{id}")).Ok().Body.Number("replies_count"));
}
[Fact]
public async Task A_status_longer_than_advertised_is_refused()
{
var author = await _host.Mastodon("wordy");
var limit = (await _client.Get("/api/v2/instance")).Ok().Body["configuration"]!["statuses"].Number("max_characters");
var refused = await author.Client.Post("/api/v1/statuses", ("status", new string('a', limit + 1)));
var accepted = await author.Client.Post("/api/v1/statuses", ("status", new string('a', limit)));
Assert.Equal(HttpStatusCode.UnprocessableEntity, refused.Status);
Assert.Equal(HttpStatusCode.OK, accepted.Status);
}
[Fact]
public async Task Joining_a_community_by_invitation_is_following_it()
{
var token = TestContext.Current.CancellationToken;
var owner = await _host.Persona(await _host.SignUp(), "commowner");
var joinerRoot = await _host.SignUp("joiner");
var joiner = await _host.Persona(joinerRoot, "joiner");
var group = await _host.Group(owner, community: true);
var groupName = group["userName"]!.GetValue<string>();
using var client = _host.As(joinerRoot.Jwt);
var joined = await client.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode = group["invitationCode"]!.GetValue<string>() });
Assert.Equal(HttpStatusCode.OK, joined.StatusCode);
Assert.Equal(1, (await _client.Fetch($"/peasants/{groupName}/groupies")).Json["totalItems"]!.GetValue<int>());
Assert.True(await DB.Default.Find<Following>().Match(f => f.AvatarId == joiner.Id && f.State == FollowState.Accepted).ExecuteAnyAsync(token));
}
public static TheoryData<string> AnsweredRoutes() => new()
{
"/api/v1/directory",
"/api/v1/tags/cats",
"/api/v1/instance/languages",
"/api/v1/instance/translation_languages",
"/api/v1/instance/domain_blocks",
"/api/v1/instance/privacy_policy",
"/api/v1/timelines/link?url=https%3A%2F%2Fexample.org%2F",
"/api/v1/accounts/000000000000000000000000/identity_proofs"
};
[Theory]
[MemberData(nameof(AnsweredRoutes))]
public async Task Mastodon_routes_we_have_nothing_behind_answer_instead_of_404(string path)
{
Assert.Equal(HttpStatusCode.OK, (await _client.Get(path)).Status);
}
[Fact]
public async Task Likes_of_one_post_come_as_one_group_of_notifications()
{
var author = await _host.Mastodon("grouped");
var first = await _host.Mastodon("fanone");
var second = await _host.Mastodon("fantwo");
var post = await author.Status("like me twice");
var id = post.Text("id");
await first.Client.Post($"/api/v1/statuses/{id}/favourite");
await second.Client.Post($"/api/v1/statuses/{id}/favourite");
var list = (await author.Client.Get("/api/v2/notifications")).Ok().Body;
var group = Assert.Single(list["notification_groups"]!.AsArray(), g => g!.Text("type") == "favourite")!;
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"));
Assert.Contains(list["statuses"]!.AsArray(), s => s!.Text("id") == id);
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);
}
[Fact]
public async Task A_hashtag_link_opens_a_page_of_public_posts_with_that_tag()
{
var author = await _host.Mastodon("tagger");
var tag = $"tag{Guid.NewGuid():N}"[..16];
await author.Status($"about #{tag}");
await author.Status($"quietly about #{tag}", ("visibility", "private"));
var page = await _client.Fetch($"/tags/{tag}", Browser);
Assert.Equal(HttpStatusCode.OK, page.Status);
Assert.Contains("about", page.Text);
Assert.DoesNotContain("quietly", page.Text);
Assert.Contains("default-src 'none'", page.Response.Headers.GetValues("Content-Security-Policy").Single());
}
[Fact]
public async Task A_communitys_announces_resolve_while_the_post_is_shown()
{
var token = TestContext.Current.CancellationToken;
var owner = await _host.Persona(await _host.SignUp(), "announcer");
var community = await _host.FederatedGroup(owner, community: true);
var post = await _host.Publish(owner, new PrivaPub.Domain.Statuses.StatusDraft { Text = "into the community", PlainText = true, GroupId = community.Id });
var kept = await DB.Default.Find<GroupAnnouncement>().Match(a => a.GroupId == community.Id && a.ObjectURI == post.ObjectURI).ExecuteAsync(token);
Assert.Equal(2, kept.Count);//the activity, and the object for Mastodon
foreach (var announcement in kept)
{
var fetched = await _client.Fetch(PathOf(announcement.ActivityURI));
Assert.Equal(HttpStatusCode.OK, fetched.Status);
Assert.Equal("Announce", fetched.Json["type"]!.GetValue<string>());
Assert.Equal(announcement.ActivityURI, fetched.Json["id"]!.GetValue<string>());
}
var listed = await _client.Fetch($"/peasants/{community.UserName}/grunts/announce-{post.ID}");
Assert.Equal(HttpStatusCode.OK, listed.Status);
await _host.Remove(owner, post);
Assert.Equal(HttpStatusCode.Gone, (await _client.Fetch(PathOf(kept[0].ActivityURI))).Status);
}
[Fact]
public async Task A_boost_points_at_the_boosted_posts_page()
{
var author = await _host.Mastodon("boosted");
var booster = await _host.Mastodon("booster");
var post = await author.Status("boost me");
var boost = (await booster.Client.Post($"/api/v1/statuses/{post.Text("id")}/reblog")).Ok().Body;
Assert.Equal(post.Text("url"), boost.Text("url"));
var browser = await _client.Fetch(PathOf(boost.Text("uri")), Browser);
Assert.Equal(HttpStatusCode.Redirect, browser.Status);
}
[Fact]
public async Task A_remote_accounts_counts_are_what_its_server_publishes()
{
var token = TestContext.Current.CancellationToken;
var reader = await _host.Mastodon("counter");
var popular = new RemoteActor(_peer, "popular");
_peer.Serve(new Uri(popular.Id).AbsolutePath + "/followers",
$$"""{"@context":"https://www.w3.org/ns/activitystreams","id":"{{popular.Id}}/followers","type":"OrderedCollection","totalItems":42}""");
var remote = _host.Get<PrivaPub.Federation.Actors.IRemoteActorService>();
var account = await remote.GetActor(popular.Id, refresh: true, token);
await _host.Run(j => j.Kind == PrivaPub.Models.Jobs.JobKind.CountAccount && j.Payload == popular.Id, token);
var shown = (await reader.Client.Get($"/api/v1/accounts/{account.ID}")).Ok().Body;
Assert.Equal(42, shown.Number("followers_count"));
Assert.Equal(0, shown.Number("following_count"));
}
[Fact]
public async Task Notification_policy_and_requests_answer_a_signed_in_reader()
{
var reader = await _host.Mastodon("policy");
Assert.Equal(HttpStatusCode.OK, (await reader.Client.Get("/api/v1/notifications/policy")).Status);
Assert.Equal("accept", (await reader.Client.Get("/api/v2/notifications/policy")).Ok().Body.Text("for_not_following"));
Assert.Empty((await reader.Client.Get("/api/v1/notifications/requests")).Ok().Array);
}
}
}
+218
View File
@@ -0,0 +1,218 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using PrivaPub.Api.Mastodon.Auth;
using PrivaPub.Services;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Security.Claims;
using System.Text;
namespace PrivaPub.Tests.Http
{
// The first-party client's one sign-in: the /clientapi JWT exchanged for one persona's Mastodon token (PersonaExchange).
[Trait("Category", "Integration")]
public sealed class PersonaExchangeTests : IAsyncLifetime
{
const string Grant = "urn:ietf:params:oauth:grant-type:token-exchange";
const string FirstParty = "decepub";
PrivaPubHost _host;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
async Task<HttpResponseMessage> Exchange(string jwt, string avatarId, string clientId = FirstParty, string clientSecret = default,
string subjectTokenType = PersonaExchange.SubjectTokenType)
{
var fields = new List<(string, string)>
{
("grant_type", Grant),
("client_id", clientId),
("subject_token", jwt),
("subject_token_type", subjectTokenType),
(PersonaExchange.AvatarParameter, avatarId),
("scope", "read write follow")
};
if (clientSecret != default)
fields.Add(("client_secret", clientSecret));
using var client = _host.Client();
return await client.Form("/oauth/token", fields.Where(f => f.Item2 != default).ToArray());
}
async Task<string> Token(Persona persona)
{
var response = await Exchange(persona.Root.Jwt, persona.Id);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return (await response.JsonBody())["access_token"]!.GetValue<string>();
}
static async Task AssertRefused(HttpResponseMessage response, string error = "invalid_grant")
{
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal(error, (await response.JsonBody())["error"]?.GetValue<string>());
}
async Task<HttpStatusCode> Me(string token)
{
using var api = _host.As(token);
return (await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode;
}
static string Jwt(string rootId, string key, DateTime expires) => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
{
Issuer = PrivaPubHost.Base,
Audience = PrivaPubHost.Base,
Subject = new ClaimsIdentity(new[] { new Claim(ClaimTypes.UserData, rootId) }),
NotBefore = expires.AddHours(-2),
IssuedAt = expires.AddHours(-2),
Expires = expires,
SigningCredentials = new(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)), SecurityAlgorithms.HmacSha512)
});
[Fact]
public async Task The_root_jwt_becomes_one_personas_token_that_never_names_the_root()
{
var persona = await _host.Persona(await _host.SignUp(), "exchange");
var root = persona.Root;
var response = await Exchange(root.Jwt, persona.Id);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.DoesNotContain(root.Id, body);
Assert.DoesNotContain(root.UserName, body);
var token = (await response.JsonBody())["access_token"]!.GetValue<string>();
using var api = _host.As(token);
var account = await api.GetStringAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken);
Assert.Contains($"\"id\":\"{persona.Id}\"", account);
Assert.DoesNotContain(root.Id, account);
foreach (var entry in await ClientApi.StoredTokens(FirstParty))
{
var payload = entry.Contains("payload") && entry["payload"].IsString ? ClientApi.JwtPayload(entry["payload"].AsString) : string.Empty;
Assert.DoesNotContain(root.Id, entry.ToString() + payload);
Assert.DoesNotContain(root.UserName, entry.ToString() + payload);
}
}
[Fact]
public async Task Each_persona_of_the_root_gets_its_own_token()
{
var root = await _host.SignUp();
var first = await _host.Persona(root, "first");
var second = await _host.Persona(root, "second");
using var firstApi = _host.As(await Token(first));
using var secondApi = _host.As(await Token(second));
Assert.Contains($"\"id\":\"{first.Id}\"", await firstApi.GetStringAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken));
Assert.Contains($"\"id\":\"{second.Id}\"", await secondApi.GetStringAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken));
}
[Fact]
public async Task Another_roots_persona_is_refused()
{
var mine = await _host.SignUp();
var theirs = await _host.Persona(await _host.SignUp(), "theirs");
await AssertRefused(await Exchange(mine.Jwt, theirs.Id));
await AssertRefused(await Exchange(mine.Jwt, "000000000000000000000000"));
await AssertRefused(await Exchange(mine.Jwt, "not an id"));
}
[Fact]
public async Task A_token_that_is_not_a_valid_root_jwt_is_refused()
{
var persona = await _host.Persona(await _host.SignUp(), "forged");
var key = _host.Services.GetRequiredService<IConfiguration>()["AppConfiguration:Jwt:Key"]!;
await AssertRefused(await Exchange("garbage", persona.Id));
await AssertRefused(await Exchange(Jwt(persona.Root.Id, key, DateTime.UtcNow.AddMinutes(-10)), persona.Id));
await AssertRefused(await Exchange(Jwt(persona.Root.Id, new string('k', 64), DateTime.UtcNow.AddHours(1)), persona.Id));
}
[Fact]
public async Task Ended_sessions_refuse_the_jwt_and_revoke_the_exchanged_tokens()
{
var persona = await _host.Persona(await _host.SignUp(), "ended");
var token = await Token(persona);
Assert.Equal(HttpStatusCode.OK, await Me(token));
using (var scope = _host.Services.CreateScope())
await scope.ServiceProvider.GetRequiredService<IRootSessions>().Revoke(persona.Root.Id, TestContext.Current.CancellationToken);
await AssertRefused(await Exchange(persona.Root.Jwt, persona.Id));
Assert.Equal(HttpStatusCode.Unauthorized, await Me(token));
}
[Fact]
public async Task A_banned_root_is_refused()
{
var persona = await _host.Persona(await _host.SignUp(), "banned");
await ClientApi.Ban(persona.Root.Id);
try
{
await AssertRefused(await Exchange(persona.Root.Jwt, persona.Id));
}
finally
{
await ClientApi.Ban(persona.Root.Id, banned: false);
}
}
[Fact]
public async Task A_revoked_token_no_longer_works()
{
var persona = await _host.Persona(await _host.SignUp(), "revoked");
var token = await Token(persona);
using var client = _host.Client();
var revoked = await client.Form("/oauth/revoke", ("token", token), ("client_id", FirstParty));
Assert.Equal(HttpStatusCode.OK, revoked.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, await Me(token));
}
[Fact]
public async Task Only_the_first_party_client_may_exchange()
{
var persona = await _host.Persona(await _host.SignUp(), "thirdparty");
using var client = _host.Client();
var app = await client.RegisterApp();
var response = await Exchange(persona.Root.Jwt, persona.Id, app.ClientId, app.ClientSecret);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("unauthorized_client", (await response.JsonBody())["error"]?.GetValue<string>());
}
[Fact]
public async Task A_mastodon_token_is_not_a_subject_token()
{
var persona = await _host.Persona(await _host.SignUp(), "mastodon");
var token = await Token(persona);
await AssertRefused(await Exchange(token, persona.Id, subjectTokenType: "urn:ietf:params:oauth:token-type:access_token"), "invalid_request");
await AssertRefused(await Exchange(token, persona.Id));
}
[Fact]
public async Task Missing_parameters_are_a_client_error()
{
var persona = await _host.Persona(await _host.SignUp(), "missing");
await AssertRefused(await Exchange(persona.Root.Jwt, default));
await AssertRefused(await Exchange(default, persona.Id), "invalid_request");
await AssertRefused(await Exchange(persona.Root.Jwt, persona.Id, subjectTokenType: default), "invalid_request");
}
}
}
+106
View File
@@ -0,0 +1,106 @@
using MongoDB.Entities;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Http
{
// A Pixelfed post's own place: stored from `location`, shown as `privapub.place`, changed by an edit.
[Trait("Category", "Integration")]
public sealed class PostPlaceTests : 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 CancellationToken Token => TestContext.Current.CancellationToken;
static JsonObject Location(string name, string latitude, string longitude) => new()
{
["type"] = "Place", ["name"] = name, ["latitude"] = latitude, ["longitude"] = longitude, ["country"] = "Italy"
};
[Fact]
public async Task A_pixelfed_place_is_shown_and_follows_the_authors_edits()
{
var alice = await _host.Mastodon("alice");
var ann = new RemoteActor(_peer, "ann");
var post = await _host.PublicPostFrom(ann, alice, "<p>the duomo</p>", note => note["location"] = Location("Milan", "45.4642", "9.19"));
var place = (await alice.Client.Get($"/api/v1/statuses/{post.ID}")).Ok().Body["privapub"]!["place"];
Assert.Equal(("Milan", "Italy"), (place.Text("name"), place.Text("country")));
Assert.Equal(45.4642, place["latitude"]!.GetValue<double>());
Assert.Equal(9.19, place["longitude"]!.GetValue<double>());
await Edit(ann, alice, post, note => note["location"] = Location("Turin", "45.0703", "7.6869"));
Assert.Equal("Turin", (await alice.Client.Get($"/api/v1/statuses/{post.ID}")).Ok().Body["privapub"]!["place"].Text("name"));
await Edit(ann, alice, post, _ => { });
Assert.Null((await alice.Client.Get($"/api/v1/statuses/{post.ID}")).Ok().Body["privapub"]!["place"]);
Assert.Null((await DB.Default.Find<Post>().MatchID(post.ID).ExecuteFirstAsync(Token)).Place);
}
[Fact]
public async Task A_post_without_a_place_has_none()
{
var alice = await _host.Mastodon("alice");
var post = await _host.PublicPostFrom(new RemoteActor(_peer, "plain"), alice);
Assert.Null((await alice.Client.Get($"/api/v1/statuses/{post.ID}")).Ok().Body["privapub"]!["place"]);
}
[Fact]
public async Task A_local_post_never_carries_a_location()
{
var alice = await _host.Mastodon("alice");
var status = await alice.Status("made here");
using var client = _host.Client();
client.DefaultRequestHeaders.Accept.ParseAdd("application/activity+json");
var note = JsonNode.Parse(await client.GetStringAsync(status.Text("uri"), Token))!;
Assert.Null(note["location"]);
}
async Task Edit(RemoteActor author, Mastodon mentioned, Post post, Action<JsonObject> shape)
{
var note = new JsonObject
{
["id"] = post.ObjectURI,
["type"] = "Note",
["attributedTo"] = author.Id,
["to"] = new JsonArray(MastodonHelpers.Public),
["cc"] = new JsonArray(mentioned.Uri),
["content"] = "<p>the duomo, again</p>",
["published"] = post.CreationDate.ToString("O"),
["updated"] = DateTime.UtcNow.ToString("O"),
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = mentioned.Uri, ["name"] = "@" + mentioned.UserName })
};
shape(note);
await _host.Deliver(author, mentioned.Mouth, new JsonObject
{
["id"] = $"{post.ObjectURI}/updates/{Guid.NewGuid():N}",
["type"] = "Update",
["actor"] = author.Id,
["to"] = new JsonArray(MastodonHelpers.Public),
["object"] = note
});
}
}
}
+118
View File
@@ -0,0 +1,118 @@
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Net.Http.Json;
using GroupEntity = PrivaPub.Models.Group.Group;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Tests.Http
{
// Deleting a root deletes its personas and their groups everywhere (owner decision 2026-10-04).
[Trait("Category", "Integration")]
public sealed class RootRemovalTests : 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();
}
async Task<HttpResponseMessage> AdminRemoves(params string[] rootIds)
{
var admin = await _host.Admin();
using var client = _host.As(admin.Jwt);
using var request = new HttpRequestMessage(HttpMethod.Delete, "/clientapi/admin/remove/users") { Content = JsonContent.Create(new { userIdList = rootIds }) };
return await client.SendAsync(request, TestContext.Current.CancellationToken);
}
[Fact]
public async Task A_removed_roots_personas_and_groups_are_deleted_everywhere_and_say_so()
{
var token = TestContext.Current.CancellationToken;
var root = await _host.SignUp("leaving");
var persona = await _host.Persona(root, "leaving");
var follower = new RemoteActor(_peer, "fan");
var member = new RemoteActor(_peer, "member");
await DB.Default.SaveAsync(new Follower { LocalActorId = persona.Id, LocalActorKind = LocalActorKind.Person, ActorURI = follower.Id, InboxURL = follower.Id + "/inbox" }, token);
var group = await _host.Group(persona, community: true);
var groupId = group["id"]!.GetValue<string>();
await DB.Default.SaveAsync(new Follower { LocalActorId = groupId, LocalActorKind = LocalActorKind.Group, ActorURI = member.Id, InboxURL = member.Id + "/inbox" }, token);
var post = await _host.Publish(persona, "goodbye soon");
var apiToken = await _host.MastodonToken(persona);
var since = DateTime.UtcNow.AddSeconds(-1);
Assert.Equal(HttpStatusCode.OK, (await AdminRemoves(root.Id)).StatusCode);
var toFollower = Assert.Single(await Jobs.Deliveries(follower.Id + "/inbox", since, token), d => d["type"]!.GetValue<string>() == "Delete");
Assert.Equal(persona.ActorUri(), toFollower["object"]!.GetValue<string>());
Assert.Equal(persona.ActorUri(), toFollower["actor"]!.GetValue<string>());
var groupName = group["userName"]!.GetValue<string>();
Assert.Contains(await Jobs.Deliveries(member.Id + "/inbox", since, token),
d => d["type"]!.GetValue<string>() == "Delete" && d["object"]!.GetValue<string>() == $"{PrivaPubHost.Base}/peasants/{groupName}");
var gone = await _client.Fetch($"/peasants/{persona.UserName}");
Assert.Equal(HttpStatusCode.Gone, gone.Status);
Assert.Equal("Person", gone.Json["formerType"]!.GetValue<string>());
Assert.Equal(HttpStatusCode.Gone, (await _client.Fetch($"/peasants/{groupName}")).Status);
Assert.Equal(HttpStatusCode.Gone, (await _client.GetAsync($"/.well-known/webfinger?resource=acct:{persona.UserName}@{PrivaPubHost.Host}", token)).StatusCode);
var stored = await DB.Default.Find<PostEntity>().MatchID(post.ID).ExecuteFirstAsync(token);
Assert.NotNull(stored.DeletedAt);
Assert.Null(stored.ContentHtml);
Assert.NotNull((await DB.Default.Find<GroupEntity>().MatchID(groupId).ExecuteFirstAsync(token)).DeletionAt);
var removed = await DB.Default.Find<RootUser>().MatchID(root.Id).ExecuteFirstAsync(token);
Assert.Equal($"deleted-{root.Id}", removed.UserName);
Assert.Null(removed.HashedPassword);
using var app = _host.Client();
app.DefaultRequestHeaders.Authorization = new("Bearer", apiToken);
Assert.Equal(HttpStatusCode.Unauthorized, (await app.GetAsync("/api/v1/accounts/verify_credentials", token)).StatusCode);
}
[Fact]
public async Task A_second_removal_does_not_collide_with_the_first()
{
var first = await _host.SignUp("first");
var second = await _host.SignUp("second");
Assert.Equal(HttpStatusCode.OK, (await AdminRemoves(first.Id)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await AdminRemoves(second.Id)).StatusCode);
Assert.NotNull((await DB.Default.Find<RootUser>().MatchID(second.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken)).DeletedAt);
}
[Fact]
public async Task A_root_deletes_itself_only_with_its_password()
{
var token = TestContext.Current.CancellationToken;
var root = await _host.SignUp("self");
var persona = await _host.Persona(root, "self");
using var client = _host.As(root.Jwt);
var refused = await client.PostJson("/clientapi/user/delete", new { password = "Not-The-Password-1" });
Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode);
Assert.Null((await DB.Default.Find<Avatar>().MatchID(persona.Id).ExecuteFirstAsync(token)).DeletionAt);
var accepted = await client.PostJson("/clientapi/user/delete", new { password = root.Password });
Assert.Equal(HttpStatusCode.OK, accepted.StatusCode);
Assert.NotNull((await DB.Default.Find<Avatar>().MatchID(persona.Id).ExecuteFirstAsync(token)).DeletionAt);
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/clientapi/user/sniff/again", token)).StatusCode);
}
}
}
@@ -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);
}
}
}
+35 -4
View File
@@ -38,6 +38,31 @@ namespace PrivaPub.Tests.Http
static string Link(JsonObject document, string rel) => static string Link(JsonObject document, string rel) =>
document["links"]!.AsArray().Single(l => l!["rel"]!.GetValue<string>() == rel)!["href"]!.GetValue<string>(); 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] [Fact]
public async Task WebFinger_finds_a_persona_by_acct_and_by_actor_uri() public async Task WebFinger_finds_a_persona_by_acct_and_by_actor_uri()
{ {
@@ -83,7 +108,7 @@ namespace PrivaPub.Tests.Http
} }
[Fact] [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 owner = await _host.Persona(await _host.SignUp(), "fingerowner");
var community = await _host.FederatedGroup(owner, community: true); 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($"acct:privapub@{Domain}", instance.Json["subject"]!.GetValue<string>());
Assert.Equal($"{Base}/peasants/privapub", Link(instance.Json, "self")); Assert.Equal($"{Base}/peasants/privapub", Link(instance.Json, "self"));
Assert.Single(instance.Json["links"]!.AsArray()); 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(HttpStatusCode.OK, group.Status);
Assert.Equal($"acct:{community.UserName}@{Domain}", group.Json["subject"]!.GetValue<string>()); Assert.Equal($"acct:{community.UserName}@{Domain}", group.Json["subject"]!.GetValue<string>());
Assert.Equal(community.Uri, Link(group.Json, "self")); Assert.Equal(community.Uri, Link(group.Json, "self"));
@@ -179,8 +208,10 @@ namespace PrivaPub.Tests.Http
return (await client.Fetch("/nodeinfo/2.1", "application/json")).Json["usage"]!["localPosts"]!.GetValue<long>(); return (await client.Fetch("/nodeinfo/2.1", "application/json")).Json["usage"]!["localPosts"]!.GetValue<long>();
} }
// localPosts is Mastodon's: the sum of statuses_count, every local post but DMs and deleted ones, boosts included,
// and circle and located posts too (owner decision 2026-10-04)
[Fact] [Fact]
public async Task Local_posts_count_only_what_anyone_may_see() public async Task Local_posts_count_as_mastodon_counts_statuses()
{ {
var persona = await _host.Persona(await _host.SignUp(), "usage"); var persona = await _host.Persona(await _host.SignUp(), "usage");
var friend = await _host.Persona(await _host.SignUp(), "usagefriend"); var friend = await _host.Persona(await _host.SignUp(), "usagefriend");
@@ -197,10 +228,10 @@ namespace PrivaPub.Tests.Http
await _host.Reblog(friend, shown); await _host.Reblog(friend, shown);
var circle = await _host.FederatedGroup(persona, community: false); var circle = await _host.FederatedGroup(persona, community: false);
await _host.Publish(persona, new StatusDraft { Text = "circle only", PlainText = true, GroupId = circle.Id }); await _host.Publish(persona, new StatusDraft { Text = "circle only", PlainText = true, GroupId = circle.Id });
Assert.Equal(start + 2, await LocalPosts()); Assert.Equal(start + 6, await LocalPosts());//followers-only, located, the boost and the circle post; not the DM nor the copy
await _host.Remove(persona, shown); await _host.Remove(persona, shown);
Assert.Equal(start + 1, await LocalPosts()); Assert.Equal(start + 4, await LocalPosts());//the post and the boost of it
} }
} }
} }
@@ -0,0 +1,111 @@
using PrivaPub.Infrastructure.Geo;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Models.Jobs;
using System.Net;
namespace PrivaPub.Tests.Infrastructure
{
public class CdnCatalogTests
{
static HttpResponseMessage Response(params (string Name, string Value)[] headers)
{
var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") };
foreach (var (name, value) in headers)
response.Headers.TryAddWithoutValidation(name, value);
return response;
}
[Theory]
[InlineData("cf-ray", "8c1f0e2b4d5a1234-FRA", "cloudflare.com")]
[InlineData("server", "cloudflare", "cloudflare.com")]
[InlineData("x-amz-cf-id", "abc==", "cloudfront.net")]
[InlineData("via", "1.1 5a1b.cloudfront.net (CloudFront)", "cloudfront.net")]
[InlineData("x-served-by", "cache-fra-eddf8230045-FRA", "fastly.com")]
[InlineData("server", "BunnyCDN-DE1-1078", "bunny.net")]
[InlineData("akamai-grn", "0.1234", "akamai.com")]
[InlineData("x-azure-ref", "20261004T101010Z-abc", "azure.microsoft.com")]
[InlineData("x-vercel-id", "fra1::abc", "vercel.com")]
[InlineData("x-iinfo", "1-2-3", "imperva.com")]
public void A_cdn_is_recognised_by_its_fingerprint(string header, string value, string domain)
{
Assert.Equal(domain, CdnCatalog.FromResponse(Response((header, value)))?.Domain);
}
[Fact]
public void A_plain_server_has_no_cdn_and_an_unknown_cdn_names_itself()
{
Assert.Null(CdnCatalog.FromResponse(Response(("server", "nginx"), ("via", "1.1 varnish"))));
var named = CdnCatalog.FromResponse(Response(("x-cdn", "EdgeOne")));
Assert.Equal(("EdgeOne", null, "edgeone"), (named.Name, named.Domain, named.Key));
Assert.Null(CdnCatalog.FromResponse(Response(("x-cdn", "<script>"))));
}
[Fact]
public void Only_networks_that_carry_nothing_but_a_cdn_name_it()
{
Assert.Equal("Cloudflare", CdnCatalog.OfAsn(13335)?.Name);
Assert.Equal("Akamai", CdnCatalog.OfAsn(20940)?.Name);
Assert.Null(CdnCatalog.OfAsn(16509));//AWS: CloudFront only by its ranges or its headers
Assert.Null(CdnCatalog.OfAsn(60068));//DataPacket also rents servers
}
[Fact]
public void Every_published_list_format_is_read()
{
Assert.Equal(new[] { "173.245.48.0/20", "2400:cb00::/32" },
CdnCatalog.Parse(CdnListFormat.Lines, "173.245.48.0/20\n# comment\n\n2400:cb00::/32\n").Select(n => n.ToString()));
Assert.Equal(new[] { "89.187.188.227/32", "2400:52e0:1500::714:1/128" },
CdnCatalog.Parse(CdnListFormat.StringArray, """["89.187.188.227","2400:52e0:1500::714:1"]""").Select(n => n.ToString()));
Assert.Equal(new[] { "23.235.32.0/20", "2a04:4e40::/32" },
CdnCatalog.Parse(CdnListFormat.AddressesJson, """{"addresses":["23.235.32.0/20"],"ipv6_addresses":["2a04:4e40::/32"]}""").Select(n => n.ToString()));
Assert.Equal(new[] { "92.223.124.12/32", "2a03:90c0::/32" },
CdnCatalog.Parse(CdnListFormat.AddressesJson, """{"addresses":["92.223.124.12/32"],"addresses_v6":["2a03:90c0::/32"]}""").Select(n => n.ToString()));
Assert.Equal(new[] { "199.83.128.0/21", "2a02:e980::/29" },
CdnCatalog.Parse(CdnListFormat.ImpervaJson, """{"ipRanges":["199.83.128.0/21"],"ipv6Ranges":["2a02:e980::/29"],"res":0}""").Select(n => n.ToString()));
Assert.Equal(new[] { "13.32.0.0/15", "2600:9000::/28" },
CdnCatalog.Parse(CdnListFormat.AwsCloudFront, """
{"prefixes":[{"ip_prefix":"3.4.12.4/32","service":"AMAZON"},{"ip_prefix":"13.32.0.0/15","service":"CLOUDFRONT"}],
"ipv6_prefixes":[{"ipv6_prefix":"2600:9000::/28","service":"CLOUDFRONT"},{"ipv6_prefix":"2406:daba::/40","service":"EC2"}]}
""").Select(n => n.ToString()));
Assert.Throws<FormatException>(() => CdnCatalog.Parse(CdnListFormat.Lines, "173.245.48.0/20\nnot an address"));
}
[Fact]
public void The_published_ranges_name_the_cdn_of_an_address()
{
var ranges = new CdnRanges();
ranges.Replace(new CdnRangeSet { Key = "cloudflare.com", Name = "Cloudflare", Ranges = new() { "104.16.0.0/13", "2606:4700::/32" }, FetchedAt = DateTime.UtcNow });
Assert.Equal("Cloudflare", ranges.Of(IPAddress.Parse("104.18.2.3"))?.Name);
Assert.Equal("Cloudflare", ranges.Of(IPAddress.Parse("::ffff:104.18.2.3"))?.Name);
Assert.Equal("Cloudflare", ranges.Of(IPAddress.Parse("2606:4700::1"))?.Name);
Assert.Null(ranges.Of(IPAddress.Parse("203.0.113.7")));
Assert.Equal(2, ranges.Status["cloudflare.com"].Ranges);
}
[Fact]
public async Task Responses_leave_a_cdn_hint_for_their_host_until_one_comes_without()
{
var hints = new EdgeHints();
var answers = new Queue<HttpResponseMessage>(new[] { Response(("cf-ray", "1-FRA")), Response(("server", "nginx")) });
using var client = new HttpClient(new EdgeHintsHandler(hints) { InnerHandler = new Answering(answers) });
await client.GetAsync("https://social.example/.well-known/nodeinfo", TestContext.Current.CancellationToken);
Assert.Equal("Cloudflare", hints.Of("social.example")?.Name);
await client.GetAsync("https://social.example/nodeinfo/2.1", TestContext.Current.CancellationToken);
Assert.Null(hints.Of("social.example"));
}
sealed class Answering : HttpMessageHandler
{
readonly Queue<HttpResponseMessage> _answers;
public Answering(Queue<HttpResponseMessage> answers) => _answers = answers;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(_answers.Dequeue());
}
}
}
@@ -0,0 +1,105 @@
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Entities;
using PrivaPub.Infrastructure.Geo;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Jobs;
using PrivaPub.Tests.Support;
using System.Net;
namespace PrivaPub.Tests.Infrastructure
{
[Trait("Category", "Integration")]
public sealed class CdnUpdaterTests : IAsyncLifetime
{
Peer _peer;
CdnRanges _ranges;
CdnUpdater _updater;
string _key;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_peer = await Peer.Start();
_ranges = new CdnRanges();
_updater = new CdnUpdater(new Clients(), new StaticOptions<StatisticsOptions>(new StatisticsOptions()), _ranges, NullLogger<CdnUpdater>.Instance);
_key = $"edge{Guid.NewGuid():N}.example";
}
public async ValueTask DisposeAsync()
{
if (_peer != default)
await _peer.DisposeAsync();
}
sealed class Clients : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new();
}
CdnProvider Provider() => new("Test Edge", _key, Array.Empty<int>(), new[]
{
new CdnList($"{_peer.A}/edge/v4", CdnListFormat.Lines),
new CdnList($"{_peer.A}/edge/v6", CdnListFormat.StringArray)
}, _ => false);
static CancellationToken Token => TestContext.Current.CancellationToken;
[Fact]
public async Task The_published_ranges_are_downloaded_stored_and_used()
{
_peer.ServeText("/edge/v4", "198.51.100.0/24\n", "text/plain");
_peer.ServeText("/edge/v6", """["2001:db8::/32","192.0.2.9"]""", "application/json");
Assert.Equal(1, await _updater.Update(new[] { Provider() }, Token));
var stored = await DB.Default.Find<CdnRangeSet>().Match(s => s.Key == _key).ExecuteSingleAsync(Token);
Assert.Equal(new[] { "198.51.100.0/24", "2001:db8::/32", "192.0.2.9/32" }, stored.Ranges);
Assert.Equal("Test Edge", _ranges.Of(IPAddress.Parse("198.51.100.20"))?.Name);
Assert.Equal("Test Edge", _ranges.Of(IPAddress.Parse("192.0.2.9"))?.Name);
var restarted = new CdnRanges();
await restarted.Load(Token);
Assert.Equal("Test Edge", restarted.Of(IPAddress.Parse("2001:db8::7"))?.Name);
}
[Theory]
[InlineData("not a network")]
[InlineData("")]
public async Task A_broken_or_empty_list_keeps_the_stored_ranges(string broken)
{
_peer.ServeText("/edge/v4", "198.51.100.0/24", "text/plain");
_peer.ServeText("/edge/v6", "[]", "application/json");
Assert.Equal(1, await _updater.Update(new[] { Provider() }, Token));
_peer.ServeText("/edge/v4", broken, "text/plain");
Assert.Equal(0, await _updater.Update(new[] { Provider() }, Token));
Assert.Equal(new[] { "198.51.100.0/24" }, (await DB.Default.Find<CdnRangeSet>().Match(s => s.Key == _key).ExecuteSingleAsync(Token)).Ranges);
Assert.Equal("Test Edge", _ranges.Of(IPAddress.Parse("198.51.100.1"))?.Name);
}
// The real lists, from the CDNs themselves: set PRIVAPUB_TEST_CDN_LIVE=1 to check that every format still parses.
[Fact]
public async Task Every_cdns_real_list_still_parses()
{
Assert.SkipUnless(Environment.GetEnvironmentVariable("PRIVAPUB_TEST_CDN_LIVE") == "1", "set PRIVAPUB_TEST_CDN_LIVE=1 to download the real lists");
var listed = CdnCatalog.All.Where(p => p.Lists.Length > 0).ToList();
Assert.Equal(listed.Count, await _updater.Update(listed, Token));
Assert.Equal("Cloudflare", _ranges.Of(IPAddress.Parse("104.16.0.1"))?.Name);
foreach (var provider in listed)
Assert.True(_ranges.Status[provider.Key].Ranges > 0, provider.Name);
}
[Fact]
public async Task An_unreachable_list_changes_nothing()
{
Assert.Equal(0, await _updater.Update(new[] { Provider() }, Token));
Assert.False(await DB.Default.Find<CdnRangeSet>().Match(s => s.Key == _key).ExecuteAnyAsync(Token));
}
}
}
@@ -27,6 +27,12 @@ namespace PrivaPub.Tests.Infrastructure
_peer.MapGet("/big", () => Results.Text("{\"a\":\"" + new string('a', FederationHttp.MaxResponseBytes) + "\"}", "application/activity+json")); _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("/hop/{n:int}", (int n) => Results.Redirect(n == 0 ? "/actor" : $"/hop/{n - 1}"));
_peer.MapGet("/gone", () => Results.StatusCode(410)); _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(); await _peer.StartAsync();
_base = _peer.Urls.First(); _base = _peer.Urls.First();
} }
@@ -64,6 +70,13 @@ namespace PrivaPub.Tests.Infrastructure
public async Task Refuses_a_fourth_redirect() => public async Task Refuses_a_fourth_redirect() =>
Assert.Null(await Client().GetJson($"{_base}/hop/3", "application/activity+json", default, TestContext.Current.CancellationToken)); 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] [Fact]
public async Task Refuses_html() => public async Task Refuses_html() =>
Assert.Null(await Client().GetJson($"{_base}/html", "application/activity+json", default, TestContext.Current.CancellationToken)); Assert.Null(await Client().GetJson($"{_base}/html", "application/activity+json", default, TestContext.Current.CancellationToken));
@@ -1,4 +1,4 @@
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using PrivaPub.Infrastructure.Geo; using PrivaPub.Infrastructure.Geo;
using PrivaPub.Infrastructure.Http; using PrivaPub.Infrastructure.Http;
@@ -45,7 +45,7 @@ namespace PrivaPub.Tests.Infrastructure
Assert.NotNull(fix); Assert.NotNull(fix);
Assert.Equal(2, fix.Country.Length); Assert.Equal(2, fix.Country.Length);
Assert.Equal(13335, fix.Asn); Assert.Equal(13335, fix.Asn);
Assert.Equal("Cloudflare", CdnNetworks.Of(fix.Asn)); Assert.Equal("Cloudflare", CdnCatalog.OfAsn(fix.Asn)?.Name);
Assert.NotNull(fix.AsnOrg); Assert.NotNull(fix.AsnOrg);
Assert.NotNull(fix.Latitude); Assert.NotNull(fix.Latitude);
Assert.Equal(Math.Round(fix.Latitude.Value, 1), fix.Latitude); Assert.Equal(Math.Round(fix.Latitude.Value, 1), fix.Latitude);
@@ -61,9 +61,9 @@ namespace PrivaPub.Tests.Infrastructure
Assert.Equal(IPAddress.Parse("203.0.113.7"), connected.Of("social.example")); Assert.Equal(IPAddress.Parse("203.0.113.7"), connected.Of("social.example"));
Assert.Null(connected.Of("other.example")); Assert.Null(connected.Of("other.example"));
Assert.Equal("Fastly", CdnNetworks.Of(54113)); Assert.Equal("Fastly", CdnCatalog.OfAsn(54113)?.Name);
Assert.Null(CdnNetworks.Of(64496)); Assert.Null(CdnCatalog.OfAsn(64496));
Assert.Null(CdnNetworks.Of(default)); Assert.Null(CdnCatalog.OfAsn(default));
} }
} }
} }
@@ -59,6 +59,8 @@ namespace PrivaPub.Tests.Infrastructure
Assert.True(await local.IsUserNameTaken(name, token)); Assert.True(await local.IsUserNameTaken(name, token));
Assert.False(await local.TryReserveUserName("admin", LocalActorKind.Person, "c", 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.InstanceUserName, LocalActorKind.Person, "c", token));
Assert.False(await local.TryReserveUserName(LocalActorService.ReporterUserName, LocalActorKind.Group, "c", token));
Assert.True(await local.IsUserNameTaken("PrivaPub_Reports", token));
} }
} }
} }
+79 -8
View File
@@ -1,4 +1,4 @@
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Entities; using MongoDB.Entities;
@@ -44,6 +44,23 @@ namespace PrivaPub.Tests.Statistics
Assert.Equal(7, facts.LocalComments); Assert.Equal(7, facts.LocalComments);
Assert.Equal(new[] { "en", "de" }, facts.Languages); Assert.Equal(new[] { "en", "de" }, facts.Languages);
Assert.Null(InstanceDocuments.NodeInfo(Json("{}"), "2.0").UsersTotal); 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] [Fact]
@@ -57,6 +74,10 @@ namespace PrivaPub.Tests.Statistics
Assert.Equal(("https://a.example/2.2", "2.2"), InstanceDocuments.NodeInfoLink(links)); Assert.Equal(("https://a.example/2.2", "2.2"), InstanceDocuments.NodeInfoLink(links));
Assert.Equal(default, InstanceDocuments.NodeInfoLink(Json("[]"))); 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] [Fact]
@@ -78,16 +99,16 @@ namespace PrivaPub.Tests.Statistics
} }
[Fact] [Fact]
public void The_public_location_depends_on_size_and_on_a_cdn() public void The_public_location_is_where_the_server_was_located_unless_a_cdn_hides_it()
{ {
var geo = new InstanceGeo { Country = "DE", City = "Berlin", Latitude = 52.5, Longitude = 13.4, AsnOrg = "Hetzner", Asn = 24940 }; var geo = new InstanceGeo { Country = "DE", City = "Berlin", Latitude = 52.5, Longitude = 13.4, AsnOrg = "Hetzner", Asn = 24940 };
Assert.Equal(new PublicLocation("DE", "Berlin", 52.5, 13.4, "Hetzner", null), PublicGeo.Project(new RemoteInstance { Geo = geo, UsersTotal = 10 }, 10)); Assert.Equal(new PublicLocation("DE", "Berlin", 52.5, 13.4, "Hetzner", null), PublicGeo.Project(geo));
Assert.Equal(new PublicLocation("DE", null, null, null, null, null), PublicGeo.Project(new RemoteInstance { Geo = geo, UsersTotal = 1 }, 10)); Assert.Equal(new PublicLocation("DE", null, null, null, null, null), PublicGeo.Project(new InstanceGeo { Country = "DE" }));
Assert.Equal(new PublicLocation("DE", null, null, null, null, null), PublicGeo.Project(new RemoteInstance { Geo = geo }, 10));
Assert.Equal(new PublicLocation(null, null, null, null, null, "Cloudflare"), Assert.Equal(new PublicLocation(null, null, null, null, null, "Cloudflare"),
PublicGeo.Project(new RemoteInstance { Geo = new InstanceGeo { Country = "US", City = "SF", Cdn = "Cloudflare" }, UsersTotal = 5000 }, 10)); PublicGeo.Project(new InstanceGeo { Country = "US", City = "SF", Latitude = 37.8, Longitude = -122.4, Cdn = "Cloudflare" }));
Assert.Null(PublicGeo.Project(new RemoteInstance(), 10)); Assert.Null(PublicGeo.Project(new InstanceGeo()));
Assert.Null(PublicGeo.Project(null));
} }
} }
@@ -112,7 +133,8 @@ namespace PrivaPub.Tests.Statistics
{ {
readonly string _peer; readonly string _peer;
public PeerDescriber(IFederationHttp http, IGeoLocator geo, IConnectedAddresses addresses, string peer) : base(http, geo, addresses) => _peer = peer; public PeerDescriber(IFederationHttp http, IGeoLocator geo, IConnectedAddresses addresses, string peer, ICdnRanges ranges = default,
IEdgeHints edges = default) : base(http, geo, addresses, ranges, edges) => _peer = peer;
protected override string Address(string url) => _peer + new Uri(url).PathAndQuery; protected override string Address(string url) => _peer + new Uri(url).PathAndQuery;
} }
@@ -155,6 +177,31 @@ namespace PrivaPub.Tests.Statistics
Assert.Equal(InstanceDescriber.Week(DateTime.UtcNow), snapshot.Week); Assert.Equal(InstanceDescriber.Week(DateTime.UtcNow), snapshot.Week);
} }
[Fact]
public async Task A_cdn_is_found_by_its_published_ranges_first_then_by_its_headers()
{
var token = TestContext.Current.CancellationToken;
var listed = $"listed{Guid.NewGuid():N}.example";
var hinted = $"hinted{Guid.NewGuid():N}.example";
ServeServer("/" + listed, "mastodon");
ServeServer("/" + hinted, "mastodon");
var addresses = new ConnectedAddresses();
var ranges = new CdnRanges();
ranges.Replace(new CdnRangeSet { Key = "cloudflare.com", Name = "Cloudflare", Ranges = new() { "127.0.0.0/8" }, FetchedAt = DateTime.UtcNow });
var edges = new EdgeHints();
edges.Remember("127.0.0.1", CdnCatalog.ByKey("fastly.com"));
await new PeerDescriber(Peer.Http(connected: addresses), new FixedGeo(), addresses, _peer.A + "/" + listed, ranges, edges).Describe(listed, crawled: false, default, token);
await new PeerDescriber(Peer.Http(connected: addresses), new FixedGeo(), addresses, _peer.A + "/" + hinted, new CdnRanges(), edges).Describe(hinted, crawled: false, default, token);
var listedOne = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == listed).ExecuteSingleAsync(token);
Assert.Equal(("Cloudflare", "cloudflare.com", "ranges"), (listedOne.Geo.Cdn, listedOne.Geo.CdnDomain, listedOne.Geo.CdnSource));
var hintedOne = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == hinted).ExecuteSingleAsync(token);
Assert.Equal(("Fastly", "fastly.com", "headers"), (hintedOne.Geo.Cdn, hintedOne.Geo.CdnDomain, hintedOne.Geo.CdnSource));
var snapshot = await DB.Default.Find<RemoteInstanceSnapshot>().Match(s => s.Host == listed).ExecuteSingleAsync(token);
Assert.Equal(("Cloudflare", "cloudflare.com", "ranges", "Amsterdam", 52.4), (snapshot.Cdn, snapshot.CdnDomain, snapshot.CdnSource, snapshot.City, snapshot.Latitude!.Value));
}
[Fact] [Fact]
public async Task Without_api_v2_the_v1_api_is_read_and_an_unreachable_week_is_recorded_too() public async Task Without_api_v2_the_v1_api_is_read_and_an_unreachable_week_is_recorded_too()
{ {
@@ -174,6 +221,30 @@ namespace PrivaPub.Tests.Statistics
Assert.False((await DB.Default.Find<RemoteInstanceSnapshot>().Match(s => s.Host == gone).ExecuteSingleAsync(token)).Reachable); 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] [Fact]
public async Task A_crawled_server_never_downgrades_a_touched_one_and_robots_are_obeyed() 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("evil host/with path"));
Assert.Equal("-", Interactions.Host(default)); Assert.Equal("-", Interactions.Host(default));
Assert.Equal("social.example", Interactions.HostOf("https://Social.Example:8443/users/x")); 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] [Fact]
+38 -14
View File
@@ -14,6 +14,7 @@ using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox; using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Inbox.Handlers; using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Relays;
using PrivaPub.Infrastructure.Jobs; using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models; using PrivaPub.Models;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
@@ -43,41 +44,54 @@ namespace PrivaPub.Tests.Support
Local = new LocalActorService(Db, new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = Base })); Local = new LocalActorService(Db, new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = Base }));
Remote = new RemoteActorService(Peer.Http(cache), Local, cache, Db); Remote = new RemoteActorService(Peer.Http(cache), Local, cache, Db);
Delivery = new DeliveryService(Db, Queue); Delivery = new DeliveryService(Db, Queue);
Relays = new Relays(Microsoft.Extensions.Options.Options.Create(RelayOptions), Remote, Local, Delivery);
Fanout = new Fanout(Db); Fanout = new Fanout(Db);
Groups = new GroupDistributor(Delivery); Groups = new GroupDistributor(Delivery);
Records = new ObjectRecords(Queue); Records = new ObjectRecords(Queue);
Polls = new PollService(Db, Local, Delivery, Queue); Polls = new PollService(Db, Local, Delivery, Queue);
Reactions = new Reactions(Db, Delivery); Reactions = new Reactions(Db, Delivery);
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue, Records, new NoPreviews()); 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); 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); 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[] Handlers = new IActivityHandler[]
{ {
new FollowHandler(Db, Local, Remote, Delivery), new FollowHandler(Db, Local, Remote, Delivery),
new AcceptHandler(Db, Local, Quotes), new AcceptHandler(Db, Local, Quotes, Approvals, Participations, Relays, Queue),
new RejectHandler(Db, Local, Quotes), new RejectHandler(Db, Local, Quotes, Approvals, Participations, Relays),
new ReplyApprovalHandler(Approvals),
new ReplyRejectionHandler(Approvals),
new UndoHandler(Db, Local, Reactions), new UndoHandler(Db, Local, Reactions),
new LikeHandler(Db, Reactions), new LikeHandler(Db, Reactions),
new EmojiReactHandler(Db, Reactions), new EmojiReactHandler(Db, Reactions),
new QuoteRequestHandler(Quotes), new QuoteRequestHandler(Quotes),
new DislikeHandler(Db), new DislikeHandler(Db),
new JoinHandler(Db, Local, Delivery), new JoinHandler(Db, Local, Delivery),
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote, Records, 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), 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 DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes),
new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes), new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes, Delivery),
new FlagHandler(Db, Local) 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); ((AnnounceHandler)Handlers.First(h => h is AnnounceHandler)).Relays = Handlers;
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance); Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance, Ledger, Local);
Content = new ContentRenderer(Local, Remote); Content = new ContentRenderer(Local, Remote);
Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }), Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }),
Local, default, NullLogger<MediaService>.Instance); 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); Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer<GenericRes>(), NullLogger<PostsService>.Instance);
Timelines = new TimelineService(Db, new KeyLocalizer<GenericRes>()); Timelines = new TimelineService(Db, new KeyLocalizer<GenericRes>());
Relationships = new RelationshipService(Db, Follows, Delivery);
Reports = new ReportService(Db, Local, Delivery); Reports = new ReportService(Db, Local, Delivery);
} }
@@ -104,8 +118,15 @@ namespace PrivaPub.Tests.Support
public PollService Polls { get; } public PollService Polls { get; }
public Reactions Reactions { get; } public Reactions Reactions { get; }
public QuoteService Quotes { get; } public QuoteService Quotes { get; }
public InteractionApprovals Approvals { get; }
public Participations Participations { get; }
public TimelineService Timelines { get; } public TimelineService Timelines { get; }
public RelationshipService Relationships { 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 ReportService Reports { get; }
public async Task FollowedBy(LocalActor local, RemoteActor follower) => 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) 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 result = await Receiver.Receive(sender.Post(Host, path, activity), default, CancellationToken.None);
var dedupe = "inbox|" + (activity is JsonObject ? activity["id"]?.GetValue<string>() : default); // the job this delivery queued: under its id, or apart (a forwarded copy; an id its sender reused)
var job = await DB.Default.Find<Job>().Match(j => j.DedupeKey == dedupe).ExecuteFirstAsync(); var id = activity is JsonObject ? activity["id"]?.GetValue<string>() : default;
if (job != 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); Assert.Equal(JobResult.Done, (await Processor.Handle(job, CancellationToken.None)).Result);
return result; return result;
} }
+4 -2
View File
@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@@ -23,7 +23,8 @@ namespace PrivaPub.Tests.Support.Host
public const string ClientHeader = "X-Test-Client"; public const string ClientHeader = "X-Test-Client";
static readonly SemaphoreSlim Boot = new(1, 1); 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; static PrivaPubHost _shared;
readonly string _mediaRoot = Path.Combine(Path.GetTempPath(), $"privapub-tests-{Guid.NewGuid():N}"); readonly string _mediaRoot = Path.Combine(Path.GetTempPath(), $"privapub-tests-{Guid.NewGuid():N}");
@@ -75,6 +76,7 @@ namespace PrivaPub.Tests.Support.Host
["Federation:FetchLinkPreviews"] = "false", ["Federation:FetchLinkPreviews"] = "false",
["Registrations:Mode"] = "Open", ["Registrations:Mode"] = "Open",
["Statistics:Geo:AutoUpdate"] = "false", ["Statistics:Geo:AutoUpdate"] = "false",
["Statistics:Cdn:AutoUpdate"] = "false",
["Media:Root"] = _mediaRoot, ["Media:Root"] = _mediaRoot,
["Logging:LogLevel:Default"] = "Warning", ["Logging:LogLevel:Default"] = "Warning",
["Serilog:MinimumLevel:Default"] = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_LOGS") == "1" ? "Information" : "Fatal" ["Serilog:MinimumLevel:Default"] = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_LOGS") == "1" ? "Information" : "Fatal"
+63 -13
View File
@@ -13,12 +13,22 @@ namespace PrivaPub.Tests.Support
public sealed class RemoteActor public sealed class RemoteActor
{ {
readonly RSA _key = RSA.Create(2048); 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]; Name = $"{name}{Guid.NewGuid():N}"[..20];
Id = $"{origin ?? peer.A}/users/{Name}"; Id = $"{origin ?? peer.A}/users/{Name}";
Type = type; Type = type;
_namesSharedInbox = sharedInbox;
_hasWall = wall;
peer.Serve($"/users/{Name}", Document().ToJsonString()); peer.Serve($"/users/{Name}", Document().ToJsonString());
} }
@@ -28,21 +38,45 @@ namespace PrivaPub.Tests.Support
public string Id { get; } public string Id { get; }
public string KeyId => Id + "#main-key"; public string KeyId => Id + "#main-key";
public string SharedInbox => Id.Split("/users/")[0] + "/inbox"; 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, activity["proof"] = PrivaPub.Federation.Signing.IntegrityProofs.Create(activity, Id + "#ed25519-key", _ed25519, DateTime.UtcNow);
["type"] = Type, return activity;
["preferredUsername"] = Name, }
["inbox"] = Id + "/inbox",
["followers"] = Id + "/followers", public JsonObject Document()
["publicKey"] = new JsonObject {
var document = new JsonObject
{ {
["id"] = KeyId, ["id"] = Id,
["owner"] = Id, ["type"] = Type,
["publicKeyPem"] = _key.ExportSubjectPublicKeyInfoPem() ["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") public HttpRequestMessage SignedGet(string path, string host = "privapub.test")
{ {
@@ -68,6 +102,22 @@ namespace PrivaPub.Tests.Support
return request; 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) string Signature(string signingString, string headers)
{ {
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
+8 -1
View File
@@ -49,7 +49,14 @@ namespace PrivaPub.Api.Mastodon.Auth
.SetTokenEndpointUris("/oauth/token") .SetTokenEndpointUris("/oauth/token")
.SetRevocationEndpointUris("/oauth/revoke") .SetRevocationEndpointUris("/oauth/revoke")
.SetConfigurationEndpointUris("/.well-known/openid-configuration", "/.well-known/oauth-authorization-server"); .SetConfigurationEndpointUris("/.well-known/openid-configuration", "/.well-known/oauth-authorization-server");
options.AllowAuthorizationCodeFlow().AllowClientCredentialsFlow(); options.AllowAuthorizationCodeFlow().AllowClientCredentialsFlow().AllowTokenExchangeFlow();
//the only subject token accepted is the root JWT, for PersonaExchange; OpenIddict's own tokens are not exchanged
options.Configure(server =>
{
server.SubjectTokenTypes.Clear();
server.SubjectTokenTypes.Add(PersonaExchange.SubjectTokenType);
});
options.AddEventHandler(RootJwtSubjectToken.Descriptor);
options.RegisterScopes(MastodonScopes.All); options.RegisterScopes(MastodonScopes.All);
options.UseReferenceAccessTokens(); options.UseReferenceAccessTokens();
options.SetAccessTokenLifetime(null); options.SetAccessTokenLifetime(null);
@@ -0,0 +1,105 @@
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using PrivaPub.Services;
using PrivaPub.StaticServices;
using System.Security.Claims;
using static OpenIddict.Abstractions.OpenIddictConstants;
using static OpenIddict.Server.OpenIddictServerEvents;
namespace PrivaPub.Api.Mastodon.Auth
{
// One sign-in for the first-party client (decePubClient): it signs in on /clientapi, then exchanges that root JWT for one
// persona's Mastodon token (RFC 8693, grant_type urn:ietf:params:oauth:grant-type:token-exchange, subject_token_type
// urn:ietf:params:oauth:token-type:jwt, avatar_id). Only the seeded first-party application holds the grant permission,
// and the issued token is the same kind the authorization code flow issues: subject the avatar, never the root.
public static class PersonaExchange
{
public const string SubjectTokenType = "urn:ietf:params:oauth:token-type:jwt";
public const string AvatarParameter = "avatar_id";
//the root the subject token belongs to; it lives on the subject token's principal only, which is never stored or issued
public const string RootClaim = "privapub_root";
public static string FirstPartyClientId(IConfiguration configuration) =>
configuration["ClientApi:FirstPartyClientId"] is { Length: > 0 } clientId ? clientId : "decepub";
//the first-party application: public (a browser keeps no secret), token exchange only, no redirect
public static async Task EnsureFirstPartyClient(IServiceProvider services, CancellationToken token)
{
using var scope = services.CreateScope();
var applications = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
var clientId = FirstPartyClientId(scope.ServiceProvider.GetRequiredService<IConfiguration>());
var descriptor = new OpenIddictApplicationDescriptor
{
ClientId = clientId,
ClientType = ClientTypes.Public,
ConsentType = ConsentTypes.Implicit,
DisplayName = "decePubClient",
Permissions =
{
Permissions.Endpoints.Token,
Permissions.Endpoints.Revocation,
Permissions.GrantTypes.TokenExchange
}
};
foreach (var scopeName in new[] { "read", "write", "follow" })
descriptor.Permissions.Add(Permissions.Prefixes.Scope + scopeName);
var existing = await applications.FindByClientIdAsync(clientId, token);
if (existing == default)
{
await applications.CreateAsync(descriptor, token);
return;
}
await applications.PopulateAsync(existing, descriptor, token);
await applications.UpdateAsync(existing, token);
}
}
// Validates a root JWT given as a token exchange subject token, before OpenIddict tries it as one of its own tokens.
// The principal it builds names the root under PersonaExchange.RootClaim for TokenController, which signs in a fresh
// identity for the avatar.
public sealed class RootJwtSubjectToken : IOpenIddictServerHandler<ValidateTokenContext>
{
public static OpenIddictServerHandlerDescriptor Descriptor { get; } = OpenIddictServerHandlerDescriptor.CreateBuilder<ValidateTokenContext>()
.UseScopedHandler<RootJwtSubjectToken>()
.SetOrder(OpenIddictServerHandlers.Protection.ValidateIdentityModelToken.Descriptor.Order - 500)
.SetType(OpenIddictServerHandlerType.Custom)
.Build();
readonly IConfiguration _configuration;
readonly DbEntities _dbEntities;
public RootJwtSubjectToken(IConfiguration configuration, DbEntities dbEntities)
{
_configuration = configuration;
_dbEntities = dbEntities;
}
public async ValueTask HandleAsync(ValidateTokenContext context)
{
if (context.ValidTokenTypes.Count != 1 || !context.ValidTokenTypes.Contains(PersonaExchange.SubjectTokenType))
return;
var root = await RootJwt.Root(context.Token, _configuration, _dbEntities, context.Transaction.CancellationToken);
if (root == default)
{
context.Reject(Errors.InvalidGrant, "The session token is not valid.");
return;
}
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);
identity.SetClaim(PersonaExchange.RootClaim, root.ID);
var principal = new ClaimsPrincipal(identity)
.SetTokenType(PersonaExchange.SubjectTokenType)
.SetCreationDate(DateTimeOffset.UtcNow);
if (context.ValidPresenters.Count > 0)
principal.SetPresenters(context.ValidPresenters);
context.Principal = principal;
}
}
}
+25 -1
View File
@@ -1,4 +1,4 @@
using Microsoft.AspNetCore; using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
@@ -48,9 +48,33 @@ namespace PrivaPub.Api.Mastodon.Auth
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
} }
if (request.IsTokenExchangeGrantType())
return await Persona(request, token);
return Refuse(Errors.UnsupportedGrantType, "The grant type is not supported."); return Refuse(Errors.UnsupportedGrantType, "The grant type is not supported.");
} }
//PersonaExchange: the root JWT, validated by RootJwtSubjectToken, for a token of one of the root's personas
async Task<IActionResult> Persona(OpenIddictRequest request, CancellationToken token)
{
var subject = (await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)).Principal;
var rootId = subject?.GetClaim(PersonaExchange.RootClaim);
var avatarId = (string)request[PersonaExchange.AvatarParameter];
if (string.IsNullOrEmpty(rootId) || string.IsNullOrEmpty(avatarId))
return Refuse(Errors.InvalidGrant, "The persona can no longer be used.");
var owns = await _dbEntities.RootToAvatars.Match(r => r.RootId == rootId && r.AvatarId == avatarId).ExecuteAnyAsync(token);
var avatar = owns ? await _dbEntities.Avatars.MatchID(avatarId).ExecuteFirstAsync(token) : default;
if (avatar == default || !await Usable(avatarId, token))
return Refuse(Errors.InvalidGrant, "The persona can no longer be used.");
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);
identity.SetClaim(Claims.Subject, avatar.ID);
identity.SetClaim(Claims.Name, avatar.UserName);
identity.SetScopes(MastodonScopes.Parse(request.Scope).Intersect(new[] { "read", "write", "follow" }));
identity.SetDestinations(_ => new[] { Destinations.AccessToken });
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
async Task<bool> Usable(string avatarId, CancellationToken token) async Task<bool> Usable(string avatarId, CancellationToken token)
{ {
if (string.IsNullOrEmpty(avatarId)) if (string.IsNullOrEmpty(avatarId))
@@ -71,8 +71,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
avatar.Settings.IsDiscoverable = discoverable; avatar.Settings.IsDiscoverable = discoverable;
if (Params.Bool("indexable") is { } indexable) if (Params.Bool("indexable") is { } indexable)
avatar.Settings.IsIndexable = indexable; avatar.Settings.IsIndexable = indexable;
if (Params.Bool("hide_collections") is { } hide) // hide_collections stays true: who follows whom is never listed here, only counted (FEDERATION.md)
avatar.Settings.HideCollections = hide;
if (Params.Get("source[privacy]") is { } privacy && privacy is "public" or "unlisted" or "private") if (Params.Get("source[privacy]") is { } privacy && privacy is "public" or "unlisted" or "private")
avatar.Settings.DefaultVisibility = privacy; avatar.Settings.DefaultVisibility = privacy;
if (Params.Bool("source[sensitive]") is { } sensitive) if (Params.Bool("source[sensitive]") is { } sensitive)
@@ -116,11 +115,11 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase)) if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
{ {
var local = await _localActors.FindByUserName(parts[0], token); 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 userName = parts[0];
var domain = parts[1].ToLowerInvariant(); var domain = parts[1].ToLowerInvariant();
var foreign = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain).ExecuteFirstAsync(token); var foreign = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain && !f.DeletionAt.HasValue).ExecuteFirstAsync(token);
return foreign == default ? NotFoundError() : Json(_mapper.Foreign(foreign)); return foreign == default ? NotFoundError() : Json(_mapper.Foreign(foreign));
} }
@@ -135,7 +134,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/accounts/search"), Scope("read:accounts")] [HttpGet("/api/v1/accounts/search"), Scope("read:accounts")]
public async Task<IActionResult> Search([FromServices] AccountSearch search, CancellationToken token) => 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] [HttpGet("/api/v1/accounts/{id}"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Get(string id, CancellationToken token) public async Task<IActionResult> Get(string id, CancellationToken token)
@@ -153,7 +154,12 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (Params.Bool("pinned") == true) if (Params.Bool("pinned") == true)
{ {
if (local == default) 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 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); 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)); return Json(await _mapper.Statuses(pinIds.Select(id => pinnedPosts.FirstOrDefault(p => p.ID == id)).Where(p => p != default).ToList(), MyId, token));
@@ -197,8 +203,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
var (local, _) = await Find(id, token); var (local, _) = await Find(id, token);
if (local == default || local.Id != MyId) if (local == default || local.Id != MyId)
return Json(Array.Empty<Account>()); return Json(Array.Empty<Account>());
var followers = await _dbEntities.Followers.Match(f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted) var followers = await Page.From(Params, Limit(40, 80))
.Sort(f => f.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token); .Fetch(_dbEntities.Followers.Match(f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted), f => f.ID, token);
Link($"/api/v1/accounts/{id}/followers", followers.LastOrDefault()?.ID, followers.FirstOrDefault()?.ID);
return Json(await AccountsFor(followers.Select(f => f.ActorURI), token)); return Json(await AccountsFor(followers.Select(f => f.ActorURI), token));
} }
@@ -208,8 +215,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
var (local, _) = await Find(id, token); var (local, _) = await Find(id, token);
if (local == default || local.Id != MyId) if (local == default || local.Id != MyId)
return Json(Array.Empty<Account>()); return Json(Array.Empty<Account>());
var following = await _dbEntities.Followings.Match(f => f.AvatarId == local.Id && f.State == FollowState.Accepted) var following = await Page.From(Params, Limit(40, 80))
.Sort(f => f.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token); .Fetch(_dbEntities.Followings.Match(f => f.AvatarId == local.Id && f.State == FollowState.Accepted), f => f.ID, token);
Link($"/api/v1/accounts/{id}/following", following.LastOrDefault()?.ID, following.FirstOrDefault()?.ID);
var accounts = await _mapper.Accounts(following.Select(f => f.TargetAccountId), token); var accounts = await _mapper.Accounts(following.Select(f => f.TargetAccountId), token);
return Json(following.Select(f => accounts.GetValueOrDefault(f.TargetAccountId)).Where(a => a != default).ToList()); return Json(following.Select(f => accounts.GetValueOrDefault(f.TargetAccountId)).Where(a => a != default).ToList());
} }
@@ -341,9 +349,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/accounts/{id}/featured_tags"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] [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>()); 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")] [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()); public IActionResult FamiliarFollowers() => Json(Params.List("id").Select(id => new { id, accounts = Array.Empty<Account>() }).ToList());
@@ -384,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 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 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 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 blockedBy = local != default
var domainBlocked = remote != default && await DB.Default.Find<AccountDomainBlock>().Match(b => b.AvatarId == MyId && b.Domain == remote.Domain).ExecuteAnyAsync(token); ? 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 return new Relationship
{ {
Id = id, Id = id,
@@ -0,0 +1,82 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Domain.Statistics;
namespace PrivaPub.Api.Mastodon.Controllers
{
// The CDNs in front of the servers this server knows, each named by its own domain (owner decision 2026-10-04): how many
// servers are behind it, how it is recognised, and week by week who joined and who left.
public class CdnsController : MastodonController
{
const int MaxWeeks = 260;
readonly IServerPlaces _places;
public CdnsController(IServerPlaces places) => _places = places;
[HttpGet("/api/privapub/v1/cdns"), Scope("read", requiresUser: false), AllowAnonymous]
public async Task<IActionResult> List(CancellationToken token) => Json((await _places.Cdns(token)).Select(Summary).ToList());
[HttpGet("/api/privapub/v1/cdns/{key}"), Scope("read", requiresUser: false), AllowAnonymous]
public async Task<IActionResult> Detail(string key, CancellationToken token)
{
var history = await _places.Cdn(key, Math.Clamp(Params.Int("weeks") ?? 52, 1, MaxWeeks), token);
if (history == default)
return NotFoundError();
var detail = new CdnDetail
{
Hosts = history.Hosts,
HostsTruncated = history.Group.Servers > history.Hosts.Count,
Weeks = history.Weeks.Select(w => new CdnWeekEntry { Week = w.Week, Servers = w.Servers, Joined = w.Joined, Left = w.Left }).ToList()
};
Fill(detail, history.Group);
return Json(detail);
}
static CdnSummary Summary(CdnGroup group)
{
var summary = new CdnSummary();
Fill(summary, group);
return summary;
}
static void Fill(CdnSummary summary, CdnGroup group)
{
summary.Key = group.Key;
summary.Name = group.Name;
summary.Domain = group.Domain;
summary.Servers = group.Servers;
summary.Ranges = group.Ranges;
summary.RangesUpdatedAt = group.RangesUpdatedAt is { } at ? MastodonJson.Time(at) : default;
summary.DetectedBy = group.DetectedBy;
}
public class CdnSummary
{
public string Key { get; set; }
public string Name { get; set; }
public string Domain { get; set; }
public int Servers { get; set; }
public int Ranges { get; set; }
public string RangesUpdatedAt { get; set; }
public List<string> DetectedBy { get; set; } = new();
}
public class CdnDetail : CdnSummary
{
public List<string> Hosts { get; set; } = new();
public bool HostsTruncated { get; set; }
public List<CdnWeekEntry> Weeks { get; set; } = new();
}
public class CdnWeekEntry
{
public string Week { get; set; }
public int Servers { get; set; }
public List<string> Joined { get; set; } = new();
public List<string> Left { get; set; } = new();
}
}
}
@@ -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 { });
}
}
}
@@ -30,28 +30,54 @@ namespace PrivaPub.Api.Mastodon.Controllers
bool Open => _registrations.CurrentValue.IsOpen; bool Open => _registrations.CurrentValue.IsOpen;
// the languages this server is set up for (AppConfiguration:SupportedLanguages), "en" when none are named
public static IReadOnlyList<string> LanguageCodes(IServiceProvider services)
{
var configured = services.GetRequiredService<Microsoft.Extensions.Options.IOptionsMonitor<Models.AppConfiguration>>().CurrentValue.SupportedLanguages;
var codes = (configured ?? new List<string>()).Where(c => !string.IsNullOrWhiteSpace(c)).Select(c => c.Split('-')[0].ToLowerInvariant()).Distinct().ToList();
return codes.Count == 0 ? new[] { "en" } : codes;
}
string Domain => new Uri(_localActors.BaseAddress).Authority; string Domain => new Uri(_localActors.BaseAddress).Authority;
static object Statuses => new { max_characters = 5000, max_media_attachments = 4, characters_reserved_per_url = 23 }; // 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);
static object MediaAttachments => new // every limit is what the server enforces, read from where it is enforced
static object Statuses => new { max_characters = PrivaPub.Domain.Statuses.StatusService.MaxCharacters, max_media_attachments = 4, characters_reserved_per_url = 23 };
object MediaAttachments
{ {
supported_mime_types = new[] { "image/jpeg", "image/png", "image/gif", "image/webp", "video/mp4", "video/webm", "audio/mpeg", "audio/ogg" }, get
image_size_limit = 16 * 1024 * 1024, {
image_matrix_limit = 16_777_216, var media = HttpContext.RequestServices.GetRequiredService<IOptionsMonitor<PrivaPub.Domain.Media.MediaOptions>>().CurrentValue;
video_size_limit = 99 * 1024 * 1024, return new
video_frame_rate_limit = 60, {
video_matrix_limit = 2_304_000 supported_mime_types = PrivaPub.Domain.Media.MediaService.SupportedTypes,
}; image_size_limit = media.MaxImageBytes,
image_matrix_limit = media.MaxImageSide * media.MaxImageSide,
video_size_limit = media.MaxVideoBytes,
video_frame_rate_limit = 60,
video_matrix_limit = 2_304_000
};
}
}
static object Polls => new { max_options = 4, max_characters_per_option = 50, min_expiration = 300, max_expiration = 2_629_746 }; static object Polls => new
{
max_options = PrivaPub.Domain.Statuses.PollService.MaxOptions,
max_characters_per_option = PrivaPub.Domain.Statuses.PollService.MaxOptionLength,
min_expiration = PrivaPub.Domain.Statuses.PollService.MinExpiration,
max_expiration = PrivaPub.Domain.Statuses.PollService.MaxExpiration
};
[HttpGet("/api/v1/instance"), AllowAnonymous] [HttpGet("/api/v1/instance"), AllowAnonymous]
public async Task<IActionResult> V1(CancellationToken token) public async Task<IActionResult> V1(CancellationToken token)
{ {
var users = await DB.Default.CountAsync<Avatar>(a => !a.DeletionAt.HasValue, token); var users = await Counted.Users(token);
var statuses = await DB.Default.CountAsync<PostEntity>(f => f.Where(p => !p.IsFederatedCopy) & f.Where(VisibilityPolicy.IsPublic), token); var statuses = await Counted.LocalPosts(token);
var domains = (await DB.Default.Find<ForeignAvatar, string>().Project(a => a.Domain).ExecuteAsync(token)).Distinct().Count(); var domains = await KnownServers(token);
return Json(new return Json(new
{ {
uri = Domain, uri = Domain,
@@ -60,10 +86,10 @@ namespace PrivaPub.Api.Mastodon.Controllers
description = Description, description = Description,
email = string.Empty, email = string.Empty,
version = Version, version = Version,
urls = new { streaming_api = $"wss://{Domain}" }, urls = new { streaming_api = Streaming },
stats = new { user_count = users, status_count = statuses, domain_count = domains }, stats = new { user_count = users, status_count = statuses, domain_count = domains },
thumbnail = $"{_localActors.BaseAddress}/media/missing-header.png", thumbnail = $"{_localActors.BaseAddress}/media/missing-header.png",
languages = new[] { "en" }, languages = LanguageCodes(HttpContext.RequestServices),
registrations = Open, registrations = Open,
approval_required = false, approval_required = false,
invites_enabled = true,//a group invitation always creates an account invites_enabled = true,//a group invitation always creates an account
@@ -73,8 +99,14 @@ namespace PrivaPub.Api.Mastodon.Controllers
}); });
} }
// the servers we have exchanged activities with, as the statistics count them. Which ones is not published
// (/instance/peers stays empty, as on a Mastodon with its peers API off): on a small server it would say who its
// personas talk to.
static async Task<long> KnownServers(CancellationToken token) =>
await DB.Default.CountAsync<Models.Jobs.RemoteInstance>(i => i.Seen == "touched", token);
[HttpGet("/api/v2/instance"), AllowAnonymous] [HttpGet("/api/v2/instance"), AllowAnonymous]
public IActionResult V2() => Json(new public async Task<IActionResult> V2(CancellationToken token) => Json(new
{ {
domain = Domain, domain = Domain,
title = "PrivaPub", title = "PrivaPub",
@@ -82,13 +114,13 @@ namespace PrivaPub.Api.Mastodon.Controllers
api_versions = new { mastodon = 7 }, api_versions = new { mastodon = 7 },
source_url = "https://git.thepra.dev/thepra/SocialPub", source_url = "https://git.thepra.dev/thepra/SocialPub",
description = Description, description = Description,
usage = new { users = new { active_month = 0 } }, usage = new { users = new { active_month = await Counted.ActiveUsers(30, token) } },
thumbnail = new { url = $"{_localActors.BaseAddress}/media/missing-header.png" }, thumbnail = new { url = $"{_localActors.BaseAddress}/media/missing-header.png" },
languages = new[] { "en" }, languages = LanguageCodes(HttpContext.RequestServices),
configuration = new configuration = new
{ {
urls = new { streaming = $"wss://{Domain}" }, urls = new { streaming = Streaming },
accounts = new { max_featured_tags = 0, max_pinned_statuses = 0 }, accounts = new { max_featured_tags = 0, max_pinned_statuses = StatusesController.MaxPins },
statuses = Statuses, statuses = Statuses,
media_attachments = MediaAttachments, media_attachments = MediaAttachments,
polls = Polls, polls = Polls,
@@ -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(); 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, id = attachment.ID,
type = attachment.Kind, type = attachment.Kind,
url = _media.Url(attachment.FilePath), url = media.Url(attachment.FilePath),
preview_url = _media.Url(attachment.PreviewPath ?? attachment.FilePath), preview_url = media.Url(attachment.PreviewPath ?? attachment.FilePath),
remote_url = default(string), remote_url = default(string),
text_url = default(string), text_url = default(string),
meta = new 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 { });
}
}
}
@@ -1,11 +1,16 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Infrastructure; using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Domain.Privacy; using PrivaPub.Domain.Privacy;
using PrivaPub.Domain.Statistics;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Infrastructure;
using PrivaPub.Infrastructure.Geo;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs; using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
@@ -19,9 +24,25 @@ namespace PrivaPub.Api.Mastodon.Controllers
{ {
public class ProvenanceController : MastodonController public class ProvenanceController : MastodonController
{ {
readonly DbEntities _dbEntities; const int MaxHosts = 40;
public ProvenanceController(DbEntities dbEntities) => _dbEntities = dbEntities; readonly DbEntities _dbEntities;
const int MaxWeeks = 260;
readonly ISelfLocation _self;
readonly IServerPlaces _places;
readonly ILocalActorService _localActors;
readonly IOptionsMonitor<RegistrationOptions> _registrations;
public ProvenanceController(DbEntities dbEntities, ISelfLocation self, IServerPlaces places, ILocalActorService localActors,
IOptionsMonitor<RegistrationOptions> registrations)
{
_dbEntities = dbEntities;
_self = self;
_places = places;
_localActors = localActors;
_registrations = registrations;
}
[HttpGet("/api/privapub/v1/statuses/{id}/provenance"), Scope("read:statuses", requiresUser: false), AllowAnonymous] [HttpGet("/api/privapub/v1/statuses/{id}/provenance"), Scope("read:statuses", requiresUser: false), AllowAnonymous]
public async Task<IActionResult> Status(string id, CancellationToken token) public async Task<IActionResult> Status(string id, CancellationToken token)
@@ -38,18 +59,104 @@ namespace PrivaPub.Api.Mastodon.Controllers
?? await DB.Default.Find<ObjectRecord>().Match(r => r.ObjectURI == post.ObjectURI).ExecuteFirstAsync(token); ?? await DB.Default.Find<ObjectRecord>().Match(r => r.ObjectURI == post.ObjectURI).ExecuteFirstAsync(token);
var host = record?.Host ?? (Uri.TryCreate(post.ObjectURI, UriKind.Absolute, out var uri) ? uri.Host.ToLowerInvariant() : default); var host = record?.Host ?? (Uri.TryCreate(post.ObjectURI, UriKind.Absolute, out var uri) ? uri.Host.ToLowerInvariant() : default);
var instance = host == default ? default : await DB.Default.Find<RemoteInstance>().Match(i => i.Host == host).ExecuteFirstAsync(token); var instance = host == default ? default : await DB.Default.Find<RemoteInstance>().Match(i => i.Host == host).ExecuteFirstAsync(token);
return Json(Describe(post, record, instance)); var beforeCdn = instance?.Geo?.Cdn == default ? default : (await _places.BeforeCdn(new[] { instance.Host }, token)).GetValueOrDefault(instance.Host);
return Json(Describe(post, record, instance, beforeCdn));
} }
[HttpGet("/api/privapub/v1/instances/{host}"), Scope("read", requiresUser: false), AllowAnonymous] [HttpGet("/api/privapub/v1/instances/{host}"), Scope("read", requiresUser: false), AllowAnonymous]
public async Task<IActionResult> Instance(string host, CancellationToken token) public async Task<IActionResult> Instance(string host, CancellationToken token)
{ {
host = host?.Trim().ToLowerInvariant(); var described = await Describe(new[] { host }, token);
var instance = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == host).ExecuteFirstAsync(token); return described.Count == 0 ? NotFoundError() : Json(described[0]);
return instance == default ? NotFoundError() : Json(Describe(instance));
} }
static Provenance Describe(PostEntity post, ObjectRecord record, RemoteInstance instance) //up to MaxHosts at once (host[]=...), in the order asked; hosts this server does not know are left out
[HttpGet("/api/privapub/v1/instances"), Scope("read", requiresUser: false), AllowAnonymous]
public async Task<IActionResult> Instances(CancellationToken token) => Json(await Describe(Params.List("host"), token));
//the server's weekly snapshots, newest first (weeks=, at most MaxWeeks): its place as shown publicly, its CDN, its size
[HttpGet("/api/privapub/v1/instances/{host}/history"), Scope("read", requiresUser: false), AllowAnonymous]
public async Task<IActionResult> History(string host, CancellationToken token)
{
host = host?.Trim().ToLowerInvariant();
if (string.IsNullOrEmpty(host) || host.Length > 253 || !await DB.Default.Find<RemoteInstance>().Match(i => i.Host == host).ExecuteAnyAsync(token))
return NotFoundError();
var weeks = Math.Clamp(Params.Int("weeks") ?? 52, 1, MaxWeeks);
return Json((await _places.History(host, weeks, token)).Select(s => new InstanceWeek
{
Week = s.Week,
TakenAt = MastodonJson.Time(s.TakenAt),
Reachable = s.Reachable,
Software = s.Software,
Version = s.SoftwareVersion,
UsersTotal = s.UsersTotal,
UsersActiveMonth = s.UsersActiveMonth,
LocalPosts = s.LocalPosts,
OpenRegistrations = s.OpenRegistrations,
Geo = Located(PublicGeo.Project(new InstanceGeo
{
Country = s.Country, City = s.City, Latitude = s.Latitude, Longitude = s.Longitude, Asn = s.Asn,
Cdn = s.Cdn, CdnDomain = s.CdnDomain, CdnSource = s.CdnSource, Ipv6 = s.Ipv6
}), new InstanceGeo { Cdn = s.Cdn, CdnDomain = s.CdnDomain ?? CdnCatalog.ByKey(s.Cdn)?.Key, CdnSource = s.CdnSource })
}).ToList());
}
async Task<List<InstanceDescription>> Describe(IEnumerable<string> hosts, CancellationToken token)
{
var wanted = hosts.Select(h => h?.Trim().ToLowerInvariant()).Where(h => !string.IsNullOrEmpty(h) && h.Length <= 253)
.Distinct().Take(MaxHosts).ToList();
var selfHost = Uri.TryCreate(_localActors.BaseAddress, UriKind.Absolute, out var self) ? self.Host.ToLowerInvariant() : default;
var remote = await DB.Default.Find<RemoteInstance>().Match(i => wanted.Contains(i.Host)).ExecuteAsync(token);
var beforeCdn = await _places.BeforeCdn(remote.Where(i => i.Geo?.Cdn != default).Select(i => i.Host), token);
var described = new List<InstanceDescription>();
foreach (var host in wanted)
{
if (host == selfHost)
described.Add(await DescribeSelf(host, token));
else if (remote.FirstOrDefault(i => i.Host == host) is { } instance)
described.Add(Describe(instance, beforeCdn.GetValueOrDefault(host)));
}
return described;
}
async Task<InstanceDescription> DescribeSelf(string host, CancellationToken token)
{
var geo = await _self.Get(token);
return new InstanceDescription
{
Host = host,
Software = "privapub",
Version = BuildInfo.Ref,
NodeName = "PrivaPub",
Protocols = new() { "activitypub" },
OpenRegistrations = _registrations.CurrentValue.IsOpen,
DescribedAt = MastodonJson.Time(DateTime.UtcNow),
Geo = Located(PublicGeo.Project(geo), geo)
};
}
//the public projection of a server's place (PublicGeo); for a CDN-fronted one, the CDN's domain, how it was found and
//where the server was before it
static InstanceLocation Located(PublicLocation location, InstanceGeo geo, PlaceBeforeCdn beforeCdn = default) => location == default ? default : new InstanceLocation
{
Precision = location.Cdn != default ? "cdn" : location.Latitude != default ? "city" : "country",
Country = location.Country,
City = location.City,
Latitude = location.Latitude,
Longitude = location.Longitude,
Network = location.Network,
Cdn = location.Cdn,
CdnDomain = location.Cdn != default ? ServerPlaces.KeyOf(geo) : default,
CdnSource = location.Cdn != default ? geo?.CdnSource : default,
BeforeCdn = location.Cdn == default || beforeCdn == default ? default : new InstancePlace
{
Week = beforeCdn.Week, Country = beforeCdn.Country, City = beforeCdn.City, Latitude = beforeCdn.Latitude, Longitude = beforeCdn.Longitude
},
Source = geo?.Source,
LocatedAt = geo?.LocatedAt is { } at ? MastodonJson.Time(at) : default
};
Provenance Describe(PostEntity post, ObjectRecord record, RemoteInstance instance, PlaceBeforeCdn beforeCdn)
{ {
var raw = record?.Raw == default ? default : JsonNode.Parse(record.Raw) as JsonObject; var raw = record?.Raw == default ? default : JsonNode.Parse(record.Raw) as JsonObject;
return new Provenance return new Provenance
@@ -86,11 +193,11 @@ namespace PrivaPub.Api.Mastodon.Controllers
RawHash = r.RawHash, RawHash = r.RawHash,
Raw = r.Raw == default ? default : JsonNode.Parse(r.Raw) Raw = r.Raw == default ? default : JsonNode.Parse(r.Raw)
}).ToList() ?? new(), }).ToList() ?? new(),
Instance = instance == default ? default : Describe(instance) Instance = instance == default ? default : Describe(instance, beforeCdn)
}; };
} }
static InstanceDescription Describe(RemoteInstance instance) => new() static InstanceDescription Describe(RemoteInstance instance, PlaceBeforeCdn beforeCdn) => new()
{ {
Host = instance.Host, Host = instance.Host,
Software = instance.Software, Software = instance.Software,
@@ -107,7 +214,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
UnavailableUntil = instance.UnavailableUntil is { } until ? MastodonJson.Time(until) : default, UnavailableUntil = instance.UnavailableUntil is { } until ? MastodonJson.Time(until) : default,
LastSuccessAt = instance.LastSuccessAt is { } success ? MastodonJson.Time(success) : default, LastSuccessAt = instance.LastSuccessAt is { } success ? MastodonJson.Time(success) : default,
LastFailureAt = instance.LastFailureAt is { } failure ? MastodonJson.Time(failure) : default LastFailureAt = instance.LastFailureAt is { } failure ? MastodonJson.Time(failure) : default
} },
Geo = Located(PublicGeo.Project(instance.Geo), instance.Geo, beforeCdn)
}; };
public class Provenance public class Provenance
@@ -170,6 +278,48 @@ namespace PrivaPub.Api.Mastodon.Controllers
public string DescriptionError { get; set; } public string DescriptionError { get; set; }
public JsonNode NodeInfo { get; set; } public JsonNode NodeInfo { get; set; }
public InstanceDelivery Delivery { get; set; } public InstanceDelivery Delivery { get; set; }
public InstanceLocation Geo { get; set; }
}
//precision: "city" (coordinates to 0.1°, city and network), "country" (located only to a country) or "cdn" (the
//CDN's name only)
public class InstanceLocation
{
public string Precision { get; set; }
public string Country { get; set; }
public string City { get; set; }
public double? Latitude { get; set; }
public double? Longitude { get; set; }
public string Network { get; set; }
public string Cdn { get; set; }
public string CdnDomain { get; set; }
public string CdnSource { get; set; }//"ranges", "headers" or "asn"
public InstancePlace BeforeCdn { get; set; }//where a CDN-fronted server was located before the CDN, by its snapshots
public string Source { get; set; }
public string LocatedAt { get; set; }
}
public class InstancePlace
{
public string Week { get; set; }
public string Country { get; set; }
public string City { get; set; }
public double? Latitude { get; set; }
public double? Longitude { get; set; }
}
public class InstanceWeek
{
public string Week { get; set; }
public string TakenAt { get; set; }
public bool Reachable { get; set; }
public string Software { get; set; }
public string Version { get; set; }
public long? UsersTotal { get; set; }
public long? UsersActiveMonth { get; set; }
public long? LocalPosts { get; set; }
public bool? OpenRegistrations { get; set; }
public InstanceLocation Geo { get; set; }
} }
public class InstanceDelivery public class InstanceDelivery
@@ -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 namespace PrivaPub.Api.Mastodon.Controllers
{ {
public class SearchController : MastodonController public partial class SearchController : MastodonController
{ {
readonly MastodonMapper _mapper; readonly MastodonMapper _mapper;
readonly DbEntities _dbEntities; readonly DbEntities _dbEntities;
@@ -35,11 +35,15 @@ namespace PrivaPub.Api.Mastodon.Controllers
_search = search; _search = search;
} }
[HttpGet("/api/v2/search"), Scope("read:search", requiresUser: false)] // Anyone may search, as on Mastodon; only resolving (which fetches from other servers) and paging need a sign-in.
[HttpGet("/api/v2/search"), Scope("read:search", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Search(CancellationToken token) public async Task<IActionResult> Search(CancellationToken token)
{ {
var q = Params.Get("q")?.Trim(); var q = Params.Get("q")?.Trim();
var type = Params.Get("type"); var type = Params.Get("type");
var offset = Params.Int("offset") ?? 0;
if (MyId == default && (Params.Bool("resolve") == true || offset > 0))
return Error(StatusCodes.Status401Unauthorized, "Search queries that resolve uris or use offset require the read:search scope");
var resolve = Params.Bool("resolve") == true && MyId != default; var resolve = Params.Bool("resolve") == true && MyId != default;
var results = new SearchResults(); var results = new SearchResults();
if (string.IsNullOrEmpty(q)) if (string.IsNullOrEmpty(q))
@@ -51,7 +55,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (post == default && resolve && type is null or "statuses") if (post == default && resolve && type is null or "statuses")
post = await _remotePosts.StoreContext(q, 0, token); post = await _remotePosts.StoreContext(q, 0, token);
if (post != default && type is null or "statuses" && await VisibilityPolicy.CanSee(post, MyId, 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") if (results.Statuses.Count == 0 && type is null or "accounts")
{ {
var local = await _localActors.FindByUri(q, token); var local = await _localActors.FindByUri(q, token);
@@ -66,61 +71,78 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (type is null or "accounts") if (type is null or "accounts")
{ {
results.Accounts = await _search.Find(q, resolve, Limit(), token); 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) // an account's handle asks for the account, never for posts that happen to share its words
results.Hashtags.Add(new { name = tag.ToLowerInvariant(), url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag.ToLowerInvariant())}", history = Array.Empty<object>() }); 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); 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 public class StubsController : MastodonController
{ {
[HttpGet("/api/v1/custom_emojis"), Microsoft.AspNetCore.Authorization.AllowAnonymous] [HttpGet("/api/v1/custom_emojis"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult CustomEmojis() => Json(Array.Empty<object>()); 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] [HttpGet("/api/v1/announcements"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult Announcements() => Json(Array.Empty<object>()); 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")] [HttpGet("/api/v1/suggestions"), Scope("read")]
public IActionResult SuggestionsV1() => Json(Array.Empty<object>()); public IActionResult SuggestionsV1() => Json(Array.Empty<object>());
[HttpGet("/api/v2/suggestions"), Scope("read")] [HttpGet("/api/v2/suggestions"), Scope("read")]
public IActionResult SuggestionsV2() => Json(Array.Empty<object>()); 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")] [HttpGet("/api/v1/endorsements"), Scope("read:accounts")]
public IActionResult Endorsements() => Json(Array.Empty<object>()); public IActionResult Endorsements() => Json(Array.Empty<object>());
[HttpGet("/api/v1/featured_tags"), Scope("read:accounts")] [HttpGet("/api/v1/featured_tags"), Scope("read:accounts")]
public IActionResult FeaturedTags() => Json(Array.Empty<object>()); 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")] [HttpGet("/api/v1/preferences"), Scope("read:accounts")]
public IActionResult Preferences() => Json(new Dictionary<string, object> public IActionResult Preferences() => Json(new Dictionary<string, object>
{ {
@@ -133,5 +155,32 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/push/subscription"), Scope("push")] [HttpGet("/api/v1/push/subscription"), Scope("push")]
public IActionResult PushSubscription() => Error(StatusCodes.Status404NotFound, "Record not found"); public IActionResult PushSubscription() => Error(StatusCodes.Status404NotFound, "Record not found");
// 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/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/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 }));
[HttpGet("/api/v1/instance/translation_languages"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult TranslationLanguages() => Json(new { });
[HttpGet("/api/v1/instance/domain_blocks"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult InstanceDomainBlocks() => Json(Array.Empty<object>());
[HttpGet("/api/v1/instance/privacy_policy"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult PrivacyPolicy() => Json(new
{
updated_at = "2026-10-04T00:00:00.000Z",
content = "<p>One private login owns several public personas, and nobody but this server's admin can tell they belong "
+ "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>"
});
} }
} }
@@ -9,6 +9,7 @@ using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Privacy; using PrivaPub.Domain.Privacy;
using PrivaPub.Domain.Statuses; using PrivaPub.Domain.Statuses;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User; using PrivaPub.Models.User;
using PrivaPub.Federation.Rendering; using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Outbox;
@@ -29,10 +30,20 @@ namespace PrivaPub.Api.Mastodon.Controllers
readonly IMemoryCache _cache; readonly IMemoryCache _cache;
readonly IQuoteService _quotes; readonly IQuoteService _quotes;
readonly IOutboxPublisher _outbox; 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, 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; _quotes = quotes;
_outbox = outbox; _outbox = outbox;
_statuses = statuses; _statuses = statuses;
@@ -52,23 +63,31 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (existing != default) if (existing != default)
return Json(await _mapper.Status(existing, MyId, token)); return Json(await _mapper.Status(existing, MyId, token));
} }
var outcome = await _statuses.Publish(Me, new StatusDraft var asked = new ScheduledParams
{ {
Text = Params.Get("status"), Text = Params.Get("status"),
PlainText = true,
SpoilerText = Params.Get("spoiler_text"), SpoilerText = Params.Get("spoiler_text"),
Sensitive = Params.Bool("sensitive") ?? Me.Settings.DefaultSensitive, Sensitive = Params.Bool("sensitive") ?? Me.Settings.DefaultSensitive,
Visibility = Visibility(Params.Get("visibility") ?? Me.Settings.DefaultVisibility), Visibility = Params.Get("visibility") ?? Me.Settings.DefaultVisibility,
InReplyTo = Params.Get("in_reply_to_id"), InReplyToId = Params.Get("in_reply_to_id"),
Language = Params.Get("language") ?? Me.Settings.DefaultLanguage, Language = Params.Get("language") ?? Me.Settings.DefaultLanguage,
MediaIds = Params.List("media_ids"), MediaIds = Params.List("media_ids").ToList(),
QuotedStatusId = Params.Get("quoted_status_id"), QuotedStatusId = Params.Get("quoted_status_id"),
QuotePolicy = Params.Get("quote_approval_policy"), QuotePolicy = Params.Get("quote_approval_policy"),
Idempotency = string.IsNullOrEmpty(idempotency) ? default : idempotency,
Poll = Params.Has("poll[options]") Poll = Params.Has("poll[options]")
? new PollDraft(Params.List("poll[options]"), Params.Int("poll[expires_in]") ?? 0, Params.Bool("poll[multiple]") == true, ? new ScheduledPoll
Params.Bool("poll[hide_totals]") == true) {
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 : 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) if (!outcome.Ok)
return Error(outcome.Status, outcome.Error); return Error(outcome.Status, outcome.Error);
if (cacheKey != default) if (cacheKey != default)
@@ -76,6 +95,35 @@ namespace PrivaPub.Api.Mastodon.Controllers
return Json(await _mapper.Status(outcome.Post, MyId, token)); 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] [HttpGet("/api/v1/statuses"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Many(CancellationToken token) 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) public async Task<IActionResult> Get(string id, CancellationToken token)
{ {
var post = await Visible(id, 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")] [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); var before = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
if (before == default) if (before == default)
return NotFoundError(); return await OffWall(id, token);
var status = await _mapper.Status(before, MyId, token); var status = await _mapper.Status(before, MyId, token);
var outcome = await _statuses.Remove(Me, id, token); var outcome = await _statuses.Remove(Me, id, token);
if (!outcome.Ok) if (!outcome.Ok)
@@ -128,6 +177,20 @@ namespace PrivaPub.Api.Mastodon.Controllers
return Json(status); 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] [HttpGet("/api/v1/statuses/{id}/context"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Context(string id, CancellationToken token) public async Task<IActionResult> Context(string id, CancellationToken token)
{ {
@@ -160,6 +223,11 @@ namespace PrivaPub.Api.Mastodon.Controllers
frontier.Add(child.ID); 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 return Json(new Context
{ {
Ancestors = await _mapper.Statuses(ancestors, MyId, token), 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 }); 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")] [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); public Task<IActionResult> Favourite(string id, CancellationToken token) => Toggle(_statuses.Favourite(Me, id, true, token), id, token);
@@ -273,7 +357,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.LocalQuotePolicy, policy).ExecuteAsync(token); await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.LocalQuotePolicy, policy).ExecuteAsync(token);
post.LocalQuotePolicy = policy; post.LocalQuotePolicy = policy;
if (!post.IsLocalOnly) if (!post.IsLocalOnly)
await _outbox.Publish(Me, post, ActivityPubRenderer.UpdateOf(post, Me, $"policy-{DateTime.UtcNow.Ticks}"), token); await _outbox.PublishUpdate(Me, post, $"policy-{DateTime.UtcNow.Ticks}", token);
return Json(await _mapper.Status(post, MyId, token)); return Json(await _mapper.Status(post, MyId, token));
} }
@@ -338,6 +422,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
try try
{ {
await DB.Default.SaveAsync(new Models.Social.Pin { AvatarId = MyId, PostId = post.ID }, token); 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) 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")] [HttpPost("/api/v1/statuses/{id}/unpin"), Scope("write:accounts")]
public async Task<IActionResult> Unpin(string id, CancellationToken token) 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); return await Unchanged(id, token);
} }
@@ -364,7 +451,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
async Task<IActionResult> Unchanged(string id, CancellationToken token) async Task<IActionResult> Unchanged(string id, CancellationToken token)
{ {
var post = await Visible(id, 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) 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; return post != default && post.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(post, MyId, token) ? post : default;
} }
static PostVisibility Visibility(string value) => value?.ToLowerInvariant() switch static PostVisibility Visibility(string value) => ScheduledStatuses.Visibility(value);
{
"unlisted" => PostVisibility.Unlisted,
"private" => PostVisibility.FollowersOnly,
"direct" => PostVisibility.Direct,
_ => PostVisibility.Public
};
} }
} }
@@ -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 Microsoft.AspNetCore.Mvc;
using MongoDB.Driver;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Entities; using PrivaPub.Api.Mastodon.Entities;
using PrivaPub.Api.Mastodon.Infrastructure; using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers; using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Privacy; using PrivaPub.Domain.Privacy;
using PrivaPub.Domain.Statuses;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Models.Social; using PrivaPub.Models.Social;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
@@ -30,9 +33,19 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/timelines/home"), Scope("read:statuses")] [HttpGet("/api/v1/timelines/home"), Scope("read:statuses")]
public async Task<IActionResult> Home(CancellationToken token) 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 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); 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); Link("/api/v1/timelines/home", entries.LastOrDefault()?.PostId, entries.FirstOrDefault()?.PostId);
return Json(statuses); return Json(statuses);
@@ -61,9 +74,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
return await Respond(query, $"/api/v1/timelines/tag/{hashtag}", token); 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")] [HttpGet("/api/v1/favourites"), Scope("read:favourites")]
public async Task<IActionResult> Favourites(CancellationToken token) public async Task<IActionResult> Favourites(CancellationToken token)
{ {
@@ -78,41 +88,79 @@ namespace PrivaPub.Api.Mastodon.Controllers
return Json(await _mapper.Statuses(visible, MyId, token)); 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")] [HttpGet("/api/v1/conversations"), Scope("read:statuses")]
public async Task<IActionResult> Conversations(CancellationToken token) 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)) var groups = await Page.From(Params, Limit(20, 40)).Fetch(
.Sort(g => g.UpdatedAt, Order.Descending).Limit(Limit()).ExecuteAsync(token); _dbEntities.DmGroups.Match(g => !g.DeletionAt.HasValue && g.LastPostId != null && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId)),
var conversations = new List<Conversation>(); g => g.LastPostId, token);
foreach (var group in groups) Link("/api/v1/conversations", groups.LastOrDefault()?.LastPostId, groups.FirstOrDefault()?.LastPostId);
{ return Json(await ConversationsOf(groups, token));
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);
} }
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")] [HttpPost("/api/v1/conversations/{id}/read"), Scope("write:conversations")]
public async Task<IActionResult> ReadConversation(string id, CancellationToken token) 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) if (group == default)
return NotFoundError(); return NotFoundError();
var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token); await ConversationStates.Read(MyId, group.ID, group.LastPostId, token);
return Json(new Conversation { Id = group.ID, LastStatus = last == default ? default : await _mapper.Status(last, MyId, 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")] [HttpGet("/api/v1/markers"), Scope("read:statuses")]
@@ -141,6 +189,10 @@ namespace PrivaPub.Api.Mastodon.Controllers
.Option(o => o.IsUpsert = true) .Option(o => o.IsUpsert = true)
.ExecuteAsync(token); .ExecuteAsync(token);
result[timeline] = new Entities.Marker { LastReadId = marker.LastReadId, Version = marker.Version, UpdatedAt = MastodonJson.Time(marker.UpdatedAt) }; 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); return Json(result);
} }
@@ -155,7 +207,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
public class NotificationsController : MastodonController public class NotificationsController : MastodonController
{ {
static readonly Dictionary<NotificationType, string> Names = new() internal static readonly Dictionary<NotificationType, string> Names = new()
{ {
[NotificationType.Mention] = "mention", [NotificationType.Mention] = "mention",
[NotificationType.Follow] = "follow", [NotificationType.Follow] = "follow",
@@ -177,23 +229,150 @@ namespace PrivaPub.Api.Mastodon.Controllers
_dbEntities = dbEntities; _dbEntities = dbEntities;
} }
[HttpGet("/api/v1/notifications"), Scope("read:notifications")] Find<NotificationEntity, NotificationEntity> Filtered()
public async Task<IActionResult> List(CancellationToken token)
{ {
var types = Params.List("types").Select(Parse).Where(t => t.HasValue).Select(t => t.Value).ToList(); 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 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); 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) if (types.Count > 0)
query.Match(n => types.Contains(n.Type)); query.Match(n => types.Contains(n.Type));
if (excluded.Count > 0) if (excluded.Count > 0)
query.Match(n => !excluded.Contains(n.Type)); query.Match(n => !excluded.Contains(n.Type));
if (Params.Get("account_id") is { } accountId) if (Params.Get("account_id") is { } accountId)
query.Match(n => n.FromAccountId == accountId); query.Match(n => n.FromAccountId == accountId);
var notifications = await Page.From(Params, Limit(15, 30)).Fetch(query, n => n.ID, token); 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)
{
var notifications = await Page.From(Params, Limit(15, 30)).Fetch(Filtered(), n => n.ID, token);
Link("/api/v1/notifications", notifications.LastOrDefault()?.ID, notifications.FirstOrDefault()?.ID); Link("/api/v1/notifications", notifications.LastOrDefault()?.ID, notifications.FirstOrDefault()?.ID);
return Json(await Map(notifications, token)); return Json(await Map(notifications, token));
} }
// 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.
internal static readonly HashSet<string> DefaultGrouped = new() { "favourite", "reblog", "follow" };
HashSet<string> Grouped() => Params.List("grouped_types") is { Count: > 0 } chosen ? chosen.ToHashSet() : DefaultGrouped;
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}"
: $"grouped-{Names[n.Type]}-{n.PostId}";
// the notifications a group key stands for
System.Linq.Expressions.Expression<Func<NotificationEntity, bool>> InGroup(string key)
{
var me = MyId;
if (key.StartsWith("ungrouped-", StringComparison.Ordinal))
{
var id = key["ungrouped-".Length..];
return n => n.AvatarId == me && n.ID == id;
}
if (key.StartsWith("grouped-follow-", StringComparison.Ordinal)
&& DateTime.TryParseExact(key["grouped-follow-".Length..], "yyyyMMddHH", System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var hour))
{
var end = hour.AddHours(1);
return n => n.AvatarId == me && n.Type == NotificationType.Follow && n.CreatedAt >= hour && n.CreatedAt < end;
}
if (key.Split('-') is ["grouped", var name, var postId] && Parse(name) is { } type)
return n => n.AvatarId == me && n.Type == type && n.PostId == postId;
return default;
}
async Task<List<NotificationEntity>> Members(string key, CancellationToken token) =>
InGroup(key) is { } filter
? 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)
{
var mapped = (await Map(page, token)).ToDictionary(n => n.Id);
var shown = page.Where(n => mapped.ContainsKey(n.ID)).ToList();
var groups = new List<object>();
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(Builders<NotificationEntity>.Filter.And(Builders<NotificationEntity>.Filter.Where(InGroup(group.Key)), NotFiltered), token);
groups.Add(new
{
group_key = group.Key,
notifications_count = Math.Max(total, group.Count()),
type = newest.Type,
most_recent_notification_id = newest.Id,
page_min_id = group.Last().ID,
page_max_id = group.First().ID,
latest_page_notification_at = newest.CreatedAt,
sample_account_ids = group.Select(n => mapped[n.ID].Account.Id).Distinct().Take(8).ToList(),
status_id = newest.Status?.Id,
emoji = newest.Emoji,
emoji_url = newest.EmojiUrl
});
}
return new
{
accounts = mapped.Values.Select(n => n.Account).DistinctBy(a => a.Id).ToList(),
statuses = mapped.Values.Where(n => n.Status != default).Select(n => n.Status).DistinctBy(s => s.Id).ToList(),
notification_groups = groups
};
}
[HttpGet("/api/v2/notifications"), Scope("read:notifications")]
public async Task<IActionResult> GroupedList(CancellationToken token)
{
var notifications = await Page.From(Params, Limit(40, 80)).Fetch(Filtered(), n => n.ID, token);
Link("/api/v2/notifications", notifications.LastOrDefault()?.ID, notifications.FirstOrDefault()?.ID);
return Json(await Groups(notifications, Grouped(), token));
}
[HttpGet("/api/v2/notifications/unread_count"), Scope("read:notifications")]
public async Task<IActionResult> GroupedUnreadCount(CancellationToken token)
{
var unread = await Filtered().Match(n => !n.IsRead).Sort(n => n.ID, Order.Descending)
.Limit(Math.Clamp(Params.Int("limit") ?? 100, 1, 1000)).ExecuteAsync(token);
var shown = (await Map(unread, token)).Select(n => n.Id).ToHashSet();
var grouped = Grouped();
return Json(new { count = unread.Where(n => shown.Contains(n.ID)).Select(n => GroupKey(n, grouped)).Distinct().Count() });
}
[HttpGet("/api/v2/notifications/{groupKey}"), Scope("read:notifications")]
public async Task<IActionResult> Group(string groupKey, CancellationToken token)
{
var members = await Members(groupKey, token);
if (members.Count == 0)
return NotFoundError();
return Json(await Groups(members, new HashSet<string>(DefaultGrouped.Append(Names[members[0].Type])), token));
}
[HttpGet("/api/v2/notifications/{groupKey}/accounts"), Scope("read:notifications")]
public async Task<IActionResult> GroupAccounts(string groupKey, CancellationToken token)
{
var members = await Members(groupKey, token);
if (members.Count == 0)
return NotFoundError();
return Json((await Map(members, token)).Select(n => n.Account).DistinctBy(a => a.Id).ToList());
}
[HttpPost("/api/v2/notifications/{groupKey}/dismiss"), Scope("write:notifications")]
public async Task<IActionResult> DismissGroup(string groupKey, CancellationToken token)
{
if (InGroup(groupKey) is not { } filter)
return NotFoundError();
await DB.Default.DeleteAsync(filter);
return Json(new { });
}
[HttpGet("/api/v1/notifications/{id}"), Scope("read:notifications")] [HttpGet("/api/v1/notifications/{id}"), Scope("read:notifications")]
public async Task<IActionResult> Get(string id, CancellationToken token) public async Task<IActionResult> Get(string id, CancellationToken token)
{ {
@@ -216,34 +395,19 @@ namespace PrivaPub.Api.Mastodon.Controllers
return Json(new { }); return Json(new { });
} }
// counts what the list would show (Map drops notifications from gone accounts and about hidden posts), at most
// `limit` of them, as Mastodon does
[HttpGet("/api/v1/notifications/unread_count"), Scope("read:notifications")] [HttpGet("/api/v1/notifications/unread_count"), Scope("read:notifications")]
public async Task<IActionResult> UnreadCount(CancellationToken token) => public async Task<IActionResult> UnreadCount(CancellationToken token)
Json(new { count = await DB.Default.CountAsync<NotificationEntity>(n => n.AvatarId == MyId && !n.IsRead, token) });
async Task<List<Entities.Notification>> Map(List<NotificationEntity> notifications, CancellationToken token)
{ {
var accounts = await _mapper.Accounts(notifications.Select(n => n.FromAccountId), token); var unread = await DB.Default.Find<NotificationEntity>().Match(n => n.AvatarId == MyId && !n.IsRead).Match(NotFiltered)
var postIds = notifications.Where(n => n.PostId != default).Select(n => n.PostId).Distinct().ToList(); .Sort(n => n.ID, Order.Descending).Limit(Math.Clamp(Params.Int("limit") ?? 100, 1, 1000)).ExecuteAsync(token);
var posts = postIds.Count == 0 return Json(new { count = (await Map(unread, token)).Count });
? 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; 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)));
}
}
+66 -2
View File
@@ -1,4 +1,4 @@
namespace PrivaPub.Api.Mastodon.Entities namespace PrivaPub.Api.Mastodon.Entities
{ {
public class Account public class Account
{ {
@@ -32,6 +32,7 @@ namespace PrivaPub.Api.Mastodon.Entities
public string HeaderDescription { get; set; } = string.Empty; public string HeaderDescription { get; set; } = string.Empty;
public List<Field> Fields { get; set; } = new(); public List<Field> Fields { get; set; } = new();
public Source Source { get; set; } 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 public class CustomEmojiEntity
@@ -65,6 +66,8 @@ namespace PrivaPub.Api.Mastodon.Entities
public class PrivaPubStatus public class PrivaPubStatus
{ {
public string ObjectType { get; set; } 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 Title { get; set; }
public string Excerpt { get; set; } public string Excerpt { get; set; }
public string Cover { get; set; } public string Cover { get; set; }
@@ -73,6 +76,8 @@ namespace PrivaPub.Api.Mastodon.Entities
public PrivaPubVideo Video { get; set; } public PrivaPubVideo Video { get; set; }
public PrivaPubAudio Audio { get; set; } public PrivaPubAudio Audio { get; set; }
public PrivaPubEvent Event { 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(); public PrivaPubVotes Votes { get; set; } = new();
} }
@@ -145,12 +150,14 @@ namespace PrivaPub.Api.Mastodon.Entities
public int? Capacity { get; set; } public int? Capacity { get; set; }
public string Status { get; set; } public string Status { get; set; }
public string Category { get; set; } public string Category { get; set; }
public string Participation { get; set; }//the viewer's: "pending", "accepted" or "rejected"
} }
public class PrivaPubPlace public class PrivaPubPlace
{ {
public string Name { get; set; } public string Name { get; set; }
public string Address { get; set; } public string Address { get; set; }
public string Country { get; set; }//a post's own place only
public double? Latitude { get; set; } public double? Latitude { get; set; }
public double? Longitude { get; set; } public double? Longitude { get; set; }
public string Url { get; set; } public string Url { get; set; }
@@ -176,6 +183,48 @@ namespace PrivaPub.Api.Mastodon.Entities
public string CurrentUser { get; set; } = "denied"; 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 class EmojiReactionEntity
{ {
public string Name { get; set; } public string Name { get; set; }
@@ -264,13 +313,28 @@ namespace PrivaPub.Api.Mastodon.Entities
public Application Application { get; set; } public Application Application { get; set; }
public string Language { get; set; } public string Language { get; set; }
public string Text { 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 PrivaPubStatus Privapub { get; set; }
public List<EmojiReactionEntity> EmojiReactions { get; set; } = new(); public List<EmojiReactionEntity> EmojiReactions { get; set; } = new();
public PleromaStatus Pleroma { get; set; } public PleromaStatus Pleroma { get; set; }
public QuoteEntity Quote { get; set; } public QuoteEntity Quote { get; set; }
public int QuotesCount { get; set; } public int QuotesCount { get; set; }
public QuoteApprovalEntity QuoteApproval { 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 public class MediaAttachment
@@ -8,14 +8,19 @@ namespace PrivaPub.Api.Mastodon.Infrastructure
{ {
readonly Dictionary<string, List<string>> _values; readonly Dictionary<string, List<string>> _values;
MastodonParams(Dictionary<string, List<string>> values) MastodonParams(Dictionary<string, List<string>> values, JsonElement? json = default)
{ {
_values = values; _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) public static async Task<MastodonParams> Read(HttpRequest request, CancellationToken token)
{ {
var values = new Dictionary<string, List<string>>(StringComparer.Ordinal); var values = new Dictionary<string, List<string>>(StringComparer.Ordinal);
JsonElement? json = default;
foreach (var pair in request.Query) foreach (var pair in request.Query)
foreach (var value in pair.Value) foreach (var value in pair.Value)
Add(values, pair.Key, 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); using var document = await JsonDocument.ParseAsync(request.Body, cancellationToken: token);
Flatten(values, default, document.RootElement); Flatten(values, default, document.RootElement);
json = document.RootElement.Clone();
} }
catch (JsonException) catch (JsonException)
{ {
} }
} }
return new MastodonParams(values); return new MastodonParams(values, json);
} }
public static MastodonParams From(IEnumerable<KeyValuePair<string, string>> pairs) 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 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 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>(); public IReadOnlyList<string> List(string name) => _values.TryGetValue(name, out var list) ? list : (IReadOnlyList<string>)Array.Empty<string>();
+42 -8
View File
@@ -19,7 +19,34 @@ namespace PrivaPub.Api.Mastodon.Mappers
_remoteActors = remoteActors; _remoteActors = remoteActors;
} }
public async Task<List<Account>> Find(string q, bool resolve, int limit, CancellationToken token) 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)
{ {
q = q?.Trim().TrimStart('@'); q = q?.Trim().TrimStart('@');
if (string.IsNullOrEmpty(q)) if (string.IsNullOrEmpty(q))
@@ -31,24 +58,31 @@ namespace PrivaPub.Api.Mastodon.Mappers
{ {
var userName = parts[0]; var userName = parts[0];
var domain = parts[1].ToLowerInvariant(); var domain = parts[1].ToLowerInvariant();
var known = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain).ExecuteFirstAsync(token); var known = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain && !f.DeletionAt.HasValue).ExecuteFirstAsync(token);
if (known == default && resolve) if (known == default && resolve)
{ {
var actorUri = await _remoteActors.ResolveHandle(q, token); var actorUri = await _remoteActors.ResolveHandle(q, token);
known = actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token); known = actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token);
} }
if (known != default) if (known is { DeletionAt: null } && offset == 0)
results.Add(_mapper.Foreign(known)); results.Add(_mapper.Foreign(known));
return results; return results;
} }
var prefix = parts[0].ToLowerInvariant(); var prefix = parts[0].ToLowerInvariant();
foreach (var avatar in await _dbEntities.Avatars.Match(a => a.UserName.StartsWith(prefix) && !a.DeletionAt.HasValue && a.Settings.IsDiscoverable != false) var barred = await Domain.Privacy.Counted.BarredPersonas(token);
.Limit(limit).ExecuteAsync(token)) var locals = await _dbEntities.Avatars
.Match(a => a.UserName.StartsWith(prefix) && !a.DeletionAt.HasValue && a.Settings.IsDiscoverable != false && !barred.Contains(a.ID))
.Sort(a => a.UserName, MongoDB.Entities.Order.Ascending)
.Limit(offset + limit).ExecuteAsync(token);
foreach (var avatar in locals.Skip(offset))
results.Add(await _mapper.Local(_localActors.FromAvatar(avatar), false, token)); results.Add(await _mapper.Local(_localActors.FromAvatar(avatar), false, token));
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => f.UserName.StartsWith(parts[0]) && f.IsDiscoverable) var skipRemote = Math.Max(0, offset - locals.Count);
.Limit(Math.Max(0, limit - results.Count)).ExecuteAsync(token)) if (results.Count < limit)
results.Add(_mapper.Foreign(foreign)); foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => f.UserName.StartsWith(parts[0]) && f.IsDiscoverable && !f.DeletionAt.HasValue)
.Sort(f => f.UserName, MongoDB.Entities.Order.Ascending)
.Skip(skipRemote).Limit(limit - results.Count).ExecuteAsync(token))
results.Add(_mapper.Foreign(foreign));
return results; return results;
} }
} }
+100 -21
View File
@@ -1,4 +1,4 @@
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Entities; using PrivaPub.Api.Mastodon.Entities;
using PrivaPub.Api.Mastodon.Infrastructure; using PrivaPub.Api.Mastodon.Infrastructure;
@@ -40,6 +40,32 @@ namespace PrivaPub.Api.Mastodon.Mappers
public string ProxiedUrl(string url) => Proxied(url); 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 MissingAvatar => $"{_localActors.BaseAddress}/media/missing-avatar.png";
string MissingHeader => $"{_localActors.BaseAddress}/media/missing-header.png"; string MissingHeader => $"{_localActors.BaseAddress}/media/missing-header.png";
@@ -50,15 +76,11 @@ namespace PrivaPub.Api.Mastodon.Mappers
? await DB.Default.CountAsync<Following>(f => f.AvatarId == actor.Id && f.State == FollowState.Accepted, token) ? await DB.Default.CountAsync<Following>(f => f.AvatarId == actor.Id && f.State == FollowState.Accepted, token)
: 0; : 0;
var latest = await _dbEntities.Posts var latest = await _dbEntities.Posts
.Match(p => (actor.Kind == LocalActorKind.Group ? p.GroupId == actor.Id : p.GroupUserId == actor.Id) && !p.IsFederatedCopy .Match(Counted.By(actor))
&& !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct && p.ReblogOfPostId == null)
.Sort(p => p.ID, Order.Descending) .Sort(p => p.ID, Order.Descending)
.Limit(1) .Limit(1)
.ExecuteFirstAsync(token); .ExecuteFirstAsync(token);
var statuses = latest == default var statuses = latest == default ? 0 : await Counted.PostsBy(actor, token);
? 0
: await DB.Default.CountAsync<PostEntity>(p => (actor.Kind == LocalActorKind.Group ? p.GroupId == actor.Id : p.GroupUserId == actor.Id)
&& !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct && p.ReblogOfPostId == null, token);
var account = new Account var account = new Account
{ {
@@ -71,7 +93,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
Discoverable = actor.Discoverable, Discoverable = actor.Discoverable,
Indexable = actor.Indexable, Indexable = actor.Indexable,
Noindex = !actor.Indexable, Noindex = !actor.Indexable,
HideCollections = actor.Settings.HideCollections, HideCollections = true,//follower and following lists are never shown to others, only their counts
Group = actor.Kind == LocalActorKind.Group, Group = actor.Kind == LocalActorKind.Group,
CreatedAt = MastodonJson.Day(actor.Published), CreatedAt = MastodonJson.Day(actor.Published),
Note = ActivityPubRenderer.Html(actor.Summary), Note = ActivityPubRenderer.Html(actor.Summary),
@@ -124,7 +146,10 @@ namespace PrivaPub.Api.Mastodon.Mappers
Avatar = Proxied(foreign.PictureURL) ?? MissingAvatar, Avatar = Proxied(foreign.PictureURL) ?? MissingAvatar,
AvatarStatic = Proxied(foreign.PictureURL) ?? MissingAvatar, AvatarStatic = Proxied(foreign.PictureURL) ?? MissingAvatar,
Header = Proxied(foreign.ThumbnailURL) ?? MissingHeader, Header = Proxied(foreign.ThumbnailURL) ?? MissingHeader,
HeaderStatic = Proxied(foreign.ThumbnailURL) ?? MissingHeader HeaderStatic = Proxied(foreign.ThumbnailURL) ?? MissingHeader,
FollowersCount = foreign.FollowersCount ?? 0,//as its server publishes them (AccountCountsJob); 0 while unknown
FollowingCount = foreign.FollowingCount ?? 0,
StatusesCount = foreign.StatusesCount ?? 0
}; };
public async Task<Account> Account(string id, CancellationToken token) => public async Task<Account> Account(string id, CancellationToken token) =>
@@ -141,18 +166,32 @@ namespace PrivaPub.Api.Mastodon.Mappers
accounts[avatar.ID] = await Local(_localActors.FromAvatar(avatar), false, token); 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)) 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); accounts[group.ID] = await Local(_localActors.FromGroup(group), false, token);
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID)).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); 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; return accounts;
} }
public async Task<Status> Status(PostEntity post, string viewerId, CancellationToken token) => 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) => public Task<List<Status>> Statuses(IReadOnlyCollection<PostEntity> posts, string viewerId, CancellationToken token) =>
Statuses(posts, viewerId, nested: false, 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 originalIds = posts.Where(p => p.ReblogOfPostId != default).Select(p => p.ReblogOfPostId).Distinct().ToList();
var originals = originalIds.Count == 0 var originals = originalIds.Count == 0
@@ -164,7 +203,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
var mentionAccounts = mentionUris.Count == 0 var mentionAccounts = mentionUris.Count == 0
? new Dictionary<string, ForeignAvatar>() ? new Dictionary<string, ForeignAvatar>()
: (await _dbEntities.ForeignAvatars.Match(f => mentionUris.Contains(f.ActorURI)).ExecuteAsync(token)).ToDictionary(f => f.ActorURI); : (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 ids = all.Select(p => p.ID).ToList();
var favourited = viewerId == default var favourited = viewerId == default
@@ -187,14 +226,20 @@ namespace PrivaPub.Api.Mastodon.Mappers
}) })
.OrderByDescending(e => e.Count) .OrderByDescending(e => e.Count)
.ToList()); .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 pollIds = all.Where(p => p.Poll != default).Select(p => p.ID).ToList();
var ownVotes = viewerId == default || pollIds.Count == 0 var ownVotes = viewerId == default || pollIds.Count == 0
? new Dictionary<string, List<int>>() ? new Dictionary<string, List<int>>()
: (await DB.Default.Find<PollVote>().Match(v => pollIds.Contains(v.PostId) && v.VoterAccountId == viewerId && v.IsLocalVoter).ExecuteAsync(token)) : (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()); .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>() ? new HashSet<string>()
: await Domain.Relationships.Hidden.AuthorsHiddenFrom(viewerId, all.Select(p => p.ActorURI), forNotifications: false, token); : 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 var reblogged = viewerId == default
? new HashSet<string>() ? new HashSet<string>()
: (await _dbEntities.Posts.Match(p => p.AuthorAccountId == viewerId && ids.Contains(p.ReblogOfPostId) && !p.DeletedAt.HasValue).ExecuteAsync(token)) : (await _dbEntities.Posts.Match(p => p.AuthorAccountId == viewerId && ids.Contains(p.ReblogOfPostId) && !p.DeletedAt.HasValue).ExecuteAsync(token))
@@ -202,8 +247,8 @@ namespace PrivaPub.Api.Mastodon.Mappers
Status Map(PostEntity post) Status Map(PostEntity post)
{ {
if (!accounts.TryGetValue(AuthorOf(post), out var account)) if (AuthorOf(post) is not { } author || !accounts.TryGetValue(author, out var account))
return default; return default;//a post whose author we cannot show is left out, never a 500
var status = new Status var status = new Status
{ {
Id = post.ID, Id = post.ID,
@@ -223,18 +268,21 @@ namespace PrivaPub.Api.Mastodon.Mappers
Bookmarked = bookmarked.Contains(post.ID), Bookmarked = bookmarked.Contains(post.ID),
Pinned = pinned.Contains(post.ID), Pinned = pinned.Contains(post.ID),
Sensitive = post.HasContentWarning, 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), Visibility = Visibility(post.Visibility),
Language = post.Language, Language = post.Language,
MediaAttachments = post.Media.Count > 0 ? post.Media.Select(Media).ToList() : Playable(post), MediaAttachments = post.Media.Count > 0 ? post.Media.Select(Media).ToList() : Playable(post),
Card = Card(post), Card = Card(post),
Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)), Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)),
Emojis = Emojis(post.Emojis), Emojis = Emojis(post.Emojis),
Privapub = Extension(post), Privapub = Extension(post, accounts),
EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new(), EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new(),
Pleroma = new PleromaStatus { 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(), 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() Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList(),
Filtered = filters.Of(post)
}; };
return status; return status;
} }
@@ -256,6 +304,9 @@ namespace PrivaPub.Api.Mastodon.Mappers
{ {
status.QuotesCount = post.QuotesCount; status.QuotesCount = post.QuotesCount;
status.QuoteApproval = post.IsFederatedCopy ? Approval(post, viewerId) : LocalApproval(post, viewerId, followsAuthor.Contains(post.GroupUserId)); 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) if (post.QuoteState == QuoteState.None)
return; return;
var state = post.QuoteState.ToString().ToLowerInvariant(); var state = post.QuoteState.ToString().ToLowerInvariant();
@@ -283,6 +334,8 @@ namespace PrivaPub.Api.Mastodon.Mappers
continue; continue;
Quote(inner, original); Quote(inner, original);
status.Reblog = inner; 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.Content = string.Empty;
status.Reblogged = reblogged.Contains(original.ID); status.Reblogged = reblogged.Contains(original.ID);
} }
@@ -326,6 +379,23 @@ namespace PrivaPub.Api.Mastodon.Mappers
return new QuoteApprovalEntity { Automatic = everyone, CurrentUser = current }; 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) static QuoteApprovalEntity Approval(PostEntity post, string viewerId)
{ {
if (!post.IsFederatedCopy || post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted)) 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) => List<CustomEmojiEntity> Emojis(IEnumerable<CustomEmoji> emojis) =>
emojis?.Select(e => new CustomEmojiEntity { Shortcode = e.Shortcode, Url = Proxied(e.URL), StaticUrl = Proxied(e.URL) }).ToList() ?? new(); 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, 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, Title = post.IsFederatedCopy ? post.Title : default,
Excerpt = post.Excerpt, Excerpt = post.Excerpt,
Cover = Proxied(post.CoverURL), Cover = Proxied(post.CoverURL),
@@ -457,6 +530,10 @@ namespace PrivaPub.Api.Mastodon.Mappers
Status = post.Event.Status, Status = post.Event.Status,
Category = post.Event.Category Category = post.Event.Category
}, },
Place = post.Place == default ? default : new PrivaPubPlace
{
Name = post.Place.Name, Latitude = post.Place.Latitude, Longitude = post.Place.Longitude, Country = post.Place.Country
},
Votes = new PrivaPubVotes { Up = post.FavouritesCount, Down = post.DownvotesCount } Votes = new PrivaPubVotes { Up = post.FavouritesCount, Down = post.DownvotesCount }
}; };
@@ -511,6 +588,8 @@ namespace PrivaPub.Api.Mastodon.Mappers
return default; 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) PreviewCard Card(string url, string title, string description, string image)
{ {
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) 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) => public async Task<IActionResult> Approve(GroupMembershipForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.ApproveMember(User.GetUserId(), form, 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 Invalid() => BadRequest(new WebResult().Invalidate(_localizer["Invalid model."]));
IActionResult Answer(WebResult result) => result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result); IActionResult Answer(WebResult result) => result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
Loaded 100 of 375 files, more files were not shown because too many files have changed in this diff. Show more