Compare commits

..
41 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 70d1ffe9a2 R5 the server's own settings: instance, rules, announcements, relays, media limits, the staff's log
Build / Build (push) Successful in 1m3s
A singleton ServerSettings (title, descriptions, contact, thumbnail, privacy policy, terms, rules, relays, media
overrides, PublishActivity), cached by InstanceSettings and read by the instance API, NodeInfo, the instance actor, the
web pages and the mailer; its defaults are the old literals, so nothing changes until an administrator edits.
/clientapi/admin/settings (and /thumbnail), /announcements, /relays for administrators; /clientapi/moderator/log, the
staff's log (AdminAction, written by domain blocks, backups, settings and the CLI; no IP, never a root paired with its
personas). The Mastodon API's rules, extended description, privacy policy, terms of service, domain blocks (Published,
Obfuscate), announcements (dismiss, reactions, streaming) and activity (only when published). Reports carry rule ids.
Media overrides clamped to hard caps. Relays named by the settings reconcile next to the configured ones.

Verified: interop.sh instance accounts relay with mastodon, relay, aoderelay and mailpit, 119 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 04:02:47 +02:00
thepraandClaude Opus 5.5 f67e2ae35e R4 server pieces: typed admin statistics, DELETE media, accounts by ids, hashtag history in search
Build / Build (push) Successful in 32s
Completion program R4 (the client catching up), the server's part.

- /clientapi/admin/statistics/hosts/{host}, /events and /server answer typed views (ViewHostDetail, ViewHostInstance,
  ViewHostDay, ViewInteractionEvent, ViewServerDay in ClientModels) instead of an anonymous object and raw entities:
  the instance's public description, never its raw instance API document; the events without their actor hash. The
  JSON names stay, so lib/interop.sh's stats_check reads them as before.
- DELETE /api/v1/media/{id} (Mastodon 4.4): a persona's own upload goes to the trash while nothing holds it; a posted
  or scheduled one answers 422.
- GET /api/v1/accounts?id[]= (Mastodon 4.3): up to 40 accounts, those that exist.
- /api/v2/search's hashtags carry their week of use (TagsController.History), not an empty history.

Verified: dotnet build PrivaPub.sln -c Release; the pasture: gts 64/64 (statistics included); by hand, an unattached
upload deleted (then 404), a posted one refused (422), accounts by ids, a hashtag's history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 03:23:06 +02:00
thepraandClaude Opus 5.5 1f3bb27e17 R3 two-factor sign-in: TOTP with recovery codes, asked for only after a correct password
Build / Build (push) Successful in 36s
Completion program R3 (docs/ROADMAP.md, 2026-10-17).

- Services/TwoFactor.cs: TOTP (RFC 6238, 30 s, 6 digits, SHA-1, one step of slack, Otp.NET 1.4.1) and ten recovery
  codes (SHA-256 kept, pulled when used). RootUser.Totp holds the secret, the last step taken (moved only by a
  conditional update, so a code works once even for two sign-ins at the same moment) and the codes' hashes.
- /clientapi/user/totp/setup (the password) answers the secret, its otpauth:// URI and a QR code as an SVG data URI
  (QRCoder 1.8.0); /totp/enable takes the app's first code, shows the recovery codes once and ends every other session
  (this device gets a new one); /totp/disable takes the password and a code. GET /clientapi/user/account says whether
  it is on and how many recovery codes are left.
- Sign-in (/clientapi/user/login, the invitation login, /oauth/login) asks for LoginForm.OneTimeCode with a 428, and
  only once the password was right; the OAuth page has the field. Recovery by email leaves two-factor on.
- PrivaPub admin reset-totp <root>: the way back for a login that lost its authenticator and its codes.
- scenarios/accounts.sh checks it all, with codes computed in Python; the OAuth page with its antiforgery token.

Verified: dotnet build PrivaPub.sln -c Release; the pasture (gts, mailpit): accounts 45/45.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 03:18:19 +02:00
thepraandClaude Opus 5.5 cff75e3a47 R2 accounts are safe: email by password and code, recovery only to it, sessions that end
Build / Build (push) Successful in 26s
Completion program R2 (docs/ROADMAP.md, 2026-10-17).

- An email address is set only with the password and a 6-digit code sent to it: /clientapi/user/email, /email/verify,
  /email/remove and GET /clientapi/user/account (EmailVerification: an hour, five tries, the code's hash only;
  EmailVerificationJob through the new IMailer). The request answers one sentence whether or not the address is free,
  and nothing goes to an address another login verified. The old POST /clientapi/user/update, which changed the
  address with neither and said when one was taken, answers 422.
- Recovery writes only to a verified address; migration _019 keeps the addresses given before verification existed.
- A changed password ends every session and every persona token, and answers a new JWT for the device that changed it.
- Sessions: every JWT carries its session's id ("si", kept by sniff/again). GET /clientapi/user/logout, which did
  nothing, ends that session alone (RootUser.EndedSessions, until its tokens would expire); POST
  /clientapi/user/logout/everywhere ends them all.
- AppConfiguration:HashingOptions was never bound, so passwords were hashed at the default 9789 iterations: it is
  bound now, at 210,000, and a hash made at other iterations is made again at the next sign-in (matched on the old
  hash, so two sign-ins never undo each other).
- Recovery and verification codes expire through TTL indexes; EmailVerification is never in a backup.
- AppConfigurationService reads appsettings as they are now, not the copy older builds saved in Mongo on first boot.
- ViewUserSettings and RootUserSettings carry SavedAt, so a client signing in keeps its own theme until one saved.
- Pasture: peers/mailpit.sh (Mailpit 1.31.4 as mailpit.test) receives PrivaPub's mail; recovery links open
  https://decepub.test. scenarios/accounts.sh, in sweep batch A, checks all of the above (28 checks).

Verified: dotnet build PrivaPub.sln -c Release; the pasture with gts and mailpit: accounts 28/28 and gts, 91 passed,
0 failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 03:13:16 +02:00
thepraandClaude Opus 5.5 ff5e635dda R1 cleanup: collAnon and dead code gone, docs current, every peer's scenario in one sweep
Build / Build (push) Successful in 1m7s
Starts the completion program (docs/ROADMAP.md, "Owner decisions on finishing everything", 2026-10-17).

- Dead code: the commented-out collAnon blocks (discussions, seeding, admin user lists, HTTP/3, the old signature
  middleware), the empty PrivaPubWorkersConfiguration, PublicAvatarUsersService and UserPolicyType, unused helpers,
  the second IPasswordHasher registration, the PasswordGenerator package, SignUpAsync/LoginAsync's unused invitation
  parameters. "Starting PrivaPub"; a signed-in login is sent to "/", not to "/discussions".
- Stored fields nothing read: RootUser.Contacts and ResetPasswordToken*, the local Avatar.AvatarServer,
  EmailRecovery.RecoveryCode/RequestIP/CreationDate, MongoSettings.LogsDatabase, and AppConfiguration's
  upload/discussion keys, RequiresFirstTimeSetup and Version (old documents still load: MongoDB.Entities ignores extra
  elements). DmPost's indexes are no longer made. 49 collAnon keys leave FieldsNameResource in every language.
- /clientapi/data/current-version answers the build's ref. UsersIds is a list that can't throw, and the admin routes
  leave the administrator out without mutating the request.
- deploy.yml checks SecureMode on a name no persona can have, and the browser redirect on the directory's first local
  persona: @thepra is the administrator's own now.
- Town: decePub's e2e remnants (expect, client.jsonl, the decePub column) and town/tests go; the dead gaps/load
  commands too. Mobilizon no longer keeps an event for a deleted test.
- tools/pasture/sweep.sh runs every peer's scenario, batch by batch, as CLAUDE.md asks before a change to what
  PrivaPub sends.
- Docs: CLAUDE.md's repo map, route table (every frozen route) and stale lines; ROADMAP's status (setup, v1.25.0,
  v1.26.0, the program C), the 2026-10-17 decisions, and dated notes where earlier text no longer holds; FEDERATION.md's
  FEP list (7888, f228, 7628, ae0c and 8967 supported) and the deploy's persona.

Verified: dotnet build PrivaPub.sln -c Release (0 errors, no new warnings); bash -n on the touched scripts; py_compile
on the town; the pasture's gts, mastodon and lemmy scenarios on this build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 02:56:57 +02:00
thepraandClaude Opus 5.5 6727e1817a No test project: PrivaPub.Tests goes, and neither CI nor the deploy runs tests
Build / Build (push) Successful in 45s
Owner decision 2026-10-17: the tests took about 22 of a release's 25 minutes on the runner (9 in build.yml after a push,
12 more in deploy.yml on the tag, for the same commit). PrivaPub.Tests is deleted with tools/ci/with-test-mongod.sh;
build.yml builds and deploy.yml publishes. CLAUDE.md's Testing section becomes Verifying (build, run locally, the
pasture), and the invariants no longer name the tests that checked them. decePubClient's tests/e2e goes too, so the
pasture's peers/decepub.sh serves a plain publish with the Pasture environment (decePubClient/publish/wwwroot).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:28:33 +02:00
thepraandClaude Opus 5.5 c42229f78c The admin CLI moves a persona to another login and deletes a login; the deploy no longer signs in
Build / Build (push) Successful in 10m53s
Deploy / privapub.thepra.dev (push) Successful in 10m5s
The owner took @thepra, which the deploy had made and kept under its own login deploy-smoke for its signed-in check, for
the administrator's login (owner decision 2026-10-17). No persona could change login, so:
- admin move-persona <persona> <root> re-points the persona's link first, so the old login can no longer exchange a token
  for it, then ends its Mastodon tokens and authorizations. Its actor, posts and followers stay; nobody outside is told,
  since nothing they hold changes.
- admin delete-root <root> deletes a login as the client deletes its own (RootRemoval): every persona it still has goes
  with it, announced deleted, and its sessions end.
- admin smoke and the deploy's signed-in step are gone, since the step would make deploy-smoke again and fail to make
  @thepra under it. The deploy keeps its anonymous checks; tools/smoke/oauth.sh stays for the pasture's privapub_token.

Tests: move-persona (the persona listed under its new login only, its old token refused, unknown persona or login exit 1)
and delete-root (the login and its persona deleted, the login refused); the whole suite passes (963).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:12:56 +02:00
thepraandClaude Opus 5.5 8156c1927c First-time setup: the first login made while no administrator exists becomes it
Build / Build (push) Successful in 10m49s
Deploy / privapub.thepra.dev (push) Successful in 14m0s
Sign-up stays closed (invitations only), so a new server's first login could only be made over SSH with admin
create-root, and an invitation needs a group, which needs a login. While no administrator exists, GET
/clientapi/user/setup says so and POST takes a SetupForm (login, password, first persona): the login is made the
administrator with its persona, and setup closes for good (owner decision 2026-10-16: first come, first served, no setup
code).

ServerSetup is one document with a fixed id, so the first request to insert it wins and two setups at once make one
administrator. A setup that fails before its login is made gives the claim back; one that named an administrator never
does, whoever is demoted or deleted later, and the CLI stays the way back. admin promote and create-root --admin seal it
too, migration _018 seals a server that already had an administrator, and no backup holds it nor does a restore drop it,
since a restore tombstones an administrator made after the backup. Public sign-up, NodeInfo and the instance API are
unchanged. RootUser.SetAdmin is the one place an administrator is made (with isModerator, which IsAdminPolicy asks for),
and the invitation endpoints share InsertPersona with setup.

Tests: ServerSetupTests on the invitations-only host (setup, the race, failed setups, sign-up still closed, the CLI's
seal) and the migration; the whole suite passes (962). In the pasture, decePub's setup and invitation pages pass end to
end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:21:41 +02:00
thepraandClaude Opus 5.5 088c54ddf3 ROADMAP: P7 so far, deployed as v1.24.0
Build / Build (push) Successful in 8m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-08 02:33:50 +02:00
thepraandClaude Opus 5.5 6fccb6fe35 P7: downvotes go out, and feeds are read through the server's reader
Build / Build (push) Successful in 8m45s
Deploy / privapub.thepra.dev (push) Successful in 9m0s
Votes out. A persona's downvote is a Dislike (POST /api/v1/statuses/:id/downvote and /undownvote, the viewer's own as
privapub.votes.downvoted). One vote a post: a changed vote is sent as the new vote alone, as Lemmy sends one, since an
Undo of the old one beside it could arrive after it and take the new one away; Undo goes only when a vote is taken back.
A vote on a post in a remote community goes to the community, which counts it, and to the author only when on another
server: one copy a server, since PieFed drops a second copy of an activity it has just seen. Mbin keeps a favourite
apart from a vote, so there a favourite stays after a switch to a downvote.

Feeds followed (owner decision 2026-10-07: through the server). Following a feed (Lemmy's multi-community, PieFed's feed)
keeps a FeedSubscription for the persona and nothing else; the new Service privapub_feeds (LocalActorKind.Reader,
reserved by migration _017) follows every community of the feeds read here, reconciled when a persona follows or leaves
one, when a feed's list is read again (kept as it was when it cannot be read) and every six hours. Its Following rows
carry FollowerKind, so nobody's home gets what it brings in and its unanswered follows are sent again as its own. The
persona reads GET /api/v1/timelines/feed/:id (the feed's threads, ours included) and lists its feeds at GET /api/v1/feeds.

Checked in the pasture, every scenario: 873 pass. The 14 failures are Hubzilla's (identical on the previous commit:
Hubzilla no longer answers a follow in this pasture since its restore) and two activities Smithereen never sent;
followsync passes once the pasture's restore is older than the 14-day pause. Live: alice's downvotes count as downvotes
on Lemmy 1.0 and PieFed, replacing her upvote; Lemmy 1.0 and PieFed take privapub_feeds' follows and their threads reach
the feed timelines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-08 02:15:28 +02:00
thepraandClaude Opus 5.5 3b3f1f6b93 P7: feeds read as accounts, and PieFed's post Move
Build / Build (push) Successful in 7m50s
Lemmy 1.0's multi-communities and PieFed's feeds (`type: Feed`) resolve as accounts: the feed's `following` is kept
once a day with its counts, each community in it is read in turn, and `/api/v1/accounts/:id/following` lists them.
Lemmy's feed carries the instance's key and its text in `description`; both are taken as they are. Following a feed
answers 422 until the owner decides how a feed is followed.

A post moved to another community (PieFed's Move{object: post, origin, target}), relayed by the community it leaves,
goes there with its thread; a move into a community we host is not taken.

Checked in the pasture: a Lemmy 1.0 feed and a PieFed feed list their communities (ours among them); a PieFed post moved
from its web code arrives moved, with its comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-07 21:42:54 +02:00
thepraandClaude Opus 5.5 62189bf11d P7: thread backfill reads conversation containers and asks with If-None-Match
Build / Build (push) Failing after 7m52s
The FetchReplies job reads a thread's FEP-171b contextHistory before its FEP-7888 context: the posts each Add (or
Forte's plain Create) brought in, read from their own servers. A thread collection read whole keeps its ETag when the
document itself changes with every post (it counts them, or holds them all with no further page); the next read sends
it as If-None-Match and a 304 ends the job. The ETag is kept as sent, since NodeBB's has no quotes and the typed header
drops it. A context naming a post we hold (Forte's first post) is not fetched.

Checked in the pasture: Mastodon 4.7.3 answers the second read 304; NodeBB 4.16's unquoted ETag is kept (23/23 in its
scenario); a Forte thread completes through its replies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-07 21:33:49 +02:00
thepraandClaude Opus 5.5 fb4949b511 P7: FEP-268d searchableBy decides what search finds
Build / Build (push) Successful in 7m58s
A remote account's searchableBy, and a post's own, outrank indexable in status search: Public lets anyone find a
public post, the author's followers only those who follow it, anything else nobody but those it already reaches
(their own, named, favourited, bookmarked or boosted posts). Read on actors and posts, kept through edits; PrivaPub
does not emit it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-07 21:17:36 +02:00
thepraandClaude Opus 5.5 d4b1788a4f P7: community flairs, from Lemmy and PieFed
Build / Build (push) Successful in 8m2s
- A post's flairs are read in both dialects: Lemmy 1.0's CommunityPostTag (id, slug, name, description, colour slot)
  and PieFed's lemmy:CommunityTag (display name, text and background colours, whether to blur images). Colours are
  kept only as a colour slot or a hex value.
- A community's own list comes from its `tag` and PieFed's older `lemmy:tagsForPosts`, and fills in a post that names
  only a flair's id and slug, as Lemmy's posts do. Edits and refreshes keep them current.
- Clients get them as `privapub.flairs`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-07 21:14:32 +02:00
thepraandClaude Opus 5.5 587997dd67 P7: Lemmy's Warn and Resolve{Flag}
Build / Build (push) Successful in 8m37s
- A Warn from a community's moderator about a persona's own post there becomes that persona's moderation_warning
  notification (Mastodon's AccountWarning, with the reason and the post), believed from the community's own server or
  from an account the community's moderators collection lists. Remote communities keep that collection's address
  (attributedTo) as ForeignAvatar.ModeratorsURL; it is read only when a warning needs it.
- A Resolve{Flag} for one of our reports (`/grunts/flag-<report id>`) is kept as the report's remote resolution when it
  comes from the server holding what was reported, or the community it was reported to; our own moderators'
  resolution is never replaced. The moderators' report list shows both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-07 21:10:58 +02:00
thepraandClaude Opus 5.5 6fc2d001b2 ROADMAP: media, backups and archives deployed (v1.23.0, decePub v1.5.0)
Build / Build (push) Successful in 7m41s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 17:55:58 +02:00
thepraandClaude Opus 5.5 4eda3da75d mongod's oplog size flag is --oplogSize on the command line
Build / Build (push) Successful in 8m26s
Deploy / privapub.thepra.dev (push) Successful in 8m53s
--oplogSizeMB is the configuration file's key (replication.oplogSizeMB); mongod refuses it as a flag, so setup.sh's
replica-set step left the unit restarting until the flag was fixed on Max.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 17:32:02 +02:00
thepraandClaude Opus 5.5 163f7e4e2b An import reads the archive's followers once, finds threads in linear time, and imports a located post once
A post's visibility no longer parses actor.json again for every outbox item; a reply's conversation is its thread's
first post's, found from what the first pass read instead of searching every planned post (quadratic on a large
archive); and a located post always records where it came from, so importing a persona's own archive twice made it
twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 12:51:58 +02:00
thepraandClaude Opus 5.5 44a5895b0c ROADMAP: the owner's decisions on media, backups and archives, and what the next deploy needs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 12:50:17 +02:00
thepraandClaude Opus 5.5 3d7d406834 A persona's archive: exported in Mastodon's layout, imported without telling anyone
A root exports one of its personas (a job) as Mastodon's account archive, so other servers' importers read it: the
actor with its public key only, its own posts and boosts with their media, likes, bookmarks and Mastodon's CSV files,
plus PrivaPub's filters, followed hashtags, notification policy, pins, scheduled and located posts; nothing of its root,
its siblings, its keys or anyone's token. A ticket link downloads it, for a week.

An archive (PrivaPub's or Mastodon's) uploaded in pieces is imported into a persona in a job, the parts the root picks,
with progress and a stop. SafeArchive refuses links, escaping paths, duplicates, bombs and oversized items, and reads the
outbox one item at a time. Imported posts are delivered to no one, put in no home and notify nobody, yet show on the
profile, outbox, hashtags and search; back home a post keeps its id, from another actor it gets one of its date and
ImportedFromURI, so importing twice changes nothing. Relationships go through the existing services; located, scheduled
and likes only when asked; followers never.

tools/pasture/scenarios/persona-archive.sh imports mastouser's real Mastodon archive (156 posts, 24 pictures) into a
persona Mastodon follows: Mastodon receives none of it, and a second import changes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 12:43:12 +02:00
thepraandClaude Opus 5.5 bdc8be4508 A restore on the live pasture: its own scenario, and two fixes it found
tools/pasture/scenarios/restore.sh backs the pasture up from the administrator's endpoint, then alice_restore deletes a
post and makes another, mastouser follows her, she blocks bob_restore and carol_restore is made; the restore, asked for
from the endpoint, keeps every protective act (19 checks). town.sh renew <peer> signs a peer's town accounts in again,
since a restore ends every session.

It found that a backup listed media files already missing when it was made, so verifying it failed and the restore was
refused: a manifest now lists only the files it holds (refusals are cut to five lines). And a local post made after the
backup now comes back as a deleted row, as a deletion leaves it, so it answers 410 and its id is never given again;
DeletedObject holds remote tombstones only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 12:24:23 +02:00
thepraandClaude Opus 5.5 9ad96f560d FEP-8fcf rests for two weeks after a restore
What a restore lost of either side is not the other server's to mend: for RestoreRecord.FollowersGrace (14 days)
deliveries carry no Collection-Synchronization header, and a follow only the remote remembers is adopted rather than
undone. Without a restore nothing changes; the interop sweep (gts, mastodon, misskey, sharkey, akkoma, pixelfed,
smithereen, followsync, pins, moves) passed, Smithereen's poll check on a second run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 12:16:35 +02:00
thepraandClaude Opus 5.5 bc5f2beaf7 The administrator's page backs up, downloads, uploads and restores
/clientapi/admin/backups (owner decision 2026-10-07, approving these endpoints in production): the list with what runs,
the restore waiting and the last restore's report; back up now; delete; a download for the password, through a ticket
good for ten minutes in the link's path, as one tar whose length is known first and which honours Range (BackupTar);
an upload in pieces of at most 32 MB, each at the offset already received or refused with it, so a broken upload
resumes, read into a backup only when it holds nothing but plain files under one backup's folder; and a restore for
the password and the host typed out. Every call checks the administrator against the database. nginx streams downloads
for an hour and takes upload pieces unbuffered, rate-limited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 12:01:03 +02:00
thepraandClaude Opus 5.5 fba57318fa A backup is restored at boot, and never undoes a protective act
PrivaPub admin restore <id> (and soon the administrator's page) checks the backup (same host, a format and newest
migration this build reads, every hash) and writes restore.json; the running service sees it within seconds and stops,
and the next start restores it in MaintenanceGate, before migrations, indexes and hosted services: a pre-restore backup
taken once, every collection dropped and imported raw with its indexes, the media the live directory lacks brought
back, then the protective merge from the pre-restore backup. Followers and follows are the live ones; blocks, mutes,
domain blocks, reserved names, tombstones, reports, filters and OAuth applications are the union; deletions win;
accounts made since become tombstones and local posts made since answer 410; every session ends.

Each attempt redoes everything; one refused before any change is abandoned and recorded, one failed midway exits 1 for
systemd to retry, and after three it exits 75, which the unit no longer restarts. Commands wait (exit 75) while a
restore is pending. RestoreRecord tells what happened (admin restore --status).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 11:55:17 +02:00
thepraandClaude Opus 5.5 12bb75809f The server backs itself up: every collection byte for byte, the media linked
A backup is a directory <stamp>-<kind> under Backups:Root (/var/lib/privapub/backups, 2770, files 0640), written as
.partial and renamed once whole: a manifest (host, build, newest migration, each collection's count, size, sha256 and
indexes, what was left out and why, the media list), each collection as gzipped canonical Extended JSON read raw, and
hard links to the files of untrashed media rows (copies where a link can't be made). On a replica set every collection
is read in one snapshot session. Never in a backup: the statistics salt, jobs, recovery codes, sessions, the
maintenance lock and the configuration's copy with its SMTP password.

One backup or restore at a time (MaintenanceLock, a heartbeat document), and the janitor purges nothing meanwhile.
BackupScheduler backs up nightly at 03:30 UTC (or at once after missing a night); rotation keeps 7 daily, 4 weekly,
3 pre-deploy and 3 pre-restore backups. CLI: admin backup [--kind] [--db-only], admin backups, admin backup verify;
these run before migrations, so the deploy's own pre-deploy backup, which replaces mongodump, is of the database as the
live build left it. EntityMaps.Warm runs once under a lock, since test hosts now boot side by side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 11:40:06 +02:00
thepraandClaude Opus 5.5 37b12c5fee Mongo is a one-member replica set
Owner decision 2026-10-07: production's mongod becomes a one-member replica set (rs0), so that a backup can read every
collection at one instant (a snapshot read session), which a standalone mongod can't. The unit runs mongod with
--replSet rs0 and a 990 MB oplog; setup.sh converts it once, idempotently (PrivaPub stopped, mongod restarted,
rs.initiate, the primary awaited, PrivaPub started); every connection string says directConnection=true, which works
against the standalone too, so this code can deploy before the conversion. The CI's throwaway mongod and the pasture's
are replica sets as well, so the snapshot path is what the tests exercise; MongoTopology tells which a mongod is, and
TopologyTests holds the test mongod to it. The suite passes on it (906).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 11:21:13 +02:00
thepraandClaude Opus 5.5 830385ea2b An empty media_ids takes an edited post's media away
A JSON body's empty array flattened to nothing, so `media_ids: []` on an edit read as "media not sent" and the
post kept every attachment, where Mastodon removes them. An empty array is now a parameter with no values: the edit
leaves the post without media, and they go to the trash. MediaLifecycleTests checks it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 11:07:17 +02:00
thepraandClaude Opus 5.5 dc63fa57b8 A login's storage is counted, and can have a quota
MediaAttachment.Size was stored and never summed: nobody, the administrator included, could tell what media took,
and nothing bounded it. Counted.Media and MediaOfRoot sum what is kept (not trashed). A login sees what its personas'
uploads and pictures take at /clientapi/user/storage (ViewStorage), with its quota when the server sets one;
Media:QuotaBytesPerRoot (0, no quota, by default) refuses an upload or a picture over it with 422, whichever persona
sends it; the statistics overview gains the media totals, the proxy cache and the trash (ViewMediaTotals). Tests: a
login's storage counts what its personas keep and forgets what is trashed; two uploads from two personas of one login
are refused together past the quota.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 11:03:25 +02:00
thepraandClaude Opus 5.5 5252b8f320 A posted picture is described again by editing its post
PUT /api/v1/media/:id on a picture already posted changed the upload but never the post, which keeps its own copy, so
the new description or focal point reached nobody. As on Mastodon, PUT now takes only media not posted yet (404
otherwise), and a status edit takes media_attributes[] (id, description, focus; from a JSON body or a form): the
uploads and the post's copies change, and the Update the edit federates carries them. An edit that sends no media
keeps them as they were described. No pasture scenario edits a description, so the sweep could not see this.
MediaLifecycleTests: PUT on a posted picture is refused, an edit describes it again and keeps its warning, flag and
language as sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 11:00:04 +02:00
thepraandClaude Opus 5.5 a4ced664cf The proxy's shared download is started once
ConcurrentDictionary.GetOrAdd may run its factory more than once when asked at the same time, and each run started a
download: five clients asking at once for a new file made three upstream requests in one run of
Clients_asking_at_once_share_one_download. The factory now makes a Lazy, of which only the one kept is ever started,
and a download that begins just after another cached the file uses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 11:00:04 +02:00
thepraandClaude Opus 5.5 8fecf7de2d A persona's avatar and header can be removed
Mastodon's DELETE /api/v1/profile/avatar and /api/v1/profile/header: the picture goes to the trash (its file stops
being served at once), the profile shows the default again, followers are sent the updated profile, and the answer is
the account with its source. The profile Update is the one a persona that never had a picture sends, so nothing new
leaves PrivaPub. MediaLifecycleTests: the avatar, then the header, removed and no longer served.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 10:55:12 +02:00
thepraandClaude Opus 5.5 3ca29603ed The media proxy is bounded
Anyone could mint signed proxy URLs (a remote account changes its icon, an anonymous lookup returns the URL), and each
anonymous request held up to 40 MB in memory; the cache grew without bound between hourly trims; two clients asking
for the same new file downloaded it twice and wrote over each other in place, so a reader could get half a file with a
7-day cache header; a file over the limit was downloaded twice on every request; a failed fetch, a 404, was cached by
browsers for a week; cached media of a server suspended later were still served, and RejectMedia skipped avatars,
emoji, covers, video variants, link cards and remote edits; /clientapi/group/members returned remote pictures raw.

Now a download is shared by everyone asking at once, streamed into a .part file and renamed into place
(FederationHttp.DownloadMedia copies bounded, never into memory), at most eight at a time; a file too big to cache is
remembered for an hour and only streamed, a failure for five minutes; the cache's size is counted as it grows and
trimmed as soon as it passes the cap; a cached file is opened before it is answered; browsers may cache only a
success; nothing of a suspended server, or of one whose media are rejected, is proxied (everything remote a client
sees goes through the proxy, so that covers every kind), and blocking one purges its cache; the proxy has its own rate
limit per client address; group members' pictures are proxied; the proxy's key is loaded once, the oldest if two
were made. This changes what PrivaPub serves its clients, not what it sends to other servers.

Tests: clients asking at once share one download, a failure isn't cached by browsers, an over-limit file is fetched
three times for two requests instead of four, a blocked server's media are refused and its cache purged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 10:54:33 +02:00
thepraandClaude Opus 5.5 8e59145826 Audio and video are processed off the request
Every audio and video upload was probed and remuxed inside the request, whatever its length; ffmpeg would read any
protocol and probe any format; `-map 0` kept the data tracks iPhones add, which mp4 refuses; nothing was ever
transcoded, so HEVC or MPEG-4 Part 2 reached browsers that can't play them, and the advertised video_matrix_limit
and frame rate limit were never applied; the output was read whole into memory, the video was saved before its
poster could fail, and the poster's frame leaked in /tmp. FLAC uploads were served as 404.

Now an upload sent to /api/v2/media is stored as sent in media-incoming (beside the media root, never served) and
answered with 202 and no url, while a ProcessMedia job, one at a time, does the work; GET /api/v1/media/:id answers 206
until it is ready, or 422 with why, and media still processing can't be posted. v1 processes before answering.
ffmpeg reads only that file (protocol whitelist, format forced from the probe) and drops data and subtitle tracks. A
video browsers play as it is (H.264, VP8, VP9, AV1 within Media:MaxVideoPixels and MaxFrameRate) is remuxed, anything
else transcoded to H.264 that fits, as Mastodon does; longer than Media:MaxSeconds is refused. Outputs move into place
only once everything succeeded, every temporary file goes, durations are kept, FLAC is served as audio/flac, and the
unit gets PrivateTmp. The instance API advertises the limits that are now applied.

No pasture scenario uploads audio or video through PrivaPub, so the sweep could not see this. MastodonMediaTests: v2
answers 202 then the job makes it playable (and an unreadable file 422 once processed), media still processing can't
be posted, MPEG-4 Part 2 becomes H.264, a video over the limit is made smaller, FLAC is served.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 10:48:56 +02:00
thepraandClaude Opus 5.5 e4f9d0b61e An image is checked before it is decoded
An upload went straight to libvips: whatever loader recognised the bytes ran (an SVG sent as image/png was rasterised),
nothing bounded how many pixels it would decode to (a small PNG could decode to gigabytes, three times over), a GIF
was loaded frame by frame and never resized, and all of it ran inside the request with nothing limiting how many at
once. A GIF was typed gifv but stayed a .gif, which a gifv player can't play; its metadata was kept; colours lost
their ICC profile without being converted; HEIC was advertised but the bundled libvips can't decode it.

Now:
- only libvips' JPEG, PNG, GIF, WebP and HEIF loaders ever run on an upload (every other loader is blocked);
- the header alone says how big an image would decode, refused above Media:MaxPixels (40 MP) or MaxFrames;
- a still image is shrunk on load, turned by its orientation and brought into sRGB (thumbnail), then written without
  metadata, a profile picture the same way;
- an animated GIF becomes a looping silent H.264 mp4 typed gifv, as on Mastodon (PostMedia.Kind keeps it a gifv),
  and a remote GIF is an image;
- processing runs Media:Concurrency at a time, and uploads have their own rate limit per credential;
- HEIC and HEIF are no longer offered.

Tests: only the upload formats load, the header tells the size, an SVG posing as a PNG and an image too large are
refused before decoding, an animated GIF becomes a gifv and a still one an image, HEIC isn't advertised. The media
scenarios against the pasture (GoToSocial, Mastodon, Misskey, Akkoma, Pixelfed, Smithereen, Vernissage, Castopod,
PeerTube) pass: 329 checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 10:48:44 +02:00
thepraandClaude Opus 5.5 65938bb2a0 Media are reserved for one post, atomically
An upload's owner checked that it was free, then attached it in a second step, so two posts asking for the same
upload at once could both get it; a post now, or an edit, could take media a scheduled post held, which then failed
when its time came (only logged) and was deleted; and a persona deleted or banned after scheduling a post still
published it.

Now a post claims its media in one conditional update before anything is written (its id is minted first), and a
post that loses the race is refused with 422, the media it took put back. Scheduling reserves media the same way.
Media held by a scheduled post are that post's alone, and its job publishes only for a persona still there whose root
is neither deleted nor banned; otherwise what it held is trashed. MastodonScheduledStatusesTests: a post now and an
edit can't take scheduled media, two racing posts never both get an upload, a gone persona's scheduled post never
publishes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 10:34:16 +02:00
thepraandClaude Opus 5.5 bb680e8cb8 A file lives exactly as long as something holds it
Deleting a post, editing media out, replacing an avatar or a header, and removing a whole root deleted no file: every
one stayed on disk and publicly served from /media/files with a year-long immutable cache, its row orphaned. Now every
upload is a row, profile pictures too (Kind avatar or header, ProfileOfAvatarId), and each of those acts trashes what
it held, as does an upload never posted for a day and a dropped scheduled post. A trashed row is marked in one
conditional update (an upload attached meanwhile is left alone), its files move into media-trash, beside the media
root and outside what /media/files serves, and the janitor deletes them a day later. Nothing is deleted for looking
unused.

Along the way: a profile picture that isn't an image, or can't be read, answers 422 instead of being silently ignored
with a 200; a removed root's scheduled posts are dropped, so nothing of it publishes later; media rows get indexes
(they had none), and the janitor's first pass comes five minutes after boot instead of an hour.

`PrivaPub admin media audit [--fix]` compares the disk with the database. With --fix (as www-data) it gives the
pictures personas show today a row, and trashes media of deleted posts or personas, rows whose files are missing, and
files nothing holds: the leftovers of every deletion until now. MediaLifecycleTests covers each act, that the trash is
never served, and the audit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 10:31:14 +02:00
thepraandClaude Opus 5.5 52d201eee9 A focal point is two finite numbers
float.TryParse takes "NaN" and "Infinity", and Math.Clamp keeps a NaN, so `focus=NaN,NaN` on an upload or a media PUT
was stored as given. Once the media was posted, every status and timeline response holding the post, and the Note
and its delivery, failed to serialise, for every viewer. FocalPoint.Parse takes finite numbers only (anything else is
ignored, as an unreadable focus was); the mapper and the renderer leave out a focus that isn't sound, and migration
_016 removes the ones stored before, from the uploads and from the copy each post keeps. What PrivaPub sends is
unchanged for any focus that could be sent before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 10:23:09 +02:00
thepraandClaude Opus 5.5 402f9e0d75 A long job keeps its lease, and runs once
A lease lasted two minutes and was never renewed, so the reaper gave any longer job to a second worker while the first
still ran it, and both finished it. The worker now renews the lease every third of its length while the handler runs;
a lease found taken (reaped and leased again) cancels the handler. Each lease carries its own owner stamp, since every
worker of a process shares one name, and Finish only counts for the lease it was given. Media processing and persona
archives will run longer than two minutes. JobQueueTests: a job three times its lease runs once with the reaper finding
nothing, and a stolen lease stops its handler and drops its outcome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 10:20:24 +02:00
thepraandClaude Opus 5.5 8492f24064 The deploy's dump leaves out the statistics salt
The pre-deploy mongodump kept InteractionSalt, the day's salt that the owner decided must be destroyed at each rollup:
a dump kept seven deploys long let the day's actor hashes be reversed. It is excluded now, and the deploy refuses a
dump that still names it (mongorestore's dry run lists every collection). The dumps already on the box still hold
old salts; removing them waits for the owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 10:16:35 +02:00
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
351 changed files with 11014 additions and 27131 deletions

No files matched your search

-3
View File
@@ -14,6 +14,3 @@ jobs:
- name: Build
run: dotnet build PrivaPub.sln -c Release
- name: Test (unit and integration, on a throwaway mongod)
run: tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln -c Release --no-build
+31 -30
View File
@@ -12,8 +12,7 @@ env:
BACKUPS: /var/backups/privapub.thepra.dev
LOCAL_URL: http://127.0.0.1:6970
PUBLIC_URL: https://privapub.thepra.dev
MONGO_URI: mongodb://127.0.0.1:27022
MONGO_DB: PrivaPub
SERVER_BACKUPS: /var/lib/privapub/backups
jobs:
site:
@@ -28,9 +27,6 @@ jobs:
echo "BUILD_REF=${GITHUB_REF_NAME:-master}" >> "$GITHUB_ENV"
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
- name: Test (unit and integration, on a throwaway mongod)
run: tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln -c Release
- name: Publish
run: |
rm -rf "$GITHUB_WORKSPACE/publish"
@@ -57,12 +53,24 @@ jobs:
echo "SNAPSHOT=$BACKUPS/site-$STAMP" >> "$GITHUB_ENV"
ls -1dt "$BACKUPS"/site-* 2>/dev/null | tail -n +4 | xargs -r rm -rf || true
- name: Dump the database
# The server's own backup (PrivaPub admin backup, owner decision 2026-10-07) of the database as the live build left it:
# the new build's command runs before its migrations. The media are listed, not linked (the runner may not link
# www-data's files; the nightly backups hold them). Never the statistics salt, which must not outlive its day (owner
# rule), nor the configuration's copy with its secrets. No deploy while a restore waits for its boot.
- name: Back up the database
run: |
DUMP="$BACKUPS/mongo-$(date +%Y%m%d-%H%M%S).archive.gz"
mongodump --quiet --uri "$MONGO_URI" --db "$MONGO_DB" --gzip --archive="$DUMP"
echo "::notice::database dumped to $DUMP ($(du -h "$DUMP" | cut -f1))"
ls -1t "$BACKUPS"/mongo-*.archive.gz 2>/dev/null | tail -n +8 | xargs -r rm -f || true
[ ! -e "$SERVER_BACKUPS/restore.json" ] || { echo "::error::a restore is pending or running ($SERVER_BACKUPS/restore.json): deploy after it"; exit 1; }
out=$(cd "$GITHUB_WORKSPACE/publish" && ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin backup --kind pre-deploy --db-only)
echo "$out"
id=$(echo "$out" | grep -oE '^[0-9]{8}-[0-9]{6}-pre-deploy' | tail -1)
[ -d "$SERVER_BACKUPS/$id" ] || { echo "::error::the backup was not made"; exit 1; }
(cd "$GITHUB_WORKSPACE/publish" && ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin backup verify "$id")
if [ -e "$SERVER_BACKUPS/$id/db/InteractionSalt.jsonl.gz" ]; then
echo "::error::the backup holds the statistics salt"
exit 1
fi
echo "BACKUP_ID=$id" >> "$GITHUB_ENV"
echo "::notice::database backed up as $id"
- name: Stop, sync, start
run: |
@@ -95,11 +103,18 @@ jobs:
[ "$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" = "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")
# SecureMode (owner decision 2026-10-04): an unsigned reader gets 401 before any lookup, so a name no persona can have
# (a hyphen is outside UserNameRegex) checks it without naming anyone; a browser asking for a persona gets its page
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$PUBLIC_URL/peasants/no-such-persona")
[ "$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; }
directory=$(curl -fsS "$PUBLIC_URL/api/v1/directory?local=true&limit=1") || { echo "::error::the directory did not answer"; exit 1; }
persona=$(echo "$directory" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['username'] if d else '')")
if [ -z "$persona" ]; then
echo "::notice::no persona in the directory: the browser redirect is not checked"
else
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: text/html' "$PUBLIC_URL/peasants/$persona")
[ "$code" = "302" ] || { echo "::error::a browser asking for @$persona got $code, not a redirect"; exit 1; }
fi
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/.well-known/nodeinfo")
[ "$code" = "200" ] || { echo "::error::nodeinfo answered $code"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/swagger/index.html")
@@ -117,22 +132,8 @@ jobs:
|| { echo "::error::/stargazing does not say the crawler is on"; exit 1; }
echo "::notice::serving $served"
# @thepra is the deploy's own persona (owner decision, 2026-10-04): the server's CLI makes or keeps it, undiscoverable,
# and gives its root a new password on every deploy, so no secret is stored and nobody has to create anything.
- name: Sign in as @thepra and check the signed-in API
run: |
creds=$(cd "$WEB_ROOT" && ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin smoke thepra | grep -E '^deploy-smoke [^ ]+$' | tail -1)
[ -n "$creds" ] || { echo "::error::the smoke persona could not be prepared"; exit 1; }
read -r login password <<< "$creds"
echo "::add-mask::$password"
read -r token cid cs <<< "$(tools/smoke/oauth.sh "$PUBLIC_URL" "$login" "$password" thepra read)"
echo "::add-mask::$token"
tools/smoke/mastodon-api.sh "$PUBLIC_URL" "$token"
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; }
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"
echo "::notice::signed in as @thepra"
# The deploy no longer signs in (owner decision 2026-10-17): it kept a login of its own, deploy-smoke, whose @thepra
# is the administrator's now. Its checks are the anonymous ones above.
# The server fetches DB-IP Lite itself (GeoUpdater) about 30 s after it starts and then daily; the deploy only checks.
- name: Geolocation databases are current
+4
View File
@@ -257,6 +257,7 @@ Generated_Code/
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
!PrivaPub/Infrastructure/Backup/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
@@ -401,6 +402,9 @@ FodyWeavers.xsd
PrivaPub/media-store/
PrivaPub/media-store-proxy/
PrivaPub/media-store-trash/
PrivaPub/media-store-incoming/
PrivaPub/media-store-backups/
tools/pasture/.publish/
tools/pasture/.flood/
.claude/worktrees/
+348 -79
View File
@@ -18,17 +18,21 @@ Its defining idea: **one private login owns several public personas.**
Root and avatar are linked only through `RootToAvatar`.
- **Personas must stay unlinkable** to other users and servers; only the instance admin can see the link.
- **`Group` is an ActivityPub Group actor.** It has members, an invitation code and an optional password. Groups are
becoming two kinds: a public **community** (FEP-1b12, Lemmy-compatible) and a private, invitation-only **circle**.
of two kinds: a public **community** (FEP-1b12, Lemmy-compatible) and a private, invitation-only **circle**.
- **`DmGroup` is a direct-message conversation.**
- **`privapub` is the instance actor** (type Application). It signs fetches no persona should be tied to.
- **`privapub_reports` is the reporter** (type Service, `LocalActorKind.Reporter`). It carries reports to Lemmy, which
takes none from an Application, and to PieFed and Mbin, and names nobody. Both are server actors
(`LocalActor.IsServerActor`): never followed, mentioned or shown as accounts.
takes none from an Application, and to PieFed and Mbin, and names nobody.
- **`privapub_feeds` is the reader of feeds** (type Service, `LocalActorKind.Reader`). When a persona follows a feed
(Lemmy's multi-community, PieFed's feed), it follows the feed's communities in the server's name
(`Domain/Social/FeedFollows.cs`, owner decision 2026-10-07); its `Following` rows carry `FollowerKind`, and nobody's
home gets what it brings in. All three are server actors (`LocalActor.IsServerActor`): never followed, mentioned or
shown as accounts.
Privacy features in the model:
- location-ranged posts (`Post.Location`, `RangeKm`), to become local-only and never federated;
- contacts a persona can share (`Avatar.SharedPersonalContacts`);
- private notes (`RootUserNote`, `PersonalNote`).
- location-ranged posts (`Post.Location`, `RangeKm`), local-only and never federated;
- contacts a persona can share (`Avatar.SharedPersonalContacts`) and private notes (`RootUserNote`, `PersonalNote`)
exist only as model classes, with no endpoint.
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`
@@ -52,10 +56,17 @@ actor-scoped route gets a name in the same spirit, agreed with the owner, and a
| Activities | `/peasants/{name}/grunts/{id}` (`create-{postId}` resolves) |
| DM context | `/peasants/{name}/whispers/{id}` |
| Quote permission (FEP-044f `QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{id}` |
| Featured (pinned posts) | `/peasants/{name}/trophies` |
| Featured tags | `/peasants/{name}/tattoos` |
| Wall (FEP-400e) | `/peasants/{name}/graffiti` |
| Followers synchronisation (FEP-8fcf) | `/peasants/{name}/groupies/roll-call` |
| A group's members and moderators | `/peasants/{name}/flock`, `/peasants/{name}/wardens` |
| A post's replies and thread | `/peasants/{name}/scribbles/{id}/replies`, `/peasants/{name}/scribbles/{id}/context` |
| Token refresh | `/clientapi/user/sniff/again` |
Agreed for later phases: `/gossip`, `/drool`, `/echoes` (replies, likes, shares), `/trophies` (featured), `/tattoos`
(featured tags), `/flock` and `/wardens` (group members and moderators).
All of these have federated, so they are frozen. Agreed for later: `/drool` and `/echoes` (likes, shares). `/gossip` was
agreed for replies before they were served at `…/scribbles/{id}/replies` (2026-10-06); whether it is still wanted is
asked in the completion program's R10.
Routes other software looks up by name stay conventional:
- `/.well-known/*` and `/nodeinfo/*`;
@@ -70,15 +81,22 @@ PrivaPub.sln
PrivaPub/ ASP.NET Core Web API, net10.0
Program.cs host, Mongo init (GUIDs Standard), pipeline, /build.json
Middleware/SocialPubConfigurations.cs every DI registration (auth, federation, services, swagger, CORS)
Controllers/ClientToServer/ /clientapi/*: RootUser (signup/login/invitations/recovery), PrivateAvatar,
Group, Post (posts + DMs), Admin, Data
Controllers/ClientToServer/ /clientapi/*: RootUser (signup, setup, login, invitations, recovery), PrivateAvatar,
Group, Post (posts + DMs), Social, Timeline, Data, PersonaArchive, Moderator (reports,
the staff's log), Admin (logins), DomainBlock, Statistics, Backup, InstanceAdmin (the
server's settings, thumbnail, announcements, relays)
Infrastructure/
Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out
Jobs/ JobQueue (leases), JobWorker, Backoff, HostCircuitBreaker
Ids/ PrivacyIds (day-only ids for personas and groups, arrival-ordered ids for remote posts)
Data/ Indexes (created at start), EntityMaps.Warm, Migrations/_NNN_*.cs
Cli/ AdminCommands (`PrivaPub admin promote|demote <root>`)
Cli/ AdminCommands (`PrivaPub admin promote|demote|create-root|move-persona|delete-root|reset-totp`,
`media audit`, `backup`, `backups`, `restore`)
Backup/ ServerBackup, ServerRestore, ProtectiveMerge, MaintenanceLock, TransferStore
Geo/ GeoLocator, GeoUpdater (DB-IP Lite), CdnCatalog, CdnUpdater
Statistics/ InteractionLedger, Rollup, TrafficMeter, StatisticsOptions
RateLimiting.cs accounts (per client address) and inbox (per sending origin) policies
RegistrationOptions.cs who may make a login (`Registrations:Mode`)
Federation/
Controllers/ PeasantsController (actor, outbox, followers, following, posts, inboxes),
WellKnownController (webfinger, nodeinfo), UsersController (redirect)
@@ -96,13 +114,20 @@ PrivaPub/ ASP.NET Core Web API, net10.0
ReplyRelay (third-party replies to a persona's posts passed on to its followers)
Outbox/ OutboxPublisher (who a post goes to), DeliveryService (queues jobs) + DeliveryJobHandler
Rendering/ ActivityPubRenderer (Mastodon @context, actors, notes, collections)
Crawler/ Crawler, PeerHosts, RobotsTxt (the opt-in crawler behind `/stargazing`)
Relays/ Relays (the instance actor's relay subscriptions)
Domain/
Content/ ContentRenderer (Markdown or plain text → HTML with h-card mentions and hashtags)
Social/ FollowService (local in-process, remote Follow/Accept), Notifications
Content/ ContentRenderer (Markdown or plain text → HTML with h-card mentions and hashtags;
Document: the server's own texts)
Instance/ InstanceSettings (the server's own settings in memory), MediaLimitsFromSettings
Moderation/ ModerationLog (the staff's log, AdminAction)
Social/ FollowService (local in-process, remote Follow/Accept), Notifications, Announcements
Timelines/ Fanout (TimelineEntry rows, Mastodon's home rules), TimelineService
Privacy/ VisibilityPolicy (IsPublic expression, CanSee)
Relationships/ RelationshipService (blocks, mutes, account domain blocks; Hidden), ReportService
Media/ MediaService (libvips, ffmpeg remux, blurhash), MediaProxy, MediaJanitor
Media/ MediaService (libvips, ffmpeg, blurhash), MediaProxy, MediaJanitor
Portability/ persona archives exported and imported (Mastodon's layout)
Statistics/ StatisticsQueries, PublicGeo, ServerPlaces, SelfLocation
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
@@ -110,31 +135,29 @@ PrivaPub/ ASP.NET Core Web API, net10.0
Auth/ OpenIddict setup (keys in Mongo), MastodonScopes, TokenController, OAuthPruner
Infrastructure/ MastodonController (avatar context, scopes, errors, Link), MastodonParams, MastodonJson, Page
Entities/ Mappers/ Mastodon entities; MastodonMapper (Account, Status), AccountSearch
Controllers/ apps, instance, accounts, statuses, timelines, notifications, search, stubs
Controllers/ apps, instance, accounts, statuses, timelines, notifications, search, lists, filters,
media, polls, reactions, reports, scheduled statuses, streaming, tags, trends, cdns,
provenance; the stubs are `StubsController` in `SearchController.cs`
Web/Pages/ Razor: /@{user}, /@{user}/{id} (public posts only, strict CSP, noindex);
OAuth/: /oauth/login (root password), /oauth/authorize (choose persona, consent)
Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, …
Models/ Mongo entities: User/, Group/, Post/, Federation/, Jobs/, AppConfiguration
Models/ Mongo entities: User/, Group/, Post/, Federation/, Jobs/, AppConfiguration, ServerSetup,
ServerSettings, AdminAction
StaticServices/ DbEntities (Find<T> accessors), AuthTokenManager (JWT), PasswordHasher
Data/InitDb.cs first-run seeding (languages)
PrivaPub.ClientModels/ DTOs + validation resources shared with clients
PrivaPub.Tests/ xUnit v3; Support/ has a fake two-origin peer and a throwaway-database fixture
deploy/ nginx vhost, systemd units (privapub, privapub-mongod), max/setup.sh
.gitea/workflows/ build.yml (push) · deploy.yml (tag v*)
docs/ROADMAP.md decisions + phased plan
```
The roadmap moves code towards `Infrastructure/`, `Federation/`, `Domain/`, `Api/{ClientApi,Mastodon}` and `Web/`,
one pure-move commit at a time.
The planned move of `/clientapi` into `Api/ClientApi` was dropped (2026-10-17): it stays in `Controllers/ClientToServer/`
and `Services/`.
## Commands
```bash
dotnet build PrivaPub.sln -c Release
dotnet test PrivaPub.sln # unit tests; integration tests skip
PRIVAPUB_TEST_MONGOD=1 dotnet test PrivaPub.sln # all of them, against mongod on 127.0.0.1:27017
# (PRIVAPUB_TEST_MONGO overrides; a fresh database per run, dropped after)
tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln # all of them, on a throwaway mongod, as CI runs them
cd PrivaPub && ASPNETCORE_ENVIRONMENT=Development \
Kestrel__Endpoints__Http__Url=http://127.0.0.1:6970 Kestrel__Endpoints__Http__Protocols=Http1AndHttp2 \
AppConfiguration__BackendBaseAddress=http://127.0.0.1:6970 MongoSettings__Database=PrivaPubTest \
@@ -146,19 +169,105 @@ curl-able. Outbound fetches only go to https DNS names resolving to public addre
`Federation__AllowPrivateNetworks=true`, `Federation__AllowPlainHttp=true` and `Federation__AcceptAnyCertificate=true`,
which startup refuses in Production.
Sign-up is closed in production (invitations only). The server's first login is made from the client instead, while no
administrator exists (owner decision 2026-10-16, first come, first served):
- `GET /clientapi/user/setup` says whether setup waits (`ViewServerSetup.IsPending`), and `POST` takes a `SetupForm`
(login, password, first persona). That login is made the administrator, with its persona.
- `ServerSetup` (`Models/ServerSetup.cs`) closes setup for good: one document with the fixed id `setup`. The first request
to insert it wins, so two setups at once make one administrator.
- It is never given back once it names an administrator, whoever is demoted or deleted later.
- `admin promote` and `create-root --admin` seal it too, and migration `_018` seals a server that already had an
administrator.
- It is never in a backup and never dropped by a restore.
The admin CLI runs with every service built but nothing started (no Kestrel, no hosted services), so it can run next to
the live service. Sign-up is closed in production (invitations only), so the first login is made here; promoting is how
an admin is made (signing up as "admin" grants nothing):
the live service. It is the way back when setup is closed and no administrator can sign in; promoting is how an admin is
made (signing up as "admin" grants nothing):
```bash
cd /var/www/privapub.thepra.dev
echo '<password>' | ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin create-root <login> --admin # password on stdin
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin promote|demote <root>
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin smoke <persona> # the deploy's: prints "deploy-smoke <new password>"
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin move-persona <persona> <root> # its link moves, its Mastodon tokens end
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin delete-root <root> # as the client deletes a login: its personas go too
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin reset-totp <root> # a login that lost its authenticator and codes
```
The deploy runs them as `build-runner`, which owns the published files, reads `appsettings.Production.json` through
group www-data and reaches the private mongod; `sudo -u www-data` works too.
group www-data and reaches the private mongod; `sudo -u www-data` works too. Every one of them that changes a login or a
persona (promote, demote, create-root, move-persona, delete-root, reset-totp) and `restore` is kept in the staff's log as
the command line's (`cli`).
## The server's own settings (completion program R5)
- **One document** (`Models/ServerSettings.cs`, fixed id `settings`), read through the `IInstanceSettings` singleton
(`Domain/Instance/InstanceSettings.cs`): `Current` is never null and never changed in place; `Update(change, byRootId)`
changes the stored document (or the defaults), saves it and swaps it in; it is read at start (after migrations), after
each save and every five minutes (`InstanceSettingsRefresher`), so an edit made elsewhere takes. **The defaults are what
PrivaPub served before, word for word** (title "PrivaPub", `ServerSettings.DefaultDescription`, the privacy policy of
2026-10-04 as Markdown that renders to the old paragraph, `/media/missing-header.png`), so nothing changes until an
administrator saves. Registrations stay configuration-only (`Registrations:Mode`, owner decision; the deploy checks it).
- **Where each fact comes from now:**
- the title: both instance APIs' `title`, NodeInfo `metadata.nodeName`, the instance actor's `name`,
`ProvenanceController.DescribeSelf`, the web pages' footer (`_Layout`), the `/oauth/login` heading and the emails'
From name (`Mailer`);
- the short description: v1 `short_description`/`description`, v2 `description`, NodeInfo `nodeDescription`, and the
instance actor's `summary` once it is not the default (until then the actor's own sentence);
- the description (Markdown): `/api/v1/instance/extended_description` (dated when it last changed, today until then);
- contact email and contact persona (`ContactAccountId`, a local persona the administrator chooses, publicly tied to
running the server; shown while it exists and its login may act): v1 `email`/`contact_account`, v2 `contact`;
- the thumbnail: v1 `thumbnail`, v2 `thumbnail.url`;
- the rules (`ServerRule`: a GUID id, text, hint, position; soft-deleted, so old reports keep their meaning): both
instance APIs' `rules` and `/api/v1/instance/rules`, those not deleted, in order;
- the privacy policy and the terms of service (Markdown): `/api/v1/instance/privacy_policy` and Mastodon 4.4's
`/api/v1/instance/terms_of_service[/{yyyy-MM-dd}]` (one version kept; 404 while there are none).
The server's texts are rendered by `ContentRenderer.Document` (Markdig with raw HTML off, then `ContentSanitizer`;
nothing is looked up).
- **Administrator routes** (`InstanceAdminController`, `[Authorize(Policy = Policies.IsAdmin)]` and checked again against
the database): `GET`/`POST /clientapi/admin/settings` (`ViewServerSettings` / `ServerSettingsForm`: a rule named by its
id keeps it, a new one gets one, one left out is deleted; a description's or policy's date moves only when its text
does), `POST`/`DELETE /clientapi/admin/settings/thumbnail` (multipart `file`), `/clientapi/admin/announcements/
list|insert|update|delete`, `/clientapi/admin/relays`, `/relays/insert`, `/relays/delete?address=`.
- **The thumbnail** goes through `IMediaService.ServerImage` (checked before it is decoded, libvips, `keep=none`, fitted
within 1200×630) into a `MediaAttachment` nobody owns, held by `ServerUse = "thumbnail"`: the janitor's never-posted
sweep leaves such rows alone, `admin media audit` keeps the one the settings name (and trashes any other), no quota
counts them, and a replaced or removed thumbnail is trashed.
- **Media limits** (`ServerSettings.Media`: quota, image and video sizes, duration): `MediaLimitsFromSettings`
(`IPostConfigureOptions<MediaOptions>`) applies them over `Media:*` within hard caps (images 40 MB, video 99 MB, which
nginx's 100m and `MediaController`'s request limit allow, four hours), and `MediaLimitsChangeSource` makes every
`IOptionsMonitor<MediaOptions>` reader (uploads, the quota, the instance API, `/clientapi/user/storage`) see a save at
once.
- **Rules in reports:** `POST /api/v1/reports` takes `rule_ids[]` (kept when the rule exists, deleted ones too, since the
dialog may have listed them; a report naming rules without a category is a `violation`, and only a violation names
rules, as on Mastodon); `Report.RuleIds`, the entity's `rule_ids`, and the moderators' `ViewReport.RuleIds` and `Rules`
(their texts). Rules never federate.
- **Announcements** (`Domain/Social/Announcements.cs`, `Api/Mastodon/Controllers/AnnouncementsController.cs`): written in
Markdown by an administrator, shown while published and not ended; `GET /api/v1/announcements` (anonymous: without
`read` and `me`), `POST …/{id}/dismiss`, `PUT`/`DELETE …/{id}/reactions/{name}` (one unicode emoji, normalised as a
post's reactions are, eight different ones at most; anything else 422). Dismissals (`AnnouncementRead`) and reactions
(`AnnouncementReaction`) are kept per persona, never federated, and go with a deleted persona (`RootRemoval`). Every
persona's `user` stream hears `announcement`, `announcement.reaction` and `announcement.delete` (`Streams.ToUsers`,
mapped per persona in `StreamingController.Payload`).
- **Public domain blocks:** `DomainBlock.Published` and `Obfuscate` (off by default: publishing a block tells the blocked
server, and everyone). `/api/v1/instance/domain_blocks` lists only published ones, as Mastodon does (`domain`, partly
starred when obfuscated, `digest` the SHA-256 of the domain, `severity`, `comment` the public comment).
- **Relays** are `Federation:Relays` ∪ `ServerSettings.Relays` (`IRelays.Named`). One `Reconcile` runs at a time; the
page's insert and delete start one at once in the background (resolving a relay is a fetch) and answer 202. A
configured relay can't be removed from the page (409). A subscription no longer named gets no `Undo` while the same
relay actor is still named under another address.
- **Activity:** `/api/v1/instance/activity` answers `[]` unless `PublishActivity` (off by default); then twelve weeks,
this one first, counted by `Counted.Weekly` (local posts as `LocalPosts` counts them, by their ids; logins made by
`RootUser.CreatedAt`; `logins` "0", since sign-ins are not kept), worked out at most hourly. Personas are never counted
by their creation (their dates are spread on purpose).
- **The staff's log** (`Models/AdminAction.cs`, `Domain/Moderation/ModerationLog.cs`): who (a root id, or `cli`), the action,
the target's kind, id and label, the report, a note; never an IP address, and never a root next to its own personas
(an act on a persona the acting root owns is kept without who acted; a root's entry names none of its personas). It
keeps domain blocks, backups (create, delete, restore), settings saves (the fields changed, never their values),
rules, the thumbnail, announcements, relays, report resolutions and the CLI's acts. `GET
/clientapi/moderator/log?before=&limit=` (`ViewAdminActionsPage`, newest first, by id): administrators read all of it
with login names; a moderator who isn't one reads what isn't about a login, named only when it acted itself or it was
the command line. Writing never fails the act it records.
## Federation invariants
@@ -217,14 +326,18 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
when a refetch answers 404, which is why this matters.
**Communities** are FEP-1b12 groups: `GroupDistributor` announces the whole activity (plus the object for new posts,
for Mastodon), top-level posts are `Page`s with a `name`, posting follows `Group.PostingPolicy`.
Located posts (`LocalGeo`) are the only local-only posts.
Located posts (`LocalGeo`) are the only local-only posts. The Mastodon API looks past them everywhere, homes
included (the fan-out gives them no `TimelineEntry`, not even their author's): they are read through
`/clientapi/post/nearby` and deleted through `/clientapi/post/delete`.
9. **A DM joins a conversation only by `DmGroup.ParticipantsKey`**, the exact set of its participants; a remote
`context` decides nothing. DMs are `Post`s with `Visibility = Direct` and a `ConversationId` (`DmPost` is legacy).
`DmGroup.LastPostId` pages `/api/v1/conversations`, and `ConversationState` keeps each persona's read and removed
marks (`Domain/Statuses/ConversationStates.cs`; writing in a conversation reads it).
10. **Nothing slow happens inside a request.** Deliveries and inbox processing are `Job`s (`Infrastructure/Jobs`):
leased, retried on Mastodon's curve, at most two per host, paused per host by `RemoteInstance`. The inbox answers
202 once it has verified and queued; a handler must be idempotent (unique `ObjectURI`, job `DedupeKey`).
202 once it has verified and queued; a handler must be idempotent (unique `ObjectURI`, job `DedupeKey`). A lease
(2 minutes, stamped with its own owner) is renewed every third of its length while the handler runs, so a long job
runs once; a lease found taken cancels the handler, and `Finish` only counts for the lease it was given.
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);
@@ -272,8 +385,7 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
4. Answer with `Json(...)` (snake_case, explicit nulls) or `Error(status, message)`; page lists with `Page` and `Link`.
5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade. Any `/api`
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
with a junk token.
`UseMastodonErrorBodies`.
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.
@@ -284,26 +396,84 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
(`NotificationPermission`).
7. **What a Mastodon `Status` cannot say goes in `Status.privapub`** (`PrivaPubStatus`): object type, title, excerpt,
cover, the author's source, link, video, audio and event details, a remote post's own place (Pixelfed's `location`,
never federated again), and up/down votes. Every media URL in it goes
never federated again), up/down votes and the viewer's own downvote (`votes.downvoted`, set through PrivaPub's
`POST /api/v1/statuses/:id/downvote` and `/undownvote`; a favourite is the upvote), and its community's flairs
(`ObjectShapes.Flairs`, both dialects). 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
one is the reader's choice.
## Media invariants
1. **No upload keeps its metadata.** Images are re-encoded by libvips with `keep=none`; audio and video are remuxed with
`-map_metadata -1`. `MediaProcessingTests` checks EXIF and XMP are gone.
2. Files live under `Media:Root` (`/var/lib/privapub/media`), never in the published directory; the proxy cache is the
sibling `media-proxy`, which `/media/files` does not serve.
3. **A client never contacts a remote server for media:** every remote URL the API returns goes through
`-map_metadata -1`.
2. **An image is checked before it is decoded.**
- **Loaders:** only libvips' JPEG, PNG, GIF, WebP and HEIF loaders ever run on an upload (`MediaService`'s static
constructor blocks every other loader), so an SVG, PDF or TIFF claiming another type never loads.
- **Size:** the header alone tells width × height (× frames for a GIF), refused above `Media:MaxPixels` (40 MP) or
`Media:MaxFrames`.
- **Then:** the image is shrunk on load to `MaxImageSide`, turned by its orientation, and its colours brought into
sRGB (`thumbnail`).
- **GIFs:** an animated GIF becomes a looping silent H.264 mp4 typed `gifv`, as on Mastodon (ffmpeg with libx264;
`PostMedia.Kind` says so), and a still GIF is an image.
- **Types:** HEIC and HEIF are not accepted, since the bundled libvips has no HEVC decoder.
- **Load:** processing runs `Media:Concurrency` at a time, and uploads have their own rate limit (`uploads`, per
credential).
3. **Audio and video are processed off the request when the client allows it.**
- **v2:** `POST /api/v2/media` stores them as sent in `media-incoming` (beside the root, never served) and answers
202 with no url, while a `ProcessMedia` job (one at a time, its lease renewed) does the work. Until then
`GET /api/v1/media/:id` answers 206, and 422 with the reason if it failed. Media still processing can't be posted.
- **v1:** processes before answering.
- **ffmpeg** reads only the stored file: `-protocol_whitelist file`, and the input format is forced from the probe.
Data and subtitle tracks are dropped (`-dn -sn`, which iPhone MOVs need).
- **Remux or transcode:** a video browsers play as it is (H.264, VP8, VP9 or AV1, within `Media:MaxVideoPixels` and
`MaxFrameRate`) is remuxed; anything else is transcoded to H.264 that fits, as Mastodon does.
- **Limits:** longer than `Media:MaxSeconds` is refused.
- **Saving:** nothing is saved until everything succeeded; outputs move into place rather than being read into
memory, and every temporary file goes. FLAC is served as `audio/flac`, and the unit runs with `PrivateTmp`.
4. Files live under `Media:Root` (`/var/lib/privapub/media`), never in the published directory; the proxy cache is the
sibling `media-proxy` and the trash the sibling `media-trash`, neither of which `/media/files` serves.
5. **A file lives exactly as long as something holds it.** Every upload is a `MediaAttachment` row, profile pictures
too (`Kind` avatar or header, `ProfileOfAvatarId`). Deleting a post, an edit leaving media out, a replaced picture,
a dropped scheduled post, a removed root, and an upload never posted for a day each trash theirs
(`IMediaService.Trash`):
- the row gets `TrashedAt` in one conditional update, so a row attached meanwhile is left alone;
- its files move into `media-trash` at once, so `/media/files` stops serving them;
- `MediaJanitor` deletes them a day later (`TrashGrace`).
What a login's media take (every persona's, trashed ones no longer) is `Counted.MediaOfRoot`, shown to the login at
`/clientapi/user/storage`; `Media:QuotaBytesPerRoot` (0, none, by default, or the administrator's override in the
server's settings) refuses an upload over it with 422; the administrator's statistics overview has the totals, the
proxy cache and the trash. The server's own pictures (`ServerUse`, its thumbnail) belong to nobody and are held while
the settings name them.
Nothing is deleted because it looks unused. `PrivaPub admin media audit [--fix]` compares disk and database: with
`--fix` (as www-data) it gives pictures shown from before their rows a row, and trashes media of deleted posts or
personas, rows whose files are missing, and files nothing holds.
6. **A client never contacts a remote server for media:** every remote URL the API returns goes through
`IMediaProxy.Wrap`, an HMAC-signed `/media/proxy/` URL fetched by `IFederationHttp.GetMedia`.
4. **The proxy serves three ways:**
- **Cached:** a file already cached is served from disk, ranges included.
- **Downloaded:** a request without a `Range` is downloaded whole, up to `Media:MaxProxiedBytes`, then cached.
7. **The proxy serves three ways:**
- **Cached:** a file already cached is served from disk, ranges included. It is opened before the answer, so a trim
that deletes it meanwhile breaks nothing.
- **Downloaded:** a request without a `Range` is downloaded whole, up to `Media:MaxProxiedBytes`, then cached:
- once for everyone asking for it at the same time;
- streamed into a `.part` file beside its place and renamed there, never held in memory;
- with its host in its `.type` sidecar, so that blocking a server can purge it.
- **Streamed:** a ranged request, or anything too big to cache, is streamed from the origin with the range passed on,
and never cached. That is how remote video plays.
and never cached. That is how remote video plays. A file found too big is remembered for an hour, so it isn't
fetched twice; one that failed is remembered for five minutes.
Also:
- The cache's size is counted as it grows and trimmed as soon as it passes `ProxyCacheBytes`; the janitor
recounts hourly.
- Browsers may cache only a success.
- Nothing of a suspended server, or of one whose media are rejected, is proxied (avatars, emoji, covers and link
cards included, since they all go through it), and blocking one purges its cache.
- The `proxy` rate limit counts per client address.
nginx has a `/media/proxy/` location with `proxy_buffering off` and a 600 s read timeout for those streams.
5. **Remote video and audio become one playable attachment** in the Mastodon API (`MastodonMapper.Playable`): the best MP4
8. **A focal point is two finite numbers** within -1..1 (`FocalPoint.Parse`); anything else is ignored. A stored NaN made
every status, timeline and Note holding its post fail to serialise; migration `_016` removed the ones stored before.
9. **Remote video and audio become one playable attachment** in the Mastodon API (`MastodonMapper.Playable`): the best MP4
up to 720p that carries both sound and picture, including PeerTube's fragmented files inside an HLS entry. HLS
playlists themselves are not rewritten.
@@ -317,6 +487,27 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
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).
- **Accounts and sessions** (completion program R2, 2026-10-17):
- An email address is set only with the password and a 6-digit code sent to it (`/clientapi/user/email`,
`/email/verify`, `/email/remove`; `EmailVerification`, five tries, an hour). The request answers one sentence
whether or not the address is free; `EmailVerificationJob` sends nothing to an address another login verified.
Recovery writes only to a verified address (`IsEmailValidated`; migration `_019` kept the addresses given before).
`POST /clientapi/user/update` no longer changes the address (422).
- A changed password ends every session too, and answers a new JWT for the device that changed it.
- Every JWT carries its session's id (`si`, kept by renewals). `GET /clientapi/user/logout` ends that session alone
(`RootUser.EndedSessions`, until its tokens would expire); `POST /clientapi/user/logout/everywhere` ends them all.
- Passwords are PBKDF2-SHA512 at `AppConfiguration:HashingOptions:Iterations` (210,000; the option was never bound
before 2026-10-17), and a hash made at other iterations is made again at the next sign-in.
- Sign-up still says a login name is taken: uniqueness has to be told, and sign-up is reachable only while setup
waits or with an invitation, rate-limited.
- `AppConfigurationService` reads appsettings as they are now; the copy older builds saved in Mongo is not read.
- **Two-factor sign-in** (R3, `Services/TwoFactor.cs`, `RootUser.Totp`): TOTP (30 s, 6 digits, SHA-1, one step of
slack, Otp.NET), set up with the password (`/clientapi/user/totp/setup`: the secret, its `otpauth://` URI and a QR
code as an SVG data URI, QRCoder), turned on by the app's first code (`/totp/enable`: ten recovery codes shown
once, every other session ended) and off with the password and a code (`/totp/disable`). Each code is taken once
(`LastStep`, moved by a conditional update); a recovery code is kept as its SHA-256 and pulled when used. Sign-in
(`/clientapi/user/login`, the invitation login and `/oauth/login`) asks for `OneTimeCode` with a 428, and only after a
correct password. Recovery by email leaves two-factor on; `PrivaPub admin reset-totp <root>` turns it off.
- **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
@@ -422,7 +613,92 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
`_NNN_` order, each runs once), then `Indexes.Create()`. A new entity needs nothing; a new unique index needs a
dedupe migration before it.
- Production runs its **own mongod** on 127.0.0.1:27022 (unit `privapub-mongod`, no auth, data in
`/var/lib/privapub/mongo`). The box's shared mongod needs credentials nobody here has.
`/var/lib/privapub/mongo`). The box's shared mongod needs credentials nobody here has. It is a one-member replica set
(`--replSet rs0`, a 990 MB oplog; owner decision 2026-10-07), so a backup reads every collection at one instant;
`setup.sh` converts it once (PrivaPub stopped, mongod restarted, `rs.initiate`), and every connection string says
`directConnection=true`, which also works against a standalone. The pasture's mongo is one too.
- **Backups** (`Infrastructure/Backup/`; owner decisions 2026-10-07: the whole server, plain files protected by their
permissions, run from the CLI, nightly and from the administrator's page). Each backup is a directory
`<yyyyMMdd-HHmmss>-<kind>` under `Backups:Root` (`/var/lib/privapub/backups`, www-data 2770, files 0640), written as
`.partial` and renamed once whole:
- `manifest.json` (`ArchiveManifest`): host, build, newest migration, whether it was read at one instant, each
collection's count, size, sha256 and indexes, what was left out and why, and the media list;
- `db/<collection>.jsonl.gz`: each document as one line of canonical Extended JSON, read raw, so every BSON type comes
back byte for byte; on a replica set every collection is read in one snapshot session
(`minSnapshotHistoryWindowInSeconds` is raised to an hour only while it reads);
- `media/`: hard links to the files of untrashed `MediaAttachment` rows (no disk spent; a deleted file stays until the
backup rotates out), copies where a link can't be made. `--db-only` lists them instead.
**Never in a backup** (`ServerBackup.Excluded`): `InteractionSalt` (owner rule), `Job` and `Delivery` (work in flight),
`EmailRecovery` and `EmailVerification`, `openiddict.tokens` and `.authorizations` (sessions), `MaintenanceLock`, `ServerSetup` (a restore
never opens setup again), and `AppConfiguration` (its SMTP password; it is made again from appsettings at boot). One
backup or restore runs at a time
(`MaintenanceLock`, a heartbeat document; a holder silent for 2 minutes is taken over), and the media janitor purges
nothing meanwhile. `BackupScheduler` backs up at `Backups:NightlyAt` (03:30 UTC), or at once when it missed the night;
rotation keeps 7 daily and 4 weekly nightly backups, 3 pre-deploy, 3 pre-restore, and manual and uploaded ones until
deleted. CLI: `PrivaPub admin backup [--kind manual|pre-deploy] [--db-only]`, `admin backups`, `admin backup verify
<id>`; these run before migrations, so the deploy's backup is of the database as the live build left it.
- **Restores** (`ServerRestore`, `ProtectiveMerge`; owner decision 2026-10-07). Asking (`PrivaPub admin restore <id>`, or
the administrator's page) checks the backup (same host, a format and newest migration this build reads, every hash)
and writes `restore.json` in the backups' root; the running service sees it within seconds (`RestoreWatcher`) and
stops, and systemd starts it again. `MaintenanceGate` (in `Program`, right after the build, before migrations, indexes
and hosted services) carries it out:
1. a pre-restore backup P, taken once (a retry reuses it, never a half-restored database);
2. every collection the backup holds dropped and imported raw with its indexes, every other one dropped, except what a
backup never holds, which stays as it is;
3. media files the live directory lacks linked back from the backup (or moved from the trash); a restore deletes no file;
4. **the protective merge from P: a restore never undoes a protective act.** Followers and follows are P's; blocks,
mutes, domain blocks, being blocked, reserved names, `DeletedObject`, reports, filters and OAuth applications are
the union, P's row winning; roots, personas, groups, posts and remote accounts deleted since are deleted again, with
what their deletion takes away; P's moderation of a persona and a root's password, e-mail, ban and policies stay;
roots, personas and groups made since become tombstones (deleted, names kept), local posts made since answer 410,
media made or trashed since go to the trash (but the server's own pictures, `ServerUse`); the staff's log
(`AdminAction`) is the union, so a restore never loses an entry, its own request included, and the server's settings
are P's: the live settings outlive a restore;
5. every session ends (a new `SessionStamp` for each root, persona tokens deleted), circuits close, and a
`RestoreRecord` (never in a backup) tells what happened (`admin restore --status`).
Each attempt redoes everything. Refused before anything changed, it is abandoned and recorded, and the server boots as
it was; failed midway, the process exits 1 and the next start tries again; after 3 failures it exits 75, which the
unit's `RestartPreventExitStatus` leaves down for someone to look. While `restore.json` exists, commands exit 75 (but
`admin restore --status`) and the deploy refuses to run. For `RestoreRecord.FollowersGrace` (14 days) after a restore,
FEP-8fcf rests: no `Collection-Synchronization` header goes out, and a follow only the remote remembers is adopted,
not undone.
- **The administrator's page** (`BackupController`, `/clientapi/admin/backups`; owner decision 2026-10-07, which approved
these endpoints in production): the list (with what runs, the restore waiting, the last restore's report, logins
deleted since each backup), back up now (202; the page polls), delete, and:
- **download** for the password: a ticket good for ten minutes in the link's path (`/download/{ticket}`, anonymous, so a
browser saves it to disk), the backup as one tar (`BackupTar`: its exact length known first, written from the
files, `Range` honoured);
- **upload** in pieces of at most 32 MB (`TransferStore`): each `PUT ?offset=` must start where the upload stands, else
409 with what was received, so a broken upload resumes; `finish` reads it into a backup (`kind` uploaded), refusing
anything but plain files under one backup's folder, a path leaving it, or more than the disk holds;
- **restore** for the password and the server's host typed out.
Every call checks the administrator against the database, not only the token. nginx streams downloads for an hour and
takes upload pieces unbuffered (`deploy/nginx`, applied by `setup.sh`).
- **A persona's archive** (`Domain/Portability/`, `PersonaArchiveController` at `/clientapi/persona/{avatarId}/archive`;
owner decision 2026-10-07), for the persona's own root only, never a banned one. State per persona in `PersonaArchive`
(never in a backup), files in `<backups>/.personas/<avatarId>/`, a week.
- **Export** (`ExportArchiveJob`): Mastodon's account-archive layout, so other servers' importers read it: `actor.json`
(its public key only), `outbox.json` (its own posts as Creates, addressed as delivered, but circles' and located
posts; its boosts as Announces), `media_attachments/files/` (what the attachments' urls name), avatar and header,
`likes.json`, `bookmarks.json` and Mastodon's CSV files; then `privapub/`: filters, followed hashtags, the
notification policy, pins, scheduled and located posts, `archive.json`. Nothing of its root, its siblings, its keys,
anyone's token. Downloaded through a ten-minute ticket in the link's path.
- **Import** (`ImportArchiveJob`, the parts the root picks, with progress, stoppable): uploaded in pieces like a backup,
read through `SafeArchive`, which refuses links, paths leaving it, names twice, too many or too large files, a file
compressed over 100:1, and streams the outbox an item at a time (none over 1 MB). Only the archive actor's own
Creates become posts; boosts, direct and circle posts are counted. **Imported posts are delivered to no one**, put in
no home and notify nobody (an exception next to located posts), yet show on the profile, the outbox, hashtags and
search. Back home (same actor) a post keeps its id and address, and one already here, even deleted, or tombstoned,
stays as it is; from another actor it gets an id of its date, its address here and `ImportedFromURI` (unique per
persona), so importing twice changes nothing. Replies and self-quotes inside the archive point at their copies; polls
come closed without votes; mentions stay links; HTML is sanitized; media go through `MediaService` and the quota.
The other parts go through the existing services: follows asked again (never on a blocked server), blocks, mutes,
blocked servers, lists (members only if followed), bookmarks (by signed fetch), filters, followed hashtags, the
notification policy, pins (no `Add` delivered), the profile (announced as Settings would). Located and scheduled
posts (PrivaPub's archives) and likes (each tells its author) only when asked. Followers are never imported.
## Code style
@@ -442,35 +718,10 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
- Libraries chosen for the roadmap: HtmlSanitizer, Markdig (`DisableHtml`), NSign (RFC 9421 inbound), OpenIddict +
OpenIddict.MongoDb, NetVips, Blurhash.Core, FFMpegCore. No ImageSharp (licence key enforced), no MassTransit.
## Testing
## Verifying
`PrivaPub.Tests` (xUnit v3). Unit tests need nothing; tests marked `Category=Integration` need a mongod and skip
without `PRIVAPUB_TEST_MONGOD=1`. CI (`build.yml` and `deploy.yml`) runs all of them through
`tools/ci/with-test-mongod.sh`, which starts a throwaway mongod on a random localhost port and deletes it afterwards.
The box's own mongods are production, so `MongoFixture` refuses port 27022, a data directory under `/var/lib/privapub`,
and in CI anything but the wrapper's mongod. With `PRIVAPUB_TEST_REQUIRE_MONGOD=1`, which the wrapper sets, a missing
mongod fails the run instead of silently skipping half the tests.
- `Support/Peer` is an in-process HTTP server answering on two origins (`127.0.0.1` and `localhost`), so origin rules
can be tested; `Support/RemoteActor` signs real deliveries with its own key.
- Inbox scenarios go through `InboxReceiver.Receive` with a signed request, not through the private handlers.
- `Support/Host/PrivaPubHost` is the whole server under test (`WebApplicationFactory<Program>`, environment `Testing`,
configured only through `UseSetting`, on the fixture's database). `PrivaPubHost.Shared()` boots it once per run;
`SecureModeHost` is the same with `Federation:SecureMode`. Background workers are removed, so a test runs the jobs it
queued with `host.Run(j => ...)` or `host.RunInbox(activityId)`. `Accounts` signs a root up, adds personas, and gets a
Mastodon token through the real `/oauth` code flow; `RemoteActor.SignedPost`/`SignedGet` sign `HttpRequestMessage`s
for the real `/peasants` routes. Each client gets its own `X-Test-Client` address, so rate limits don't collide.
- All test classes share one database and run in parallel, so a test touches only rows it made:
- random names and GUIDs;
- `Harness.Outgoing` sees only deliveries queued since that harness started, because Peer ports are reused;
- a worker gets a scoped `new JobQueue(j => ...)` so it never leases another test's jobs;
- domain blocks are set with `DomainBlocks.Load`, never written to the database.
A test that must change something database-wide (drop indexes, run a migration over every post, let deliveries to
`localhost` fail and trip its breaker) goes in `[Xunit.Collection(nameof(Exclusive))]`, which runs alone, and
cleans up after itself. Pure logic belongs in an unconditional unit test, not in a Mongo-gated class.
Beyond the tests, verify by building, running locally, and exercising:
There is no test project (owner decision 2026-10-17: `PrivaPub.Tests` was removed, with the CI and deploy steps that
ran it). Don't add one back unless asked. Verify by building, running locally, and exercising:
- the client API (sign up, create an avatar, a group, a post);
- the ActivityPub endpoints with curl and `Accept: application/activity+json`.
@@ -479,11 +730,12 @@ Interop is checked against real servers, starting with the workstation's own pas
```bash
DOTNET=~/.dotnet/dotnet tools/pasture/run.sh up [gts mastodon ...] # podman: PrivaPub + the named peers (default gts) + Mongo, behind Caddy
tools/pasture/interop.sh [gts mastodon ...] # each peer's scenario, then what PrivaPub's statistics saw of it
tools/pasture/sweep.sh [A|B|C|D ...] # every peer's scenario, batch by batch (up, interop, down); out/sweep-<batch>.log
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
is run against every peer's scenario before it is committed, not only the peers it was written for (`sweep.sh`). 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`
@@ -536,7 +788,7 @@ tools/pasture/run.sh down # removes e
read from its `user_relationships`. `akkoma_token <user>` signs one of its users in. 45 checks.
- **Lemmy (1.0.0-beta.2):** the backend alone on the shared Postgres, its admin made by `setup` in the generated
`config.hjson`. It trusts Caddy's CA through `SSL_CERT_FILE` and reaches the network through
`DANGER_FEDERATION_ALLOW_LOCAL_IP=1`; 0.19 cannot join (its rustls trusts only its bundled roots). Its API is
`DANGER_FEDERATION_ALLOW_LOCAL_IP=1`; 0.19's release can't join as it is, so it runs as its own build (`lemmy19`, below). Its API is
`/api/v4/<path>` with a bearer token (`lm` in the scenario), and `sort` values are lowercase. It logs no refused
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
@@ -623,6 +875,21 @@ tools/pasture/run.sh down # removes e
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.
- **The server's own settings (`scenarios/instance.sh`, needs relay and aoderelay; last in batch A):** promotes
pastureroot (`admin promote`, as `stats_check` does) and edits everything through `/clientapi/admin/settings`, then reads
it back from both instance APIs, NodeInfo, the instance actor (exempt from SecureMode), `/instance/rules`,
`extended_description`, `privacy_policy`, `terms_of_service` (and by date) and the OAuth page; a published (obfuscated)
and an unpublished domain block; a report with `rule_ids` reaching the moderators with the rules' texts; an announcement
listed, dismissed by one persona, reacted to and streamed on `user`; an image limit reaching the instance API and a 1 KB
quota refusing an upload, then reset; the thumbnail; a relay added through the settings (`https://relay.test/inbox`,
beside the configured actor), accepted and removed without ending the configured subscription; the activity once
published; the staff's log listing it all without an address. It saves the settings it found at the start back at the
end (the description's and the policy's dates then say today).
- **Mailpit (1.31.4, `peers/mailpit.sh`, as `mailpit.test`):** the pasture's mail server. `appsettings.Pasture.json`
sends PrivaPub's mail to `pasture-mailpit:1025` (plain SMTP, any login accepted) and its recovery links to
`https://decepub.test`. `scenarios/accounts.sh` reads what arrived through Mailpit's API: an address set with the
password and its code, recovery only to a verified address and its link working once, logout ending one device,
a password change and "sign out everywhere" ending the others, an old hash made again at sign-in, the codes' TTL.
- **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
@@ -768,24 +1035,26 @@ tools/pasture/town.sh backlog --write # docs/INTEROP-BACKLOG.md
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`.
- decePubClient is served as `https://decepub.test` (`peers/decepub.sh`) once published with the Pasture environment
(in decePubClient: `dotnet publish decePubClient.csproj -c Release -p:WasmApplicationEnvironmentName=Pasture -o publish`),
its API then `https://privapub.test`.
After the pasture, verify.funfedi.dev and the owner's GoToSocial at social.arasaka.software. **Ask before acting from
the owner's GoToSocial account.**
## Deploy
- **CI/CD:** push to `master` runs `build.yml` (build + tests) on the instance-wide `build` runner. A `v*` tag runs
`deploy.yml`: tests, self-contained linux-x64 publish, snapshot and `mongodump` to `/var/backups/privapub.thepra.dev`,
- **CI/CD:** push to `master` runs `build.yml` (a build) on the instance-wide `build` runner. A `v*` tag runs
`deploy.yml`: a self-contained linux-x64 publish, a snapshot of the site to `/var/backups/privapub.thepra.dev`,
the server's own pre-deploy backup (`PrivaPub admin backup --kind pre-deploy --db-only`, run from the new build before
its migrations, verified, and checked to hold no statistics salt; no deploy while `restore.json` waits),
stop → rsync → start, a `127.0.0.1:6970/build.json` health loop with rollback, then public checks (actor, NodeInfo,
Swagger 404, inbox junk 400, unsigned 401) and `tools/smoke/mastodon-api.sh` (app registration, client credentials,
discovery, instance, public timeline). Then it checks that what production should be running is running:
- NodeInfo and the instance API agree on registrations (closed, invitations only);
- `/stargazing` says the crawler is on;
- **@thepra signs in**: `PrivaPub admin smoke thepra` gives the root `deploy-smoke` a new password, `tools/smoke/oauth.sh`
runs the real OAuth flow (the pasture's `privapub_token` uses the same script), the signed-in API is checked, the
persona must be undiscoverable, and the token is revoked;
- nothing signs in (owner decision 2026-10-17): the deploy's own login, `deploy-smoke`, gave its @thepra to the
administrator and was deleted, and the signed-in check went with it;
- the DB-IP Lite databases, which the server fetches itself (`GeoUpdater`), exist and are at most 40 days old.
- **The box:** Max (`nuvola.xyz`). Unit `privapub` runs as www-data from `/var/www/privapub.thepra.dev` with
`ASPNETCORE_ENVIRONMENT=Production`.
+55 -20
View File
@@ -65,8 +65,8 @@ Checked both ways, where the peer has the feature:
- communities and circles;
- blocks and unfollows.
`docs/INTEROP.md` has each peer's evidence and what is still expected to fail. The platforms not yet in the pasture are
covered by unit tests written in their documents' shape.
`docs/INTEROP.md` has each peer's evidence and what is still expected to fail. The platforms not in the pasture are
read from documents in their published shape (`docs/INTEROP.md` records each one); there is no test project.
## Supported FEPs
@@ -98,10 +98,19 @@ covered by unit tests written in their documents' shape.
(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")
- [FEP-7888: Demystifying the context property](https://codeberg.org/fediverse/fep/src/branch/main/fep/7888/fep-7888.md)
and [FEP-f228: Backfilling conversations](https://codeberg.org/fediverse/fep/src/branch/main/fep/f228/fep-f228.md):
a root post serves its `context` collection, and a remote thread is read from its `context` (or `contextHistory`)
before its `replies` (see "Activities")
- [FEP-7628: Move actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/7628/fep-7628.md): received; a persona's
follows move with the account (see `Move` under "Activities")
- [FEP-ae0c: Fediverse Relay Protocols](https://codeberg.org/fediverse/fep/src/branch/main/fep/ae0c/fep-ae0c.md): the
instance actor follows the relays configured, and their `Announce`s are taken as their origin vouches (see "Relays")
- [FEP-8967: Generic attachments, link previews](https://codeberg.org/fediverse/fep/src/branch/main/fep/8967/fep-8967.md):
received; a `preview` builds the card
Planned (see `docs/ROADMAP.md`, phases P7 and P8, and the per-platform notes in `docs/INTEROP.md`): FEP-9098 (custom
emoji), FEP-7888 and FEP-f228 (threads), FEP-7628 (Move), FEP-8967 (link
attachments), FEP-ae0c (relays).
Planned (see `docs/ROADMAP.md`, the completion program, and the per-platform notes in `docs/INTEROP.md`): FEP-9098
custom emoji offered (R7) and FEP-8967 link attachments sent (R10).
## Actors
@@ -128,14 +137,19 @@ The names are the project's own and are stable; resolve actors through WebFinger
- 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
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).
`discoverable` is `true` unless it turns that off.
- 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. It also answers at the server's root (`/`) for a request that asks for
is used to read another server's content. Its `name` is the server's name and its `summary` the server's short
description, as the administrator sets them (the server's settings; until then "PrivaPub" and a sentence saying what it
does); NodeInfo's `metadata.nodeName` and `nodeDescription` come from the same settings. 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.
- The reader of feeds is `/peasants/privapub_feeds` (type `Service`, "Feeds read on <host>"), one for the whole server
with its own key. When a persona here follows a feed, the reader follows each community in it, and stops following one
no feed read here holds any more; no community and no feed learns which persona reads it. It does nothing else.
## Groups
@@ -167,7 +181,14 @@ A group is either a **community** or a **circle**.
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`.
`blocked_by` on the community and refuses the persona's posts and replies there until the `Undo`. A post moved to
another community (PieFed's `Announce{Move{object: post, origin, target}}`, relayed by the community it leaves) goes
there with its thread; a move into a community this server hosts is not taken.
- **Feeds** (Lemmy 1.0's multi-communities and PieFed's feeds, `type: Feed`) are read as accounts whose following is
their communities: the feed's `following` collection, read once a day with its counts, and each community in it read in
turn. A persona following a feed is followed through the server (owner decision 2026-10-07): the reader of feeds
follows its communities, the feed itself is sent nothing, and the persona reads the feed's threads in a timeline of
its own (`GET /api/v1/timelines/feed/:id`; the feeds it reads are `GET /api/v1/feeds`), not in its home.
## Activities
@@ -185,6 +206,8 @@ Received:
| `Like` | counted and notified, on posts the liker could see |
| `Dislike` | counted as a downvote (Lemmy, PieFed, Mbin, Friendica); `Undo` takes it back |
| `EmojiReact`, `Like` with an emoji `content` | an emoji reaction (FEP-c0e0; Pleroma, Akkoma, Iceshrimp.NET, Misskey, Sharkey), Unicode or a custom emoji from its `tag`; a `Like` whose content is ❤ stays a favourite; `Undo` takes it back |
| `Warn` (Lemmy 1.0) | a community moderator's warning about a persona's post there becomes the persona's `moderation_warning` notification, believed from the community's own server or an account its moderators collection lists |
| `Resolve{Flag}` (Lemmy 1.0) | a report we sent is marked resolved by the server holding what was reported (kept beside our own moderators' resolution) |
| `Join` | answered with `Ignore`: PrivaPub hosts no events of its own yet (FEP-8a8e) |
| `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin |
| `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back, though a new post its server publishes under that id after the deletion (Gancio numbers events from the last one kept) is kept |
@@ -194,8 +217,14 @@ Received:
| `Block` of a persona | the follows between them end, the blocker's posts and notifications are hidden from the persona, nothing of the persona's is addressed to the blocker, and the relationship says `blocked_by`; `Undo{Block}` lifts it |
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll votes, `EmojiReact` and its `Undo`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
`Reject{Follow}`, `Like`, `Announce` and their `Undo`, `Flag`, `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`.
`Reject{Follow}`, `Like`, `Dislike`, `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`.
- **Votes.** A favourite is a `Like`, a downvote a `Dislike`; a persona has one vote a post, and a changed vote is sent
as the new vote alone, as Lemmy sends one (an `Undo` of the old one beside it could arrive after it and take the new
one away). `Undo` is sent only when the vote is taken back. A vote on a post in a remote community goes to the
community, which counts it and passes it on, as Lemmy sends one, and to the post's author only when it lives on
another server: one copy a server, since PieFed drops a second copy of an activity it has just seen.
- **Attachments** are `Document`s with `mediaType`, `name` (alt text), `blurhash`, `focalPoint`, `width` and `height`.
Uploaded files have all metadata removed.
@@ -227,7 +256,9 @@ persona's posts passed on to its followers. A deleted post answers 410 with a `T
- **Pinned posts** are the actor's `featured` collection (`/trophies`); `featuredTags` is `/tattoos`. A pin or an unpin
is told to the post's audience as `Add` or `Remove` on `featured`, as Mastodon tells it.
- **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it
followed); an unblock sends `Undo{Block}`.
followed); an unblock sends `Undo{Block}`. A server's block of a whole server sends that server nothing; the
administrator may list it at `/api/v1/instance/domain_blocks` (off for each block by default, its name partly hidden
when asked, with its SHA-256 digest), as Mastodon lists its blocks.
- **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`.
@@ -337,10 +368,11 @@ days and shared by every account on the server. Images are served to clients onl
## 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
PrivaPub reads from the relays `Federation:Relays` names, and those its administrator adds in the server's settings (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, every six hours and at once when the administrator adds
or removes one: an unanswered or refused subscription is asked again a day later, and a relay no longer named gets the
`Undo`, unless the same relay is still named under another address). 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
@@ -392,12 +424,15 @@ Posts with a location (shown to nearby users of this server) never leave the ser
- **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three
redirects and read at most 1 MB.
- **Threads.** A reply's missing parents are fetched, up to 10 levels. When someone here opens a public remote thread,
its replies are read from their servers, at most once an hour: the thread's `context` collection (FEP-7888) if it has
one, else its `replies` (PeerTube's `comments`) and theirs, two levels down; 5 pages and 100 posts at most. Only
public and unlisted replies are kept, each fetched from its own origin.
its replies are read from their servers, at most once an hour: the thread's conversation container (FEP-171b's
`contextHistory`: the posts its owner's `Add`s brought in) or its `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. A thread collection read whole, which counts its posts or
holds them all, is asked again with `If-None-Match` and its last ETag; a 304 ends the read.
- **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.
Mastodon's authorized fetch; the server's own actors (the instance actor `/peasants/privapub`, the reporter
`/peasants/privapub_reports` and the reader of feeds `/peasants/privapub_feeds`) 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
@@ -12,6 +12,9 @@ namespace PrivaPub.ClientModels.Admin
public bool Suspend { get; set; } = true;
public bool RejectMedia { get; set; }
// listed at /api/v1/instance/domain_blocks (off by default: it tells the blocked server, and everyone, of the block)
public bool Published { get; set; } = false;
public bool Obfuscate { get; set; } = false;//listed with part of its name hidden
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string PublicComment { get; set; }
@@ -28,6 +31,8 @@ namespace PrivaPub.ClientModels.Admin
public bool RejectMedia { get; set; }
public string PublicComment { get; set; }
public string PrivateComment { get; set; }
public bool Published { get; set; } = false;
public bool Obfuscate { get; set; } = false;
public DateTime CreatedAt { get; set; }
}
}
@@ -0,0 +1,26 @@
namespace PrivaPub.ClientModels.Admin
{
// the staff's log, newest first (GET /clientapi/moderator/log?before=&limit=): Next is the `before` of the next page,
// null on the last
public class ViewAdminActionsPage
{
public List<ViewAdminAction> Items { get; set; } = [];
public string Next { get; set; }
}
public class ViewAdminAction
{
public string Id { get; set; }
public DateTime At { get; set; }
// who acted: a login name (to administrators; to a moderator only its own), "cli" for the command line, null when not
// told (an act on a persona of the acting login keeps nobody's name)
public string By { get; set; }
public bool ByYou { get; set; } = false;
public string Action { get; set; }//e.g. domain_block.insert, settings.update, report.resolve, root.promote
public string TargetKind { get; set; }//root, persona, account, domain, report, settings, rule, announcement, emoji, relay, backup
public string TargetId { get; set; }
public string TargetLabel { get; set; }
public string ReportId { get; set; }
public string Note { get; set; }
}
}
@@ -0,0 +1,47 @@
using PrivaPub.ClientModels.Resources;
using System.ComponentModel.DataAnnotations;
namespace PrivaPub.ClientModels.Admin
{
// an announcement as the administrator's page shows it (/clientapi/admin/announcements/list): its Markdown and what it
// renders to, whether it is published and shown now, how many personas dismissed it and its reactions
public class ViewAnnouncement
{
public string Id { get; set; }
public string Text { get; set; }
public string Html { get; set; }
public DateTime? StartsAt { get; set; }
public DateTime? EndsAt { get; set; }
public bool AllDay { get; set; } = false;
public bool IsPublished { get; set; } = false;
public bool IsShown { get; set; } = false;//published and not ended
public DateTime? PublishedAt { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public long Reads { get; set; } = 0;
public List<ViewAnnouncementReaction> Reactions { get; set; } = [];
}
public class ViewAnnouncementReaction
{
public string Name { get; set; }
public long Count { get; set; } = 0;
}
// insert (no Id) and update (its Id)
public class AnnouncementForm
{
[StringLength(64, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Id { get; set; }
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(5000, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Text { get; set; }//Markdown
public DateTime? StartsAt { get; set; }
public DateTime? EndsAt { get; set; }//once past, it is no longer shown
public bool AllDay { get; set; } = false;
public bool IsPublished { get; set; } = false;
}
}
+103
View File
@@ -0,0 +1,103 @@
using PrivaPub.ClientModels.Resources;
using System.ComponentModel.DataAnnotations;
namespace PrivaPub.ClientModels.Admin
{
// the server's backups as the administrator's page shows them, with what is running and the last restore
public class ViewBackups
{
public List<ViewBackup> Backups { get; set; } = [];
public string Running { get; set; }//"backup" or "restore" while one runs
public ViewRestoreWaiting Waiting { get; set; }
public ViewRestore LastRestore { get; set; }
public string Host { get; set; }//what a restore asks to be typed
public long FreeBytes { get; set; }
}
public class ViewBackup
{
public string Id { get; set; }
public string Kind { get; set; }//nightly, manual, pre-deploy, pre-restore, uploaded
public DateTime CreatedAt { get; set; }
public long Bytes { get; set; }//the database's files
public long MediaBytes { get; set; }
public int MediaFiles { get; set; }
public int MediaMissing { get; set; }
public bool DbOnly { get; set; }
public bool Consistent { get; set; }
public int Collections { get; set; }
public long Documents { get; set; }
public string AppCommit { get; set; }
public int MigrationNumber { get; set; }
public int RootsDeletedSince { get; set; }//logins deleted after it, which a restore deletes again
public string NotRestorable { get; set; }//why not, from its manifest; hashes are checked when asked
}
public class ViewRestoreWaiting
{
public string Backup { get; set; }
public string State { get; set; }
public int Attempts { get; set; }
public string RequestedBy { get; set; }
public DateTime RequestedAt { get; set; }
public string Error { get; set; }
}
public class ViewRestore
{
public string Backup { get; set; }
public DateTime BackupCreatedAt { get; set; }
public string PreRestore { get; set; }
public string RequestedBy { get; set; }
public DateTime RestoredAt { get; set; }
public bool Abandoned { get; set; }
public string Error { get; set; }
public long Documents { get; set; }
public int Collections { get; set; }
public int MediaRestored { get; set; }
public int MediaMissing { get; set; }
public int RootsDeleted { get; set; }
public int PersonasDeleted { get; set; }
public int GroupsDeleted { get; set; }
public int PostsDeleted { get; set; }
public List<string> RootsTombstoned { get; set; } = [];
public List<string> PersonasTombstoned { get; set; } = [];
public List<string> GroupsTombstoned { get; set; } = [];
public int PostsGone { get; set; }
public int MediaTrashed { get; set; }
public int ProtectiveKept { get; set; }
public int SessionsEnded { get; set; }
}
// a backup downloaded or restored: the administrator's password again, and for a restore the server's host typed out
public class BackupPasswordForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(200, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Password { get; set; }
}
public class RestoreBackupForm : BackupPasswordForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(253, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Host { get; set; }
}
// a download link good for a while, for one backup
public class ViewBackupTicket
{
public string Url { get; set; }
public DateTime ExpiresAt { get; set; }
public long Bytes { get; set; }
}
// a backup being uploaded in pieces: how much has arrived
public class ViewBackupUpload
{
public string Id { get; set; }
public long Received { get; set; }
public long ChunkBytes { get; set; }//the largest piece accepted
}
}
@@ -11,10 +11,14 @@ namespace PrivaPub.ClientModels.Admin
public List<string> PostIds { get; set; } = new();
public List<string> ObjectURIs { get; set; } = new();
public string Category { get; set; }
public List<string> RuleIds { get; set; } = [];//the server's rules it says were broken
public List<string> Rules { get; set; } = [];//their texts, in the same order (a deleted rule's too)
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; }
public DateTime? RemoteResolvedAt { get; set; }//the server holding what was reported said it dealt with it
public string RemoteResolvedBy { get; set; }
}
}
@@ -0,0 +1,167 @@
using PrivaPub.ClientModels.Resources;
using System.ComponentModel.DataAnnotations;
namespace PrivaPub.ClientModels.Admin
{
// The server's own settings as the administrator's page shows them (GET /clientapi/admin/settings): what the instance
// API, NodeInfo, the instance actor, the web pages and the emails say of the server, its rules, its relays, its media
// limits next to the configured ones, and whether its weekly activity is published. Registrations are read-only: they
// stay in the configuration (Registrations:Mode).
public class ViewServerSettings
{
public string Title { get; set; }
public string ShortDescription { get; set; }
public string Description { get; set; }//Markdown
public DateTime? DescriptionUpdatedAt { get; set; }
public string ContactEmail { get; set; }
public string ContactAccountId { get; set; }
public ViewContactAccount ContactAccount { get; set; }//null when none is set, or it is gone
public string ThumbnailUrl { get; set; }//what the instance API shows (the default picture when none was uploaded)
public bool ThumbnailIsDefault { get; set; } = true;
public string PrivacyPolicy { get; set; }//Markdown
public DateTime? PrivacyPolicyUpdatedAt { get; set; }
public string TermsOfService { get; set; }//Markdown; null: none
public DateTime? TermsEffectiveAt { get; set; }
public List<ViewServerRule> Rules { get; set; } = [];//deleted ones too (IsDeleted), as old reports name them
public List<ViewRelay> Relays { get; set; } = [];
public ViewMediaLimits Media { get; set; } = new();
public string RegistrationMode { get; set; }//"Invitations" or "Open", from the configuration
public bool PublishActivity { get; set; } = false;
public DateTime? UpdatedAt { get; set; }//null: never saved, every value is the default
public string UpdatedBy { get; set; }//the administrator's login, or "cli"
}
public class ViewContactAccount
{
public string Id { get; set; }
public string UserName { get; set; }
public string Name { get; set; }
public string Handle { get; set; }//user@host
}
public class ViewServerRule
{
public string Id { get; set; }
public string Text { get; set; }
public string Hint { get; set; }
public int Position { get; set; } = 0;
public bool IsDeleted { get; set; } = false;
public DateTime? DeletedAt { get; set; }
}
// the administrator's overrides (null: the configured value holds), what the configuration says, and the hard caps
public class ViewMediaLimits
{
public long? QuotaBytesPerRoot { get; set; }
public long? MaxImageBytes { get; set; }
public long? MaxVideoBytes { get; set; }
public int? MaxSeconds { get; set; }
public long ConfiguredQuotaBytesPerRoot { get; set; } = 0;//0: no quota
public long ConfiguredMaxImageBytes { get; set; } = 0;
public long ConfiguredMaxVideoBytes { get; set; } = 0;
public int ConfiguredMaxSeconds { get; set; } = 0;
public long EffectiveQuotaBytesPerRoot { get; set; } = 0;
public long EffectiveMaxImageBytes { get; set; } = 0;
public long EffectiveMaxVideoBytes { get; set; } = 0;
public int EffectiveMaxSeconds { get; set; } = 0;
public long HardMaxImageBytes { get; set; } = ServerMediaLimitsForm.HardMaxImageBytes;
public long HardMaxVideoBytes { get; set; } = ServerMediaLimitsForm.HardMaxVideoBytes;
public int HardMaxSeconds { get; set; } = ServerMediaLimitsForm.HardMaxSeconds;
}
// POST /clientapi/admin/settings: everything the page edits at once (relays and the thumbnail have their own routes).
// A rule keeps its id when the form names it; one without an id is new; one left out is deleted (kept, marked).
public class ServerSettingsForm
{
public const int MaxRules = 50;
public const int MaxText = 20000;
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(64, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "Title", ResourceType = typeof(FieldsNameResource))]
public string Title { get; set; }
[StringLength(500, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
public string ShortDescription { get; set; }
[StringLength(MaxText, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "Description", ResourceType = typeof(FieldsNameResource))]
public string Description { get; set; }
[EmailAddress(ErrorMessageResourceName = "InvalidEmail", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(254, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "Email", ResourceType = typeof(FieldsNameResource))]
public string ContactEmail { get; set; }
[StringLength(64, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
public string ContactAccountId { get; set; }//a local persona's account id (its Mastodon id), or none
[StringLength(MaxText, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
public string PrivacyPolicy { get; set; }
[StringLength(MaxText, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
public string TermsOfService { get; set; }//empty: none
public DateTime? TermsEffectiveAt { get; set; }//a date; today when terms are given without one
[MaxLength(MaxRules, ErrorMessageResourceName = "MaxLengthArray", ErrorMessageResourceType = typeof(ErrorsResource))]
public List<ServerRuleForm> Rules { get; set; } = [];
public ServerMediaLimitsForm Media { get; set; } = new();
public bool PublishActivity { get; set; } = false;
}
public class ServerRuleForm
{
[StringLength(64, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Id { get; set; }//an existing rule's id, or none for a new rule
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(500, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Text { get; set; }
[StringLength(1000, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Hint { get; set; }
}
// null keeps the configured value
public class ServerMediaLimitsForm
{
public const long HardMaxImageBytes = 40L * 1024 * 1024;
public const long HardMaxVideoBytes = 99L * 1024 * 1024;
public const int HardMaxSeconds = 4 * 3600;
[Range(0L, long.MaxValue, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
public long? QuotaBytesPerRoot { get; set; }//0: no quota
[Range(1L, HardMaxImageBytes, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
public long? MaxImageBytes { get; set; }
[Range(1L, HardMaxVideoBytes, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
public long? MaxVideoBytes { get; set; }
[Range(1, HardMaxSeconds, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
public int? MaxSeconds { get; set; }
}
// a relay the instance actor subscribes to: named by the configuration (Federation:Relays, removed only there) or by
// the settings (added and removed from the page)
public class ViewRelay
{
public string Address { get; set; }
public string Source { get; set; }//"configuration" or "settings"
public string State { get; set; }//"pending", "accepted", "rejected", or "none" before its first request
public string ActorURI { get; set; }
public DateTime? RequestedAt { get; set; }
public DateTime? AnsweredAt { get; set; }
}
public class RelayForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(2048, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Address { get; set; }//the relay's actor (https://relay.example/actor) or its inbox
}
}
@@ -12,6 +12,16 @@ namespace PrivaPub.ClientModels.Admin
public ViewHostCounts Hosts { get; set; } = new();
public int Accounts { get; set; }
public ViewLedgerCounts Ledger { get; set; } = new();
public ViewMediaTotals Media { get; set; } = new();
}
// what media take on the server: the uploads and pictures kept, the remote media cache, the trash waiting its grace
public class ViewMediaTotals
{
public long MediaBytes { get; set; }
public long MediaFiles { get; set; }
public long ProxyCacheBytes { get; set; }
public long TrashBytes { get; set; }
}
public class ViewLatency
@@ -86,4 +96,96 @@ namespace PrivaPub.ClientModels.Admin
public string CrawlError { get; set; }
public string Software { get; set; }
}
// /clientapi/admin/statistics/hosts/{host}: one server, what it says of itself, its days and its latest events
public class ViewHostDetail
{
public string Host { get; set; }
public ViewHostInstance Instance { get; set; }
public List<ViewHostDay> Days { get; set; } = [];
public List<ViewInteractionEvent> Events { get; set; } = [];
public string Attribution { get; set; }
}
public class ViewHostInstance
{
public string Software { get; set; }
public string SoftwareVersion { get; set; }
public string NodeName { get; set; }
public string Title { get; set; }
public string NodeDescription { get; set; }
public bool? OpenRegistrations { get; set; }
public string RegistrationMode { get; set; }
public long? UsersTotal { get; set; }
public long? UsersActiveMonth { get; set; }
public long? LocalPosts { get; set; }
public List<string> Languages { get; set; } = [];
public string Seen { get; set; }
public DateTime? FirstSeenAt { get; set; }
public DateTime? LastSeenAt { get; set; }
public DateTime? DescribedAt { get; set; }
public string DescriptionError { get; set; }
public DateTime? LastSuccessAt { get; set; }
public DateTime? LastFailureAt { get; set; }
public string LastError { get; set; }
public int ConsecutiveFailures { get; set; }
public DateTime? UnavailableUntil { get; set; }
public string Country { get; set; }
public string City { get; set; }
public int? Asn { get; set; }
public string AsnOrg { get; set; }
public string Cdn { get; set; }
public string InstanceApi { get; set; }
public string SourceUrl { get; set; }
}
public class ViewHostDay
{
public DateTime Day { get; set; }
public Dictionary<string, long> Counters { get; set; } = new();
public Dictionary<string, long> Latency { get; set; } = new();
public Dictionary<string, long> Bytes { get; set; } = new();
public Dictionary<string, long> Reads { get; set; } = new();
public int Accounts { get; set; }
public bool Live { get; set; }//today's or a day not rolled up yet, folded from its events
}
// one ledger entry: what was received, delivered or fetched, and how it ended; never who
public class ViewInteractionEvent
{
public DateTime At { get; set; }
public string Host { get; set; }
public string Channel { get; set; }
public string Activity { get; set; }
public string Object { get; set; }
public string Purpose { get; set; }
public string Trigger { get; set; }
public string Outcome { get; set; }
public string Reason { get; set; }
public int? Status { get; set; }
public int? LatencyMs { get; set; }
public int? WaitMs { get; set; }
public long? Bytes { get; set; }
public int? Attempt { get; set; }
public string Audience { get; set; }
public string LocalKind { get; set; }
public string Inbox { get; set; }
public string Signature { get; set; }
public List<string> Features { get; set; } = [];
public long? AgeSeconds { get; set; }
}
// /clientapi/admin/statistics/server: the server's own day, its client API and the documents it served
public class ViewServerDay
{
public DateTime Day { get; set; }
public Dictionary<string, long> Client { get; set; } = new();
public Dictionary<string, long> ClientLatency { get; set; } = new();
public Dictionary<string, long> Served { get; set; } = new();
public long LedgerWritten { get; set; }
public long LedgerDropped { get; set; }
public long LedgerFailed { get; set; }
public int Accounts { get; set; }
public int Hosts { get; set; }
}
}
+5
View File
@@ -34,5 +34,10 @@ namespace PrivaPub.ClientModels
public bool ThemeIsDarkMode { get; set; } = false;
public string InvitationPassword { get; set; }
// the authenticator app's code, or one of the recovery codes, for a login with two-factor sign-in; asked for with a
// 428 once the password was right
[StringLength(32, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string OneTimeCode { get; set; }
}
}
@@ -60,132 +60,6 @@ namespace PrivaPub.ClientModels.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to (necessary in case of password recovery).
/// </summary>
public static string _necessary_in_case_of_password_recovery_ {
get {
return ResourceManager.GetString("(necessary in case of password recovery)", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to (&lt;a class=&quot;is-underlined has-text-info&quot; tabindex=&quot;-1&quot; target=&quot;_blank&quot; href=&quot;https://keepassxc.org/&quot;&gt;password manager&lt;/a&gt; suggested).
/// </summary>
public static string _password_manager_suggested_ {
get {
return ResourceManager.GetString("(password manager suggested)", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Amount.
/// </summary>
public static string Amount {
get {
return ResourceManager.GetString("Amount", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Answer id.
/// </summary>
public static string AnswerId {
get {
return ResourceManager.GetString("AnswerId", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Answer text.
/// </summary>
public static string AnswerText {
get {
return ResourceManager.GetString("AnswerText", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Custom character(emoji).
/// </summary>
public static string Character {
get {
return ResourceManager.GetString("Character", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Colour.
/// </summary>
public static string ColourIndex {
get {
return ResourceManager.GetString("ColourIndex", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Comment id.
/// </summary>
public static string CommentId {
get {
return ResourceManager.GetString("CommentId", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Comment text.
/// </summary>
public static string CommentText {
get {
return ResourceManager.GetString("CommentText", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Confrontation.
/// </summary>
public static string Confrontation {
get {
return ResourceManager.GetString("Confrontation", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Confrontation id.
/// </summary>
public static string ConfrontationId {
get {
return ResourceManager.GetString("ConfrontationId", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Context.
/// </summary>
public static string Context {
get {
return ResourceManager.GetString("Context", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to End date.
/// </summary>
public static string DateEnd {
get {
return ResourceManager.GetString("DateEnd", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Start date.
/// </summary>
public static string DateStart {
get {
return ResourceManager.GetString("DateStart", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Description.
/// </summary>
@@ -195,60 +69,6 @@ namespace PrivaPub.ClientModels.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Discussion.
/// </summary>
public static string Discussion {
get {
return ResourceManager.GetString("Discussion", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to End date.
/// </summary>
public static string DiscussionEndDate {
get {
return ResourceManager.GetString("DiscussionEndDate", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to End time.
/// </summary>
public static string DiscussionEndTime {
get {
return ResourceManager.GetString("DiscussionEndTime", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Discussion id.
/// </summary>
public static string DiscussionId {
get {
return ResourceManager.GetString("DiscussionId", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Discussion text.
/// </summary>
public static string DiscussionText {
get {
return ResourceManager.GetString("DiscussionText", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Discussion title.
/// </summary>
public static string DiscussionTitle {
get {
return ResourceManager.GetString("DiscussionTitle", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Email.
/// </summary>
@@ -267,42 +87,6 @@ namespace PrivaPub.ClientModels.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Max amount of confrontations.
/// </summary>
public static string ExpectedMaxConfrontationsBeforeClosingAndShowing {
get {
return ResourceManager.GetString("ExpectedMaxConfrontationsBeforeClosingAndShowing", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Fact.
/// </summary>
public static string Fact {
get {
return ResourceManager.GetString("Fact", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Facts.
/// </summary>
public static string Facts {
get {
return ResourceManager.GetString("Facts", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to File id.
/// </summary>
public static string FileId {
get {
return ResourceManager.GetString("FileId", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Groups.
/// </summary>
@@ -312,15 +96,6 @@ namespace PrivaPub.ClientModels.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Include the first answer in the invitation preview? (making it more engaging to join the discussion).
/// </summary>
public static string IncludeFirstAnswerInInvitation {
get {
return ResourceManager.GetString("IncludeFirstAnswerInInvitation", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Invitation code.
/// </summary>
@@ -330,15 +105,6 @@ namespace PrivaPub.ClientModels.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Invitation code or link.
/// </summary>
public static string InvitationCodeLink {
get {
return ResourceManager.GetString("InvitationCodeLink", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Invitation password.
/// </summary>
@@ -348,42 +114,6 @@ namespace PrivaPub.ClientModels.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Require password for preview.
/// </summary>
public static string IsPasswordProtectedForPreview {
get {
return ResourceManager.GetString("IsPasswordProtectedForPreview", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Require password to show the preview.
/// </summary>
public static string IsPasswordProtectedForPreviewText {
get {
return ResourceManager.GetString("IsPasswordProtectedForPreviewText", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Add password protection.
/// </summary>
public static string IsPasswordProtectedText {
get {
return ResourceManager.GetString("IsPasswordProtectedText", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to IsStructured.
/// </summary>
public static string IsStructured {
get {
return ResourceManager.GetString("IsStructured", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Language id.
/// </summary>
@@ -426,15 +156,6 @@ namespace PrivaPub.ClientModels.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Enable notifications?(useful to receive app updates).
/// </summary>
public static string NativeNotificationsEnabled {
get {
return ResourceManager.GetString("NativeNotificationsEnabled", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to New password.
/// </summary>
@@ -462,15 +183,6 @@ namespace PrivaPub.ClientModels.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to One time only.
/// </summary>
public static string OneTime {
get {
return ResourceManager.GetString("OneTime", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Password.
/// </summary>
@@ -480,24 +192,6 @@ namespace PrivaPub.ClientModels.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Perception.
/// </summary>
public static string Perception {
get {
return ResourceManager.GetString("Perception", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Perceptions.
/// </summary>
public static string Perceptions {
get {
return ResourceManager.GetString("Perceptions", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Prefer system theme.
/// </summary>
@@ -516,96 +210,6 @@ namespace PrivaPub.ClientModels.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Recurring.
/// </summary>
public static string Recurring {
get {
return ResourceManager.GetString("Recurring", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Scan for:.
/// </summary>
public static string Scan_for_ {
get {
return ResourceManager.GetString("Scan for:", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Search.
/// </summary>
public static string SearchText {
get {
return ResourceManager.GetString("SearchText", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Sharing Secret Code.
/// </summary>
public static string ShareCode {
get {
return ResourceManager.GetString("ShareCode", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Show donator badge.
/// </summary>
public static string ShowDonatorBadge {
get {
return ResourceManager.GetString("ShowDonatorBadge", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Show the logs tab to support troubleshooting..
/// </summary>
public static string ShowLogs {
get {
return ResourceManager.GetString("ShowLogs", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Take type.
/// </summary>
public static string TakeType {
get {
return ResourceManager.GetString("TakeType", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Simple.
/// </summary>
public static string TakeTypeSimple {
get {
return ResourceManager.GetString("TakeTypeSimple", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Structured.
/// </summary>
public static string TakeTypeStructured {
get {
return ResourceManager.GetString("TakeTypeStructured", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Theme image.
/// </summary>
public static string Theme_image {
get {
return ResourceManager.GetString("Theme image", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Dark mode.
/// </summary>
@@ -615,15 +219,6 @@ namespace PrivaPub.ClientModels.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Ticks.
/// </summary>
public static string Ticks {
get {
return ResourceManager.GetString("Ticks", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Title.
/// </summary>
@@ -633,24 +228,6 @@ namespace PrivaPub.ClientModels.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Update reason.
/// </summary>
public static string UpdateReason {
get {
return ResourceManager.GetString("UpdateReason", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Submit anonymously.
/// </summary>
public static string UpsertUserTakeIsAnonymous {
get {
return ResourceManager.GetString("UpsertUserTakeIsAnonymous", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Username.
/// </summary>
@@ -659,23 +236,5 @@ namespace PrivaPub.ClientModels.Resources {
return ResourceManager.GetString("Username", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Username to search and add.
/// </summary>
public static string Username_to_search_and_add {
get {
return ResourceManager.GetString("Username to search and add", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Usernames to search and add.
/// </summary>
public static string Usernames_to_search_and_add {
get {
return ResourceManager.GetString("Usernames to search and add", resourceCulture);
}
}
}
}
@@ -117,123 +117,30 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="(necessary in case of password recovery)" xml:space="preserve">
<value>(необходимо в случай на възстановяване на парола)</value>
</data>
<data name="(password manager suggested)" xml:space="preserve">
<value>(&lt;a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/"&gt;предложен мениджър на пароли&lt;/a&gt;)</value>
</data>
<data name="Amount" xml:space="preserve">
<value>Сума</value>
</data>
<data name="AnswerId" xml:space="preserve">
<value>Отговор id</value>
</data>
<data name="AnswerText" xml:space="preserve">
<value>Текст на отговора</value>
</data>
<data name="Character" xml:space="preserve">
<value>Персонализиран символ (емотикон)</value>
</data>
<data name="ColourIndex" xml:space="preserve">
<value>Цвят</value>
</data>
<data name="CommentId" xml:space="preserve">
<value>Коментар id</value>
</data>
<data name="CommentText" xml:space="preserve">
<value>Текст на коментара</value>
</data>
<data name="Confrontation" xml:space="preserve">
<value>Конфронтация</value>
</data>
<data name="ConfrontationId" xml:space="preserve">
<value>Конфронтация id</value>
</data>
<data name="Context" xml:space="preserve">
<value>Контекст</value>
</data>
<data name="DateEnd" xml:space="preserve">
<value>Крайна дата</value>
</data>
<data name="DateStart" xml:space="preserve">
<value>Начална дата</value>
</data>
<data name="Description" xml:space="preserve">
<value>Описание</value>
</data>
<data name="Discussion" xml:space="preserve">
<value>Дискусия</value>
</data>
<data name="DiscussionEndDate" xml:space="preserve">
<value>Крайна дата</value>
</data>
<data name="DiscussionEndTime" xml:space="preserve">
<value>Краен час</value>
</data>
<data name="DiscussionId" xml:space="preserve">
<value>Дискусия id</value>
</data>
<data name="DiscussionText" xml:space="preserve">
<value>Текст за обсъждане</value>
</data>
<data name="DiscussionTitle" xml:space="preserve">
<value>Заглавие на дискусията</value>
</data>
<data name="Email" xml:space="preserve">
<value>Имейл</value>
</data>
<data name="Emoji" xml:space="preserve">
<value>Емотикони</value>
</data>
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
<value>Максимален брой конфронтации</value>
</data>
<data name="Fact" xml:space="preserve">
<value>Факт</value>
</data>
<data name="Facts" xml:space="preserve">
<value>Факти</value>
</data>
<data name="FileId" xml:space="preserve">
<value>Идентификатор на файла</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Групи</value>
</data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>Да включите първия отговор в предварителния преглед на поканата? (за да се включите в дискусията по-ангажиращо)</value>
</data>
<data name="InvitationCode" xml:space="preserve">
<value>Код на поканата</value>
</data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Код или връзка за покана</value>
</data>
<data name="InvitationPassword" xml:space="preserve">
<value>Парола за покана</value>
</data>
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
<value>Изискване на парола за предварителен преглед</value>
</data>
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
<value>Изискване на парола за показване на предварителния преглед</value>
</data>
<data name="IsPasswordProtectedText" xml:space="preserve">
<value>Добавяне на защита с парола</value>
</data>
<data name="IsStructured" xml:space="preserve">
<value>IsStructured</value>
</data>
<data name="LanguageId" xml:space="preserve">
<value>Идентификатор на езика</value>
</data>
<data name="Name" xml:space="preserve">
<value>Име</value>
</data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Активиране на известията?(полезно за получаване на актуализации на приложението)</value>
</data>
<data name="NewPassword" xml:space="preserve">
<value>Нова парола</value>
</data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve">
<value>Стара парола</value>
</data>
<data name="OneTime" xml:space="preserve">
<value>Само веднъж</value>
</data>
<data name="Password" xml:space="preserve">
<value>Парола</value>
</data>
<data name="Perception" xml:space="preserve">
<value>Възприемане</value>
</data>
<data name="Perceptions" xml:space="preserve">
<value>Възприятия</value>
</data>
<data name="PreferSystemTheming" xml:space="preserve">
<value>Предпочитайте темата на системата</value>
</data>
<data name="RecoveryCode" xml:space="preserve">
<value>Код за възстановяване</value>
</data>
<data name="Recurring" xml:space="preserve">
<value>Повтарящи се</value>
</data>
<data name="Scan for:" xml:space="preserve">
<value>Сканиране за:</value>
</data>
<data name="SearchText" xml:space="preserve">
<value>Търсене</value>
</data>
<data name="ShareCode" xml:space="preserve">
<value>Споделяне на тайния код</value>
</data>
<data name="ShowDonatorBadge" xml:space="preserve">
<value>Покажи значката на дарителя</value>
</data>
<data name="ShowLogs" xml:space="preserve">
<value>Покажете раздела с дневници, за да подпомогнете отстраняването на неизправности.</value>
</data>
<data name="TakeType" xml:space="preserve">
<value>Вид на вземането</value>
</data>
<data name="TakeTypeSimple" xml:space="preserve">
<value>Прост</value>
</data>
<data name="TakeTypeStructured" xml:space="preserve">
<value>Структуриран</value>
</data>
<data name="Theme image" xml:space="preserve">
<value>Изображение на темата</value>
</data>
<data name="ThemeIsDarkMode" xml:space="preserve">
<value>Тъмен режим</value>
</data>
<data name="Ticks" xml:space="preserve">
<value>Кърлежи</value>
</data>
<data name="Title" xml:space="preserve">
<value>Заглавие</value>
</data>
<data name="UpdateReason" xml:space="preserve">
<value>Причина за актуализиране</value>
</data>
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
<value>Подайте анонимно</value>
</data>
<data name="Username" xml:space="preserve">
<value>Потребителско име</value>
</data>
<data name="Username to search and add" xml:space="preserve">
<value>Потребителско име за търсене и добавяне</value>
</data>
<data name="Usernames to search and add" xml:space="preserve">
<value>Потребителски имена за търсене и добавяне</value>
</data>
</root>
@@ -117,123 +117,30 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="(necessary in case of password recovery)" xml:space="preserve">
<value>(nutné v případě obnovení hesla)</value>
</data>
<data name="(password manager suggested)" xml:space="preserve">
<value>(&lt;a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/"&gt;správce hesel&lt;/a&gt; navrženo)</value>
</data>
<data name="Amount" xml:space="preserve">
<value>Částka</value>
</data>
<data name="AnswerId" xml:space="preserve">
<value>Odpověď id</value>
</data>
<data name="AnswerText" xml:space="preserve">
<value>Text odpovědi</value>
</data>
<data name="Character" xml:space="preserve">
<value>Vlastní znak(emoji)</value>
</data>
<data name="ColourIndex" xml:space="preserve">
<value>Barva</value>
</data>
<data name="CommentId" xml:space="preserve">
<value>Komentář id</value>
</data>
<data name="CommentText" xml:space="preserve">
<value>Text komentáře</value>
</data>
<data name="Confrontation" xml:space="preserve">
<value>Konfrontace</value>
</data>
<data name="ConfrontationId" xml:space="preserve">
<value>Id konfrontace</value>
</data>
<data name="Context" xml:space="preserve">
<value>Kontext</value>
</data>
<data name="DateEnd" xml:space="preserve">
<value>Datum ukončení</value>
</data>
<data name="DateStart" xml:space="preserve">
<value>Datum zahájení</value>
</data>
<data name="Description" xml:space="preserve">
<value>Popis</value>
</data>
<data name="Discussion" xml:space="preserve">
<value>Diskuse</value>
</data>
<data name="DiscussionEndDate" xml:space="preserve">
<value>Datum ukončení</value>
</data>
<data name="DiscussionEndTime" xml:space="preserve">
<value>Čas ukončení</value>
</data>
<data name="DiscussionId" xml:space="preserve">
<value>Diskuze id</value>
</data>
<data name="DiscussionText" xml:space="preserve">
<value>Text k diskusi</value>
</data>
<data name="DiscussionTitle" xml:space="preserve">
<value>Název diskuse</value>
</data>
<data name="Email" xml:space="preserve">
<value>E-mail</value>
</data>
<data name="Emoji" xml:space="preserve">
<value>Emoji</value>
</data>
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
<value>Maximální počet konfrontací</value>
</data>
<data name="Fact" xml:space="preserve">
<value>Fakta</value>
</data>
<data name="Facts" xml:space="preserve">
<value>Fakta</value>
</data>
<data name="FileId" xml:space="preserve">
<value>Identifikátor souboru</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Skupiny</value>
</data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>Zahrnout první odpověď do náhledu pozvánky? (abyste se mohli zapojit do diskuse)</value>
</data>
<data name="InvitationCode" xml:space="preserve">
<value>Kód pozvánky</value>
</data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Pozvánkový kód nebo odkaz</value>
</data>
<data name="InvitationPassword" xml:space="preserve">
<value>Heslo pozvánky</value>
</data>
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
<value>Vyžadovat heslo pro náhled</value>
</data>
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
<value>Vyžadování hesla pro zobrazení náhledu</value>
</data>
<data name="IsPasswordProtectedText" xml:space="preserve">
<value>Přidání ochrany heslem</value>
</data>
<data name="IsStructured" xml:space="preserve">
<value>IsStructured</value>
</data>
<data name="LanguageId" xml:space="preserve">
<value>Identifikace jazyka</value>
</data>
<data name="Name" xml:space="preserve">
<value>Název</value>
</data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Povolit oznámení?(užitečné pro příjem aktualizací aplikace)</value>
</data>
<data name="NewPassword" xml:space="preserve">
<value>Nové heslo</value>
</data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve">
<value>Staré heslo</value>
</data>
<data name="OneTime" xml:space="preserve">
<value>Pouze jednou</value>
</data>
<data name="Password" xml:space="preserve">
<value>Heslo</value>
</data>
<data name="Perception" xml:space="preserve">
<value>Vnímání</value>
</data>
<data name="Perceptions" xml:space="preserve">
<value>Vnímání</value>
</data>
<data name="PreferSystemTheming" xml:space="preserve">
<value>Preferovat systémové téma</value>
</data>
<data name="RecoveryCode" xml:space="preserve">
<value>Kód obnovy</value>
</data>
<data name="Recurring" xml:space="preserve">
<value>Opakující se</value>
</data>
<data name="Scan for:" xml:space="preserve">
<value>Vyhledat:</value>
</data>
<data name="SearchText" xml:space="preserve">
<value>Vyhledávání</value>
</data>
<data name="ShareCode" xml:space="preserve">
<value>Sdílení tajného kódu</value>
</data>
<data name="ShowDonatorBadge" xml:space="preserve">
<value>Zobrazit odznak dárce</value>
</data>
<data name="ShowLogs" xml:space="preserve">
<value>Zobrazit kartu protokolů pro podporu řešení problémů.</value>
</data>
<data name="TakeType" xml:space="preserve">
<value>Vezměte si typ</value>
</data>
<data name="TakeTypeSimple" xml:space="preserve">
<value>Jednoduché</value>
</data>
<data name="TakeTypeStructured" xml:space="preserve">
<value>Strukturovaný</value>
</data>
<data name="Theme image" xml:space="preserve">
<value>Tématický obrázek</value>
</data>
<data name="ThemeIsDarkMode" xml:space="preserve">
<value>Tmavý režim</value>
</data>
<data name="Ticks" xml:space="preserve">
<value>Klíšťata</value>
</data>
<data name="Title" xml:space="preserve">
<value>Název</value>
</data>
<data name="UpdateReason" xml:space="preserve">
<value>Důvod aktualizace</value>
</data>
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
<value>Anonymní odeslání</value>
</data>
<data name="Username" xml:space="preserve">
<value>Uživatelské jméno</value>
</data>
<data name="Username to search and add" xml:space="preserve">
<value>Uživatelské jméno pro vyhledávání a přidávání</value>
</data>
<data name="Usernames to search and add" xml:space="preserve">
<value>Uživatelská jména pro vyhledávání a přidávání</value>
</data>
</root>
@@ -117,123 +117,30 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="(necessary in case of password recovery)" xml:space="preserve">
<value>(nødvendig i tilfælde af inddrivelse af adgangskode)</value>
</data>
<data name="(password manager suggested)" xml:space="preserve">
<value>(&lt;a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/"&gt;password manager&lt;/a&gt; foreslået)</value>
</data>
<data name="Amount" xml:space="preserve">
<value>Beløb</value>
</data>
<data name="AnswerId" xml:space="preserve">
<value>Svar id</value>
</data>
<data name="AnswerText" xml:space="preserve">
<value>Svar tekst</value>
</data>
<data name="Character" xml:space="preserve">
<value>Brugerdefineret tegn (emoji)</value>
</data>
<data name="ColourIndex" xml:space="preserve">
<value>Farve</value>
</data>
<data name="CommentId" xml:space="preserve">
<value>Kommentar id</value>
</data>
<data name="CommentText" xml:space="preserve">
<value>Kommentar tekst</value>
</data>
<data name="Confrontation" xml:space="preserve">
<value>Konfrontation</value>
</data>
<data name="ConfrontationId" xml:space="preserve">
<value>Konfrontation id</value>
</data>
<data name="Context" xml:space="preserve">
<value>Kontekst</value>
</data>
<data name="DateEnd" xml:space="preserve">
<value>Slutdato</value>
</data>
<data name="DateStart" xml:space="preserve">
<value>Startdato</value>
</data>
<data name="Description" xml:space="preserve">
<value>Beskrivelse</value>
</data>
<data name="Discussion" xml:space="preserve">
<value>Diskussion</value>
</data>
<data name="DiscussionEndDate" xml:space="preserve">
<value>Slutdato</value>
</data>
<data name="DiscussionEndTime" xml:space="preserve">
<value>Sluttidspunkt</value>
</data>
<data name="DiscussionId" xml:space="preserve">
<value>Diskussion id</value>
</data>
<data name="DiscussionText" xml:space="preserve">
<value>Tekst til drøftelse</value>
</data>
<data name="DiscussionTitle" xml:space="preserve">
<value>Diskussionstitel</value>
</data>
<data name="Email" xml:space="preserve">
<value>E-mail</value>
</data>
<data name="Emoji" xml:space="preserve">
<value>Emoji</value>
</data>
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
<value>Maksimalt antal konfrontationer</value>
</data>
<data name="Fact" xml:space="preserve">
<value>Fakta</value>
</data>
<data name="Facts" xml:space="preserve">
<value>Fakta</value>
</data>
<data name="FileId" xml:space="preserve">
<value>Fil id</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Grupper</value>
</data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>Medtage det første svar i forhåndsvisningen af invitationen? (gør det mere indbydende at deltage i diskussionen)</value>
</data>
<data name="InvitationCode" xml:space="preserve">
<value>Kode for indbydelse</value>
</data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Indbydelseskode eller link</value>
</data>
<data name="InvitationPassword" xml:space="preserve">
<value>Adgangskode til invitationen</value>
</data>
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
<value>Kræver adgangskode til visning af preview</value>
</data>
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
<value>Kræver adgangskode for at vise forhåndsvisningen</value>
</data>
<data name="IsPasswordProtectedText" xml:space="preserve">
<value>Tilføj adgangskodebeskyttelse</value>
</data>
<data name="IsStructured" xml:space="preserve">
<value>IsStructured</value>
</data>
<data name="LanguageId" xml:space="preserve">
<value>Sprog-id</value>
</data>
<data name="Name" xml:space="preserve">
<value>Navn</value>
</data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Aktiver notifikationer? (nyttigt for at modtage opdateringer til appen)</value>
</data>
<data name="NewPassword" xml:space="preserve">
<value>Ny adgangskode</value>
</data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve">
<value>Gammel adgangskode</value>
</data>
<data name="OneTime" xml:space="preserve">
<value>Kun én gang</value>
</data>
<data name="Password" xml:space="preserve">
<value>Adgangskode</value>
</data>
<data name="Perception" xml:space="preserve">
<value>Opfattelse</value>
</data>
<data name="Perceptions" xml:space="preserve">
<value>Opfattelser</value>
</data>
<data name="PreferSystemTheming" xml:space="preserve">
<value>Foretrækker systemtema</value>
</data>
<data name="RecoveryCode" xml:space="preserve">
<value>Kode for inddrivelse</value>
</data>
<data name="Recurring" xml:space="preserve">
<value>Tilbagevendende</value>
</data>
<data name="Scan for:" xml:space="preserve">
<value>Scan efter:</value>
</data>
<data name="SearchText" xml:space="preserve">
<value>Søg på</value>
</data>
<data name="ShareCode" xml:space="preserve">
<value>Deling af den hemmelige kode</value>
</data>
<data name="ShowDonatorBadge" xml:space="preserve">
<value>Vis donator-badge</value>
</data>
<data name="ShowLogs" xml:space="preserve">
<value>Vis fanen Logfiler for at støtte fejlfinding.</value>
</data>
<data name="TakeType" xml:space="preserve">
<value>Tag type</value>
</data>
<data name="TakeTypeSimple" xml:space="preserve">
<value>Enkel</value>
</data>
<data name="TakeTypeStructured" xml:space="preserve">
<value>Struktureret</value>
</data>
<data name="Theme image" xml:space="preserve">
<value>Temabillede</value>
</data>
<data name="ThemeIsDarkMode" xml:space="preserve">
<value>Mørk tilstand</value>
</data>
<data name="Ticks" xml:space="preserve">
<value>Flåter</value>
</data>
<data name="Title" xml:space="preserve">
<value>Titel</value>
</data>
<data name="UpdateReason" xml:space="preserve">
<value>Årsag til opdatering</value>
</data>
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
<value>Indsend anonymt</value>
</data>
<data name="Username" xml:space="preserve">
<value>Brugernavn</value>
</data>
<data name="Username to search and add" xml:space="preserve">
<value>Brugernavn til at søge og tilføje</value>
</data>
<data name="Usernames to search and add" xml:space="preserve">
<value>Brugernavne til at søge og tilføje</value>
</data>
</root>
@@ -117,123 +117,30 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="(necessary in case of password recovery)" xml:space="preserve">
<value>(notwendig für die Wiederherstellung des Passworts)</value>
</data>
<data name="(password manager suggested)" xml:space="preserve">
<value>(&lt;a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/"&gt;Passwortmanager&lt;/a&gt; vorgeschlagen)</value>
</data>
<data name="Amount" xml:space="preserve">
<value>Betrag</value>
</data>
<data name="AnswerId" xml:space="preserve">
<value>Antwort id</value>
</data>
<data name="AnswerText" xml:space="preserve">
<value>Antwort-Text</value>
</data>
<data name="Character" xml:space="preserve">
<value>Benutzerdefiniertes Zeichen (Emoji)</value>
</data>
<data name="ColourIndex" xml:space="preserve">
<value>Farbe</value>
</data>
<data name="CommentId" xml:space="preserve">
<value>Kommentar</value>
</data>
<data name="CommentText" xml:space="preserve">
<value>Text kommentieren</value>
</data>
<data name="Confrontation" xml:space="preserve">
<value>Konfrontation</value>
</data>
<data name="ConfrontationId" xml:space="preserve">
<value>Konfrontation id</value>
</data>
<data name="Context" xml:space="preserve">
<value>Kontext</value>
</data>
<data name="DateEnd" xml:space="preserve">
<value>Datum des Endes</value>
</data>
<data name="DateStart" xml:space="preserve">
<value>Datum des Beginns</value>
</data>
<data name="Description" xml:space="preserve">
<value>Beschreibung</value>
</data>
<data name="Discussion" xml:space="preserve">
<value>Diskussion</value>
</data>
<data name="DiscussionEndDate" xml:space="preserve">
<value>Datum des Endes</value>
</data>
<data name="DiscussionEndTime" xml:space="preserve">
<value>Endzeit</value>
</data>
<data name="DiscussionId" xml:space="preserve">
<value>Diskussion id</value>
</data>
<data name="DiscussionText" xml:space="preserve">
<value>Text zur Diskussion</value>
</data>
<data name="DiscussionTitle" xml:space="preserve">
<value>Titel der Diskussion</value>
</data>
<data name="Email" xml:space="preserve">
<value>E-Mail</value>
</data>
<data name="Emoji" xml:space="preserve">
<value>Emoji</value>
</data>
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
<value>Maximale Anzahl von Konfrontationen</value>
</data>
<data name="Fact" xml:space="preserve">
<value>Tatsache</value>
</data>
<data name="Facts" xml:space="preserve">
<value>Fakten</value>
</data>
<data name="FileId" xml:space="preserve">
<value>Datei-ID</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Gruppen</value>
</data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>Die erste Antwort in die Vorschau der Einladung aufnehmen? (um den Anreiz zur Teilnahme an der Diskussion zu erhöhen)</value>
</data>
<data name="InvitationCode" xml:space="preserve">
<value>Einladungs-Code</value>
</data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Einladungscode oder Link</value>
</data>
<data name="InvitationPassword" xml:space="preserve">
<value>Passwort für die Einladung</value>
</data>
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
<value>Passwort für Vorschau erforderlich</value>
</data>
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
<value>Passwort erforderlich, um die Vorschau anzuzeigen</value>
</data>
<data name="IsPasswordProtectedText" xml:space="preserve">
<value>Passwortschutz hinzufügen</value>
</data>
<data name="IsStructured" xml:space="preserve">
<value>IsStructured</value>
</data>
<data name="LanguageId" xml:space="preserve">
<value>Sprache id</value>
</data>
<data name="Name" xml:space="preserve">
<value>Name</value>
</data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Benachrichtigungen aktivieren?(nützlich, um App-Updates zu erhalten)</value>
</data>
<data name="NewPassword" xml:space="preserve">
<value>Neues Passwort</value>
</data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve">
<value>Altes Passwort</value>
</data>
<data name="OneTime" xml:space="preserve">
<value>Nur einmalig</value>
</data>
<data name="Password" xml:space="preserve">
<value>Passwort</value>
</data>
<data name="Perception" xml:space="preserve">
<value>Wahrnehmung</value>
</data>
<data name="Perceptions" xml:space="preserve">
<value>Wahrnehmungen</value>
</data>
<data name="PreferSystemTheming" xml:space="preserve">
<value>Systemthema bevorzugen</value>
</data>
<data name="RecoveryCode" xml:space="preserve">
<value>Wiederherstellungscode</value>
</data>
<data name="Recurring" xml:space="preserve">
<value>Wiederkehrende</value>
</data>
<data name="Scan for:" xml:space="preserve">
<value>Scannen Sie nach:</value>
</data>
<data name="SearchText" xml:space="preserve">
<value>Suche</value>
</data>
<data name="ShareCode" xml:space="preserve">
<value>Gemeinsamer Geheimcode</value>
</data>
<data name="ShowDonatorBadge" xml:space="preserve">
<value>Spenderausweis anzeigen</value>
</data>
<data name="ShowLogs" xml:space="preserve">
<value>Zeigen Sie die Registerkarte Protokolle an, um die Fehlersuche zu unterstützen.</value>
</data>
<data name="TakeType" xml:space="preserve">
<value>Typ nehmen</value>
</data>
<data name="TakeTypeSimple" xml:space="preserve">
<value>Einfach</value>
</data>
<data name="TakeTypeStructured" xml:space="preserve">
<value>Strukturiert</value>
</data>
<data name="Theme image" xml:space="preserve">
<value>Thema Bild</value>
</data>
<data name="ThemeIsDarkMode" xml:space="preserve">
<value>Dunkler Modus</value>
</data>
<data name="Ticks" xml:space="preserve">
<value>Zecken</value>
</data>
<data name="Title" xml:space="preserve">
<value>Titel</value>
</data>
<data name="UpdateReason" xml:space="preserve">
<value>Grund für die Aktualisierung</value>
</data>
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
<value>Anonym einreichen</value>
</data>
<data name="Username" xml:space="preserve">
<value>Benutzername</value>
</data>
<data name="Username to search and add" xml:space="preserve">
<value>Benutzername zum Suchen und Hinzufügen</value>
</data>
<data name="Usernames to search and add" xml:space="preserve">
<value>Nutzernamen zum Suchen und Hinzufügen</value>
</data>
</root>
@@ -117,123 +117,30 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="(necessary in case of password recovery)" xml:space="preserve">
<value>(απαραίτητο σε περίπτωση ανάκτησης κωδικού πρόσβασης)</value>
</data>
<data name="(password manager suggested)" xml:space="preserve">
<value>(&lt;a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/"&gt;διαχειριστής κωδικών πρόσβασης&lt;/a&gt; προτείνεται)</value>
</data>
<data name="Amount" xml:space="preserve">
<value>Ποσό</value>
</data>
<data name="AnswerId" xml:space="preserve">
<value>Απάντηση id</value>
</data>
<data name="AnswerText" xml:space="preserve">
<value>Κείμενο απάντησης</value>
</data>
<data name="Character" xml:space="preserve">
<value>Προσαρμοσμένος χαρακτήρας (emoji)</value>
</data>
<data name="ColourIndex" xml:space="preserve">
<value>Χρώμα</value>
</data>
<data name="CommentId" xml:space="preserve">
<value>Σχόλιο id</value>
</data>
<data name="CommentText" xml:space="preserve">
<value>Κείμενο σχολιασμού</value>
</data>
<data name="Confrontation" xml:space="preserve">
<value>Αντιπαράθεση</value>
</data>
<data name="ConfrontationId" xml:space="preserve">
<value>id αντιπαράθεσης</value>
</data>
<data name="Context" xml:space="preserve">
<value>Πλαίσιο</value>
</data>
<data name="DateEnd" xml:space="preserve">
<value>Ημερομηνία λήξης</value>
</data>
<data name="DateStart" xml:space="preserve">
<value>Ημερομηνία έναρξης</value>
</data>
<data name="Description" xml:space="preserve">
<value>Περιγραφή</value>
</data>
<data name="Discussion" xml:space="preserve">
<value>Συζήτηση</value>
</data>
<data name="DiscussionEndDate" xml:space="preserve">
<value>Ημερομηνία λήξης</value>
</data>
<data name="DiscussionEndTime" xml:space="preserve">
<value>Ώρα λήξης</value>
</data>
<data name="DiscussionId" xml:space="preserve">
<value>Συζήτηση id</value>
</data>
<data name="DiscussionText" xml:space="preserve">
<value>Κείμενο συζήτησης</value>
</data>
<data name="DiscussionTitle" xml:space="preserve">
<value>Τίτλος συζήτησης</value>
</data>
<data name="Email" xml:space="preserve">
<value>Ηλεκτρονικό ταχυδρομείο</value>
</data>
<data name="Emoji" xml:space="preserve">
<value>Emoji</value>
</data>
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
<value>Μέγιστος αριθμός αντιπαραθέσεων</value>
</data>
<data name="Fact" xml:space="preserve">
<value>Γεγονός</value>
</data>
<data name="Facts" xml:space="preserve">
<value>Γεγονότα</value>
</data>
<data name="FileId" xml:space="preserve">
<value>id αρχείου</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Ομάδες</value>
</data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>Να συμπεριλάβετε την πρώτη απάντηση στην προεπισκόπηση της πρόσκλησης; (καθιστώντας την πιο ελκυστική για να συμμετάσχετε στη συζήτηση)</value>
</data>
<data name="InvitationCode" xml:space="preserve">
<value>Κωδικός πρόσκλησης</value>
</data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Κωδικός πρόσκλησης ή σύνδεσμος</value>
</data>
<data name="InvitationPassword" xml:space="preserve">
<value>Κωδικός πρόσκλησης</value>
</data>
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
<value>Απαίτηση κωδικού πρόσβασης για προεπισκόπηση</value>
</data>
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
<value>Απαιτείται κωδικός πρόσβασης για την εμφάνιση της προεπισκόπησης</value>
</data>
<data name="IsPasswordProtectedText" xml:space="preserve">
<value>Προσθήκη προστασίας με κωδικό πρόσβασης</value>
</data>
<data name="IsStructured" xml:space="preserve">
<value>IsStructured</value>
</data>
<data name="LanguageId" xml:space="preserve">
<value>Id γλώσσας</value>
</data>
<data name="Name" xml:space="preserve">
<value>Όνομα</value>
</data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Ενεργοποίηση ειδοποιήσεων; (χρήσιμο για να λαμβάνετε ενημερώσεις εφαρμογών)</value>
</data>
<data name="NewPassword" xml:space="preserve">
<value>Νέος κωδικός πρόσβασης</value>
</data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve">
<value>Παλαιός κωδικός πρόσβασης</value>
</data>
<data name="OneTime" xml:space="preserve">
<value>Μόνο μία φορά</value>
</data>
<data name="Password" xml:space="preserve">
<value>Κωδικός πρόσβασης</value>
</data>
<data name="Perception" xml:space="preserve">
<value>Αντίληψη</value>
</data>
<data name="Perceptions" xml:space="preserve">
<value>Αντιλήψεις</value>
</data>
<data name="PreferSystemTheming" xml:space="preserve">
<value>Προτιμήστε το θέμα του συστήματος</value>
</data>
<data name="RecoveryCode" xml:space="preserve">
<value>Κωδικός ανάκτησης</value>
</data>
<data name="Recurring" xml:space="preserve">
<value>Επαναλαμβανόμενο</value>
</data>
<data name="Scan for:" xml:space="preserve">
<value>Σάρωση για:</value>
</data>
<data name="SearchText" xml:space="preserve">
<value>Αναζήτηση</value>
</data>
<data name="ShareCode" xml:space="preserve">
<value>Κοινή χρήση μυστικού κώδικα</value>
</data>
<data name="ShowDonatorBadge" xml:space="preserve">
<value>Εμφάνιση σήματος δωρητή</value>
</data>
<data name="ShowLogs" xml:space="preserve">
<value>Εμφάνιση της καρτέλας αρχείων καταγραφής για την υποστήριξη της αντιμετώπισης προβλημάτων.</value>
</data>
<data name="TakeType" xml:space="preserve">
<value>Πάρτε τον τύπο</value>
</data>
<data name="TakeTypeSimple" xml:space="preserve">
<value>Απλό</value>
</data>
<data name="TakeTypeStructured" xml:space="preserve">
<value>Δομημένο</value>
</data>
<data name="Theme image" xml:space="preserve">
<value>Εικόνα θέματος</value>
</data>
<data name="ThemeIsDarkMode" xml:space="preserve">
<value>Σκοτεινή λειτουργία</value>
</data>
<data name="Ticks" xml:space="preserve">
<value>Κρότωνες</value>
</data>
<data name="Title" xml:space="preserve">
<value>Τίτλος</value>
</data>
<data name="UpdateReason" xml:space="preserve">
<value>Αιτία ενημέρωσης</value>
</data>
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
<value>Υποβάλετε ανώνυμα</value>
</data>
<data name="Username" xml:space="preserve">
<value>Όνομα χρήστη</value>
</data>
<data name="Username to search and add" xml:space="preserve">
<value>Όνομα χρήστη για αναζήτηση και προσθήκη</value>
</data>
<data name="Usernames to search and add" xml:space="preserve">
<value>Ονόματα χρηστών για αναζήτηση και προσθήκη</value>
</data>
</root>
@@ -117,123 +117,30 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="(necessary in case of password recovery)" xml:space="preserve">
<value>(necesario en caso de recuperación de la contraseña)</value>
</data>
<data name="(password manager suggested)" xml:space="preserve">
<value>(&lt;a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/"&gt;gestor de contraseñas&lt;/a&gt; sugerido)</value>
</data>
<data name="Amount" xml:space="preserve">
<value>Importe</value>
</data>
<data name="AnswerId" xml:space="preserve">
<value>Respuesta</value>
</data>
<data name="AnswerText" xml:space="preserve">
<value>Texto de respuesta</value>
</data>
<data name="Character" xml:space="preserve">
<value>Carácter personalizado (emoji)</value>
</data>
<data name="ColourIndex" xml:space="preserve">
<value>Color</value>
</data>
<data name="CommentId" xml:space="preserve">
<value>Comentario id</value>
</data>
<data name="CommentText" xml:space="preserve">
<value>Texto de los comentarios</value>
</data>
<data name="Confrontation" xml:space="preserve">
<value>Confrontación</value>
</data>
<data name="ConfrontationId" xml:space="preserve">
<value>Identificación de la confrontación</value>
</data>
<data name="Context" xml:space="preserve">
<value>Contexto</value>
</data>
<data name="DateEnd" xml:space="preserve">
<value>Fecha de finalización</value>
</data>
<data name="DateStart" xml:space="preserve">
<value>Fecha de inicio</value>
</data>
<data name="Description" xml:space="preserve">
<value>Descripción</value>
</data>
<data name="Discussion" xml:space="preserve">
<value>Discusión</value>
</data>
<data name="DiscussionEndDate" xml:space="preserve">
<value>Fecha de finalización</value>
</data>
<data name="DiscussionEndTime" xml:space="preserve">
<value>Hora de finalización</value>
</data>
<data name="DiscussionId" xml:space="preserve">
<value>Discusión</value>
</data>
<data name="DiscussionText" xml:space="preserve">
<value>Texto de debate</value>
</data>
<data name="DiscussionTitle" xml:space="preserve">
<value>Título del debate</value>
</data>
<data name="Email" xml:space="preserve">
<value>Envíe un correo electrónico a</value>
</data>
<data name="Emoji" xml:space="preserve">
<value>Emoji</value>
</data>
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
<value>Máxima cantidad de enfrentamientos</value>
</data>
<data name="Fact" xml:space="preserve">
<value>Datos</value>
</data>
<data name="Facts" xml:space="preserve">
<value>Hechos</value>
</data>
<data name="FileId" xml:space="preserve">
<value>ID de archivo</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Grupos</value>
</data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>¿Incluir la primera respuesta en la vista previa de la invitación? (para que sea más atractivo participar en el debate)</value>
</data>
<data name="InvitationCode" xml:space="preserve">
<value>Código de invitación</value>
</data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Código o enlace de invitación</value>
</data>
<data name="InvitationPassword" xml:space="preserve">
<value>Contraseña de la invitación</value>
</data>
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
<value>Requerir contraseña para la vista previa</value>
</data>
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
<value>Requiere contraseña para mostrar la vista previa</value>
</data>
<data name="IsPasswordProtectedText" xml:space="preserve">
<value>Añadir protección por contraseña</value>
</data>
<data name="IsStructured" xml:space="preserve">
<value>IsStructured</value>
</data>
<data name="LanguageId" xml:space="preserve">
<value>Id. de idioma</value>
</data>
<data name="Name" xml:space="preserve">
<value>Nombre</value>
</data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Activar las notificaciones (útiles para recibir las actualizaciones de la aplicación)</value>
</data>
<data name="NewPassword" xml:space="preserve">
<value>Nueva contraseña</value>
</data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve">
<value>Contraseña antigua</value>
</data>
<data name="OneTime" xml:space="preserve">
<value>Una sola vez</value>
</data>
<data name="Password" xml:space="preserve">
<value>Contraseña</value>
</data>
<data name="Perception" xml:space="preserve">
<value>Percepción</value>
</data>
<data name="Perceptions" xml:space="preserve">
<value>Percepciones</value>
</data>
<data name="PreferSystemTheming" xml:space="preserve">
<value>Prefiero el tema del sistema</value>
</data>
<data name="RecoveryCode" xml:space="preserve">
<value>Código de recuperación</value>
</data>
<data name="Recurring" xml:space="preserve">
<value>Recurrente</value>
</data>
<data name="Scan for:" xml:space="preserve">
<value>Busca:</value>
</data>
<data name="SearchText" xml:space="preserve">
<value>Buscar en</value>
</data>
<data name="ShareCode" xml:space="preserve">
<value>Compartir el código secreto</value>
</data>
<data name="ShowDonatorBadge" xml:space="preserve">
<value>Mostrar el distintivo de donante</value>
</data>
<data name="ShowLogs" xml:space="preserve">
<value>Mostrar la pestaña de registros para apoyar la resolución de problemas.</value>
</data>
<data name="TakeType" xml:space="preserve">
<value>Toma de tipo</value>
</data>
<data name="TakeTypeSimple" xml:space="preserve">
<value>Simple</value>
</data>
<data name="TakeTypeStructured" xml:space="preserve">
<value>Estructurado</value>
</data>
<data name="Theme image" xml:space="preserve">
<value>Imagen del tema</value>
</data>
<data name="ThemeIsDarkMode" xml:space="preserve">
<value>Modo oscuro</value>
</data>
<data name="Ticks" xml:space="preserve">
<value>Garrapatas</value>
</data>
<data name="Title" xml:space="preserve">
<value>Título</value>
</data>
<data name="UpdateReason" xml:space="preserve">
<value>Motivo de actualización</value>
</data>
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
<value>Presentar de forma anónima</value>
</data>
<data name="Username" xml:space="preserve">
<value>Nombre de usuario</value>
</data>
<data name="Username to search and add" xml:space="preserve">
<value>Nombre de usuario para buscar y añadir</value>
</data>
<data name="Usernames to search and add" xml:space="preserve">
<value>Nombres de usuario para buscar y añadir</value>
</data>
</root>
@@ -117,123 +117,30 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="(necessary in case of password recovery)" xml:space="preserve">
<value>(vajalik parooli taastamise korral)</value>
</data>
<data name="(password manager suggested)" xml:space="preserve">
<value>(&lt;a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/"&gt;Password manager&lt;/a&gt; soovitatav)</value>
</data>
<data name="Amount" xml:space="preserve">
<value>Summa</value>
</data>
<data name="AnswerId" xml:space="preserve">
<value>Vastus id</value>
</data>
<data name="AnswerText" xml:space="preserve">
<value>Vastuse tekst</value>
</data>
<data name="Character" xml:space="preserve">
<value>Kohandatud märk (emotikoni)</value>
</data>
<data name="ColourIndex" xml:space="preserve">
<value>Värv</value>
</data>
<data name="CommentId" xml:space="preserve">
<value>Kommentaar id</value>
</data>
<data name="CommentText" xml:space="preserve">
<value>Kommentaaride tekst</value>
</data>
<data name="Confrontation" xml:space="preserve">
<value>Vastasseis</value>
</data>
<data name="ConfrontationId" xml:space="preserve">
<value>Vastasseisu id</value>
</data>
<data name="Context" xml:space="preserve">
<value>Kontekst</value>
</data>
<data name="DateEnd" xml:space="preserve">
<value>Lõppkuupäev</value>
</data>
<data name="DateStart" xml:space="preserve">
<value>Alguskuupäev</value>
</data>
<data name="Description" xml:space="preserve">
<value>Kirjeldus</value>
</data>
<data name="Discussion" xml:space="preserve">
<value>Arutelu</value>
</data>
<data name="DiscussionEndDate" xml:space="preserve">
<value>Lõppkuupäev</value>
</data>
<data name="DiscussionEndTime" xml:space="preserve">
<value>Lõpuaeg</value>
</data>
<data name="DiscussionId" xml:space="preserve">
<value>Arutelu id</value>
</data>
<data name="DiscussionText" xml:space="preserve">
<value>Arutelu tekst</value>
</data>
<data name="DiscussionTitle" xml:space="preserve">
<value>Arutelu pealkiri</value>
</data>
<data name="Email" xml:space="preserve">
<value>E-post</value>
</data>
<data name="Emoji" xml:space="preserve">
<value>Emoji</value>
</data>
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
<value>Vastasseisude maksimaalne arv</value>
</data>
<data name="Fact" xml:space="preserve">
<value>Fakt</value>
</data>
<data name="Facts" xml:space="preserve">
<value>Faktid</value>
</data>
<data name="FileId" xml:space="preserve">
<value>Faili id</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Rühmad</value>
</data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>Lisage esimene vastus kutse eelvaates? (muudab aruteluga liitumise atraktiivsemaks)</value>
</data>
<data name="InvitationCode" xml:space="preserve">
<value>Kutse kood</value>
</data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Kutse kood või link</value>
</data>
<data name="InvitationPassword" xml:space="preserve">
<value>Kutse parool</value>
</data>
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
<value>Nõuab eelvaate jaoks parooli</value>
</data>
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
<value>Eelvaate kuvamiseks nõutakse parooli</value>
</data>
<data name="IsPasswordProtectedText" xml:space="preserve">
<value>Lisage paroolikaitse</value>
</data>
<data name="IsStructured" xml:space="preserve">
<value>IsStructured</value>
</data>
<data name="LanguageId" xml:space="preserve">
<value>Keele id</value>
</data>
<data name="Name" xml:space="preserve">
<value>Nimi</value>
</data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Teavituste lubamine?(kasulik, et saada rakenduse uuendusi)</value>
</data>
<data name="NewPassword" xml:space="preserve">
<value>Uus parool</value>
</data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve">
<value>Vana parool</value>
</data>
<data name="OneTime" xml:space="preserve">
<value>Ainult üks kord</value>
</data>
<data name="Password" xml:space="preserve">
<value>Parool</value>
</data>
<data name="Perception" xml:space="preserve">
<value>Tajumine</value>
</data>
<data name="Perceptions" xml:space="preserve">
<value>Arvamused</value>
</data>
<data name="PreferSystemTheming" xml:space="preserve">
<value>Eelistage süsteemi teemat</value>
</data>
<data name="RecoveryCode" xml:space="preserve">
<value>Taastamiskood</value>
</data>
<data name="Recurring" xml:space="preserve">
<value>Korduvad</value>
</data>
<data name="Scan for:" xml:space="preserve">
<value>Skaneeri:</value>
</data>
<data name="SearchText" xml:space="preserve">
<value>Otsi</value>
</data>
<data name="ShareCode" xml:space="preserve">
<value>Salajase koodi jagamine</value>
</data>
<data name="ShowDonatorBadge" xml:space="preserve">
<value>Näita doonori märki</value>
</data>
<data name="ShowLogs" xml:space="preserve">
<value>Näidake veaotsingu toetamiseks logide vahekaarti.</value>
</data>
<data name="TakeType" xml:space="preserve">
<value>Võtke tüüp</value>
</data>
<data name="TakeTypeSimple" xml:space="preserve">
<value>Lihtne</value>
</data>
<data name="TakeTypeStructured" xml:space="preserve">
<value>Struktureeritud</value>
</data>
<data name="Theme image" xml:space="preserve">
<value>Teema pilt</value>
</data>
<data name="ThemeIsDarkMode" xml:space="preserve">
<value>Tume režiim</value>
</data>
<data name="Ticks" xml:space="preserve">
<value>Puugid</value>
</data>
<data name="Title" xml:space="preserve">
<value>Pealkiri</value>
</data>
<data name="UpdateReason" xml:space="preserve">
<value>Uuendamise põhjus</value>
</data>
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
<value>Esitage anonüümselt</value>
</data>
<data name="Username" xml:space="preserve">
<value>Kasutajanimi</value>
</data>
<data name="Username to search and add" xml:space="preserve">
<value>Kasutajanimi otsimiseks ja lisamiseks</value>
</data>
<data name="Usernames to search and add" xml:space="preserve">
<value>Kasutajanimed otsimiseks ja lisamiseks</value>
</data>
</root>
@@ -117,123 +117,30 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="(necessary in case of password recovery)" xml:space="preserve">
<value>(tarvitaan salasanan palautuksen yhteydessä)</value>
</data>
<data name="(password manager suggested)" xml:space="preserve">
<value>(&lt;a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/"&gt;salasananhallinta&lt;/a&gt; ehdotettu)</value>
</data>
<data name="Amount" xml:space="preserve">
<value>Määrä</value>
</data>
<data name="AnswerId" xml:space="preserve">
<value>Vastaa id</value>
</data>
<data name="AnswerText" xml:space="preserve">
<value>Vastaus teksti</value>
</data>
<data name="Character" xml:space="preserve">
<value>Mukautettu merkki (emoji)</value>
</data>
<data name="ColourIndex" xml:space="preserve">
<value>Väri</value>
</data>
<data name="CommentId" xml:space="preserve">
<value>Kommentti id</value>
</data>
<data name="CommentText" xml:space="preserve">
<value>Kommenttiteksti</value>
</data>
<data name="Confrontation" xml:space="preserve">
<value>Vastakkainasettelu</value>
</data>
<data name="ConfrontationId" xml:space="preserve">
<value>Vastakkainasettelu id</value>
</data>
<data name="Context" xml:space="preserve">
<value>Konteksti</value>
</data>
<data name="DateEnd" xml:space="preserve">
<value>Loppupäivä</value>
</data>
<data name="DateStart" xml:space="preserve">
<value>Aloituspäivä</value>
</data>
<data name="Description" xml:space="preserve">
<value>Kuvaus</value>
</data>
<data name="Discussion" xml:space="preserve">
<value>Keskustelu</value>
</data>
<data name="DiscussionEndDate" xml:space="preserve">
<value>Loppupäivä</value>
</data>
<data name="DiscussionEndTime" xml:space="preserve">
<value>Loppuaika</value>
</data>
<data name="DiscussionId" xml:space="preserve">
<value>Keskustelu id</value>
</data>
<data name="DiscussionText" xml:space="preserve">
<value>Keskusteluteksti</value>
</data>
<data name="DiscussionTitle" xml:space="preserve">
<value>Keskustelun otsikko</value>
</data>
<data name="Email" xml:space="preserve">
<value>Sähköposti</value>
</data>
<data name="Emoji" xml:space="preserve">
<value>Emoji</value>
</data>
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
<value>Vastakkainasettelujen enimmäismäärä</value>
</data>
<data name="Fact" xml:space="preserve">
<value>Fakta</value>
</data>
<data name="Facts" xml:space="preserve">
<value>Faktat</value>
</data>
<data name="FileId" xml:space="preserve">
<value>Tiedoston id</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Ryhmät</value>
</data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>Sisällytä ensimmäinen vastaus kutsun esikatseluun? (mikä tekee keskusteluun osallistumisesta houkuttelevampaa)</value>
</data>
<data name="InvitationCode" xml:space="preserve">
<value>Kutsun koodi</value>
</data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Kutsukoodi tai linkki</value>
</data>
<data name="InvitationPassword" xml:space="preserve">
<value>Kutsun salasana</value>
</data>
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
<value>Vaadi salasana esikatselua varten</value>
</data>
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
<value>Vaadi salasana esikatselun näyttämiseksi</value>
</data>
<data name="IsPasswordProtectedText" xml:space="preserve">
<value>Lisää salasanasuojaus</value>
</data>
<data name="IsStructured" xml:space="preserve">
<value>IsStructured</value>
</data>
<data name="LanguageId" xml:space="preserve">
<value>Kielitunnus</value>
</data>
<data name="Name" xml:space="preserve">
<value>Nimi</value>
</data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Ota ilmoitukset käyttöön?(hyödyllinen sovelluksen päivitysten saamiseksi)</value>
</data>
<data name="NewPassword" xml:space="preserve">
<value>Uusi salasana</value>
</data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve">
<value>Vanha salasana</value>
</data>
<data name="OneTime" xml:space="preserve">
<value>Vain kerran</value>
</data>
<data name="Password" xml:space="preserve">
<value>Salasana</value>
</data>
<data name="Perception" xml:space="preserve">
<value>Havainto</value>
</data>
<data name="Perceptions" xml:space="preserve">
<value>Käsitykset</value>
</data>
<data name="PreferSystemTheming" xml:space="preserve">
<value>Suositeltava järjestelmäteema</value>
</data>
<data name="RecoveryCode" xml:space="preserve">
<value>Palautuskoodi</value>
</data>
<data name="Recurring" xml:space="preserve">
<value>Toistuvat</value>
</data>
<data name="Scan for:" xml:space="preserve">
<value>Etsi:</value>
</data>
<data name="SearchText" xml:space="preserve">
<value>Etsi</value>
</data>
<data name="ShareCode" xml:space="preserve">
<value>Salaisen koodin jakaminen</value>
</data>
<data name="ShowDonatorBadge" xml:space="preserve">
<value>Näytä lahjoittajan merkki</value>
</data>
<data name="ShowLogs" xml:space="preserve">
<value>Näytä lokit-välilehti vianmäärityksen tueksi.</value>
</data>
<data name="TakeType" xml:space="preserve">
<value>Ota tyyppi</value>
</data>
<data name="TakeTypeSimple" xml:space="preserve">
<value>Yksinkertainen</value>
</data>
<data name="TakeTypeStructured" xml:space="preserve">
<value>Strukturoitu</value>
</data>
<data name="Theme image" xml:space="preserve">
<value>Aiheen kuva</value>
</data>
<data name="ThemeIsDarkMode" xml:space="preserve">
<value>Tumma tila</value>
</data>
<data name="Ticks" xml:space="preserve">
<value>Punkit</value>
</data>
<data name="Title" xml:space="preserve">
<value>Otsikko</value>
</data>
<data name="UpdateReason" xml:space="preserve">
<value>Päivityksen syy</value>
</data>
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
<value>Lähetä nimettömänä</value>
</data>
<data name="Username" xml:space="preserve">
<value>Käyttäjätunnus</value>
</data>
<data name="Username to search and add" xml:space="preserve">
<value>Käyttäjätunnus hakuun ja lisäämiseen</value>
</data>
<data name="Usernames to search and add" xml:space="preserve">
<value>Käyttäjätunnukset, joita voit etsiä ja lisätä</value>
</data>
</root>
@@ -117,123 +117,30 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="(necessary in case of password recovery)" xml:space="preserve">
<value>(nécessaire en cas de récupération du mot de passe)</value>
</data>
<data name="(password manager suggested)" xml:space="preserve">
<value>(&lt;a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/"&gt;gestionnaire de mots de passe&lt;/a&gt; suggéré)</value>
</data>
<data name="Amount" xml:space="preserve">
<value>Montant</value>
</data>
<data name="AnswerId" xml:space="preserve">
<value>Réponse id</value>
</data>
<data name="AnswerText" xml:space="preserve">
<value>Texte de réponse</value>
</data>
<data name="Character" xml:space="preserve">
<value>Caractère personnalisé (emoji)</value>
</data>
<data name="ColourIndex" xml:space="preserve">
<value>Couleur</value>
</data>
<data name="CommentId" xml:space="preserve">
<value>Commentaire</value>
</data>
<data name="CommentText" xml:space="preserve">
<value>Texte du commentaire</value>
</data>
<data name="Confrontation" xml:space="preserve">
<value>Confrontation</value>
</data>
<data name="ConfrontationId" xml:space="preserve">
<value>Confrontation id</value>
</data>
<data name="Context" xml:space="preserve">
<value>Contexte</value>
</data>
<data name="DateEnd" xml:space="preserve">
<value>Date de fin</value>
</data>
<data name="DateStart" xml:space="preserve">
<value>Date de début</value>
</data>
<data name="Description" xml:space="preserve">
<value>Description</value>
</data>
<data name="Discussion" xml:space="preserve">
<value>Discussion</value>
</data>
<data name="DiscussionEndDate" xml:space="preserve">
<value>Date de fin</value>
</data>
<data name="DiscussionEndTime" xml:space="preserve">
<value>Heure de fin</value>
</data>
<data name="DiscussionId" xml:space="preserve">
<value>ID de discussion</value>
</data>
<data name="DiscussionText" xml:space="preserve">
<value>Texte de discussion</value>
</data>
<data name="DiscussionTitle" xml:space="preserve">
<value>Titre de la discussion</value>
</data>
<data name="Email" xml:space="preserve">
<value>Courriel</value>
</data>
<data name="Emoji" xml:space="preserve">
<value>Emoji</value>
</data>
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
<value>Nombre maximal de confrontations</value>
</data>
<data name="Fact" xml:space="preserve">
<value>Fait</value>
</data>
<data name="Facts" xml:space="preserve">
<value>Faits et chiffres</value>
</data>
<data name="FileId" xml:space="preserve">
<value>Identifiant du fichier</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Groupes</value>
</data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>Inclure la première réponse dans l'aperçu de l'invitation ? (ce qui rend plus attrayant le fait de rejoindre la discussion)</value>
</data>
<data name="InvitationCode" xml:space="preserve">
<value>Code d'invitation</value>
</data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Code ou lien d'invitation</value>
</data>
<data name="InvitationPassword" xml:space="preserve">
<value>Mot de passe de l'invitation</value>
</data>
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
<value>Exiger un mot de passe pour la prévisualisation</value>
</data>
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
<value>Exiger un mot de passe pour afficher l'aperçu</value>
</data>
<data name="IsPasswordProtectedText" xml:space="preserve">
<value>Ajouter une protection par mot de passe</value>
</data>
<data name="IsStructured" xml:space="preserve">
<value>EstStructuré</value>
</data>
<data name="LanguageId" xml:space="preserve">
<value>Identifiant de langue</value>
</data>
<data name="Name" xml:space="preserve">
<value>Nom</value>
</data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Activer les notifications ? (utile pour recevoir les mises à jour de l'application)</value>
</data>
<data name="NewPassword" xml:space="preserve">
<value>Nouveau mot de passe</value>
</data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve">
<value>Ancien mot de passe</value>
</data>
<data name="OneTime" xml:space="preserve">
<value>En une seule fois</value>
</data>
<data name="Password" xml:space="preserve">
<value>Mot de passe</value>
</data>
<data name="Perception" xml:space="preserve">
<value>Perception</value>
</data>
<data name="Perceptions" xml:space="preserve">
<value>Perceptions</value>
</data>
<data name="PreferSystemTheming" xml:space="preserve">
<value>Préférer le thème du système</value>
</data>
<data name="RecoveryCode" xml:space="preserve">
<value>Code de récupération</value>
</data>
<data name="Recurring" xml:space="preserve">
<value>Récurrent</value>
</data>
<data name="Scan for:" xml:space="preserve">
<value>Scanner pour :</value>
</data>
<data name="SearchText" xml:space="preserve">
<value>Recherche</value>
</data>
<data name="ShareCode" xml:space="preserve">
<value>Partage du code secret</value>
</data>
<data name="ShowDonatorBadge" xml:space="preserve">
<value>Afficher le badge de donateur</value>
</data>
<data name="ShowLogs" xml:space="preserve">
<value>Affichez l'onglet des journaux pour faciliter le dépannage.</value>
</data>
<data name="TakeType" xml:space="preserve">
<value>Type de prise</value>
</data>
<data name="TakeTypeSimple" xml:space="preserve">
<value>Simple</value>
</data>
<data name="TakeTypeStructured" xml:space="preserve">
<value>Structuré</value>
</data>
<data name="Theme image" xml:space="preserve">
<value>Image du thème</value>
</data>
<data name="ThemeIsDarkMode" xml:space="preserve">
<value>Mode foncé</value>
</data>
<data name="Ticks" xml:space="preserve">
<value>Tiques</value>
</data>
<data name="Title" xml:space="preserve">
<value>Titre</value>
</data>
<data name="UpdateReason" xml:space="preserve">
<value>Raison de la mise à jour</value>
</data>
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
<value>Soumettre anonymement</value>
</data>
<data name="Username" xml:space="preserve">
<value>Nom d'utilisateur</value>
</data>
<data name="Username to search and add" xml:space="preserve">
<value>Nom d'utilisateur pour rechercher et ajouter</value>
</data>
<data name="Usernames to search and add" xml:space="preserve">
<value>Noms d'utilisateur à rechercher et à ajouter</value>
</data>
</root>
@@ -117,123 +117,30 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="(necessary in case of password recovery)" xml:space="preserve">
<value>(jelszó helyreállítása esetén szükséges)</value>
</data>
<data name="(password manager suggested)" xml:space="preserve">
<value>(&lt;a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/"&gt;jelszókezelő&lt;/a&gt; javasolt)</value>
</data>
<data name="Amount" xml:space="preserve">
<value>Összeg</value>
</data>
<data name="AnswerId" xml:space="preserve">
<value>Válasz id</value>
</data>
<data name="AnswerText" xml:space="preserve">
<value>Válasz szöveg</value>
</data>
<data name="Character" xml:space="preserve">
<value>Egyéni karakter (emoji)</value>
</data>
<data name="ColourIndex" xml:space="preserve">
<value>Színes</value>
</data>
<data name="CommentId" xml:space="preserve">
<value>Megjegyzés id</value>
</data>
<data name="CommentText" xml:space="preserve">
<value>Megjegyzés szövege</value>
</data>
<data name="Confrontation" xml:space="preserve">
<value>Konfrontáció</value>
</data>
<data name="ConfrontationId" xml:space="preserve">
<value>Konfrontációs azonosító</value>
</data>
<data name="Context" xml:space="preserve">
<value>Kontextus</value>
</data>
<data name="DateEnd" xml:space="preserve">
<value>Végdátum</value>
</data>
<data name="DateStart" xml:space="preserve">
<value>Kezdeti időpont</value>
</data>
<data name="Description" xml:space="preserve">
<value>Leírás</value>
</data>
<data name="Discussion" xml:space="preserve">
<value>Megbeszélés</value>
</data>
<data name="DiscussionEndDate" xml:space="preserve">
<value>Végdátum</value>
</data>
<data name="DiscussionEndTime" xml:space="preserve">
<value>Végidő</value>
</data>
<data name="DiscussionId" xml:space="preserve">
<value>Megbeszélés id</value>
</data>
<data name="DiscussionText" xml:space="preserve">
<value>Megbeszélés szövege</value>
</data>
<data name="DiscussionTitle" xml:space="preserve">
<value>Vita címe</value>
</data>
<data name="Email" xml:space="preserve">
<value>E-mail</value>
</data>
<data name="Emoji" xml:space="preserve">
<value>Emoji</value>
</data>
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
<value>A konfrontációk maximális száma</value>
</data>
<data name="Fact" xml:space="preserve">
<value>Tény</value>
</data>
<data name="Facts" xml:space="preserve">
<value>Tények</value>
</data>
<data name="FileId" xml:space="preserve">
<value>Fájl azonosító</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Csoportok</value>
</data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>Az első válasz szerepeljen a meghívó előnézetében? (így vonzóbbá teszi a vitához való csatlakozást)</value>
</data>
<data name="InvitationCode" xml:space="preserve">
<value>Meghívó kód</value>
</data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Meghívó kód vagy link</value>
</data>
<data name="InvitationPassword" xml:space="preserve">
<value>Meghívó jelszó</value>
</data>
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
<value>Jelszó megkövetelése az előnézethez</value>
</data>
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
<value>Jelszó megkövetelése az előnézet megjelenítéséhez</value>
</data>
<data name="IsPasswordProtectedText" xml:space="preserve">
<value>Jelszóvédelem hozzáadása</value>
</data>
<data name="IsStructured" xml:space="preserve">
<value>IsStructured</value>
</data>
<data name="LanguageId" xml:space="preserve">
<value>Nyelvi azonosító</value>
</data>
<data name="Name" xml:space="preserve">
<value>Név</value>
</data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Értesítések engedélyezése?(hasznos az alkalmazás frissítéseinek fogadásához)</value>
</data>
<data name="NewPassword" xml:space="preserve">
<value>Új jelszó</value>
</data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve">
<value>Régi jelszó</value>
</data>
<data name="OneTime" xml:space="preserve">
<value>Csak egyszeri alkalom</value>
</data>
<data name="Password" xml:space="preserve">
<value>Jelszó</value>
</data>
<data name="Perception" xml:space="preserve">
<value>Percepció</value>
</data>
<data name="Perceptions" xml:space="preserve">
<value>Perceptions</value>
</data>
<data name="PreferSystemTheming" xml:space="preserve">
<value>A rendszer témájának előnyben részesítése</value>
</data>
<data name="RecoveryCode" xml:space="preserve">
<value>Helyreállítási kód</value>
</data>
<data name="Recurring" xml:space="preserve">
<value>Visszatérő</value>
</data>
<data name="Scan for:" xml:space="preserve">
<value>Keresés:</value>
</data>
<data name="SearchText" xml:space="preserve">
<value>Keresés</value>
</data>
<data name="ShareCode" xml:space="preserve">
<value>Titkos kód megosztása</value>
</data>
<data name="ShowDonatorBadge" xml:space="preserve">
<value>Adományozói jelvény megjelenítése</value>
</data>
<data name="ShowLogs" xml:space="preserve">
<value>A naplók lap megjelenítése a hibaelhárítás támogatására.</value>
</data>
<data name="TakeType" xml:space="preserve">
<value>Vegye a típust</value>
</data>
<data name="TakeTypeSimple" xml:space="preserve">
<value>Egyszerű</value>
</data>
<data name="TakeTypeStructured" xml:space="preserve">
<value>Strukturált</value>
</data>
<data name="Theme image" xml:space="preserve">
<value>Témakép</value>
</data>
<data name="ThemeIsDarkMode" xml:space="preserve">
<value>Sötét üzemmód</value>
</data>
<data name="Ticks" xml:space="preserve">
<value>Kullancsok</value>
</data>
<data name="Title" xml:space="preserve">
<value>Cím</value>
</data>
<data name="UpdateReason" xml:space="preserve">
<value>Frissítés oka</value>
</data>
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
<value>Névtelenül beküldeni</value>
</data>
<data name="Username" xml:space="preserve">
<value>Felhasználónév</value>
</data>
<data name="Username to search and add" xml:space="preserve">
<value>Felhasználónév kereséshez és hozzáadáshoz</value>
</data>
<data name="Usernames to search and add" xml:space="preserve">
<value>Felhasználónevek keresése és hozzáadása</value>
</data>
</root>
@@ -117,123 +117,30 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="(necessary in case of password recovery)" xml:space="preserve">
<value>(necessaria in caso di recupero password)</value>
</data>
<data name="(password manager suggested)" xml:space="preserve">
<value>(è suggerito l'uso del &lt;a class="is-underlined has-text-info" tabindex="-1" target="_blank" href="https://keepassxc.org/"&gt;gestore password&lt;/a&gt;)</value>
</data>
<data name="Amount" xml:space="preserve">
<value>Importo</value>
</data>
<data name="AnswerId" xml:space="preserve">
<value>ID risposta</value>
</data>
<data name="AnswerText" xml:space="preserve">
<value>Testo risposta</value>
</data>
<data name="Character" xml:space="preserve">
<value>Carattere personalizzato (emoji)</value>
</data>
<data name="ColourIndex" xml:space="preserve">
<value>Colore</value>
</data>
<data name="CommentId" xml:space="preserve">
<value>ID commento</value>
</data>
<data name="CommentText" xml:space="preserve">
<value>Testo commento</value>
</data>
<data name="Confrontation" xml:space="preserve">
<value>Confronto</value>
</data>
<data name="ConfrontationId" xml:space="preserve">
<value>Id confronto</value>
</data>
<data name="Context" xml:space="preserve">
<value>Contesto</value>
</data>
<data name="DateEnd" xml:space="preserve">
<value>Data di fine</value>
</data>
<data name="DateStart" xml:space="preserve">
<value>Data di inizio</value>
</data>
<data name="Description" xml:space="preserve">
<value>Descrizione</value>
</data>
<data name="Discussion" xml:space="preserve">
<value>Discussione</value>
</data>
<data name="DiscussionEndDate" xml:space="preserve">
<value>Data di fine</value>
</data>
<data name="DiscussionEndTime" xml:space="preserve">
<value>Tempo di fine</value>
</data>
<data name="DiscussionId" xml:space="preserve">
<value>ID discussione</value>
</data>
<data name="DiscussionText" xml:space="preserve">
<value>Testo discussione</value>
</data>
<data name="DiscussionTitle" xml:space="preserve">
<value>Titolo discussione</value>
</data>
<data name="Email" xml:space="preserve">
<value>Email</value>
</data>
<data name="Emoji" xml:space="preserve">
<value>Emoji</value>
</data>
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
<value>Quantità massima di scontri</value>
</data>
<data name="Fact" xml:space="preserve">
<value>Fatto</value>
</data>
<data name="Facts" xml:space="preserve">
<value>I fatti</value>
</data>
<data name="FileId" xml:space="preserve">
<value>ID file</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Gruppi</value>
</data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>Includere la prima risposta nell'anteprima dell'invito? (rendendo più coinvolgente l'adesione alla discussione)</value>
</data>
<data name="InvitationCode" xml:space="preserve">
<value>Codice d'invito</value>
</data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Collegamento o codice di invito</value>
</data>
<data name="InvitationPassword" xml:space="preserve">
<value>Password dell'invito</value>
</data>
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
<value>Richiedi la password per l'anteprima</value>
</data>
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
<value>Richiedi la password per l'anteprima della discussione</value>
</data>
<data name="IsPasswordProtectedText" xml:space="preserve">
<value>Aggiungere la protezione con password</value>
</data>
<data name="IsStructured" xml:space="preserve">
<value>È strutturato</value>
</data>
<data name="LanguageId" xml:space="preserve">
<value>ID lingua</value>
</data>
<data name="Name" xml:space="preserve">
<value>Nome</value>
</data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Abilitare le notifiche?(utili per percepire aggiornamenti)</value>
</data>
<data name="NewPassword" xml:space="preserve">
<value>Nuova password</value>
</data>
@@ -243,78 +150,24 @@
<data name="OldPassword" xml:space="preserve">
<value>Attuale password</value>
</data>
<data name="OneTime" xml:space="preserve">
<value>Paga una volta</value>
</data>
<data name="Password" xml:space="preserve">
<value>Password</value>
</data>
<data name="Perception" xml:space="preserve">
<value>La percezione</value>
</data>
<data name="Perceptions" xml:space="preserve">
<value>Percezioni</value>
</data>
<data name="PreferSystemTheming" xml:space="preserve">
<value>Preferire il tema di sistema</value>
</data>
<data name="RecoveryCode" xml:space="preserve">
<value>Codice di recupero</value>
</data>
<data name="Recurring" xml:space="preserve">
<value>Ricorrente</value>
</data>
<data name="Scan for:" xml:space="preserve">
<value>Scansione per:</value>
</data>
<data name="SearchText" xml:space="preserve">
<value>Ricerca</value>
</data>
<data name="ShareCode" xml:space="preserve">
<value>Condivisione del codice segreto</value>
</data>
<data name="ShowDonatorBadge" xml:space="preserve">
<value>Mostra il badge del donatore</value>
</data>
<data name="ShowLogs" xml:space="preserve">
<value>Mostra il tab dei log a supporto della risoluzione dei problemi.</value>
</data>
<data name="TakeType" xml:space="preserve">
<value>Prendi il tipo</value>
</data>
<data name="TakeTypeSimple" xml:space="preserve">
<value>Semplice</value>
</data>
<data name="TakeTypeStructured" xml:space="preserve">
<value>Strutturato</value>
</data>
<data name="Theme image" xml:space="preserve">
<value>Immagine a tema</value>
</data>
<data name="ThemeIsDarkMode" xml:space="preserve">
<value>Modalità scura</value>
</data>
<data name="Ticks" xml:space="preserve">
<value>Zecche</value>
</data>
<data name="Title" xml:space="preserve">
<value>Titolo</value>
</data>
<data name="UpdateReason" xml:space="preserve">
<value>Motivo dell'aggiornamento</value>
</data>
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
<value>Invia in forma anonima</value>
</data>
<data name="Username" xml:space="preserve">
<value>Soprannome</value>
</data>
<data name="Username to search and add" xml:space="preserve">
<value>Soprannome da ricercare e aggiungere</value>
</data>
<data name="Usernames to search and add" xml:space="preserve">
<value>Soprannomi da ricercare e aggiungere</value>
</data>
<data name="AvatarId" xml:space="preserve">
<value>Avatar</value>
</data>
@@ -117,123 +117,30 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="(necessary in case of password recovery)" xml:space="preserve">
<value>(パスワード復旧時に必要)</value>
</data>
<data name="(password manager suggested)" xml:space="preserve">
<value>(&lt;a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/"&gt;パスワードマネージャー&lt;/a&gt;の提案)</value>
</data>
<data name="Amount" xml:space="preserve">
<value>金額</value>
</data>
<data name="AnswerId" xml:space="preserve">
<value>回答ID</value>
</data>
<data name="AnswerText" xml:space="preserve">
<value>回答テキスト</value>
</data>
<data name="Character" xml:space="preserve">
<value>カスタムキャラクター(絵文字)</value>
</data>
<data name="ColourIndex" xml:space="preserve">
<value>カラー</value>
</data>
<data name="CommentId" xml:space="preserve">
<value>コメントID</value>
</data>
<data name="CommentText" xml:space="preserve">
<value>コメント文</value>
</data>
<data name="Confrontation" xml:space="preserve">
<value>対決</value>
</data>
<data name="ConfrontationId" xml:space="preserve">
<value>コンフロンティアID</value>
</data>
<data name="Context" xml:space="preserve">
<value>コンテキスト</value>
</data>
<data name="DateEnd" xml:space="preserve">
<value>終了日</value>
</data>
<data name="DateStart" xml:space="preserve">
<value>開始日</value>
</data>
<data name="Description" xml:space="preserve">
<value>商品説明</value>
</data>
<data name="Discussion" xml:space="preserve">
<value>ディスカッション</value>
</data>
<data name="DiscussionEndDate" xml:space="preserve">
<value>終了日</value>
</data>
<data name="DiscussionEndTime" xml:space="preserve">
<value>終了時間</value>
</data>
<data name="DiscussionId" xml:space="preserve">
<value>ディスカッションID</value>
</data>
<data name="DiscussionText" xml:space="preserve">
<value>ディスカッションテキスト</value>
</data>
<data name="DiscussionTitle" xml:space="preserve">
<value>議論のタイトル</value>
</data>
<data name="Email" xml:space="preserve">
<value>電子メール</value>
</data>
<data name="Emoji" xml:space="preserve">
<value>絵文字</value>
</data>
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
<value>対決の最大量</value>
</data>
<data name="Fact" xml:space="preserve">
<value>事実</value>
</data>
<data name="Facts" xml:space="preserve">
<value>事実</value>
</data>
<data name="FileId" xml:space="preserve">
<value>ファイルID</value>
</data>
<data name="Groups" xml:space="preserve">
<value>グループ</value>
</data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>招待状のプレビューに最初の回答を含める?(議論への参加をより魅力的にするために)</value>
</data>
<data name="InvitationCode" xml:space="preserve">
<value>招待状コード</value>
</data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>招待コードまたはリンク</value>
</data>
<data name="InvitationPassword" xml:space="preserve">
<value>招待状のパスワード</value>
</data>
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
<value>プレビューにパスワードが必要</value>
</data>
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
<value>プレビューを表示するにはパスワードが必要です</value>
</data>
<data name="IsPasswordProtectedText" xml:space="preserve">
<value>パスワード保護機能の追加</value>
</data>
<data name="IsStructured" xml:space="preserve">
<value>IsStructured</value>
</data>
<data name="LanguageId" xml:space="preserve">
<value>言語ID</value>
</data>
<data name="Name" xml:space="preserve">
<value>名前</value>
</data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>通知を有効にしますか?(アプリの更新情報を受け取るのに便利です)</value>
</data>
<data name="NewPassword" xml:space="preserve">
<value>新しいパスワード</value>
</data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve">
<value>古いパスワード</value>
</data>
<data name="OneTime" xml:space="preserve">
<value>1回限り</value>
</data>
<data name="Password" xml:space="preserve">
<value>パスワード</value>
</data>
<data name="Perception" xml:space="preserve">
<value>パーセプション</value>
</data>
<data name="Perceptions" xml:space="preserve">
<value>認知度</value>
</data>
<data name="PreferSystemTheming" xml:space="preserve">
<value>システムテーマを優先する</value>
</data>
<data name="RecoveryCode" xml:space="preserve">
<value>リカバリーコード</value>
</data>
<data name="Recurring" xml:space="preserve">
<value>リカーリング</value>
</data>
<data name="Scan for:" xml:space="preserve">
<value>スキャンしてください:</value>
</data>
<data name="SearchText" xml:space="preserve">
<value>検索</value>
</data>
<data name="ShareCode" xml:space="preserve">
<value>秘密の暗号を共有する</value>
</data>
<data name="ShowDonatorBadge" xml:space="preserve">
<value>寄付者バッジを表示する</value>
</data>
<data name="ShowLogs" xml:space="preserve">
<value>トラブルシューティングをサポートするために、ログタブを表示します。</value>
</data>
<data name="TakeType" xml:space="preserve">
<value>テイクタイプ</value>
</data>
<data name="TakeTypeSimple" xml:space="preserve">
<value>シンプル</value>
</data>
<data name="TakeTypeStructured" xml:space="preserve">
<value>構造化</value>
</data>
<data name="Theme image" xml:space="preserve">
<value>テーマイメージ</value>
</data>
<data name="ThemeIsDarkMode" xml:space="preserve">
<value>ダークモード</value>
</data>
<data name="Ticks" xml:space="preserve">
<value>ダニ</value>
</data>
<data name="Title" xml:space="preserve">
<value>タイトル</value>
</data>
<data name="UpdateReason" xml:space="preserve">
<value>更新理由</value>
</data>
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
<value>匿名で投稿する</value>
</data>
<data name="Username" xml:space="preserve">
<value>ユーザー名</value>
</data>
<data name="Username to search and add" xml:space="preserve">
<value>検索して追加するユーザー名</value>
</data>
<data name="Usernames to search and add" xml:space="preserve">
<value>検索して追加するユーザー名</value>
</data>
</root>
@@ -117,123 +117,30 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="(necessary in case of password recovery)" xml:space="preserve">
<value>(būtina slaptažodžio atkūrimo atveju)</value>
</data>
<data name="(password manager suggested)" xml:space="preserve">
<value>(&lt;a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/"&gt;paslapčių tvarkyklė&lt;/a&gt; siūloma)</value>
</data>
<data name="Amount" xml:space="preserve">
<value>Suma</value>
</data>
<data name="AnswerId" xml:space="preserve">
<value>Atsakymas id</value>
</data>
<data name="AnswerText" xml:space="preserve">
<value>Atsakymo tekstas</value>
</data>
<data name="Character" xml:space="preserve">
<value>Pasirinktinis simbolis (emoji)</value>
</data>
<data name="ColourIndex" xml:space="preserve">
<value>Spalva</value>
</data>
<data name="CommentId" xml:space="preserve">
<value>Komentaras id</value>
</data>
<data name="CommentText" xml:space="preserve">
<value>Komentaro tekstas</value>
</data>
<data name="Confrontation" xml:space="preserve">
<value>Konfrontacija</value>
</data>
<data name="ConfrontationId" xml:space="preserve">
<value>Konfrontacijos id</value>
</data>
<data name="Context" xml:space="preserve">
<value>Kontekstas</value>
</data>
<data name="DateEnd" xml:space="preserve">
<value>Galutinė data</value>
</data>
<data name="DateStart" xml:space="preserve">
<value>Pradžios data</value>
</data>
<data name="Description" xml:space="preserve">
<value>Aprašymas</value>
</data>
<data name="Discussion" xml:space="preserve">
<value>Diskusija</value>
</data>
<data name="DiscussionEndDate" xml:space="preserve">
<value>Galutinė data</value>
</data>
<data name="DiscussionEndTime" xml:space="preserve">
<value>Pabaigos laikas</value>
</data>
<data name="DiscussionId" xml:space="preserve">
<value>Diskusijos id</value>
</data>
<data name="DiscussionText" xml:space="preserve">
<value>Diskusijos tekstas</value>
</data>
<data name="DiscussionTitle" xml:space="preserve">
<value>Diskusijos pavadinimas</value>
</data>
<data name="Email" xml:space="preserve">
<value>El. paštas</value>
</data>
<data name="Emoji" xml:space="preserve">
<value>Emoji</value>
</data>
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
<value>Didžiausias konfrontacijų skaičius</value>
</data>
<data name="Fact" xml:space="preserve">
<value>Faktas</value>
</data>
<data name="Facts" xml:space="preserve">
<value>Faktai</value>
</data>
<data name="FileId" xml:space="preserve">
<value>Failo ID</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Grupės</value>
</data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>Įtraukti pirmąjį atsakymą į kvietimo peržiūrą? (kad būtų patraukliau prisijungti prie diskusijos)</value>
</data>
<data name="InvitationCode" xml:space="preserve">
<value>Kvietimo kodas</value>
</data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Kvietimo kodas arba nuoroda</value>
</data>
<data name="InvitationPassword" xml:space="preserve">
<value>Kvietimo slaptažodis</value>
</data>
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
<value>Reikalauti slaptažodžio peržiūrai</value>
</data>
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
<value>Reikalauti slaptažodžio, kad būtų rodoma peržiūra</value>
</data>
<data name="IsPasswordProtectedText" xml:space="preserve">
<value>Pridėti apsaugą slaptažodžiu</value>
</data>
<data name="IsStructured" xml:space="preserve">
<value>IsStructured</value>
</data>
<data name="LanguageId" xml:space="preserve">
<value>Kalbos ID</value>
</data>
<data name="Name" xml:space="preserve">
<value>Pavadinimas</value>
</data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Įjungti pranešimus? (naudinga norint gauti programėlės atnaujinimus)</value>
</data>
<data name="NewPassword" xml:space="preserve">
<value>Naujas slaptažodis</value>
</data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve">
<value>Senasis slaptažodis</value>
</data>
<data name="OneTime" xml:space="preserve">
<value>Tik vieną kartą</value>
</data>
<data name="Password" xml:space="preserve">
<value>Slaptažodis</value>
</data>
<data name="Perception" xml:space="preserve">
<value>Suvokimas</value>
</data>
<data name="Perceptions" xml:space="preserve">
<value>Suvokimas</value>
</data>
<data name="PreferSystemTheming" xml:space="preserve">
<value>Pirmenybė sistemos temai</value>
</data>
<data name="RecoveryCode" xml:space="preserve">
<value>Atkūrimo kodas</value>
</data>
<data name="Recurring" xml:space="preserve">
<value>Pasikartojantis</value>
</data>
<data name="Scan for:" xml:space="preserve">
<value>Ieškoti:</value>
</data>
<data name="SearchText" xml:space="preserve">
<value>Paieška</value>
</data>
<data name="ShareCode" xml:space="preserve">
<value>Dalijimasis slaptuoju kodu</value>
</data>
<data name="ShowDonatorBadge" xml:space="preserve">
<value>Rodyti rėmėjo ženklelį</value>
</data>
<data name="ShowLogs" xml:space="preserve">
<value>Parodykite skirtuką "Žurnalai", kad galėtumėte padėti šalinti trikdžius.</value>
</data>
<data name="TakeType" xml:space="preserve">
<value>Paimkite tipą</value>
</data>
<data name="TakeTypeSimple" xml:space="preserve">
<value>Paprastas</value>
</data>
<data name="TakeTypeStructured" xml:space="preserve">
<value>Struktūrinis</value>
</data>
<data name="Theme image" xml:space="preserve">
<value>Temos vaizdas</value>
</data>
<data name="ThemeIsDarkMode" xml:space="preserve">
<value>Tamsus režimas</value>
</data>
<data name="Ticks" xml:space="preserve">
<value>Erkės</value>
</data>
<data name="Title" xml:space="preserve">
<value>Pavadinimas</value>
</data>
<data name="UpdateReason" xml:space="preserve">
<value>Atnaujinimo priežastis</value>
</data>
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
<value>Pateikti anonimiškai</value>
</data>
<data name="Username" xml:space="preserve">
<value>Vartotojo vardas</value>
</data>
<data name="Username to search and add" xml:space="preserve">
<value>Vartotojo vardas paieškai ir pridėjimui</value>
</data>
<data name="Usernames to search and add" xml:space="preserve">
<value>Vartotojų vardų paieška ir pridėjimas</value>
</data>
</root>
@@ -117,123 +117,30 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="(necessary in case of password recovery)" xml:space="preserve">
<value>(nepieciešams paroles atjaunošanas gadījumā)</value>
</data>
<data name="(password manager suggested)" xml:space="preserve">
<value>(&lt;a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/"&gt;vārdu pārvaldnieks&lt;/a&gt; ierosināts)</value>
</data>
<data name="Amount" xml:space="preserve">
<value>Summa</value>
</data>
<data name="AnswerId" xml:space="preserve">
<value>Atbilde id</value>
</data>
<data name="AnswerText" xml:space="preserve">
<value>Atbildes teksts</value>
</data>
<data name="Character" xml:space="preserve">
<value>Pielāgots raksturs (emodži)</value>
</data>
<data name="ColourIndex" xml:space="preserve">
<value>Krāsa</value>
</data>
<data name="CommentId" xml:space="preserve">
<value>Komentārs id</value>
</data>
<data name="CommentText" xml:space="preserve">
<value>Komentāru teksts</value>
</data>
<data name="Confrontation" xml:space="preserve">
<value>Konfrontācija</value>
</data>
<data name="ConfrontationId" xml:space="preserve">
<value>Konfrontācijas id</value>
</data>
<data name="Context" xml:space="preserve">
<value>Konteksts</value>
</data>
<data name="DateEnd" xml:space="preserve">
<value>Beigu datums</value>
</data>
<data name="DateStart" xml:space="preserve">
<value>Sākuma datums</value>
</data>
<data name="Description" xml:space="preserve">
<value>Apraksts</value>
</data>
<data name="Discussion" xml:space="preserve">
<value>Diskusija</value>
</data>
<data name="DiscussionEndDate" xml:space="preserve">
<value>Beigu datums</value>
</data>
<data name="DiscussionEndTime" xml:space="preserve">
<value>Beigu laiks</value>
</data>
<data name="DiscussionId" xml:space="preserve">
<value>Diskusijas id</value>
</data>
<data name="DiscussionText" xml:space="preserve">
<value>Diskusijas teksts</value>
</data>
<data name="DiscussionTitle" xml:space="preserve">
<value>Diskusijas nosaukums</value>
</data>
<data name="Email" xml:space="preserve">
<value>E-pasts</value>
</data>
<data name="Emoji" xml:space="preserve">
<value>Emocijas</value>
</data>
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
<value>Maksimālais konfrontāciju skaits</value>
</data>
<data name="Fact" xml:space="preserve">
<value>Fakti</value>
</data>
<data name="Facts" xml:space="preserve">
<value>Fakti</value>
</data>
<data name="FileId" xml:space="preserve">
<value>Faila id</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Grupas</value>
</data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>Iekļaut pirmo atbildi uzaicinājuma priekšskatījumā? (lai iesaistīšanās diskusijā būtu saistošāka)</value>
</data>
<data name="InvitationCode" xml:space="preserve">
<value>Uzaicinājuma kods</value>
</data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Uzaicinājuma kods vai saite</value>
</data>
<data name="InvitationPassword" xml:space="preserve">
<value>Uzaicinājuma parole</value>
</data>
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
<value>Pieprasīt paroli priekšskatīšanai</value>
</data>
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
<value>Pieprasīt paroli, lai parādītu priekšskatījumu</value>
</data>
<data name="IsPasswordProtectedText" xml:space="preserve">
<value>Pievienojiet paroles aizsardzību</value>
</data>
<data name="IsStructured" xml:space="preserve">
<value>IsStructured</value>
</data>
<data name="LanguageId" xml:space="preserve">
<value>Valodas identifikators</value>
</data>
<data name="Name" xml:space="preserve">
<value>Nosaukums</value>
</data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Ieslēgt paziņojumus?(noderīgi, lai saņemtu lietotnes atjauninājumus)</value>
</data>
<data name="NewPassword" xml:space="preserve">
<value>Jauna parole</value>
</data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve">
<value>Vecā parole</value>
</data>
<data name="OneTime" xml:space="preserve">
<value>Tikai vienu reizi</value>
</data>
<data name="Password" xml:space="preserve">
<value>Parole</value>
</data>
<data name="Perception" xml:space="preserve">
<value>Uztvere</value>
</data>
<data name="Perceptions" xml:space="preserve">
<value>Uzskati</value>
</data>
<data name="PreferSystemTheming" xml:space="preserve">
<value>Dodiet priekšroku sistēmas tēmai</value>
</data>
<data name="RecoveryCode" xml:space="preserve">
<value>Atgūšanas kods</value>
</data>
<data name="Recurring" xml:space="preserve">
<value>Atkārtoti</value>
</data>
<data name="Scan for:" xml:space="preserve">
<value>Skenēt, lai atrastu:</value>
</data>
<data name="SearchText" xml:space="preserve">
<value>Meklēšana</value>
</data>
<data name="ShareCode" xml:space="preserve">
<value>Koplietošanas slepenais kods</value>
</data>
<data name="ShowDonatorBadge" xml:space="preserve">
<value>Rādīt ziedotāja nozīmīti</value>
</data>
<data name="ShowLogs" xml:space="preserve">
<value>Parādiet cilni žurnāli, lai atbalstītu problēmu novēršanu.</value>
</data>
<data name="TakeType" xml:space="preserve">
<value>Veikt veidu</value>
</data>
<data name="TakeTypeSimple" xml:space="preserve">
<value>Vienkāršs</value>
</data>
<data name="TakeTypeStructured" xml:space="preserve">
<value>Strukturēts</value>
</data>
<data name="Theme image" xml:space="preserve">
<value>Tēmas attēls</value>
</data>
<data name="ThemeIsDarkMode" xml:space="preserve">
<value>Tumšais režīms</value>
</data>
<data name="Ticks" xml:space="preserve">
<value>Ērces</value>
</data>
<data name="Title" xml:space="preserve">
<value>Nosaukums</value>
</data>
<data name="UpdateReason" xml:space="preserve">
<value>Atjaunināšanas iemesls</value>
</data>
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
<value>Iesniedziet anonīmi</value>
</data>
<data name="Username" xml:space="preserve">
<value>Lietotājvārds</value>
</data>
<data name="Username to search and add" xml:space="preserve">
<value>Lietotājvārds, lai meklētu un pievienotu</value>
</data>
<data name="Usernames to search and add" xml:space="preserve">
<value>Meklēšanas un pievienošanas lietotājvārdi</value>
</data>
</root>
@@ -117,123 +117,30 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="(necessary in case of password recovery)" xml:space="preserve">
<value>(nodig in geval van wachtwoordherstel)</value>
</data>
<data name="(password manager suggested)" xml:space="preserve">
<value>(&lt;a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/"&gt;wachtwoord manager&lt;/a&gt; voorgesteld)</value>
</data>
<data name="Amount" xml:space="preserve">
<value>Bedrag</value>
</data>
<data name="AnswerId" xml:space="preserve">
<value>Antwoord id</value>
</data>
<data name="AnswerText" xml:space="preserve">
<value>Antwoord tekst</value>
</data>
<data name="Character" xml:space="preserve">
<value>Aangepast karakter (emoji)</value>
</data>
<data name="ColourIndex" xml:space="preserve">
<value>Kleur</value>
</data>
<data name="CommentId" xml:space="preserve">
<value>Commentaar id</value>
</data>
<data name="CommentText" xml:space="preserve">
<value>Commentaar tekst</value>
</data>
<data name="Confrontation" xml:space="preserve">
<value>Confrontatie</value>
</data>
<data name="ConfrontationId" xml:space="preserve">
<value>Confrontatie id</value>
</data>
<data name="Context" xml:space="preserve">
<value>Context</value>
</data>
<data name="DateEnd" xml:space="preserve">
<value>Einddatum</value>
</data>
<data name="DateStart" xml:space="preserve">
<value>Startdatum</value>
</data>
<data name="Description" xml:space="preserve">
<value>Beschrijving</value>
</data>
<data name="Discussion" xml:space="preserve">
<value>Discussie</value>
</data>
<data name="DiscussionEndDate" xml:space="preserve">
<value>Einddatum</value>
</data>
<data name="DiscussionEndTime" xml:space="preserve">
<value>Eindtijd</value>
</data>
<data name="DiscussionId" xml:space="preserve">
<value>Discussie id</value>
</data>
<data name="DiscussionText" xml:space="preserve">
<value>Besprekingstekst</value>
</data>
<data name="DiscussionTitle" xml:space="preserve">
<value>Titel van de discussie</value>
</data>
<data name="Email" xml:space="preserve">
<value>E-mail</value>
</data>
<data name="Emoji" xml:space="preserve">
<value>Emoji</value>
</data>
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
<value>Maximum aantal confrontaties</value>
</data>
<data name="Fact" xml:space="preserve">
<value>Feit</value>
</data>
<data name="Facts" xml:space="preserve">
<value>Feiten</value>
</data>
<data name="FileId" xml:space="preserve">
<value>Bestand id</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Groepen</value>
</data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>Het eerste antwoord opnemen in de preview van de uitnodiging? (maakt het aantrekkelijker om deel te nemen aan de discussie)</value>
</data>
<data name="InvitationCode" xml:space="preserve">
<value>Uitnodiging code</value>
</data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Uitnodigingscode of link</value>
</data>
<data name="InvitationPassword" xml:space="preserve">
<value>Uitnodiging wachtwoord</value>
</data>
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
<value>Wachtwoord nodig voor preview</value>
</data>
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
<value>Wachtwoord vereist om de preview te tonen</value>
</data>
<data name="IsPasswordProtectedText" xml:space="preserve">
<value>Wachtwoordbeveiliging toevoegen</value>
</data>
<data name="IsStructured" xml:space="preserve">
<value>IsStructured</value>
</data>
<data name="LanguageId" xml:space="preserve">
<value>Taal id</value>
</data>
<data name="Name" xml:space="preserve">
<value>Naam</value>
</data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Meldingen inschakelen?(handig om app updates te ontvangen)</value>
</data>
<data name="NewPassword" xml:space="preserve">
<value>Nieuw wachtwoord</value>
</data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve">
<value>Oud wachtwoord</value>
</data>
<data name="OneTime" xml:space="preserve">
<value>Eenmalig</value>
</data>
<data name="Password" xml:space="preserve">
<value>Wachtwoord</value>
</data>
<data name="Perception" xml:space="preserve">
<value>Perceptie</value>
</data>
<data name="Perceptions" xml:space="preserve">
<value>Percepties</value>
</data>
<data name="PreferSystemTheming" xml:space="preserve">
<value>Voorkeur systeemthema</value>
</data>
<data name="RecoveryCode" xml:space="preserve">
<value>Herstelcode</value>
</data>
<data name="Recurring" xml:space="preserve">
<value>Terugkerend</value>
</data>
<data name="Scan for:" xml:space="preserve">
<value>Scan voor:</value>
</data>
<data name="SearchText" xml:space="preserve">
<value>Zoeken op</value>
</data>
<data name="ShareCode" xml:space="preserve">
<value>Het delen van de geheime code</value>
</data>
<data name="ShowDonatorBadge" xml:space="preserve">
<value>Toon donateur badge</value>
</data>
<data name="ShowLogs" xml:space="preserve">
<value>Toon de logs tab ter ondersteuning van troubleshooting.</value>
</data>
<data name="TakeType" xml:space="preserve">
<value>Neem type</value>
</data>
<data name="TakeTypeSimple" xml:space="preserve">
<value>Eenvoudig</value>
</data>
<data name="TakeTypeStructured" xml:space="preserve">
<value>Gestructureerd</value>
</data>
<data name="Theme image" xml:space="preserve">
<value>Thema-afbeelding</value>
</data>
<data name="ThemeIsDarkMode" xml:space="preserve">
<value>Donkere modus</value>
</data>
<data name="Ticks" xml:space="preserve">
<value>Teken</value>
</data>
<data name="Title" xml:space="preserve">
<value>Titel</value>
</data>
<data name="UpdateReason" xml:space="preserve">
<value>Update reden</value>
</data>
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
<value>Anoniem indienen</value>
</data>
<data name="Username" xml:space="preserve">
<value>Gebruikersnaam</value>
</data>
<data name="Username to search and add" xml:space="preserve">
<value>Gebruikersnaam om te zoeken en toe te voegen</value>
</data>
<data name="Usernames to search and add" xml:space="preserve">
<value>Gebruikersnamen om te zoeken en toe te voegen</value>
</data>
</root>
@@ -117,123 +117,30 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="(necessary in case of password recovery)" xml:space="preserve">
<value>(konieczne w przypadku odzyskiwania hasła)</value>
</data>
<data name="(password manager suggested)" xml:space="preserve">
<value>(&lt;a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/"&gt;password manager&lt;/a&gt; sugerowane)</value>
</data>
<data name="Amount" xml:space="preserve">
<value>Kwota</value>
</data>
<data name="AnswerId" xml:space="preserve">
<value>Odpowiedź</value>
</data>
<data name="AnswerText" xml:space="preserve">
<value>Odpowiedź</value>
</data>
<data name="Character" xml:space="preserve">
<value>Znak niestandardowy (emoji)</value>
</data>
<data name="ColourIndex" xml:space="preserve">
<value>Kolor</value>
</data>
<data name="CommentId" xml:space="preserve">
<value>Komentarz id</value>
</data>
<data name="CommentText" xml:space="preserve">
<value>Tekst komentarza</value>
</data>
<data name="Confrontation" xml:space="preserve">
<value>Konfrontacja</value>
</data>
<data name="ConfrontationId" xml:space="preserve">
<value>Konfrontacja id</value>
</data>
<data name="Context" xml:space="preserve">
<value>Kontekst</value>
</data>
<data name="DateEnd" xml:space="preserve">
<value>Data końcowa</value>
</data>
<data name="DateStart" xml:space="preserve">
<value>Data rozpoczęcia</value>
</data>
<data name="Description" xml:space="preserve">
<value>Opis</value>
</data>
<data name="Discussion" xml:space="preserve">
<value>Dyskusja</value>
</data>
<data name="DiscussionEndDate" xml:space="preserve">
<value>Data końcowa</value>
</data>
<data name="DiscussionEndTime" xml:space="preserve">
<value>Czas zakończenia</value>
</data>
<data name="DiscussionId" xml:space="preserve">
<value>Dyskusja id</value>
</data>
<data name="DiscussionText" xml:space="preserve">
<value>Tekst do dyskusji</value>
</data>
<data name="DiscussionTitle" xml:space="preserve">
<value>Tytuł dyskusji</value>
</data>
<data name="Email" xml:space="preserve">
<value>E-mail</value>
</data>
<data name="Emoji" xml:space="preserve">
<value>Emoji</value>
</data>
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
<value>Maksymalna ilość konfrontacji</value>
</data>
<data name="Fact" xml:space="preserve">
<value>Fakt</value>
</data>
<data name="Facts" xml:space="preserve">
<value>Fakty</value>
</data>
<data name="FileId" xml:space="preserve">
<value>Id pliku</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Grupy</value>
</data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>Zamieścić pierwszą odpowiedź w podglądzie zaproszenia? (sprawia, że dołączenie do dyskusji jest bardziej zachęcające)</value>
</data>
<data name="InvitationCode" xml:space="preserve">
<value>Kod zaproszenia</value>
</data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Kod lub link do zaproszenia</value>
</data>
<data name="InvitationPassword" xml:space="preserve">
<value>Hasło do zaproszenia</value>
</data>
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
<value>Wymagaj hasła do podglądu</value>
</data>
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
<value>Wymagaj hasła, aby wyświetlić podgląd</value>
</data>
<data name="IsPasswordProtectedText" xml:space="preserve">
<value>Dodaj ochronę hasłem</value>
</data>
<data name="IsStructured" xml:space="preserve">
<value>IsStructured</value>
</data>
<data name="LanguageId" xml:space="preserve">
<value>Język id</value>
</data>
<data name="Name" xml:space="preserve">
<value>Nazwa</value>
</data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Włączyć powiadomienia? (przydatne do otrzymywania aktualizacji aplikacji)</value>
</data>
<data name="NewPassword" xml:space="preserve">
<value>Nowe hasło</value>
</data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve">
<value>Stare hasło</value>
</data>
<data name="OneTime" xml:space="preserve">
<value>Tylko jeden raz</value>
</data>
<data name="Password" xml:space="preserve">
<value>Hasło</value>
</data>
<data name="Perception" xml:space="preserve">
<value>Percepcja</value>
</data>
<data name="Perceptions" xml:space="preserve">
<value>Spostrzeżenia</value>
</data>
<data name="PreferSystemTheming" xml:space="preserve">
<value>Preferowany motyw systemowy</value>
</data>
<data name="RecoveryCode" xml:space="preserve">
<value>Kod odzyskiwania</value>
</data>
<data name="Recurring" xml:space="preserve">
<value>Powtarzające się</value>
</data>
<data name="Scan for:" xml:space="preserve">
<value>Skanuj dla:</value>
</data>
<data name="SearchText" xml:space="preserve">
<value>Szukaj</value>
</data>
<data name="ShareCode" xml:space="preserve">
<value>Dzielenie się tajnym kodem</value>
</data>
<data name="ShowDonatorBadge" xml:space="preserve">
<value>Pokaż odznakę darczyńcy</value>
</data>
<data name="ShowLogs" xml:space="preserve">
<value>Pokaż kartę logów, aby ułatwić rozwiązywanie problemów.</value>
</data>
<data name="TakeType" xml:space="preserve">
<value>Rodzaj ujęcia</value>
</data>
<data name="TakeTypeSimple" xml:space="preserve">
<value>Proste</value>
</data>
<data name="TakeTypeStructured" xml:space="preserve">
<value>Strukturalne</value>
</data>
<data name="Theme image" xml:space="preserve">
<value>Obrazek tematyczny</value>
</data>
<data name="ThemeIsDarkMode" xml:space="preserve">
<value>Tryb ciemny</value>
</data>
<data name="Ticks" xml:space="preserve">
<value>Kleszcze</value>
</data>
<data name="Title" xml:space="preserve">
<value>Tytuł</value>
</data>
<data name="UpdateReason" xml:space="preserve">
<value>Powód aktualizacji</value>
</data>
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
<value>Zgłoś się anonimowo</value>
</data>
<data name="Username" xml:space="preserve">
<value>Nazwa użytkownika</value>
</data>
<data name="Username to search and add" xml:space="preserve">
<value>Nazwa użytkownika do wyszukiwania i dodawania</value>
</data>
<data name="Usernames to search and add" xml:space="preserve">
<value>Nazwy użytkowników do wyszukiwania i dodawania</value>
</data>
</root>
@@ -117,123 +117,30 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="(necessary in case of password recovery)" xml:space="preserve">
<value>(necessário em caso de recuperação de senha)</value>
</data>
<data name="(password manager suggested)" xml:space="preserve">
<value>(&lt;a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/"&gt;gerenciador de palavras-passe&lt;/a&gt; sugerido)</value>
</data>
<data name="Amount" xml:space="preserve">
<value>Montante</value>
</data>
<data name="AnswerId" xml:space="preserve">
<value>Responder id</value>
</data>
<data name="AnswerText" xml:space="preserve">
<value>Responder texto</value>
</data>
<data name="Character" xml:space="preserve">
<value>Personalidade(emoji)</value>
</data>
<data name="ColourIndex" xml:space="preserve">
<value>Cor</value>
</data>
<data name="CommentId" xml:space="preserve">
<value>Comentar id</value>
</data>
<data name="CommentText" xml:space="preserve">
<value>Comentar texto</value>
</data>
<data name="Confrontation" xml:space="preserve">
<value>Confrontação</value>
</data>
<data name="ConfrontationId" xml:space="preserve">
<value>Id de confronto</value>
</data>
<data name="Context" xml:space="preserve">
<value>Contexto</value>
</data>
<data name="DateEnd" xml:space="preserve">
<value>Data final</value>
</data>
<data name="DateStart" xml:space="preserve">
<value>Data de início</value>
</data>
<data name="Description" xml:space="preserve">
<value>Descrição</value>
</data>
<data name="Discussion" xml:space="preserve">
<value>Discussão</value>
</data>
<data name="DiscussionEndDate" xml:space="preserve">
<value>Data final</value>
</data>
<data name="DiscussionEndTime" xml:space="preserve">
<value>Tempo final</value>
</data>
<data name="DiscussionId" xml:space="preserve">
<value>Id de discussão</value>
</data>
<data name="DiscussionText" xml:space="preserve">
<value>Texto para discussão</value>
</data>
<data name="DiscussionTitle" xml:space="preserve">
<value>Título do debate</value>
</data>
<data name="Email" xml:space="preserve">
<value>Email</value>
</data>
<data name="Emoji" xml:space="preserve">
<value>Emoji</value>
</data>
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
<value>Quantidade máxima de confrontos</value>
</data>
<data name="Fact" xml:space="preserve">
<value>Facto</value>
</data>
<data name="Facts" xml:space="preserve">
<value>Fatos</value>
</data>
<data name="FileId" xml:space="preserve">
<value>Identificação do arquivo</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Grupos</value>
</data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>Incluir a primeira resposta na pré-visualização do convite? (tornando-a mais envolvente para participar da discussão)</value>
</data>
<data name="InvitationCode" xml:space="preserve">
<value>Código de convite</value>
</data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Código do convite ou link</value>
</data>
<data name="InvitationPassword" xml:space="preserve">
<value>Senha do convite</value>
</data>
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
<value>Exigir senha para visualização</value>
</data>
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
<value>Exigir senha para mostrar a visualização</value>
</data>
<data name="IsPasswordProtectedText" xml:space="preserve">
<value>Adicionar proteção por senha</value>
</data>
<data name="IsStructured" xml:space="preserve">
<value>IsStructured</value>
</data>
<data name="LanguageId" xml:space="preserve">
<value>Identificação do idioma</value>
</data>
<data name="Name" xml:space="preserve">
<value>Nome</value>
</data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Habilitar notificações?(útil para receber atualizações de aplicativos)</value>
</data>
<data name="NewPassword" xml:space="preserve">
<value>Nova senha</value>
</data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve">
<value>Senha antiga</value>
</data>
<data name="OneTime" xml:space="preserve">
<value>Só uma vez</value>
</data>
<data name="Password" xml:space="preserve">
<value>Senha</value>
</data>
<data name="Perception" xml:space="preserve">
<value>Percepção</value>
</data>
<data name="Perceptions" xml:space="preserve">
<value>Percepções</value>
</data>
<data name="PreferSystemTheming" xml:space="preserve">
<value>Tema do sistema de preferências</value>
</data>
<data name="RecoveryCode" xml:space="preserve">
<value>Código de recuperação</value>
</data>
<data name="Recurring" xml:space="preserve">
<value>Recurrente</value>
</data>
<data name="Scan for:" xml:space="preserve">
<value>Procura por:</value>
</data>
<data name="SearchText" xml:space="preserve">
<value>Pesquisa</value>
</data>
<data name="ShareCode" xml:space="preserve">
<value>Partilha do Código Secreto</value>
</data>
<data name="ShowDonatorBadge" xml:space="preserve">
<value>Mostrar crachá do doador</value>
</data>
<data name="ShowLogs" xml:space="preserve">
<value>Mostrar o separador de registos para suportar a resolução de problemas.</value>
</data>
<data name="TakeType" xml:space="preserve">
<value>Tirar tipo</value>
</data>
<data name="TakeTypeSimple" xml:space="preserve">
<value>Simples</value>
</data>
<data name="TakeTypeStructured" xml:space="preserve">
<value>Estruturado</value>
</data>
<data name="Theme image" xml:space="preserve">
<value>Imagem temática</value>
</data>
<data name="ThemeIsDarkMode" xml:space="preserve">
<value>Modo escuro</value>
</data>
<data name="Ticks" xml:space="preserve">
<value>Carraças</value>
</data>
<data name="Title" xml:space="preserve">
<value>Título</value>
</data>
<data name="UpdateReason" xml:space="preserve">
<value>Motivo da actualização</value>
</data>
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
<value>Submeter de forma anónima</value>
</data>
<data name="Username" xml:space="preserve">
<value>Nome de usuário</value>
</data>
<data name="Username to search and add" xml:space="preserve">
<value>Nome de usuário para pesquisar e adicionar</value>
</data>
<data name="Usernames to search and add" xml:space="preserve">
<value>Nomes de usuário para pesquisar e adicionar</value>
</data>
</root>
@@ -117,123 +117,30 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="(necessary in case of password recovery)" xml:space="preserve">
<value>(necessary in case of password recovery)</value>
</data>
<data name="(password manager suggested)" xml:space="preserve">
<value>(&lt;a class="is-underlined has-text-info" tabindex="-1" target="_blank" href="https://keepassxc.org/"&gt;password manager&lt;/a&gt; suggested)</value>
</data>
<data name="Amount" xml:space="preserve">
<value>Amount</value>
</data>
<data name="AnswerId" xml:space="preserve">
<value>Answer id</value>
</data>
<data name="AnswerText" xml:space="preserve">
<value>Answer text</value>
</data>
<data name="Character" xml:space="preserve">
<value>Custom character(emoji)</value>
</data>
<data name="ColourIndex" xml:space="preserve">
<value>Colour</value>
</data>
<data name="CommentId" xml:space="preserve">
<value>Comment id</value>
</data>
<data name="CommentText" xml:space="preserve">
<value>Comment text</value>
</data>
<data name="Confrontation" xml:space="preserve">
<value>Confrontation</value>
</data>
<data name="ConfrontationId" xml:space="preserve">
<value>Confrontation id</value>
</data>
<data name="Context" xml:space="preserve">
<value>Context</value>
</data>
<data name="DateEnd" xml:space="preserve">
<value>End date</value>
</data>
<data name="DateStart" xml:space="preserve">
<value>Start date</value>
</data>
<data name="Description" xml:space="preserve">
<value>Description</value>
</data>
<data name="Discussion" xml:space="preserve">
<value>Discussion</value>
</data>
<data name="DiscussionEndDate" xml:space="preserve">
<value>End date</value>
</data>
<data name="DiscussionEndTime" xml:space="preserve">
<value>End time</value>
</data>
<data name="DiscussionId" xml:space="preserve">
<value>Discussion id</value>
</data>
<data name="DiscussionText" xml:space="preserve">
<value>Discussion text</value>
</data>
<data name="DiscussionTitle" xml:space="preserve">
<value>Discussion title</value>
</data>
<data name="Email" xml:space="preserve">
<value>Email</value>
</data>
<data name="Emoji" xml:space="preserve">
<value>Emoji</value>
</data>
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
<value>Max amount of confrontations</value>
</data>
<data name="Fact" xml:space="preserve">
<value>Fact</value>
</data>
<data name="Facts" xml:space="preserve">
<value>Facts</value>
</data>
<data name="FileId" xml:space="preserve">
<value>File id</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Groups</value>
</data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>Include the first answer in the invitation preview? (making it more engaging to join the discussion)</value>
</data>
<data name="InvitationCode" xml:space="preserve">
<value>Invitation code</value>
</data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Invitation code or link</value>
</data>
<data name="InvitationPassword" xml:space="preserve">
<value>Invitation password</value>
</data>
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
<value>Require password for preview</value>
</data>
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
<value>Require password to show the preview</value>
</data>
<data name="IsPasswordProtectedText" xml:space="preserve">
<value>Add password protection</value>
</data>
<data name="IsStructured" xml:space="preserve">
<value>IsStructured</value>
</data>
<data name="LanguageId" xml:space="preserve">
<value>Language id</value>
</data>
<data name="Name" xml:space="preserve">
<value>Name</value>
</data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Enable notifications?(useful to receive app updates)</value>
</data>
<data name="NewPassword" xml:space="preserve">
<value>New password</value>
</data>
@@ -243,78 +150,24 @@
<data name="OldPassword" xml:space="preserve">
<value>Old password</value>
</data>
<data name="OneTime" xml:space="preserve">
<value>One time only</value>
</data>
<data name="Password" xml:space="preserve">
<value>Password</value>
</data>
<data name="Perception" xml:space="preserve">
<value>Perception</value>
</data>
<data name="Perceptions" xml:space="preserve">
<value>Perceptions</value>
</data>
<data name="PreferSystemTheming" xml:space="preserve">
<value>Prefer system theme</value>
</data>
<data name="RecoveryCode" xml:space="preserve">
<value>Recovery code</value>
</data>
<data name="Recurring" xml:space="preserve">
<value>Recurring</value>
</data>
<data name="Scan for:" xml:space="preserve">
<value>Scan for:</value>
</data>
<data name="SearchText" xml:space="preserve">
<value>Search</value>
</data>
<data name="ShareCode" xml:space="preserve">
<value>Sharing Secret Code</value>
</data>
<data name="ShowDonatorBadge" xml:space="preserve">
<value>Show donator badge</value>
</data>
<data name="ShowLogs" xml:space="preserve">
<value>Show the logs tab to support troubleshooting.</value>
</data>
<data name="TakeType" xml:space="preserve">
<value>Take type</value>
</data>
<data name="TakeTypeSimple" xml:space="preserve">
<value>Simple</value>
</data>
<data name="TakeTypeStructured" xml:space="preserve">
<value>Structured</value>
</data>
<data name="Theme image" xml:space="preserve">
<value>Theme image</value>
</data>
<data name="ThemeIsDarkMode" xml:space="preserve">
<value>Dark mode</value>
</data>
<data name="Ticks" xml:space="preserve">
<value>Ticks</value>
</data>
<data name="Title" xml:space="preserve">
<value>Title</value>
</data>
<data name="UpdateReason" xml:space="preserve">
<value>Update reason</value>
</data>
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
<value>Submit anonymously</value>
</data>
<data name="Username" xml:space="preserve">
<value>Username</value>
</data>
<data name="Username to search and add" xml:space="preserve">
<value>Username to search and add</value>
</data>
<data name="Usernames to search and add" xml:space="preserve">
<value>Usernames to search and add</value>
</data>
<data name="AvatarId" xml:space="preserve">
<value>Avatar</value>
</data>
@@ -117,123 +117,30 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="(necessary in case of password recovery)" xml:space="preserve">
<value>(necesar în cazul recuperării parolei)</value>
</data>
<data name="(password manager suggested)" xml:space="preserve">
<value>(&lt;a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/"&gt;manager de parole&lt;/a&gt; sugerat)</value>
</data>
<data name="Amount" xml:space="preserve">
<value>Suma</value>
</data>
<data name="AnswerId" xml:space="preserve">
<value>Răspuns id</value>
</data>
<data name="AnswerText" xml:space="preserve">
<value>Text de răspuns</value>
</data>
<data name="Character" xml:space="preserve">
<value>Caracter personalizat (emoji)</value>
</data>
<data name="ColourIndex" xml:space="preserve">
<value>Culoare</value>
</data>
<data name="CommentId" xml:space="preserve">
<value>Comentariu id</value>
</data>
<data name="CommentText" xml:space="preserve">
<value>Comentariu text</value>
</data>
<data name="Confrontation" xml:space="preserve">
<value>Confruntare</value>
</data>
<data name="ConfrontationId" xml:space="preserve">
<value>Id de confruntare</value>
</data>
<data name="Context" xml:space="preserve">
<value>Context</value>
</data>
<data name="DateEnd" xml:space="preserve">
<value>Data de încheiere</value>
</data>
<data name="DateStart" xml:space="preserve">
<value>Data de începere</value>
</data>
<data name="Description" xml:space="preserve">
<value>Descriere</value>
</data>
<data name="Discussion" xml:space="preserve">
<value>Discuție</value>
</data>
<data name="DiscussionEndDate" xml:space="preserve">
<value>Data de încheiere</value>
</data>
<data name="DiscussionEndTime" xml:space="preserve">
<value>Ora de sfârșit</value>
</data>
<data name="DiscussionId" xml:space="preserve">
<value>Discuție id</value>
</data>
<data name="DiscussionText" xml:space="preserve">
<value>Text de discuție</value>
</data>
<data name="DiscussionTitle" xml:space="preserve">
<value>Titlul discuției</value>
</data>
<data name="Email" xml:space="preserve">
<value>Email</value>
</data>
<data name="Emoji" xml:space="preserve">
<value>Emoji</value>
</data>
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
<value>Numărul maxim de confruntări</value>
</data>
<data name="Fact" xml:space="preserve">
<value>Fapt</value>
</data>
<data name="Facts" xml:space="preserve">
<value>Fapte</value>
</data>
<data name="FileId" xml:space="preserve">
<value>ID fișier</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Grupuri</value>
</data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>Includeți primul răspuns în previzualizarea invitației? (pentru a face mai atractivă participarea la discuție)</value>
</data>
<data name="InvitationCode" xml:space="preserve">
<value>Codul de invitație</value>
</data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Cod sau link de invitație</value>
</data>
<data name="InvitationPassword" xml:space="preserve">
<value>Parola de invitație</value>
</data>
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
<value>Necesită o parolă pentru previzualizare</value>
</data>
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
<value>Necesită o parolă pentru a afișa previzualizarea</value>
</data>
<data name="IsPasswordProtectedText" xml:space="preserve">
<value>Adăugați protecție prin parolă</value>
</data>
<data name="IsStructured" xml:space="preserve">
<value>IsStructured</value>
</data>
<data name="LanguageId" xml:space="preserve">
<value>ID-ul limbii</value>
</data>
<data name="Name" xml:space="preserve">
<value>Nume</value>
</data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Activați notificările?(util pentru a primi actualizări ale aplicației)</value>
</data>
<data name="NewPassword" xml:space="preserve">
<value>Parolă nouă</value>
</data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve">
<value>Parolă veche</value>
</data>
<data name="OneTime" xml:space="preserve">
<value>O singură dată</value>
</data>
<data name="Password" xml:space="preserve">
<value>Parola</value>
</data>
<data name="Perception" xml:space="preserve">
<value>Percepție</value>
</data>
<data name="Perceptions" xml:space="preserve">
<value>Percepții</value>
</data>
<data name="PreferSystemTheming" xml:space="preserve">
<value>Preferați tema de sistem</value>
</data>
<data name="RecoveryCode" xml:space="preserve">
<value>Cod de recuperare</value>
</data>
<data name="Recurring" xml:space="preserve">
<value>Recurent</value>
</data>
<data name="Scan for:" xml:space="preserve">
<value>Scanează pentru:</value>
</data>
<data name="SearchText" xml:space="preserve">
<value>Căutare</value>
</data>
<data name="ShareCode" xml:space="preserve">
<value>Partajarea codului secret</value>
</data>
<data name="ShowDonatorBadge" xml:space="preserve">
<value>Arată insigna de donator</value>
</data>
<data name="ShowLogs" xml:space="preserve">
<value>Afișați fila jurnale pentru a sprijini depanarea.</value>
</data>
<data name="TakeType" xml:space="preserve">
<value>Tipul de luare</value>
</data>
<data name="TakeTypeSimple" xml:space="preserve">
<value>Simplu</value>
</data>
<data name="TakeTypeStructured" xml:space="preserve">
<value>Structurat</value>
</data>
<data name="Theme image" xml:space="preserve">
<value>Imagine tematică</value>
</data>
<data name="ThemeIsDarkMode" xml:space="preserve">
<value>Mod întunecat</value>
</data>
<data name="Ticks" xml:space="preserve">
<value>Căpușe</value>
</data>
<data name="Title" xml:space="preserve">
<value>Titlu</value>
</data>
<data name="UpdateReason" xml:space="preserve">
<value>Motivul actualizării</value>
</data>
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
<value>Trimiteți în mod anonim</value>
</data>
<data name="Username" xml:space="preserve">
<value>Nume utilizator</value>
</data>
<data name="Username to search and add" xml:space="preserve">
<value>Nume de utilizator pentru a căuta și adăuga</value>
</data>
<data name="Usernames to search and add" xml:space="preserve">
<value>Nume de utilizator pentru căutare și adăugare</value>
</data>
</root>
@@ -117,123 +117,30 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="(necessary in case of password recovery)" xml:space="preserve">
<value>(необходимо в случае восстановления пароля)</value>
</data>
<data name="(password manager suggested)" xml:space="preserve">
<value>(Предлагается &lt;a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/"&gt;password manager&lt;/a&gt;)</value>
</data>
<data name="Amount" xml:space="preserve">
<value>Сумма</value>
</data>
<data name="AnswerId" xml:space="preserve">
<value>Идентификатор ответа</value>
</data>
<data name="AnswerText" xml:space="preserve">
<value>Текст ответа</value>
</data>
<data name="Character" xml:space="preserve">
<value>Пользовательский символ (эмодзи)</value>
</data>
<data name="ColourIndex" xml:space="preserve">
<value>Цвет</value>
</data>
<data name="CommentId" xml:space="preserve">
<value>Идентификатор комментария</value>
</data>
<data name="CommentText" xml:space="preserve">
<value>Текст комментария</value>
</data>
<data name="Confrontation" xml:space="preserve">
<value>Противостояние</value>
</data>
<data name="ConfrontationId" xml:space="preserve">
<value>Идентификатор противостояния</value>
</data>
<data name="Context" xml:space="preserve">
<value>Контекст</value>
</data>
<data name="DateEnd" xml:space="preserve">
<value>Дата окончания</value>
</data>
<data name="DateStart" xml:space="preserve">
<value>Дата начала</value>
</data>
<data name="Description" xml:space="preserve">
<value>Описание</value>
</data>
<data name="Discussion" xml:space="preserve">
<value>Обсуждение</value>
</data>
<data name="DiscussionEndDate" xml:space="preserve">
<value>Дата окончания</value>
</data>
<data name="DiscussionEndTime" xml:space="preserve">
<value>Время окончания</value>
</data>
<data name="DiscussionId" xml:space="preserve">
<value>Обсуждаемый иди</value>
</data>
<data name="DiscussionText" xml:space="preserve">
<value>Текст для обсуждения</value>
</data>
<data name="DiscussionTitle" xml:space="preserve">
<value>Название дискуссии</value>
</data>
<data name="Email" xml:space="preserve">
<value>Электронная почта</value>
</data>
<data name="Emoji" xml:space="preserve">
<value>Emoji</value>
</data>
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
<value>Максимальное количество противостояний</value>
</data>
<data name="Fact" xml:space="preserve">
<value>Факт</value>
</data>
<data name="Facts" xml:space="preserve">
<value>Факты</value>
</data>
<data name="FileId" xml:space="preserve">
<value>Идентификатор файла</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Группы</value>
</data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>Включить первый ответ в предварительный просмотр приглашения? (что делает его более привлекательным для участия в обсуждении)</value>
</data>
<data name="InvitationCode" xml:space="preserve">
<value>Код приглашения</value>
</data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Код приглашения или ссылка</value>
</data>
<data name="InvitationPassword" xml:space="preserve">
<value>Пароль приглашения</value>
</data>
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
<value>Требуется пароль для предварительного просмотра</value>
</data>
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
<value>Требуется пароль для показа предварительного просмотра</value>
</data>
<data name="IsPasswordProtectedText" xml:space="preserve">
<value>Добавить защиту паролем</value>
</data>
<data name="IsStructured" xml:space="preserve">
<value>IsStructured</value>
</data>
<data name="LanguageId" xml:space="preserve">
<value>Языковой идентификатор</value>
</data>
<data name="Name" xml:space="preserve">
<value>Имя</value>
</data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Включить уведомления? (полезно для получения обновлений приложения)</value>
</data>
<data name="NewPassword" xml:space="preserve">
<value>Новый пароль</value>
</data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve">
<value>Старый пароль</value>
</data>
<data name="OneTime" xml:space="preserve">
<value>Только один раз</value>
</data>
<data name="Password" xml:space="preserve">
<value>Пароль</value>
</data>
<data name="Perception" xml:space="preserve">
<value>Восприятие</value>
</data>
<data name="Perceptions" xml:space="preserve">
<value>Восприятие</value>
</data>
<data name="PreferSystemTheming" xml:space="preserve">
<value>Предпочитайте системную тему</value>
</data>
<data name="RecoveryCode" xml:space="preserve">
<value>Код восстановления</value>
</data>
<data name="Recurring" xml:space="preserve">
<value>Повторяющийся</value>
</data>
<data name="Scan for:" xml:space="preserve">
<value>Сканирование для:</value>
</data>
<data name="SearchText" xml:space="preserve">
<value>Поиск</value>
</data>
<data name="ShareCode" xml:space="preserve">
<value>Обмен секретным кодом</value>
</data>
<data name="ShowDonatorBadge" xml:space="preserve">
<value>Показать значок жертвователя</value>
</data>
<data name="ShowLogs" xml:space="preserve">
<value>Покажите вкладку журналов для поддержки поиска и устранения неисправностей.</value>
</data>
<data name="TakeType" xml:space="preserve">
<value>Возьмите тип</value>
</data>
<data name="TakeTypeSimple" xml:space="preserve">
<value>Простой</value>
</data>
<data name="TakeTypeStructured" xml:space="preserve">
<value>Структурированный</value>
</data>
<data name="Theme image" xml:space="preserve">
<value>Тематическое изображение</value>
</data>
<data name="ThemeIsDarkMode" xml:space="preserve">
<value>Темный режим</value>
</data>
<data name="Ticks" xml:space="preserve">
<value>Клещи</value>
</data>
<data name="Title" xml:space="preserve">
<value>Название</value>
</data>
<data name="UpdateReason" xml:space="preserve">
<value>Причина обновления</value>
</data>
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
<value>Подавать анонимно</value>
</data>
<data name="Username" xml:space="preserve">
<value>Имя пользователя</value>
</data>
<data name="Username to search and add" xml:space="preserve">
<value>Имя пользователя для поиска и добавления</value>
</data>
<data name="Usernames to search and add" xml:space="preserve">
<value>Имена пользователей для поиска и добавления</value>
</data>
</root>
@@ -117,123 +117,30 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="(necessary in case of password recovery)" xml:space="preserve">
<value>(potrebné v prípade obnovenia hesla)</value>
</data>
<data name="(password manager suggested)" xml:space="preserve">
<value>(&lt;a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/"&gt;správca hesiel&lt;/a&gt; navrhnuté)</value>
</data>
<data name="Amount" xml:space="preserve">
<value>Suma</value>
</data>
<data name="AnswerId" xml:space="preserve">
<value>Odpoveď id</value>
</data>
<data name="AnswerText" xml:space="preserve">
<value>Text odpovede</value>
</data>
<data name="Character" xml:space="preserve">
<value>Vlastný znak (emoji)</value>
</data>
<data name="ColourIndex" xml:space="preserve">
<value>Farba</value>
</data>
<data name="CommentId" xml:space="preserve">
<value>Komentár id</value>
</data>
<data name="CommentText" xml:space="preserve">
<value>Text komentára</value>
</data>
<data name="Confrontation" xml:space="preserve">
<value>Konfrontácia</value>
</data>
<data name="ConfrontationId" xml:space="preserve">
<value>Id konfrontácie</value>
</data>
<data name="Context" xml:space="preserve">
<value>Kontext</value>
</data>
<data name="DateEnd" xml:space="preserve">
<value>Dátum ukončenia</value>
</data>
<data name="DateStart" xml:space="preserve">
<value>Dátum začiatku</value>
</data>
<data name="Description" xml:space="preserve">
<value>Popis</value>
</data>
<data name="Discussion" xml:space="preserve">
<value>Diskusia</value>
</data>
<data name="DiscussionEndDate" xml:space="preserve">
<value>Dátum ukončenia</value>
</data>
<data name="DiscussionEndTime" xml:space="preserve">
<value>Čas ukončenia</value>
</data>
<data name="DiscussionId" xml:space="preserve">
<value>Diskusia id</value>
</data>
<data name="DiscussionText" xml:space="preserve">
<value>Text diskusie</value>
</data>
<data name="DiscussionTitle" xml:space="preserve">
<value>Názov diskusie</value>
</data>
<data name="Email" xml:space="preserve">
<value>E-mail</value>
</data>
<data name="Emoji" xml:space="preserve">
<value>Emoji</value>
</data>
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
<value>Maximálny počet konfrontácií</value>
</data>
<data name="Fact" xml:space="preserve">
<value>Skutočnosť</value>
</data>
<data name="Facts" xml:space="preserve">
<value>Fakty</value>
</data>
<data name="FileId" xml:space="preserve">
<value>Identifikátor súboru</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Skupiny</value>
</data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>Zahrnúť prvú odpoveď do náhľadu pozvánky? (aby bolo zapojenie do diskusie pútavejšie)</value>
</data>
<data name="InvitationCode" xml:space="preserve">
<value>Pozývací kód</value>
</data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Pozývací kód alebo odkaz</value>
</data>
<data name="InvitationPassword" xml:space="preserve">
<value>Heslo pozvánky</value>
</data>
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
<value>Vyžadovať heslo pre náhľad</value>
</data>
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
<value>Vyžadovať heslo na zobrazenie náhľadu</value>
</data>
<data name="IsPasswordProtectedText" xml:space="preserve">
<value>Pridanie ochrany heslom</value>
</data>
<data name="IsStructured" xml:space="preserve">
<value>IsStructured</value>
</data>
<data name="LanguageId" xml:space="preserve">
<value>Identifikátor jazyka</value>
</data>
<data name="Name" xml:space="preserve">
<value>Názov</value>
</data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Povolenie oznámení?(užitočné na prijímanie aktualizácií aplikácie)</value>
</data>
<data name="NewPassword" xml:space="preserve">
<value>Nové heslo</value>
</data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve">
<value>Staré heslo</value>
</data>
<data name="OneTime" xml:space="preserve">
<value>Iba jedenkrát</value>
</data>
<data name="Password" xml:space="preserve">
<value>Heslo</value>
</data>
<data name="Perception" xml:space="preserve">
<value>Vnímanie</value>
</data>
<data name="Perceptions" xml:space="preserve">
<value>Vnímanie</value>
</data>
<data name="PreferSystemTheming" xml:space="preserve">
<value>Uprednostniť tému systému</value>
</data>
<data name="RecoveryCode" xml:space="preserve">
<value>Kód obnovy</value>
</data>
<data name="Recurring" xml:space="preserve">
<value>Opakujúce sa</value>
</data>
<data name="Scan for:" xml:space="preserve">
<value>Vyhľadávanie:</value>
</data>
<data name="SearchText" xml:space="preserve">
<value>Vyhľadávanie</value>
</data>
<data name="ShareCode" xml:space="preserve">
<value>Zdieľanie tajného kódu</value>
</data>
<data name="ShowDonatorBadge" xml:space="preserve">
<value>Zobraziť odznak darcu</value>
</data>
<data name="ShowLogs" xml:space="preserve">
<value>Zobrazenie karty protokolov na podporu riešenia problémov.</value>
</data>
<data name="TakeType" xml:space="preserve">
<value>Vezmite si typ</value>
</data>
<data name="TakeTypeSimple" xml:space="preserve">
<value>Jednoduché</value>
</data>
<data name="TakeTypeStructured" xml:space="preserve">
<value>Štruktúrované</value>
</data>
<data name="Theme image" xml:space="preserve">
<value>Obrázok témy</value>
</data>
<data name="ThemeIsDarkMode" xml:space="preserve">
<value>Tmavý režim</value>
</data>
<data name="Ticks" xml:space="preserve">
<value>Kliešte</value>
</data>
<data name="Title" xml:space="preserve">
<value>Názov</value>
</data>
<data name="UpdateReason" xml:space="preserve">
<value>Dôvod aktualizácie</value>
</data>
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
<value>Odoslať anonymne</value>
</data>
<data name="Username" xml:space="preserve">
<value>Používateľské meno</value>
</data>
<data name="Username to search and add" xml:space="preserve">
<value>Používateľské meno na vyhľadávanie a pridávanie</value>
</data>
<data name="Usernames to search and add" xml:space="preserve">
<value>Používateľské mená na vyhľadávanie a pridávanie</value>
</data>
</root>
@@ -117,123 +117,30 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="(necessary in case of password recovery)" xml:space="preserve">
<value>(potrebno v primeru obnovitve gesla)</value>
</data>
<data name="(password manager suggested)" xml:space="preserve">
<value>(&lt;a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/"&gt;predlagani upravitelj gesel&lt;/a&gt;)</value>
</data>
<data name="Amount" xml:space="preserve">
<value>Znesek</value>
</data>
<data name="AnswerId" xml:space="preserve">
<value>Odgovor id</value>
</data>
<data name="AnswerText" xml:space="preserve">
<value>Besedilo odgovora</value>
</data>
<data name="Character" xml:space="preserve">
<value>Znak po meri (emoji)</value>
</data>
<data name="ColourIndex" xml:space="preserve">
<value>Barva</value>
</data>
<data name="CommentId" xml:space="preserve">
<value>Id komentarja</value>
</data>
<data name="CommentText" xml:space="preserve">
<value>Besedilo komentarja</value>
</data>
<data name="Confrontation" xml:space="preserve">
<value>Konfrontacija</value>
</data>
<data name="ConfrontationId" xml:space="preserve">
<value>Id konfrontacije</value>
</data>
<data name="Context" xml:space="preserve">
<value>Kontekst</value>
</data>
<data name="DateEnd" xml:space="preserve">
<value>Končni datum</value>
</data>
<data name="DateStart" xml:space="preserve">
<value>Datum začetka</value>
</data>
<data name="Description" xml:space="preserve">
<value>Opis</value>
</data>
<data name="Discussion" xml:space="preserve">
<value>Razprava</value>
</data>
<data name="DiscussionEndDate" xml:space="preserve">
<value>Končni datum</value>
</data>
<data name="DiscussionEndTime" xml:space="preserve">
<value>Končni čas</value>
</data>
<data name="DiscussionId" xml:space="preserve">
<value>Razprava id</value>
</data>
<data name="DiscussionText" xml:space="preserve">
<value>Besedilo za razpravo</value>
</data>
<data name="DiscussionTitle" xml:space="preserve">
<value>Naslov razprave</value>
</data>
<data name="Email" xml:space="preserve">
<value>E-pošta</value>
</data>
<data name="Emoji" xml:space="preserve">
<value>Emoji</value>
</data>
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
<value>Največje število soočenj</value>
</data>
<data name="Fact" xml:space="preserve">
<value>Dejstva</value>
</data>
<data name="Facts" xml:space="preserve">
<value>Dejstva</value>
</data>
<data name="FileId" xml:space="preserve">
<value>Id datoteke</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Skupine</value>
</data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>Vključiti prvi odgovor v predogled vabila? (da bo vključitev v razpravo bolj privlačna)</value>
</data>
<data name="InvitationCode" xml:space="preserve">
<value>Koda vabila</value>
</data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Koda vabila ali povezava</value>
</data>
<data name="InvitationPassword" xml:space="preserve">
<value>Geslo za povabilo</value>
</data>
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
<value>Zahtevajte geslo za predogled</value>
</data>
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
<value>Zahtevajte geslo za prikaz predogleda</value>
</data>
<data name="IsPasswordProtectedText" xml:space="preserve">
<value>Dodajanje zaščite z geslom</value>
</data>
<data name="IsStructured" xml:space="preserve">
<value>IsStructured</value>
</data>
<data name="LanguageId" xml:space="preserve">
<value>Id jezika</value>
</data>
<data name="Name" xml:space="preserve">
<value>Ime</value>
</data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Omogočite obvestila? (koristno za prejemanje posodobitev aplikacije)</value>
</data>
<data name="NewPassword" xml:space="preserve">
<value>Novo geslo</value>
</data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve">
<value>Staro geslo</value>
</data>
<data name="OneTime" xml:space="preserve">
<value>Samo enkrat</value>
</data>
<data name="Password" xml:space="preserve">
<value>Geslo</value>
</data>
<data name="Perception" xml:space="preserve">
<value>Zaznavanje</value>
</data>
<data name="Perceptions" xml:space="preserve">
<value>Dojemanje</value>
</data>
<data name="PreferSystemTheming" xml:space="preserve">
<value>Prednost sistemske teme</value>
</data>
<data name="RecoveryCode" xml:space="preserve">
<value>Koda za obnovitev</value>
</data>
<data name="Recurring" xml:space="preserve">
<value>Ponavljajoče se</value>
</data>
<data name="Scan for:" xml:space="preserve">
<value>Iskanje za:</value>
</data>
<data name="SearchText" xml:space="preserve">
<value>Iskanje</value>
</data>
<data name="ShareCode" xml:space="preserve">
<value>Deljenje skrivne kode</value>
</data>
<data name="ShowDonatorBadge" xml:space="preserve">
<value>Prikaži značko donatorja</value>
</data>
<data name="ShowLogs" xml:space="preserve">
<value>Prikažite zavihek dnevniki za podporo pri odpravljanju težav.</value>
</data>
<data name="TakeType" xml:space="preserve">
<value>Vrsta posnetka</value>
</data>
<data name="TakeTypeSimple" xml:space="preserve">
<value>Enostavno</value>
</data>
<data name="TakeTypeStructured" xml:space="preserve">
<value>Strukturiran</value>
</data>
<data name="Theme image" xml:space="preserve">
<value>Slika teme</value>
</data>
<data name="ThemeIsDarkMode" xml:space="preserve">
<value>Temni način</value>
</data>
<data name="Ticks" xml:space="preserve">
<value>Klopi</value>
</data>
<data name="Title" xml:space="preserve">
<value>Naslov</value>
</data>
<data name="UpdateReason" xml:space="preserve">
<value>Razlog za posodobitev</value>
</data>
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
<value>Pošlji anonimno</value>
</data>
<data name="Username" xml:space="preserve">
<value>Uporabniško ime</value>
</data>
<data name="Username to search and add" xml:space="preserve">
<value>Uporabniško ime za iskanje in dodajanje</value>
</data>
<data name="Usernames to search and add" xml:space="preserve">
<value>Uporabniška imena za iskanje in dodajanje</value>
</data>
</root>
@@ -117,123 +117,30 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="(necessary in case of password recovery)" xml:space="preserve">
<value>(nödvändig vid återställning av lösenord)</value>
</data>
<data name="(password manager suggested)" xml:space="preserve">
<value>(&lt;a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/"&gt;password manager&lt;/a&gt; föreslås)</value>
</data>
<data name="Amount" xml:space="preserve">
<value>Belopp</value>
</data>
<data name="AnswerId" xml:space="preserve">
<value>Svar id</value>
</data>
<data name="AnswerText" xml:space="preserve">
<value>Svarstext</value>
</data>
<data name="Character" xml:space="preserve">
<value>Anpassat tecken (emoji)</value>
</data>
<data name="ColourIndex" xml:space="preserve">
<value>Färg</value>
</data>
<data name="CommentId" xml:space="preserve">
<value>Kommentar id</value>
</data>
<data name="CommentText" xml:space="preserve">
<value>Kommentar</value>
</data>
<data name="Confrontation" xml:space="preserve">
<value>Konfrontation</value>
</data>
<data name="ConfrontationId" xml:space="preserve">
<value>Konfrontation id</value>
</data>
<data name="Context" xml:space="preserve">
<value>Kontext</value>
</data>
<data name="DateEnd" xml:space="preserve">
<value>Slutdatum</value>
</data>
<data name="DateStart" xml:space="preserve">
<value>Startdatum</value>
</data>
<data name="Description" xml:space="preserve">
<value>Beskrivning</value>
</data>
<data name="Discussion" xml:space="preserve">
<value>Diskussion</value>
</data>
<data name="DiscussionEndDate" xml:space="preserve">
<value>Slutdatum</value>
</data>
<data name="DiscussionEndTime" xml:space="preserve">
<value>Sluttid</value>
</data>
<data name="DiscussionId" xml:space="preserve">
<value>Diskussion id</value>
</data>
<data name="DiscussionText" xml:space="preserve">
<value>Diskussionstext</value>
</data>
<data name="DiscussionTitle" xml:space="preserve">
<value>Titel på diskussionen</value>
</data>
<data name="Email" xml:space="preserve">
<value>E-post</value>
</data>
<data name="Emoji" xml:space="preserve">
<value>Emoji</value>
</data>
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
<value>Max antal konfrontationer</value>
</data>
<data name="Fact" xml:space="preserve">
<value>Fakta</value>
</data>
<data name="Facts" xml:space="preserve">
<value>Fakta</value>
</data>
<data name="FileId" xml:space="preserve">
<value>Filid</value>
</data>
<data name="Groups" xml:space="preserve">
<value>Grupper</value>
</data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>Inkludera det första svaret i förhandsgranskningen av inbjudan? (för att göra det mer lockande att delta i diskussionen)</value>
</data>
<data name="InvitationCode" xml:space="preserve">
<value>Kod för inbjudan</value>
</data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Inbjudningskod eller länk</value>
</data>
<data name="InvitationPassword" xml:space="preserve">
<value>Lösenord för inbjudan</value>
</data>
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
<value>Kräver lösenord för förhandsgranskning</value>
</data>
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
<value>Kräver lösenord för att visa förhandsgranskningen</value>
</data>
<data name="IsPasswordProtectedText" xml:space="preserve">
<value>Lägg till lösenordsskydd</value>
</data>
<data name="IsStructured" xml:space="preserve">
<value>IsStructured</value>
</data>
<data name="LanguageId" xml:space="preserve">
<value>Språk-id</value>
</data>
<data name="Name" xml:space="preserve">
<value>Namn</value>
</data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Aktivera meddelanden (användbart för att få uppdateringar av appen)</value>
</data>
<data name="NewPassword" xml:space="preserve">
<value>Nytt lösenord</value>
</data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve">
<value>Gammalt lösenord</value>
</data>
<data name="OneTime" xml:space="preserve">
<value>Endast en gång</value>
</data>
<data name="Password" xml:space="preserve">
<value>Lösenord</value>
</data>
<data name="Perception" xml:space="preserve">
<value>Uppfattning</value>
</data>
<data name="Perceptions" xml:space="preserve">
<value>Uppfattningar</value>
</data>
<data name="PreferSystemTheming" xml:space="preserve">
<value>Föredrar systemtema</value>
</data>
<data name="RecoveryCode" xml:space="preserve">
<value>Kod för återvinning</value>
</data>
<data name="Recurring" xml:space="preserve">
<value>Återkommande</value>
</data>
<data name="Scan for:" xml:space="preserve">
<value>Skanna efter:</value>
</data>
<data name="SearchText" xml:space="preserve">
<value>Sök på</value>
</data>
<data name="ShareCode" xml:space="preserve">
<value>Att dela med sig av den hemliga koden</value>
</data>
<data name="ShowDonatorBadge" xml:space="preserve">
<value>Visa donatormärket</value>
</data>
<data name="ShowLogs" xml:space="preserve">
<value>Visa fliken Loggar för att underlätta felsökning.</value>
</data>
<data name="TakeType" xml:space="preserve">
<value>Typ av tagning</value>
</data>
<data name="TakeTypeSimple" xml:space="preserve">
<value>Enkelt</value>
</data>
<data name="TakeTypeStructured" xml:space="preserve">
<value>Strukturerad</value>
</data>
<data name="Theme image" xml:space="preserve">
<value>Tema bild</value>
</data>
<data name="ThemeIsDarkMode" xml:space="preserve">
<value>Mörkt läge</value>
</data>
<data name="Ticks" xml:space="preserve">
<value>Fästingar</value>
</data>
<data name="Title" xml:space="preserve">
<value>Titel</value>
</data>
<data name="UpdateReason" xml:space="preserve">
<value>Orsak till uppdatering</value>
</data>
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
<value>Skicka in anonymt</value>
</data>
<data name="Username" xml:space="preserve">
<value>Användarnamn</value>
</data>
<data name="Username to search and add" xml:space="preserve">
<value>Användarnamn för att söka och lägga till</value>
</data>
<data name="Usernames to search and add" xml:space="preserve">
<value>Användarnamn att söka och lägga till</value>
</data>
</root>
@@ -117,123 +117,30 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="(necessary in case of password recovery)" xml:space="preserve">
<value>(在恢复密码的情况下是必要的)</value>
</data>
<data name="(password manager suggested)" xml:space="preserve">
<value>(&lt;a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/"&gt;密码管理器&lt;/a&gt;建议)</value>
</data>
<data name="Amount" xml:space="preserve">
<value>数量</value>
</data>
<data name="AnswerId" xml:space="preserve">
<value>答案是</value>
</data>
<data name="AnswerText" xml:space="preserve">
<value>回答文本</value>
</data>
<data name="Character" xml:space="preserve">
<value>自定义字符(表情符号)</value>
</data>
<data name="ColourIndex" xml:space="preserve">
<value>颜色</value>
</data>
<data name="CommentId" xml:space="preserve">
<value>评论编号</value>
</data>
<data name="CommentText" xml:space="preserve">
<value>评论文本</value>
</data>
<data name="Confrontation" xml:space="preserve">
<value>对抗</value>
</data>
<data name="ConfrontationId" xml:space="preserve">
<value>对抗的身份</value>
</data>
<data name="Context" xml:space="preserve">
<value>背景介绍</value>
</data>
<data name="DateEnd" xml:space="preserve">
<value>结束日期</value>
</data>
<data name="DateStart" xml:space="preserve">
<value>开始日期</value>
</data>
<data name="Description" xml:space="preserve">
<value>描述</value>
</data>
<data name="Discussion" xml:space="preserve">
<value>讨论</value>
</data>
<data name="DiscussionEndDate" xml:space="preserve">
<value>结束日期</value>
</data>
<data name="DiscussionEndTime" xml:space="preserve">
<value>结束时间</value>
</data>
<data name="DiscussionId" xml:space="preserve">
<value>讨论的内容</value>
</data>
<data name="DiscussionText" xml:space="preserve">
<value>讨论文本</value>
</data>
<data name="DiscussionTitle" xml:space="preserve">
<value>讨论题目</value>
</data>
<data name="Email" xml:space="preserve">
<value>电子邮件</value>
</data>
<data name="Emoji" xml:space="preserve">
<value>表情符号</value>
</data>
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
<value>对抗的最大数量</value>
</data>
<data name="Fact" xml:space="preserve">
<value>事实</value>
</data>
<data name="Facts" xml:space="preserve">
<value>事实</value>
</data>
<data name="FileId" xml:space="preserve">
<value>文件编号</value>
</data>
<data name="Groups" xml:space="preserve">
<value>群体</value>
</data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>在邀请预览中包括第一个答案?(使其更加吸引人加入讨论)</value>
</data>
<data name="InvitationCode" xml:space="preserve">
<value>邀请函代码</value>
</data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>邀请函代码或链接</value>
</data>
<data name="InvitationPassword" xml:space="preserve">
<value>邀请函密码</value>
</data>
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
<value>预览时需要密码</value>
</data>
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
<value>需要密码才能显示预览</value>
</data>
<data name="IsPasswordProtectedText" xml:space="preserve">
<value>添加密码保护</value>
</data>
<data name="IsStructured" xml:space="preserve">
<value>是结构化的</value>
</data>
<data name="LanguageId" xml:space="preserve">
<value>语言标识</value>
</data>
<data name="Name" xml:space="preserve">
<value>命名</value>
</data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>启用通知?(对接收应用程序的更新很有用)</value>
</data>
<data name="NewPassword" xml:space="preserve">
<value>新密码</value>
</data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve">
<value>旧密码</value>
</data>
<data name="OneTime" xml:space="preserve">
<value>仅限一次</value>
</data>
<data name="Password" xml:space="preserve">
<value>密码</value>
</data>
<data name="Perception" xml:space="preserve">
<value>感知</value>
</data>
<data name="Perceptions" xml:space="preserve">
<value>观念</value>
</data>
<data name="PreferSystemTheming" xml:space="preserve">
<value>倾向于系统主题</value>
</data>
<data name="RecoveryCode" xml:space="preserve">
<value>恢复代码</value>
</data>
<data name="Recurring" xml:space="preserve">
<value>经常性的</value>
</data>
<data name="Scan for:" xml:space="preserve">
<value>进行扫描:</value>
</data>
<data name="SearchText" xml:space="preserve">
<value>搜索</value>
</data>
<data name="ShareCode" xml:space="preserve">
<value>分享秘密代码</value>
</data>
<data name="ShowDonatorBadge" xml:space="preserve">
<value>显示捐赠者徽章</value>
</data>
<data name="ShowLogs" xml:space="preserve">
<value>显示日志标签以支持故障排除。</value>
</data>
<data name="TakeType" xml:space="preserve">
<value>采取类型</value>
</data>
<data name="TakeTypeSimple" xml:space="preserve">
<value>简单</value>
</data>
<data name="TakeTypeStructured" xml:space="preserve">
<value>结构化</value>
</data>
<data name="Theme image" xml:space="preserve">
<value>主题图片</value>
</data>
<data name="ThemeIsDarkMode" xml:space="preserve">
<value>黑暗模式</value>
</data>
<data name="Ticks" xml:space="preserve">
<value>蜱虫</value>
</data>
<data name="Title" xml:space="preserve">
<value>标题</value>
</data>
<data name="UpdateReason" xml:space="preserve">
<value>更新原因</value>
</data>
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
<value>匿名提交</value>
</data>
<data name="Username" xml:space="preserve">
<value>帐号</value>
</data>
<data name="Username to search and add" xml:space="preserve">
<value>搜索和添加的用户名</value>
</data>
<data name="Usernames to search and add" xml:space="preserve">
<value>搜索和添加的用户名</value>
</data>
</root>
+18
View File
@@ -0,0 +1,18 @@
using PrivaPub.ClientModels.Resources;
using System.ComponentModel.DataAnnotations;
namespace PrivaPub.ClientModels
{
// The server's first login, made while it has no administrator (/clientapi/user/setup), with its first persona.
public class SetupForm : LoginForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(32, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
RegularExpression(Constants.UserNameRegex, ErrorMessageResourceName = "InvalidUserName", ErrorMessageResourceType = typeof(ErrorsResource))]
public string AvatarUserName { get; set; }
[StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string AvatarName { get; set; }
}
}
@@ -0,0 +1,94 @@
using PrivaPub.ClientModels.Resources;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace PrivaPub.ClientModels.User
{
// The login's email address, set only with its password and only once a code sent to it comes back
// (/clientapi/user/email, /email/verify).
public class EmailChangeForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
EmailAddress(ErrorMessageResourceName = "InvalidEmail", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(254, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "Email", ResourceType = typeof(FieldsNameResource))]
public string Email { get; set; }
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
DataType(DataType.Password),
PasswordPropertyText(true),
StringLength(Constants.MaxPasswordLength, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
public string Password { get; set; }
}
public class EmailCodeForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(16, MinimumLength = 6, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "RecoveryCode", ResourceType = typeof(FieldsNameResource))]
public string Code { get; set; }
}
// what only the login's password may do (removing its email address)
public class PasswordForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
DataType(DataType.Password),
PasswordPropertyText(true),
StringLength(Constants.MaxPasswordLength, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
public string Password { get; set; }
}
public class ViewAccount
{
public string UserName { get; set; }
public string Email { get; set; }
public bool IsEmailValidated { get; set; } = false;
public string PendingEmail { get; set; }//waiting for its code
public DateTime CreatedAt { get; set; }
public bool TwoFactorEnabled { get; set; } = false;
public int RecoveryCodesLeft { get; set; } = 0;
}
// a code from the authenticator app (six digits) or a recovery code
public class TotpCodeForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(32, MinimumLength = 6, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "RecoveryCode", ResourceType = typeof(FieldsNameResource))]
public string Code { get; set; }
}
public class TotpDisableForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
DataType(DataType.Password),
PasswordPropertyText(true),
StringLength(Constants.MaxPasswordLength, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
public string Password { get; set; }
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(32, MinimumLength = 6, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "RecoveryCode", ResourceType = typeof(FieldsNameResource))]
public string Code { get; set; }
}
// what an authenticator app needs: the secret, as text and as the otpauth:// URI its QR code holds
public class ViewTotpSetup
{
public string Secret { get; set; }
public string OtpAuthUri { get; set; }
public string QrDataUri { get; set; }//an SVG image, as a data: URI
}
// shown once: the recovery codes, and this device's new session (turning two-factor on ends every other one)
public class ViewTotpEnabled
{
public List<string> RecoveryCodes { get; set; } = [];
public JwtUser Session { get; set; }
}
}
+1 -1
View File
@@ -2,6 +2,6 @@
{
public class UsersIds
{
public IList<string> UserIdList { get; set; } = Array.Empty<string>();
public List<string> UserIdList { get; set; } = [];
}
}
@@ -0,0 +1,48 @@
namespace PrivaPub.ClientModels.User
{
// A persona's archive (owner decision 2026-10-07): made for it to take away, in Mastodon's account-archive layout, and
// restored into it. States: none, queued, running, ready (export) or done (import), failed, stopped.
public class ViewPersonaArchive
{
public string ExportState { get; set; }
public DateTime? ExportAskedAt { get; set; }
public DateTime? ExportReadyAt { get; set; }
public DateTime? ExportExpiresAt { get; set; }
public long ExportBytes { get; set; }
public string ExportError { get; set; }
public string ImportState { get; set; }
public DateTime? ImportAskedAt { get; set; }
public DateTime? ImportEndedAt { get; set; }
public List<string> ImportParts { get; set; } = [];
public long ImportDone { get; set; }
public long ImportTotal { get; set; }
public Dictionary<string, int> ImportCounts { get; set; } = [];//what was imported and what skipped, by why
public string ImportError { get; set; }
public string ImportFrom { get; set; }//the archive's actor
public bool ImportSameActor { get; set; }
}
// the parts of an archive to import: posts (with their media), profile, follows, blocks, mutes, domainblocks, lists,
// bookmarks, filters, tags, notificationpolicy, pins; and only when asked, located, scheduled, likes (which tell their
// authors)
public class ImportPersonaArchiveForm
{
public string UploadId { get; set; }
public List<string> Parts { get; set; } = [];
}
public class ViewArchiveTicket
{
public string Url { get; set; }
public DateTime ExpiresAt { get; set; }
public long Bytes { get; set; }
}
public class ViewArchiveUpload
{
public string Id { get; set; }
public long Received { get; set; }
public long ChunkBytes { get; set; }
}
}
@@ -0,0 +1,8 @@
namespace PrivaPub.ClientModels.User
{
public class ViewServerSetup
{
// no administrator yet: the first login made through /clientapi/user/setup becomes it
public bool IsPending { get; set; } = false;
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace PrivaPub.ClientModels.User
{
// /clientapi/user/storage: what the login's media take (every persona's uploads and pictures), and its quota when the
// server sets one
public class ViewStorage
{
public long MediaBytes { get; set; }
public long MediaFiles { get; set; }
public long? QuotaBytes { get; set; }
}
}
@@ -21,5 +21,8 @@ namespace PrivaPub.ClientModels.User
public bool ThemeIsDarkMode { get; set; } = false;
public bool ThemeIsLightGray { get; set; } = false;
public bool ThemeIsDarkGray { get; set; } = false;
// when a client last saved these; null until one did, so a client signing in keeps its own until then
public DateTime? SavedAt { get; set; }
}
}
-82
View File
@@ -1,82 +0,0 @@
using Microsoft.AspNetCore.Http;
using PrivaPub.Api.Mastodon.Auth;
using PrivaPub.Api.Mastodon.Infrastructure;
using System.Text;
namespace PrivaPub.Tests.Api
{
public class MastodonParamsTests
{
static async Task<MastodonParams> Read(string query = "", string contentType = default, string body = default)
{
var context = new DefaultHttpContext();
context.Request.QueryString = new QueryString(query);
if (body != default)
{
context.Request.ContentType = contentType;
var bytes = Encoding.UTF8.GetBytes(body);
context.Request.Body = new MemoryStream(bytes);
context.Request.ContentLength = bytes.Length;
}
return await MastodonParams.Read(context.Request, TestContext.Current.CancellationToken);
}
[Fact]
public async Task Absent_values_are_null_not_false_or_zero()
{
var parameters = await Read();
Assert.Null(parameters.Bool("sensitive"));
Assert.Null(parameters.Int("limit"));
Assert.Null(parameters.Get("status"));
Assert.Empty(parameters.List("media_ids"));
}
[Fact]
public async Task Reads_rails_style_arrays_from_the_query()
{
var parameters = await Read("?id[]=1&id[]=2&limit=5&local=true");
Assert.Equal(new[] { "1", "2" }, parameters.List("id"));
Assert.Equal(5, parameters.Int("limit"));
Assert.True(parameters.Bool("local"));
}
[Fact]
public async Task Reads_forms_and_nested_keys()
{
var parameters = await Read(contentType: "application/x-www-form-urlencoded",
body: "status=hi&media_ids[]=a&media_ids[]=b&poll[options][]=x&poll[options][]=y&sensitive=0");
Assert.Equal("hi", parameters.Get("status"));
Assert.Equal(new[] { "a", "b" }, parameters.List("media_ids"));
Assert.Equal(new[] { "x", "y" }, parameters.List("poll[options]"));
Assert.False(parameters.Bool("sensitive"));
}
[Fact]
public async Task Reads_json_bodies_the_same_way()
{
var parameters = await Read(contentType: "application/json",
body: """{"status":"hi","media_ids":["a","b"],"poll":{"options":["x","y"],"expires_in":300},"sensitive":true,"source":{"privacy":"unlisted"}}""");
Assert.Equal(new[] { "a", "b" }, parameters.List("media_ids"));
Assert.Equal(new[] { "x", "y" }, parameters.List("poll[options]"));
Assert.Equal(300, parameters.Int("poll[expires_in]"));
Assert.True(parameters.Bool("sensitive"));
Assert.Equal("unlisted", parameters.Get("source[privacy]"));
}
[Theory]
[InlineData(new[] { "read" }, "read:statuses", true)]
[InlineData(new[] { "read:statuses" }, "read:statuses", true)]
[InlineData(new[] { "read:accounts" }, "read:statuses", false)]
[InlineData(new[] { "write" }, "read:statuses", false)]
[InlineData(new[] { "follow" }, "write:follows", true)]
[InlineData(new[] { "follow" }, "write:statuses", false)]
public void Scopes_grant_like_mastodons(string[] granted, string required, bool expected) =>
Assert.Equal(expected, MastodonScopes.Grants(granted, required));
}
}
@@ -1,61 +0,0 @@
using Microsoft.Extensions.Caching.Memory;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels.Social;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using System.Text.Json;
namespace PrivaPub.Tests.Api
{
[Trait("Category", "Integration")]
public sealed class PersonaSeparationTests : 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 Nothing_shown_to_one_persona_names_its_sibling()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var (_, sibling) = await _harness.Persona("sibling", root);
var (bobRoot, bob) = await _harness.Persona("bob");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, token);
await _harness.Follows.Follow(root, new FollowForm { AvatarId = sibling.Id, Target = bob.UserName }, token);
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = sibling.Id, Text = "from the sibling" }, token);
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "from alice" }, token);
await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = "hi both" }, token);
var mapper = new MastodonMapper(_harness.Db, _harness.Local);
var account = await mapper.Local(alice, withSource: true, token);
var homeIds = (await _harness.Db.TimelineEntries.Match(e => e.AvatarId == alice.Id).ExecuteAsync(token)).Select(e => e.PostId).ToList();
var home = await _harness.Db.Posts.Match(p => homeIds.Contains(p.ID)).ExecuteAsync(token);
var statuses = await mapper.Statuses(home, alice.Id, token);
var bobAsSeenByAlice = await mapper.Account(bob.Id, token);
var json = JsonSerializer.Serialize(new object[] { account, statuses, bobAsSeenByAlice }, MastodonJson.Options);
Assert.DoesNotContain(sibling.Id, json);
Assert.DoesNotContain(sibling.UserName, json);
Assert.DoesNotContain(root, json);
Assert.Contains("hi both", json);
Assert.Equal(2, bobAsSeenByAlice.FollowersCount);
}
}
}
-29
View File
@@ -1,29 +0,0 @@
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Models.Post;
namespace PrivaPub.Tests.Api
{
public class StatusContentTests
{
[Fact]
public void A_remote_article_shows_its_title_above_its_body() =>
Assert.Equal("<p><strong>Rain &amp; sun</strong></p><p>body</p>", MastodonMapper.Content(new Post
{
IsFederatedCopy = true, ObjectType = "Article", Title = "Rain & sun", ContentHtml = "<p>body</p>"
}));
[Fact]
public void A_remote_note_keeps_its_content_alone() =>
Assert.Equal("<p>body</p>", MastodonMapper.Content(new Post
{
IsFederatedCopy = true, ObjectType = "Note", Title = "a poll choice", ContentHtml = "<p>body</p>"
}));
[Fact]
public void A_remote_event_without_a_body_shows_its_excerpt() =>
Assert.Equal("<p><strong>Meetup</strong></p><p>Saturday &lt;10:00&gt;</p>", MastodonMapper.Content(new Post
{
IsFederatedCopy = true, ObjectType = "Event", Title = "Meetup", Excerpt = "Saturday <10:00>", ContentHtml = string.Empty
}));
}
}
@@ -1,94 +0,0 @@
using PrivaPub.Domain.Content;
using PrivaPub.Federation.Actors;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Tests.Domain
{
public class ContentRendererTests
{
const string Base = "https://privapub.test";
readonly ContentRenderer _renderer = new(new StubLocalActors(), new StubRemoteActors());
[Fact]
public async Task Decorates_local_and_remote_mentions_and_hashtags()
{
var rendered = await _renderer.Markdown("hello @alice and @bob@m.example about #Fedi **now** <script>x</script>", TestContext.Current.CancellationToken);
Assert.Contains("<span class=\"h-card\" translate=\"no\"><a href=\"https://privapub.test/peasants/alice\" class=\"u-url mention\"", rendered.Html);
Assert.Contains("href=\"https://m.example/@bob\"", rendered.Html);
Assert.Contains("href=\"https://privapub.test/tags/fedi\" class=\"mention hashtag\"", rendered.Html);
Assert.Contains("<strong>now</strong>", rendered.Html);
Assert.DoesNotContain("<script>", rendered.Html);
Assert.Equal(new[] { "https://m.example/users/bob", "https://privapub.test/peasants/alice" }, rendered.Mentions.Select(m => m.ActorUri).Order(StringComparer.Ordinal));
Assert.Equal("fedi", Assert.Single(rendered.Tags));
}
[Theory]
[InlineData("`@alice` in code")]
[InlineData("mail me@alice.example")]
[InlineData("@nobody is unknown")]
[InlineData("see https://x.example/page#alice")]
public async Task Leaves_what_is_not_a_mention_alone(string text)
{
var rendered = await _renderer.Markdown(text, TestContext.Current.CancellationToken);
Assert.Empty(rendered.Mentions);
Assert.DoesNotContain("h-card", rendered.Html);
}
[Theory]
[InlineData("issue #123")]
[InlineData("it&#39;s fine")]
[InlineData("[link](https://x.example/#tag)")]
public async Task Leaves_what_is_not_a_hashtag_alone(string text) =>
Assert.Empty((await _renderer.Markdown(text, TestContext.Current.CancellationToken)).Tags);
[Fact]
public async Task Renders_plain_text_like_mastodon()
{
var rendered = await _renderer.PlainText("line one\nline <two>\n\nsee https://x.example/a?b=1. #Tag", TestContext.Current.CancellationToken);
Assert.Equal("<p>line one<br>line &lt;two&gt;</p><p>see <a href=\"https://x.example/a?b=1\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">https://x.example/a?b=1</a>. "
+ "<a href=\"https://privapub.test/tags/tag\" class=\"mention hashtag\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">#<span>Tag</span></a></p>", rendered.Html);
}
sealed class StubLocalActors : ILocalActorService
{
public string BaseAddress => Base;
public Task<GoneActor> Gone(string userName, CancellationToken token) => Task.FromResult<GoneActor>(default);
public Task<LocalActor> FindByUserName(string userName, CancellationToken token) =>
Task.FromResult(userName.Equals("alice", StringComparison.OrdinalIgnoreCase)
? new LocalActor { Id = "a1", UserName = "alice", BaseAddress = Base, Kind = LocalActorKind.Person }
: default);
public Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token) => throw new NotSupportedException();
public Task<LocalActor> FindByUri(string actorUri, CancellationToken token) => throw new NotSupportedException();
public Task<LocalActor> FindByAddress(string address, CancellationToken token) => throw new NotSupportedException();
public Task<LocalActor> GetInstanceActor(CancellationToken token) => throw new NotSupportedException();
public Task<LocalActor> GetReporterActor(CancellationToken token) => throw new NotSupportedException();
public Task<bool> IsUserNameTaken(string userName, CancellationToken token) => throw new NotSupportedException();
public Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token) => throw new NotSupportedException();
public LocalActor FromAvatar(Avatar avatar) => throw new NotSupportedException();
public LocalActor FromGroup(GroupEntity group) => throw new NotSupportedException();
}
sealed class StubRemoteActors : IRemoteActorService
{
public Task<string> ResolveHandle(string handle, CancellationToken token) =>
Task.FromResult(handle == "bob@m.example" ? "https://m.example/users/bob" : default);
public Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token) =>
Task.FromResult(actorUri == "https://m.example/users/bob"
? new ForeignAvatar { ID = "f1", ActorURI = actorUri, UserName = "bob", Url = "https://m.example/@bob", InboxURL = actorUri + "/inbox" }
: default);
public Task<FetchedJson> FetchObject(string uri, CancellationToken token) => throw new NotSupportedException();
public Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token) => throw new NotSupportedException();
}
}
}
-124
View File
@@ -1,124 +0,0 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Social;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public sealed class FollowTests : 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_local_follow_is_accepted_at_once_and_notified()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var (_, bob) = await _harness.Persona("bob");
var result = await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = "@" + bob.UserName }, token);
Assert.True(result.IsValid);
var following = await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id).ExecuteSingleAsync(token);
Assert.Equal(FollowState.Accepted, following.State);
Assert.True(following.TargetIsLocal);
Assert.True((await DB.Default.Find<Follower>().Match(f => f.LocalActorId == bob.Id && f.ActorURI == alice.Uri).ExecuteSingleAsync(token)).IsAccepted);
Assert.Equal(NotificationType.Follow, (await DB.Default.Find<Notification>().Match(n => n.AvatarId == bob.Id).ExecuteSingleAsync(token)).Type);
Assert.Empty(await _harness.Outgoing(bob.Inbox));
}
[Fact]
public async Task Someone_elses_persona_cannot_be_made_to_follow()
{
var (_, alice) = await _harness.Persona("alice");
var (_, bob) = await _harness.Persona("bob");
var result = await _harness.Follows.Follow("someone-else", new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, TestContext.Current.CancellationToken);
Assert.False(result.IsValid);
Assert.Equal(404, result.StatusCode);
}
[Fact]
public async Task A_remote_follow_is_requested_then_accepted()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var carol = new RemoteActor(_harness.Peer, "carol");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = carol.Id }, token);
var following = await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id).ExecuteSingleAsync(token);
Assert.Equal(FollowState.Requested, following.State);
var follow = Assert.Single(await _harness.Outgoing(carol.Id + "/inbox"));
Assert.Equal("Follow", follow["type"]!.GetValue<string>());
Assert.Equal(alice.Uri, follow["actor"]!.GetValue<string>());
Assert.StartsWith(alice.Uri + "/grunts/follow-", follow["id"]!.GetValue<string>());
var accept = new JsonObject
{
["id"] = $"{carol.Id}#accepts/{Guid.NewGuid():N}",
["type"] = "Accept",
["actor"] = carol.Id,
["object"] = follow.DeepClone()
};
Assert.Equal(202, (await _harness.Deliver(carol, $"/peasants/{alice.UserName}/mouth", accept)).StatusCode);
Assert.Equal(FollowState.Accepted, (await DB.Default.Find<Following>().OneAsync(following.ID, token)).State);
}
[Fact]
public async Task An_accept_from_someone_else_changes_nothing_and_a_reject_ends_the_request()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var carol = new RemoteActor(_harness.Peer, "carol");
var mallory = new RemoteActor(_harness.Peer, "mallory");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = carol.Id }, token);
var following = await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id).ExecuteSingleAsync(token);
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = $"{mallory.Id}#accepts/{Guid.NewGuid():N}", ["type"] = "Accept", ["actor"] = mallory.Id, ["object"] = following.FollowActivityURI
});
Assert.Equal(FollowState.Requested, (await DB.Default.Find<Following>().OneAsync(following.ID, token)).State);
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = $"{carol.Id}#rejects/{Guid.NewGuid():N}", ["type"] = "Reject", ["actor"] = carol.Id, ["object"] = following.FollowActivityURI
});
Assert.Null(await DB.Default.Find<Following>().OneAsync(following.ID, token));
}
[Fact]
public async Task Unfollowing_a_remote_account_sends_an_undo()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var carol = new RemoteActor(_harness.Peer, "carol");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = carol.Id }, token);
await _harness.Follows.Unfollow(root, new FollowForm { AvatarId = alice.Id, Target = carol.Id }, token);
Assert.False(await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id).ExecuteAnyAsync(token));
var undo = (await _harness.Outgoing(carol.Id + "/inbox")).Single(a => a["type"]!.GetValue<string>() == "Undo");
Assert.Equal("Follow", undo["object"]!["type"]!.GetValue<string>());
}
}
}
-43
View File
@@ -1,43 +0,0 @@
using PrivaPub.Domain.Content;
using PrivaPub.Models.Post;
namespace PrivaPub.Tests.Domain
{
public class LinkPreviewTests
{
[Fact]
public void Reads_open_graph_then_twitter_then_the_page_title()
{
var preview = LinkPreviews.Read("https://news.example/a?utm_source=x", new Uri("https://news.example/a"), """
<html><head><title>Fallback title</title>
<meta property="og:title" content="Rain &amp; sun">
<meta name="twitter:description" content="A <b>long</b> story">
<meta property="og:image" content="/img/cover.jpg">
<meta property="og:site_name" content="News">
</head><body></body></html>
""");
Assert.Equal("Rain & sun", preview.Title);
Assert.Equal("A long story", preview.Description);
Assert.Equal("https://news.example/img/cover.jpg", preview.ImageURL);
Assert.Equal("News", preview.SiteName);
Assert.False(preview.Failed);
Assert.True(LinkPreviews.Read("https://x.example", new Uri("https://x.example"), "<html><body>nothing</body></html>").Failed);
}
[Fact]
public void Picks_the_first_real_link_and_skips_mentions_tags_quotes_and_ourselves() =>
Assert.Equal("https://blog.example/post", LinkPreviews.FirstLink(new Post
{
Url = "https://m.example/@a/1",
ContentHtml = """
<p><span class="h-card"><a href="https://m.example/@bob" class="u-url mention">@bob</a></span>
<a href="https://m.example/tags/x" class="mention hashtag" rel="tag">#x</a>
<span class="quote-inline">RE: <a href="https://k.example/notes/1">link</a></span>
<a href="https://privapub.test/@me">me</a>
<a href="https://m.example/@a/1">self</a>
<a href="https://blog.example/post">read this</a></p>
"""
}, "https://privapub.test"));
}
}
-102
View File
@@ -1,102 +0,0 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Memory;
using MongoDB.Entities;
using NetVips;
using PrivaPub.Domain.Media;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Media;
using PrivaPub.Tests.Support;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public sealed class MediaFlowTests : 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 byte[] Png(int width, int height)
{
using var image = (Image.Black(width, height, bands: 3) + new double[] { 10, 120, 200 }).Cast(Enums.BandFormat.Uchar);
return image.WriteToBuffer(".png");
}
static IFormFile Upload(byte[] bytes, string contentType) =>
new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "picture") { Headers = new HeaderDictionary(), ContentType = contentType };
[Fact]
public async Task An_upload_is_attached_once_and_federated_with_its_alt_text()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var (_, mallory) = await _harness.Persona("mallory");
var upload = await _harness.Media.Upload(alice, Upload(Png(300, 200), "image/png"), "a blue square", "0.25,-0.5", token);
Assert.True(upload.Ok);
Assert.True(File.Exists(Path.Combine(_harness.Media.Root, upload.Attachment.FilePath)));
var stolen = await _harness.Statuses.Publish(mallory, new StatusDraft { Text = "mine", MediaIds = new[] { upload.Attachment.ID } }, token);
Assert.False(stolen.Ok);
var posted = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "look", MediaIds = new[] { upload.Attachment.ID } }, token);
Assert.True(posted.Ok);
Assert.Equal(posted.Post.ID, (await DB.Default.Find<MediaAttachment>().OneAsync(upload.Attachment.ID, token)).PostId);
var note = ActivityPubRenderer.Note(posted.Post, alice, default, default);
var attachment = note["attachment"]![0]!;
Assert.Equal("a blue square", attachment["name"]!.GetValue<string>());
Assert.Equal(300, attachment["width"]!.GetValue<int>());
Assert.Equal(-0.5f, attachment["focalPoint"]![1]!.GetValue<float>());
Assert.StartsWith("https://privapub.test/media/files/", attachment["url"]!.GetValue<string>());
var reused = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "again", MediaIds = new[] { upload.Attachment.ID } }, token);
Assert.False(reused.Ok);
}
[Fact]
public async Task Unsupported_files_are_refused()
{
var (_, alice) = await _harness.Persona("alice");
var outcome = await _harness.Media.Upload(alice, Upload(System.Text.Encoding.UTF8.GetBytes("<svg/>"), "image/svg+xml"), default, default, TestContext.Current.CancellationToken);
Assert.False(outcome.Ok);
Assert.Equal(422, outcome.Status);
}
[Fact]
public async Task The_proxy_serves_signed_remote_media_and_nothing_else()
{
var token = TestContext.Current.CancellationToken;
var path = $"/files/{Guid.NewGuid():N}.png";
_harness.Peer.ServeFile(path, Png(10, 10), "image/png");
var proxy = new MediaProxy(_harness.Local, Peer.Http(), _harness.Media, new StaticOptions<MediaOptions>(new MediaOptions()));
var wrapped = proxy.Wrap(_harness.Peer.A + path);
var parts = new Uri(wrapped).AbsolutePath.Split('/');
var (file, contentType) = await proxy.Fetch(parts[3], parts[4], token);
var (tampered, _) = await proxy.Fetch(parts[3].Replace(parts[3][0], parts[3][0] == 'A' ? 'B' : 'A'), parts[4], token);
Assert.StartsWith("https://privapub.test/media/proxy/", wrapped);
Assert.Equal("image/png", contentType);
Assert.True(File.Exists(file));
Assert.StartsWith(_harness.Media.ProxyRoot, file);
Assert.Null(tampered);
}
}
}
@@ -1,72 +0,0 @@
using NetVips;
using PrivaPub.Domain.Media;
namespace PrivaPub.Tests.Domain
{
public class MediaProcessingTests
{
static byte[] JpegWithMetadata(int width, int height)
{
using var image = (Image.Black(width, height, bands: 3) + new double[] { 200, 40, 90 }).Cast(Enums.BandFormat.Uchar);
using var tagged = image.Mutate(m =>
{
m.Set(GValue.GStrType, "exif-ifd0-ImageDescription", "where I live");
m.Set(GValue.GStrType, "exif-ifd0-Artist", "Alice Smith");
m.Set(GValue.GStrType, "exif-ifd2-UserComment", "at home");
m.Set(GValue.BlobType, "xmp-data", System.Text.Encoding.UTF8.GetBytes("<x:xmpmeta xmlns:x='adobe:ns:meta/'><secret/></x:xmpmeta>"));
});
return tagged.WriteToBuffer(".jpg");
}
[Fact]
public void Uploaded_images_lose_every_kind_of_metadata()
{
var input = JpegWithMetadata(800, 600);
using (var original = Image.NewFromBuffer(input))
Assert.Contains("exif-data", original.GetFields());
var processed = MediaService.ProcessImage(input, 4096, 640, animated: false);
using var output = Image.NewFromBuffer(processed.Bytes);
var fields = output.GetFields();
Assert.DoesNotContain("exif-data", fields);
Assert.DoesNotContain("xmp-data", fields);
Assert.DoesNotContain("iptc-data", fields);
Assert.DoesNotContain(fields, f => f.StartsWith("exif-ifd"));
Assert.Equal((800, 600), (processed.Width, processed.Height));
Assert.Equal("image/jpeg", processed.ContentType);
using var preview = Image.NewFromBuffer(processed.Preview);
Assert.Equal(640, Math.Max(preview.Width, preview.Height));
Assert.DoesNotContain("exif-data", preview.GetFields());
}
[Fact]
public void Large_images_are_capped()
{
var processed = MediaService.ProcessImage(JpegWithMetadata(5000, 2500), 4096, 640, animated: false);
Assert.Equal((4096, 2048), (processed.Width, processed.Height));
}
[Fact]
public void A_blurhash_is_well_formed()
{
var processed = MediaService.ProcessImage(JpegWithMetadata(64, 64), 4096, 640, animated: false);
Assert.Equal(28, processed.Blurhash.Length);
Assert.Equal('L', processed.Blurhash[0]);
}
[Fact]
public void A_white_image_encodes_white_as_its_average()
{
var pixels = Enumerable.Range(0, 16 * 16).SelectMany(_ => new byte[] { 255, 255, 255 }).ToArray();
var hash = Blurhash.Encode(pixels, 16, 16);
Assert.Equal(28, hash.Length);
Assert.Equal("TSUA", hash[2..6]);
}
}
}
-77
View File
@@ -1,77 +0,0 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Outbox;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using System.Text.Json;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public sealed class NearbyTests : 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_located_post_reaches_only_those_within_its_radius_and_never_leaves_the_server()
{
var token = TestContext.Current.CancellationToken;
var latitude = 10 + Random.Shared.NextDouble() * 50;
var longitude = 10 + Random.Shared.NextDouble() * 50;
var (aliceRoot, alice) = await _harness.Persona("alice");
var (bobRoot, bob) = await _harness.Persona("bob");
var (carolRoot, carol) = await _harness.Persona("carol");
var follower = new RemoteActor(_harness.Peer, "follower");
await _harness.FollowedBy(alice, follower);
var created = await _harness.Posts.InsertPost(aliceRoot, new InsertPostForm
{
AvatarId = alice.Id, Text = "free bread at the bakery", Latitude = latitude, Longitude = longitude, RangeKm = 5
}, token);
var post = await DB.Default.Find<Post>().OneAsync(((ViewPost)created.Data).Id, token);
var three = (List<ViewPost>)(await _harness.Posts.Nearby(bobRoot, bob.Id, latitude + 0.027, longitude, token)).Data;
var eight = (List<ViewPost>)(await _harness.Posts.Nearby(carolRoot, carol.Id, latitude + 0.072, longitude, token)).Data;
Assert.Equal(PostVisibility.LocalGeo, post.Visibility);
Assert.Equal(Math.Round(latitude, 2), post.Geo.Coordinates[1]);
Assert.Contains(three, p => p.Id == post.ID);
Assert.InRange(three.First(p => p.Id == post.ID).DistanceKm.Value, 2.0, 4.0);
Assert.DoesNotContain(eight, p => p.Id == post.ID);
Assert.False(await VisibilityPolicy.CanSee(post, bob.Id, token));
var outgoing = (await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver).ExecuteAsync(token))
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
.Where(p => p.Body.Contains(post.ID));
Assert.Empty(outgoing);
}
[Fact]
public async Task A_located_post_cannot_be_direct_or_in_a_group()
{
var (root, alice) = await _harness.Persona("alice");
var result = await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft
{
Text = "x", Latitude = 45, Longitude = 7, Visibility = PostVisibility.Direct
}, TestContext.Current.CancellationToken);
Assert.False(result.Ok);
}
}
}
-94
View File
@@ -1,94 +0,0 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public sealed class OutboxTests : 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<ForeignAvatar> Known(RemoteActor actor) =>
await _harness.Remote.GetActor(actor.Id, refresh: false, TestContext.Current.CancellationToken);
[Fact]
public async Task The_audience_follows_mastodons_table()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var follower = new RemoteActor(_harness.Peer, "follower");
var mentioned = await Known(new RemoteActor(_harness.Peer, "mentioned"));
var parentAuthor = await Known(new RemoteActor(_harness.Peer, "parent"));
await _harness.FollowedBy(alice, follower);
var mention = new PostMention { ActorURI = mentioned.ActorURI };
async Task<IReadOnlyList<string>> Audience(PostVisibility visibility, string replyTo = default) =>
await _harness.Outbox.Audience(alice, new Post { Visibility = visibility, Mentions = new() { mention }, To = new() { mentioned.ActorURI }, InReplyToAccountId = replyTo }, token);
Assert.Equal(new[] { follower.SharedInbox, mentioned.InboxURL }, await Audience(PostVisibility.Public));
Assert.Equal(new[] { follower.SharedInbox, mentioned.InboxURL, parentAuthor.InboxURL }, await Audience(PostVisibility.Unlisted, parentAuthor.ID));
Assert.Equal(new[] { follower.SharedInbox, mentioned.InboxURL }, await Audience(PostVisibility.FollowersOnly));
Assert.Equal(new[] { mentioned.InboxURL }, await Audience(PostVisibility.Direct, parentAuthor.ID));
Assert.Empty(await Audience(PostVisibility.Circle));
}
[Fact]
public async Task A_post_is_created_edited_and_deleted_for_its_audience()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var follower = new RemoteActor(_harness.Peer, "follower");
await _harness.FollowedBy(alice, follower);
var created = await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "first", Visibility = "followersonly" }, token);
var postId = ((ViewPost)created.Data).Id;
await _harness.Posts.UpdatePost(root, new UpdatePostForm { AvatarId = alice.Id, PostId = postId, Text = "second", SpoilerText = "cw" }, token);
await _harness.Posts.DeletePost(root, new DeletePostForm { AvatarId = alice.Id, PostId = postId }, token);
var sent = await _harness.Outgoing(follower.SharedInbox);
Assert.Equal(new[] { "Create", "Update", "Delete" }, sent.Select(a => a["type"]!.GetValue<string>()));
Assert.DoesNotContain(sent[0]["to"]!.AsArray(), n => n!.GetValue<string>().EndsWith("#Public"));
// Pleroma reads an empty cc without a "/followers" address as a direct message
Assert.Contains(sent[0]["object"]!["cc"]!.AsArray(), n => n!.GetValue<string>() == alice.Followers);
Assert.Equal("cw", sent[1]["object"]!["summary"]!.GetValue<string>());
Assert.NotNull(sent[1]["object"]!["updated"]);
Assert.Equal(sent[0]["to"]!.ToJsonString(), sent[2]["to"]!.ToJsonString());
var stored = await DB.Default.Find<Post>().OneAsync(postId, token);
Assert.NotNull(stored.DeletedAt);
Assert.Null(stored.ContentHtml);
Assert.Empty(stored.Revisions);
}
[Fact]
public async Task Someone_elses_post_cannot_be_edited()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var (otherRoot, bob) = await _harness.Persona("bob");
var created = await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "mine" }, token);
var result = await _harness.Posts.UpdatePost(otherRoot, new UpdatePostForm { AvatarId = bob.Id, PostId = ((ViewPost)created.Data).Id, Text = "yours" }, token);
Assert.False(result.IsValid);
Assert.Equal(404, result.StatusCode);
}
}
}
-146
View File
@@ -1,146 +0,0 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels.Social;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public sealed class RelationshipTests : 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_cuts_follows_both_ways_and_hides_the_account()
{
var token = TestContext.Current.CancellationToken;
var (aliceRoot, alice) = await _harness.Persona("alice");
var (bobRoot, bob) = await _harness.Persona("bob");
await _harness.Follows.Follow(aliceRoot, new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, token);
await _harness.Follows.Follow(bobRoot, new FollowForm { AvatarId = bob.Id, Target = alice.UserName }, token);
await _harness.Relationships.Block(alice, bob.Uri, bob.Id, token);
await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = $"hey @{alice.UserName}" }, token);
Assert.False(await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id || f.AvatarId == bob.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id || f.LocalActorId == bob.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.AuthorAccountId == bob.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_blocked_remote_account_is_refused_when_it_follows()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
await _harness.Relationships.Block(alice, mallory.Id, default, token);
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(mallory)}/follows/{Guid.NewGuid():N}", ["type"] = "Follow", ["actor"] = mallory.Id, ["object"] = alice.Uri
});
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id).ExecuteAnyAsync(token));
var sent = (await _harness.Outgoing(mallory.Id + "/inbox")).Select(a => a["type"]!.GetValue<string>()).ToList();
Assert.Equal(new[] { "Block", "Reject" }, sent.Order());
}
[Fact]
public async Task A_block_is_told_to_the_blocked_server_and_so_is_the_unblock()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(mallory)}/follows/{Guid.NewGuid():N}", ["type"] = "Follow", ["actor"] = mallory.Id, ["object"] = alice.Uri
});
await _harness.Relationships.Block(alice, mallory.Id, default, token);
await _harness.Relationships.Unblock(alice, mallory.Id, token);
var sent = await _harness.Outgoing(mallory.Id + "/inbox");
var block = Assert.Single(sent, a => a["type"]!.GetValue<string>() == "Block");
Assert.Equal(mallory.Id, block["object"]!.GetValue<string>());
Assert.Equal(alice.Uri, block["actor"]!.GetValue<string>());
var undo = Assert.Single(sent, a => a["type"]!.GetValue<string>() == "Undo");
Assert.Equal(block["id"]!.GetValue<string>(), undo["object"]!["id"]!.GetValue<string>());
Assert.False(await DB.Default.Find<Block>().Match(b => b.AvatarId == alice.Id).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_muted_account_stays_out_of_home_and_notifications()
{
var token = TestContext.Current.CancellationToken;
var (aliceRoot, alice) = await _harness.Persona("alice");
var (bobRoot, bob) = await _harness.Persona("bob");
await _harness.Follows.Follow(aliceRoot, new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, token);
await _harness.Relationships.Mute(alice, bob.Uri, bob.Id, hideNotifications: true, default, token);
await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = $"hey @{alice.UserName}" }, token);
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id && n.Type == NotificationType.Mention).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_report_is_forwarded_by_the_instance_not_the_persona()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
var foreign = await _harness.Remote.GetActor(mallory.Id, refresh: false, token);
var report = await _harness.Reports.File(alice, foreign.ID, Array.Empty<string>(), "spam everywhere", "spam", forward: true, token);
Assert.True(report.Forwarded);
var flag = Assert.Single(await _harness.Outgoing(mallory.Id + "/inbox"));
Assert.Equal("Flag", flag["type"]!.GetValue<string>());
Assert.EndsWith("/peasants/privapub", flag["actor"]!.GetValue<string>());
Assert.DoesNotContain(alice.UserName, flag.ToJsonString());
Assert.DoesNotContain(alice.Id, flag.ToJsonString());
}
[Fact]
public async Task An_inbound_flag_becomes_a_report_for_moderators()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var reporter = new RemoteActor(_harness.Peer, "reporter");
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "bad post" }, token);
var post = await DB.Default.Find<PrivaPub.Models.Post.Post>().Match(p => p.GroupUserId == alice.Id).ExecuteSingleAsync(token);
await _harness.Deliver(reporter, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(reporter)}/flags/{Guid.NewGuid():N}", ["type"] = "Flag", ["actor"] = reporter.Id,
["content"] = "please look", ["object"] = new JsonArray(alice.Uri, post.ObjectURI)
});
var report = await DB.Default.Find<Report>().Match(r => r.TargetAccountId == alice.Id).ExecuteSingleAsync(token);
Assert.Equal(reporter.Id, report.ReporterActorURI);
Assert.Equal(new[] { post.ID }, report.PostIds);
Assert.Equal("please look", report.Comment);
}
}
}
-162
View File
@@ -1,162 +0,0 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels.Social;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public sealed class TimelineTests : 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<List<ViewPost>> Home(string root, string avatarId) =>
(List<ViewPost>)(await _harness.Timelines.Home(root, avatarId, default, 40, TestContext.Current.CancellationToken)).Data;
async Task Accepted(string root, string avatarId, RemoteActor target)
{
await _harness.Follows.Follow(root, new FollowForm { AvatarId = avatarId, Target = target.Id }, TestContext.Current.CancellationToken);
await DB.Default.Update<Following>().Match(f => f.AvatarId == avatarId && f.TargetActorURI == target.Id)
.Modify(f => f.State, FollowState.Accepted).ExecuteAsync(TestContext.Current.CancellationToken);
}
static JsonObject Create(RemoteActor author, string inReplyTo = default, string text = "hello", params (string Uri, string Name)[] mentions)
{
var origin = new Uri(author.Id).GetLeftPart(UriPartial.Authority);
var note = new JsonObject
{
["id"] = $"{origin}/notes/{Guid.NewGuid():N}",
["type"] = "Note",
["attributedTo"] = author.Id,
["content"] = $"<p>{text}</p>",
["to"] = new JsonArray(Addressing.Public),
["cc"] = new JsonArray(mentions.Select(m => (JsonNode)m.Uri).Prepend(author.Id + "/followers").ToArray()),
["tag"] = new JsonArray(mentions.Select(m => (JsonNode)new JsonObject { ["type"] = "Mention", ["href"] = m.Uri, ["name"] = m.Name }).ToArray())
};
if (inReplyTo != default)
note["inReplyTo"] = inReplyTo;
return new JsonObject { ["id"] = $"{origin}/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Id, ["object"] = note };
}
[Fact]
public async Task A_local_post_reaches_the_author_and_local_followers_only()
{
var token = TestContext.Current.CancellationToken;
var (aliceRoot, alice) = await _harness.Persona("alice");
var (bobRoot, bob) = await _harness.Persona("bob");
var (carolRoot, carol) = await _harness.Persona("carol");
await _harness.Follows.Follow(bobRoot, new FollowForm { AvatarId = bob.Id, Target = alice.UserName }, token);
await _harness.Posts.InsertPost(aliceRoot, new InsertPostForm { AvatarId = alice.Id, Text = "for my followers" }, token);
Assert.Single(await Home(aliceRoot, alice.Id));
Assert.Equal("<p>for my followers</p>", Assert.Single(await Home(bobRoot, bob.Id)).ContentHtml);
Assert.Empty(await Home(carolRoot, carol.Id));
Assert.Equal(404, (await _harness.Timelines.Home(carolRoot, bob.Id, default, 20, token)).StatusCode);
}
[Fact]
public async Task A_followed_remote_account_is_kept_and_its_replies_follow_mastodons_rule()
{
var (bobRoot, bob) = await _harness.Persona("bob");
var carol = new RemoteActor(_harness.Peer, "carol");
var dave = new RemoteActor(_harness.Peer, "dave");
await Accepted(bobRoot, bob.Id, carol);
var top = Create(carol, text: "top");
await _harness.Deliver(carol, "/human-centipede", top);
await _harness.Deliver(carol, "/human-centipede", Create(carol, top["object"]!["id"]!.GetValue<string>(), "self reply"));
await _harness.Deliver(dave, "/human-centipede", Create(dave, text: "dave alone"));
var daveRoot = Create(dave, text: "unfollowed root");
await _harness.Deliver(carol, "/human-centipede", Create(carol, $"{new Uri(dave.Id).GetLeftPart(UriPartial.Authority)}/notes/unknown", "reply to a stranger"));
var home = (await Home(bobRoot, bob.Id)).Select(p => p.ContentHtml).ToList();
Assert.Contains("<p>top</p>", home);
Assert.Contains("<p>self reply</p>", home);
Assert.DoesNotContain("<p>dave alone</p>", home);
Assert.DoesNotContain("<p>reply to a stranger</p>", home);
}
[Fact]
public async Task A_reply_brings_its_public_parent_and_the_grandparent_is_queued()
{
var token = TestContext.Current.CancellationToken;
var (bobRoot, bob) = await _harness.Persona("bob");
var carol = new RemoteActor(_harness.Peer, "carol");
var dave = new RemoteActor(_harness.Peer, "dave");
await Accepted(bobRoot, bob.Id, carol);
var origin = new Uri(dave.Id).GetLeftPart(UriPartial.Authority);
var grandparentId = $"{origin}/notes/{Guid.NewGuid():N}";
var parentId = $"{origin}/notes/{Guid.NewGuid():N}";
_harness.Peer.Serve(new Uri(parentId).AbsolutePath, new JsonObject
{
["id"] = parentId, ["type"] = "Note", ["attributedTo"] = dave.Id, ["content"] = "<p>parent</p>",
["to"] = new JsonArray(Addressing.Public), ["inReplyTo"] = grandparentId
}.ToJsonString());
await _harness.Deliver(carol, "/human-centipede", Create(carol, parentId, "reply to dave"));
var parent = await DB.Default.Find<PrivaPub.Models.Post.Post>().Match(p => p.ObjectURI == parentId).ExecuteSingleAsync(token);
var reply = await DB.Default.Find<PrivaPub.Models.Post.Post>().Match(p => p.InReplyToURI == parentId).ExecuteSingleAsync(token);
Assert.Equal(parent.ID, reply.AnsweringToPostId);
Assert.Equal(parent.AuthorAccountId, reply.InReplyToAccountId);
Assert.True(await DB.Default.Find<PrivaPub.Models.Jobs.Job>().Match(j => j.DedupeKey == "ancestors|" + parent.ID).ExecuteAnyAsync(token));
Assert.DoesNotContain("<p>reply to dave</p>", (await Home(bobRoot, bob.Id)).Select(p => p.ContentHtml));
Assert.DoesNotContain("<p>parent</p>", (await Home(bobRoot, bob.Id)).Select(p => p.ContentHtml));
}
[Fact]
public async Task A_remote_delete_takes_the_post_out_of_home()
{
var (bobRoot, bob) = await _harness.Persona("bob");
var carol = new RemoteActor(_harness.Peer, "carol");
await Accepted(bobRoot, bob.Id, carol);
var create = Create(carol, text: "soon gone");
await _harness.Deliver(carol, "/human-centipede", create);
Assert.Single(await Home(bobRoot, bob.Id));
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = $"{new Uri(carol.Id).GetLeftPart(UriPartial.Authority)}/deletes/{Guid.NewGuid():N}", ["type"] = "Delete", ["actor"] = carol.Id,
["object"] = new JsonObject { ["id"] = create["object"]!["id"]!.GetValue<string>(), ["type"] = "Tombstone" }
});
Assert.Empty(await Home(bobRoot, bob.Id));
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == bob.Id).ExecuteAnyAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task A_mention_notifies_the_persona()
{
var token = TestContext.Current.CancellationToken;
var (bobRoot, bob) = await _harness.Persona("bob");
var carol = new RemoteActor(_harness.Peer, "carol");
await _harness.Deliver(carol, "/human-centipede", Create(carol, mentions: (bob.Uri, "@" + bob.Handle)));
var notifications = (List<ViewNotification>)(await _harness.Timelines.Notifications(bobRoot, bob.Id, default, 20, token)).Data;
var mention = Assert.Single(notifications);
Assert.Equal("Mention", mention.Type);
Assert.Equal(carol.Id, mention.FromActorURI);
await _harness.Timelines.MarkNotificationsRead(bobRoot, bob.Id, token);
Assert.True(((List<ViewNotification>)(await _harness.Timelines.Notifications(bobRoot, bob.Id, default, 20, token)).Data).Single().IsRead);
}
}
}
@@ -1,42 +0,0 @@
using MongoDB.Entities;
using PrivaPub.Domain.Privacy;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public class VisibilityPolicyTests
{
[Fact]
public async Task Who_sees_what()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var conversation = new DmGroup { Members = new() { new GroupMember { AvatarId = "alice" }, new GroupMember { AvatarId = "https://r.example/u/bob", IsForeign = true } } };
var circle = new GroupEntity { UserName = $"c{Guid.NewGuid():N}"[..20], Members = new() { new GroupMember { AvatarId = "carol" } } };
await DB.Default.SaveAsync(conversation, token);
await DB.Default.SaveAsync(circle, token);
var direct = new Post { Visibility = PostVisibility.Direct, ConversationId = conversation.ID, GroupUserId = "dave" };
var circlePost = new Post { Visibility = PostVisibility.Circle, GroupId = circle.ID };
var followersOnly = new Post { Visibility = PostVisibility.FollowersOnly, Mentions = new() { new PostMention { IsLocal = true, AccountId = "erin" } } };
var deleted = new Post { Visibility = PostVisibility.Public, DeletedAt = DateTime.UtcNow };
Assert.True(await VisibilityPolicy.CanSee(new Post(), default, token));
Assert.True(await VisibilityPolicy.CanSee(direct, "alice", token));
Assert.True(await VisibilityPolicy.CanSee(direct, "dave", token));
Assert.False(await VisibilityPolicy.CanSee(direct, "carol", token));
Assert.False(await VisibilityPolicy.CanSee(direct, default, token));
Assert.True(await VisibilityPolicy.CanSee(circlePost, "carol", token));
Assert.False(await VisibilityPolicy.CanSee(circlePost, "alice", token));
Assert.True(await VisibilityPolicy.CanSee(followersOnly, "erin", token));
Assert.False(await VisibilityPolicy.CanSee(followersOnly, "alice", token));
Assert.False(await VisibilityPolicy.CanSee(deleted, "alice", token));
}
}
}
@@ -1,50 +0,0 @@
using PrivaPub.Federation.Actors;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
public class ActorFeaturesTests
{
[Fact]
public void A_rich_actor_reports_the_extensions_it_uses()
{
using var rsa = RSA.Create(4096);
var actor = new JsonObject
{
["id"] = "https://social.example/users/a",
["type"] = "Person",
["featured"] = "https://social.example/users/a/featured",
["alsoKnownAs"] = new JsonArray("https://old.example/users/a"),
["endpoints"] = new JsonObject { ["sharedInbox"] = "https://social.example/inbox" },
["assertionMethod"] = new JsonArray(),
["attachment"] = new JsonArray(new JsonObject { ["type"] = "PropertyValue", ["name"] = "x", ["value"] = "y" }),
["manuallyApprovesFollowers"] = true,
["discoverable"] = false,
["indexable"] = true,
["isCat"] = true,
["_misskey_summary"] = "hi",
["publicKey"] = new JsonObject { ["id"] = "https://social.example/users/a#main-key", ["publicKeyPem"] = rsa.ExportSubjectPublicKeyInfoPem() }
};
var features = ActorFeatures.Detect(JsonDocument.Parse(actor.ToJsonString()).RootElement);
Assert.Equal(new[]
{
"featured", "also-known-as", "shared-inbox", "fep-521a-assertion-method", "property-value", "misskey", "is-cat",
"indexable", "undiscoverable", "locked", "key:rsa-4096"
}, features);
}
[Fact]
public void A_bare_actor_reports_nothing_and_junk_is_survived()
{
Assert.Empty(ActorFeatures.Detect(JsonDocument.Parse("{\"id\":\"https://a.example/u\",\"type\":\"Person\"}").RootElement));
Assert.Equal(new[] { "public-key-array" },
ActorFeatures.Detect(JsonDocument.Parse("{\"publicKey\":[{\"publicKeyPem\":\"not a key\"}]}").RootElement));
Assert.Empty(ActorFeatures.Detect(JsonDocument.Parse("[1,2]").RootElement));
}
}
}
@@ -1,41 +0,0 @@
using PrivaPub.Federation.Actors;
using System.Text.Json;
namespace PrivaPub.Tests.Federation
{
public class ActorProfileTests
{
[Fact]
public void Reads_a_mastodon_profile_with_its_header_fields_emoji_and_lock()
{
using var json = JsonDocument.Parse("""
{
"id": "https://m.example/users/ann", "type": "Person", "preferredUsername": "ann", "name": "Ann :sparkle:",
"inbox": "https://m.example/users/ann/inbox", "manuallyApprovesFollowers": true, "indexable": true, "memorial": false,
"published": "2023-04-01T00:00:00Z", "movedTo": "https://n.example/users/ann",
"icon": { "type": "Image", "url": "https://m.example/a.png", "summary": "a cat" },
"image": { "type": "Image", "url": "https://m.example/h.png", "name": "mountains" },
"tag": [{ "type": "Emoji", "name": ":sparkle:", "icon": { "type": "Image", "url": "https://m.example/emoji/sparkle.png" } }],
"attachment": [
{ "type": "PropertyValue", "name": "Site", "value": "<a href=\"https://ann.example\" rel=\"me\">ann.example</a><script>x</script>" },
{ "type": "PropertyValue", "name": "Pronouns", "value": "she/her" }
]
}
""");
var actor = ActorDocument.Parse(json.RootElement);
Assert.True(actor.Locked);
Assert.True(actor.Indexable);
Assert.Equal("https://m.example/h.png", actor.Header);
Assert.Equal("a cat", actor.IconDescription);
Assert.Equal("mountains", actor.HeaderDescription);
Assert.Equal("https://n.example/users/ann", actor.MovedTo);
Assert.Equal(new DateTime(2023, 4, 1, 0, 0, 0, DateTimeKind.Utc), actor.Published);
Assert.Equal("sparkle", Assert.Single(actor.Emojis).Shortcode);
Assert.Equal(new[] { "Site", "Pronouns" }, actor.Fields.Keys);
Assert.DoesNotContain("<script>", actor.Fields["Site"]);
}
}
}
@@ -1,277 +0,0 @@
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Text.Json.Nodes;
using static PrivaPub.Tests.Support.FederatedSeeds;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Tests.Federation
{
public class AuthorGoneRuleTests
{
[Fact]
public async Task A_post_whose_author_is_gone_is_neither_shown_nor_public_nor_seen_by_anyone()
{
var gone = new Post { Visibility = PostVisibility.Public, AuthorGone = true, GroupUserId = "alice" };
Assert.False(VisibilityPolicy.Shown(gone));
Assert.False(VisibilityPolicy.IsPublic.Compile()(gone));
Assert.False(await VisibilityPolicy.CanSee(gone, default, TestContext.Current.CancellationToken));
Assert.False(await VisibilityPolicy.CanSee(gone, "alice", TestContext.Current.CancellationToken));
Assert.True(VisibilityPolicy.Shown(new Post()));
Assert.False(VisibilityPolicy.Shown(new Post { DeletedAt = DateTime.UtcNow }));
Assert.False(VisibilityPolicy.Shown(default));
}
}
[Trait("Category", "Integration")]
public sealed class AuthorGoneTests : 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<Post> Delivered(RemoteActor author, JsonObject note)
{
await _harness.Deliver(author, "/human-centipede", Create(author, note));
return await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(note)).ExecuteFirstAsync(TestContext.Current.CancellationToken);
}
[Fact]
public async Task Deleting_itself_keeps_every_post_of_the_account_but_hides_them_and_drops_its_follows_and_rows()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var ghost = new RemoteActor(_harness.Peer, "ghost");
var friend = new RemoteActor(_harness.Peer, "friend");
await Follows(alice.Id, ghost);
await Follows(alice.Id, friend);
await _harness.FollowedBy(alice, ghost);
var post = await Delivered(ghost, PublicNote(ghost, "<p>soon gone</p>"));
var friends = await Delivered(friend, PublicNote(friend, "<p>still here</p>"));
await _harness.Deliver(ghost, "/human-centipede", new JsonObject
{
["id"] = NewId(ghost, "announces"), ["type"] = "Announce", ["actor"] = ghost.Id, ["object"] = friends.ObjectURI,
["to"] = new JsonArray(Addressing.Public)
});
var ghostAccount = await DB.Default.Find<ForeignAvatar>().Match(f => f.ActorURI == ghost.Id).ExecuteSingleAsync(token);
Assert.True(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.AuthorAccountId == ghostAccount.ID).ExecuteAnyAsync(token));
Assert.Equal(202, (await _harness.Deliver(ghost, "/human-centipede", Activity(ghost, "Delete", JsonValue.Create(ghost.Id)!))).StatusCode);
var kept = await DB.Default.Find<Post>().Match(p => p.ActorURI == ghost.Id).ExecuteAsync(token);
Assert.Equal(2, kept.Count);
Assert.All(kept, p => Assert.True(p.AuthorGone));
Assert.Contains(kept, p => p.ID == post.ID && p.ContentHtml == "<p>soon gone</p>");
Assert.Contains(kept, p => p.ReblogOfPostId == friends.ID);
Assert.False((await DB.Default.Find<Post>().OneAsync(friends.ID, token)).AuthorGone);
Assert.All(kept, p => Assert.False(VisibilityPolicy.Shown(p)));
foreach (var hidden in kept)
Assert.False(await VisibilityPolicy.CanSee(hidden, alice.Id, token));
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == ghost.Id).Match(VisibilityPolicy.IsPublic).ExecuteAnyAsync(token));
Assert.Equal(AvatarAccountState.Deleted, (await DB.Default.Find<ForeignAvatar>().OneAsync(ghostAccount.ID, token)).AccountState);
Assert.False(await DB.Default.Find<Follower>().Match(f => f.ActorURI == ghost.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Following>().Match(f => f.TargetActorURI == ghost.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.AuthorAccountId == ghostAccount.ID).ExecuteAnyAsync(token));
var mapper = new MastodonMapper(_harness.Db, _harness.Local);
Assert.Empty(await mapper.Statuses(kept, alice.Id, token));
var home = (List<PrivaPub.ClientModels.Post.ViewPost>)(await _harness.Timelines.Home(root, alice.Id, default, 40, token)).Data;
Assert.DoesNotContain(home, v => v.AuthorActorURI == ghost.Id);
Assert.Contains(home, v => v.Id == friends.ID);
}
[Fact]
public async Task A_post_by_an_account_already_gone_is_hidden_from_the_moment_it_is_stored()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var ghost = new RemoteActor(_harness.Peer, "ghost");
await _harness.FollowedBy(alice, ghost);
await _harness.Deliver(ghost, "/human-centipede", Activity(ghost, "Delete", JsonValue.Create(ghost.Id)!));
var late = PublicNote(ghost, "<p>from beyond</p>");
_harness.Peer.Serve(new Uri(IdOf(late)).AbsolutePath, late.ToJsonString());
var stored = await _harness.RemotePosts.StoreContext(IdOf(late), 0, token);
Assert.NotNull(stored);
Assert.True(stored.AuthorGone);
Assert.False(await VisibilityPolicy.CanSee(stored, alice.Id, token));
}
}
[Trait("Category", "Integration")]
public sealed class AuthorGoneOverHttpTests : 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 async Task<(HttpStatusCode Status, JsonNode Body)> Get(HttpClient client, string path)
{
var response = await client.GetAsync(path, TestContext.Current.CancellationToken);
var text = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
return (response.StatusCode, string.IsNullOrEmpty(text) || response.Content.Headers.ContentType?.MediaType?.Contains("json") != true ? default : JsonNode.Parse(text));
}
static async Task<List<string>> Ids(HttpClient client, string path)
{
var (status, body) = await Get(client, path);
Assert.Equal(HttpStatusCode.OK, status);
return body!.AsArray().Select(s => IdOf(s!)).ToList();
}
static async Task<bool> Found(HttpClient client, string path) => (await Get(client, path)).Status == HttpStatusCode.OK;
sealed record Seen(bool Status, bool Context, bool FavouritedBy, bool RebloggedBy, bool History, bool Many, bool Boost, bool InAncestors,
bool BoostersListed, bool OnProfile, bool OnTag, bool OnPublic, bool OnHome, bool Notified, bool InConversation, bool Searched,
bool Favourited, bool Bookmarked, bool Provenance, bool InCommunityOutbox);
[Fact]
public async Task Every_lookup_treats_a_post_whose_author_deleted_itself_as_not_found()
{
var token = TestContext.Current.CancellationToken;
var persona = await _host.Persona(await _host.SignUp(), "reader");
var bearer = await _host.MastodonToken(persona);
using var client = _host.As(bearer);
var (privateKey, publicKey) = PrivaPub.Federation.Actors.Keys.NewKeyPair();
var community = new GroupEntity
{
UserName = $"commons{Guid.NewGuid():N}"[..20], Kind = GroupKind.Community, PostingPolicy = PostingPolicy.Anyone,
PrivateKey = privateKey, PublicKey = publicKey, Members = new() { new GroupMember { AvatarId = persona.Id, Role = GroupRole.Owner } }
};
await DB.Default.SaveAsync(community, token);
var personaUri = $"{PrivaPubHost.Base}/peasants/{persona.UserName}";
var communityUri = $"{PrivaPubHost.Base}/peasants/{community.UserName}";
var ghost = new RemoteActor(_peer, "ghost");
var friend = new RemoteActor(_peer, "friend");
await Follows(persona.Id, ghost);
await Follows(persona.Id, friend);
var tag = $"gone{Guid.NewGuid():N}"[..16];
var note = PublicNote(ghost, $"<p>hello #{tag}</p>", personaUri, communityUri);
note["tag"] = new JsonArray(
new JsonObject { ["type"] = "Mention", ["href"] = personaUri, ["name"] = "@reader" },
new JsonObject { ["type"] = "Hashtag", ["name"] = "#" + tag, ["href"] = $"{Origin(ghost)}/tags/{tag}" });
Assert.Equal(HttpStatusCode.Accepted, await DeliverSigned(_host, ghost, Create(ghost, note)));
var friendsNote = PublicNote(friend, "<p>a friend's post</p>");
await DeliverSigned(_host, friend, Create(friend, friendsNote));
var reply = PublicNote(friend, "<p>a reply</p>");
reply["inReplyTo"] = IdOf(note);
await DeliverSigned(_host, friend, Create(friend, reply));
await DeliverSigned(_host, friend, new JsonObject
{
["id"] = NewId(friend, "announces"), ["type"] = "Announce", ["actor"] = friend.Id, ["object"] = IdOf(note), ["to"] = new JsonArray(Addressing.Public)
});
var ghostBoostId = NewId(ghost, "announces");
await DeliverSigned(_host, ghost, new JsonObject
{
["id"] = ghostBoostId, ["type"] = "Announce", ["actor"] = ghost.Id, ["object"] = IdOf(friendsNote), ["to"] = new JsonArray(Addressing.Public)
});
var dm = new JsonObject
{
["id"] = NewId(ghost, "notes"), ["type"] = "Note", ["attributedTo"] = ghost.Id, ["content"] = "<p>psst</p>", ["to"] = new JsonArray(personaUri),
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = personaUri, ["name"] = "@reader" })
};
await DeliverSigned(_host, ghost, Create(ghost, dm));
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token);
var friends = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(friendsNote)).ExecuteSingleAsync(token);
var replyPost = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(reply)).ExecuteSingleAsync(token);
var ghostBoost = await DB.Default.Find<Post>().Match(p => p.ObjectURI == ghostBoostId).ExecuteSingleAsync(token);
var dmPost = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(dm)).ExecuteSingleAsync(token);
var ghostAccount = await DB.Default.Find<ForeignAvatar>().Match(f => f.ActorURI == ghost.Id).ExecuteSingleAsync(token);
Assert.Equal(community.ID, post.GroupId);
//search looks up https addresses only, which the peer does not have
var searchable = new Post
{
ObjectURI = $"https://ghost{Guid.NewGuid():N}.example/notes/1", ActorURI = ghost.Id, AuthorAccountId = ghostAccount.ID, IsFederatedCopy = true,
Visibility = PostVisibility.Public, ContentHtml = "<p>found by its address</p>"
};
await DB.Default.SaveAsync(searchable, token);
Assert.Equal(HttpStatusCode.OK, (await client.PostAsync($"/api/v1/statuses/{post.ID}/favourite", default, token)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await client.PostAsync($"/api/v1/statuses/{post.ID}/bookmark", default, token)).StatusCode);
async Task<Seen> Look()
{
var home = (await Get(client, "/api/v1/timelines/home?limit=40")).Body!.AsArray();
var notifications = (await Get(client, "/api/v1/notifications?limit=30")).Body!.AsArray();
var conversations = (await Get(client, "/api/v1/conversations")).Body!.AsArray();
var search = (await Get(client, $"/api/v2/search?type=statuses&q={Uri.EscapeDataString(searchable.ObjectURI)}")).Body!;
using var anonymous = _host.Client();
using var outboxRequest = new HttpRequestMessage(HttpMethod.Get, $"/peasants/{community.UserName}/anus?page=true");
outboxRequest.Headers.Accept.ParseAdd("application/activity+json");
var outbox = JsonNode.Parse(await (await anonymous.SendAsync(outboxRequest, token)).Content.ReadAsStringAsync(token))!;
return new Seen(
await Found(client, $"/api/v1/statuses/{post.ID}"),
await Found(client, $"/api/v1/statuses/{post.ID}/context"),
await Found(client, $"/api/v1/statuses/{post.ID}/favourited_by"),
await Found(client, $"/api/v1/statuses/{post.ID}/reblogged_by"),
await Found(client, $"/api/v1/statuses/{post.ID}/history"),
(await Ids(client, $"/api/v1/statuses?id[]={post.ID}")).Contains(post.ID),
await Found(client, $"/api/v1/statuses/{ghostBoost.ID}"),
(await Get(client, $"/api/v1/statuses/{replyPost.ID}/context")).Body!["ancestors"]!.AsArray().Any(a => IdOf(a!) == post.ID),
(await Ids(client, $"/api/v1/statuses/{friends.ID}/reblogged_by")).Contains(ghostAccount.ID),
(await Ids(client, $"/api/v1/accounts/{ghostAccount.ID}/statuses")).Count > 0,
(await Ids(client, $"/api/v1/timelines/tag/{tag}")).Contains(post.ID),
(await Ids(client, $"/api/v1/timelines/public?remote=true&limit=1&max_id={IdAbove(post.ID)}")).Contains(post.ID),
home.Any(s => IdOf(s!) == post.ID || IdOf(s!) == ghostBoost.ID || s!["reblog"] is JsonObject inner && IdOf(inner) == post.ID),
notifications.Any(n => n!["status"] is JsonObject status && IdOf(status) == post.ID),
conversations.Any(c => c!["last_status"] is JsonObject last && IdOf(last) == dmPost.ID),
search["statuses"]!.AsArray().Count > 0,
(await Ids(client, "/api/v1/favourites")).Contains(post.ID),
(await Ids(client, "/api/v1/bookmarks")).Contains(post.ID),
await Found(client, $"/api/privapub/v1/statuses/{post.ID}/provenance"),
outbox["orderedItems"]!.AsArray().Any(a => a!["object"]?.GetValue<string>() == post.ObjectURI));
}
var before = await Look();
Assert.Equal(HttpStatusCode.Accepted, await DeliverSigned(_host, ghost, Activity(ghost, "Delete", JsonValue.Create(ghost.Id)!)));
var after = await Look();
var everything = new Seen(true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true);
var nothing = new Seen(false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false);
Assert.Equal(everything, before);
Assert.Equal(nothing, after);
Assert.True(await DB.Default.Find<Post>().Match(p => p.ID == post.ID && p.AuthorGone && p.DeletedAt == null).ExecuteAnyAsync(token));
Assert.True(await Found(client, $"/api/v1/statuses/{friends.ID}"));
Assert.True(await Found(client, $"/api/v1/statuses/{replyPost.ID}"));
}
}
}
@@ -1,78 +0,0 @@
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));
}
}
}
@@ -1,75 +0,0 @@
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);
}
}
}
}
@@ -1,223 +0,0 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Social;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class CommunityEditsTests : 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 post in a community the persona follows; the community on the poster's server unless another origin is given
async Task<(RemoteActor Community, RemoteActor Poster, JsonObject Note, Post Post)> Announced(string communityOrigin = default)
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var community = new RemoteActor(_harness.Peer, "cats", communityOrigin, type: "Group");
var poster = new RemoteActor(_harness.Peer, "poster");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token);
await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
var note = new JsonObject
{
["id"] = $"{Origin(poster)}/notes/{Guid.NewGuid():N}",
["type"] = "Page",
["name"] = "a title",
["attributedTo"] = poster.Id,
["content"] = "<p>first</p>",
["to"] = new JsonArray(community.Id, Addressing.Public),
["audience"] = community.Id,
["published"] = DateTime.UtcNow.AddMinutes(-5).ToString("O")
};
_harness.Peer.Serve(new Uri(note["id"]!.GetValue<string>()).AbsolutePath, note.ToJsonString());
await Announce(community, new JsonObject { ["id"] = $"{Origin(poster)}/create/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = poster.Id, ["object"] = note.DeepClone() });
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == note["id"]!.GetValue<string>()).ExecuteSingleAsync(token);
return (community, poster, note, post);
}
Task Announce(RemoteActor community, JsonObject inner) =>
_harness.Deliver(community, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(community)}/announce/{Guid.NewGuid():N}",
["type"] = "Announce",
["actor"] = community.Id,
["to"] = new JsonArray(Addressing.Public),
["object"] = inner
});
[Fact]
public async Task A_community_edit_is_an_edit_only_when_newer_and_keeps_its_history()
{
var token = TestContext.Current.CancellationToken;
var (community, poster, note, post) = await Announced();
var refresh = (JsonObject)note.DeepClone();
_harness.Peer.Serve(new Uri(note["id"]!.GetValue<string>()).AbsolutePath, refresh.ToJsonString());
await Announce(community, new JsonObject { ["id"] = $"{Origin(poster)}/update/{Guid.NewGuid():N}", ["type"] = "Update", ["actor"] = poster.Id, ["object"] = note["id"]!.GetValue<string>() });
var afterRefresh = await DB.Default.Find<Post>().OneAsync(post.ID, token);
var edit = (JsonObject)note.DeepClone();
edit["content"] = "<p>second</p>";
edit["updated"] = DateTime.UtcNow.ToString("O");
_harness.Peer.Serve(new Uri(note["id"]!.GetValue<string>()).AbsolutePath, edit.ToJsonString());
await Announce(community, new JsonObject { ["id"] = $"{Origin(poster)}/update/{Guid.NewGuid():N}", ["type"] = "Update", ["actor"] = poster.Id, ["object"] = note["id"]!.GetValue<string>() });
var afterEdit = await DB.Default.Find<Post>().OneAsync(post.ID, token);
var record = await DB.Default.Find<ObjectRecord>().Match(r => r.ObjectURI == post.ObjectURI).ExecuteSingleAsync(token);
Assert.Empty(afterRefresh.Revisions);
Assert.Null(afterRefresh.EditedAt);
Assert.Equal("<p>second</p>", afterEdit.ContentHtml);
Assert.Equal("a title", afterEdit.Title);
Assert.Single(afterEdit.Revisions);
Assert.NotNull(afterEdit.EditedAt);
Assert.Equal(2, record.Revisions.Count);
Assert.Equal(new[] { "refresh", "edit" }, _harness.Ledger.Of("in").Where(e => e.Activity == "Announce" && e.Object == "Update").Select(e => e.Reason));
}
[Fact]
public async Task A_community_delete_leaves_a_tombstone_and_no_trace()
{
var token = TestContext.Current.CancellationToken;
var (community, poster, note, post) = await Announced();
_harness.Peer.Serve(new Uri(note["id"]!.GetValue<string>()).AbsolutePath, new JsonObject { ["id"] = note["id"]!.GetValue<string>(), ["type"] = "Tombstone" }.ToJsonString());
await Announce(community, new JsonObject { ["id"] = $"{Origin(poster)}/delete/{Guid.NewGuid():N}", ["type"] = "Delete", ["actor"] = poster.Id, ["object"] = note["id"]!.GetValue<string>() });
Assert.False(await DB.Default.Find<Post>().Match(p => p.ID == post.ID).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<ObjectRecord>().Match(r => r.ObjectURI == post.ObjectURI).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.PostId == post.ID).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]
public async Task A_fetched_object_does_not_wear_the_signature_of_the_activity_that_caused_the_fetch()
{
var token = TestContext.Current.CancellationToken;
var (_, _, _, post) = await Announced();
var record = await DB.Default.Find<ObjectRecord>().Match(r => r.ObjectURI == post.ObjectURI).ExecuteSingleAsync(token);
Assert.Equal(ObjectPath.Fetched, record.Path);
Assert.Null(record.KeyId);
Assert.Null(record.SignatureScheme);
Assert.Empty(record.SignedHeaders);
Assert.Null(record.ActivityContext);
Assert.Equal("Announce", record.ActivityType);
Assert.InRange(record.ReceivedAt, DateTime.UtcNow.AddMinutes(-1), DateTime.UtcNow.AddSeconds(1));
Assert.Contains("fep-1b12-audience", record.Extensions);
Assert.NotNull(record.ContextNamespaces);
}
}
}
@@ -1,51 +0,0 @@
using PrivaPub.Federation.Objects;
namespace PrivaPub.Tests.Federation
{
public class ContentSanitizerTests
{
[Theory]
[InlineData("<script>alert(1)</script><p>hi</p>", "<p>hi</p>")]
[InlineData("<p onclick=\"alert(1)\">hi</p>", "<p>hi</p>")]
[InlineData("<img src=x onerror=alert(1)>", "")]
[InlineData("<a href=\"javascript:alert(1)\">x</a>", "<a rel=\"nofollow noopener noreferrer\" target=\"_blank\">x</a>")]
[InlineData("<a href=\"data:text/html,<script>alert(1)</script>\">x</a>", "<a rel=\"nofollow noopener noreferrer\" target=\"_blank\">x</a>")]
[InlineData("<a href=\"/relative\">x</a>", "<a rel=\"nofollow noopener noreferrer\" target=\"_blank\">x</a>")]
[InlineData("<iframe src=\"https://evil.example\"></iframe>text", "text")]
[InlineData("<style>body{display:none}</style><p>x</p>", "<p>x</p>")]
[InlineData("<svg><script>alert(1)</script></svg>", "")]
[InlineData("<p style=\"position:fixed\">x</p>", "<p>x</p>")]
[InlineData("<form action=\"https://evil.example\"><input name=p></form>ok", "ok")]
[InlineData("<p>a<!-- comment -->b</p>", "<p>ab</p>")]
[InlineData("<div><p>kept</p></div>", "<p>kept</p>")]
[InlineData("<h2>Title</h2>", "<p><strong>Title</strong></p>")]
[InlineData("<span class=\"evil big\">x</span>", "<span>x</span>")]
public void Removes_what_is_not_allowed(string input, string expected) =>
Assert.Equal(expected, ContentSanitizer.Html(input));
[Fact]
public void Keeps_mastodon_mention_and_hashtag_markup()
{
var html = "<p><span class=\"h-card\" translate=\"no\"><a href=\"https://m.example/@alice\" class=\"u-url mention\">@<span>alice</span></a></span> "
+ "<a href=\"https://m.example/tags/fedi\" class=\"mention hashtag\" rel=\"tag\">#<span>fedi</span></a> "
+ "<a href=\"https://example.org/a-long-link\"><span class=\"invisible\">https://</span><span class=\"ellipsis\">example.org/a-long</span></a></p>";
var sanitized = ContentSanitizer.Html(html);
Assert.Contains("class=\"h-card\"", sanitized);
Assert.Contains("class=\"u-url mention\"", sanitized);
Assert.Contains("class=\"mention hashtag\"", sanitized);
Assert.Contains("class=\"invisible\"", sanitized);
Assert.Contains("class=\"ellipsis\"", sanitized);
Assert.Contains("href=\"https://m.example/@alice\"", sanitized);
Assert.DoesNotContain("rel=\"tag\"", sanitized);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void Empty_input_is_empty(string input) =>
Assert.Equal(string.Empty, ContentSanitizer.Html(input));
}
}
-32
View File
@@ -1,32 +0,0 @@
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);
}
}
}
@@ -1,142 +0,0 @@
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));
}
}
}
@@ -1,37 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using PrivaPub.Federation.Moderation;
using PrivaPub.Models.Federation;
namespace PrivaPub.Tests.Federation
{
public class DomainBlocksTests
{
[Fact]
public void A_block_covers_subdomains_but_not_lookalikes()
{
var blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance);
blocks.Load(new[] { new DomainBlock { Domain = "evil.example", Severity = DomainBlockSeverity.Silence } });
Assert.NotNull(blocks.Find("evil.example"));
Assert.NotNull(blocks.Find("A.Evil.Example."));
Assert.Null(blocks.Find("notevil.example"));
Assert.False(blocks.IsSuspended("evil.example"));
}
[Fact]
public void A_suspension_is_found_from_any_subdomain()
{
var blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance);
blocks.Load(new[]
{
new DomainBlock { Domain = " Spam.Example. ", Severity = DomainBlockSeverity.Suspend },
new DomainBlock { Domain = "", Severity = DomainBlockSeverity.Suspend }
});
Assert.True(blocks.IsSuspended("deep.inside.spam.example"));
Assert.False(blocks.IsSuspended("example"));
Assert.Null(blocks.Find(default));
}
}
}
-143
View File
@@ -1,143 +0,0 @@
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));
}
}
}
@@ -1,282 +0,0 @@
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);
}
}
}
@@ -1,97 +0,0 @@
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
@@ -1,167 +0,0 @@
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));
}
}
}
-301
View File
@@ -1,301 +0,0 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Social;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Signing;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.StaticServices;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class GroupTests : 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<(GroupEntity Entity, PrivaPub.Federation.Actors.LocalActor Actor)> Group(GroupKind kind, PostingPolicy policy, string ownerId, params string[] remoteMembers)
{
var (privateKey, publicKey) = PrivaPub.Federation.Actors.Keys.NewKeyPair();
var group = new GroupEntity
{
UserName = $"{kind}{Guid.NewGuid():N}"[..20].ToLowerInvariant(),
Kind = kind,
PostingPolicy = policy,
PrivateKey = privateKey,
PublicKey = publicKey,
Members = new() { new GroupMember { AvatarId = ownerId, Role = GroupRole.Owner } }
};
group.Members.AddRange(remoteMembers.Select(m => new GroupMember { AvatarId = m, IsForeign = true }));
await DB.Default.SaveAsync(group);
return (group, _harness.Local.FromGroup(group));
}
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
static JsonObject Create(RemoteActor author, IEnumerable<string> to, string audience = default)
{
var note = new JsonObject
{
["id"] = $"{Origin(author)}/notes/{Guid.NewGuid():N}",
["type"] = "Note",
["attributedTo"] = author.Id,
["content"] = "<p>to the group</p>",
["to"] = new JsonArray(to.Select(t => (JsonNode)t).ToArray())
};
if (audience != default)
note["audience"] = audience;
return new JsonObject { ["id"] = $"{Origin(author)}/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Id, ["object"] = note };
}
[Fact]
public async Task A_follower_posting_to_a_community_is_announced_activity_and_object()
{
var token = TestContext.Current.CancellationToken;
var (_, owner) = await _harness.Persona("owner");
var (_, community) = await Group(GroupKind.Community, PostingPolicy.Followers, owner.Id);
var lemmy = new RemoteActor(_harness.Peer, "lemmy");
var stranger = new RemoteActor(_harness.Peer, "stranger");
await _harness.FollowedBy(community, lemmy);
await _harness.Deliver(lemmy, "/human-centipede", Create(lemmy, new[] { community.Uri, Addressing.Public }, community.Uri));
await _harness.Deliver(stranger, "/human-centipede", Create(stranger, new[] { community.Uri, Addressing.Public }, community.Uri));
Assert.True(await DB.Default.Find<Post>().Match(p => p.GroupId == community.Id && p.ActorURI == lemmy.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Post>().Match(p => p.GroupId == community.Id && p.ActorURI == stranger.Id).ExecuteAnyAsync(token));
var announces = (await _harness.Outgoing(lemmy.SharedInbox)).Where(a => a["actor"]!.GetValue<string>() == community.Uri).ToList();
Assert.Equal(2, announces.Count);
Assert.Contains(announces, a => a["object"] is JsonObject inner && inner["type"]!.GetValue<string>() == "Create" && a["audience"]!.GetValue<string>() == community.Uri);
Assert.Contains(announces, a => a["object"] is JsonValue);
}
[Fact]
public async Task A_comment_in_a_community_is_announced_activity_and_object_too()
{
var (_, owner) = await _harness.Persona("owner");
var (_, community) = await Group(GroupKind.Community, PostingPolicy.Followers, owner.Id);
var lemmy = new RemoteActor(_harness.Peer, "lemmy");
var akkoma = new RemoteActor(_harness.Peer, "akkoma");
await _harness.FollowedBy(community, lemmy);
await _harness.FollowedBy(community, akkoma);
var thread = Create(lemmy, new[] { community.Uri, Addressing.Public }, community.Uri);
await _harness.Deliver(lemmy, "/human-centipede", thread);
var comment = Create(lemmy, new[] { community.Uri, Addressing.Public }, community.Uri);
comment["object"]!["inReplyTo"] = thread["object"]!["id"]!.GetValue<string>();
await _harness.Deliver(lemmy, "/human-centipede", comment);
var commentId = comment["object"]!["id"]!.GetValue<string>();
var announces = (await _harness.Outgoing(akkoma.SharedInbox)).Where(a => a["actor"]!.GetValue<string>() == community.Uri).ToList();
Assert.Contains(announces, a => a["object"] is JsonObject inner && inner["object"]?["id"]?.GetValue<string>() == commentId);
Assert.Contains(announces, a => a["object"] is JsonValue bare && bare.GetValue<string>() == commentId);
}
[Fact]
public async Task An_open_community_takes_posts_from_anyone()
{
var token = TestContext.Current.CancellationToken;
var (_, owner) = await _harness.Persona("owner");
var (_, community) = await Group(GroupKind.Community, PostingPolicy.Anyone, owner.Id);
var stranger = new RemoteActor(_harness.Peer, "stranger");
await _harness.Deliver(stranger, "/human-centipede", Create(stranger, new[] { community.Uri, Addressing.Public }));
Assert.True(await DB.Default.Find<Post>().Match(p => p.GroupId == community.Id && p.ActorURI == stranger.Id).ExecuteAnyAsync(token));
}
// Lemmy keeps its user's post pending until the community announces it back, whether anyone there follows the
// community or not
[Fact]
public async Task A_post_into_a_community_is_announced_back_to_its_authors_server_though_nobody_there_follows_it()
{
var (_, owner) = await _harness.Persona("owner");
var (_, community) = await Group(GroupKind.Community, PostingPolicy.Anyone, owner.Id);
var stranger = new RemoteActor(_harness.Peer, "stranger", sharedInbox: true);
await _harness.Deliver(stranger, "/human-centipede", Create(stranger, new[] { community.Uri, Addressing.Public }, community.Uri));
var announces = (await _harness.Outgoing(stranger.SharedInbox)).Where(a => a["actor"]!.GetValue<string>() == community.Uri).ToList();
Assert.Contains(announces, a => a["object"] is JsonObject inner && inner["type"]!.GetValue<string>() == "Create");
}
[Fact]
public async Task Mentioning_a_community_posts_into_it_as_a_titled_page()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var (_, community) = await Group(GroupKind.Community, PostingPolicy.Anyone, alice.Id);
var follower = new RemoteActor(_harness.Peer, "follower");
await _harness.FollowedBy(community, follower);
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = $"@{community.UserName} a new thread", PlainText = true }, token);
Assert.Equal(community.Id, outcome.Post.GroupId);
var note = ActivityPubRenderer.Note(outcome.Post, alice, community, default);
Assert.Equal("Page", note["type"]!.GetValue<string>());
Assert.Equal(community.Uri, note["audience"]!.GetValue<string>());
Assert.False(string.IsNullOrEmpty(note["name"]!.GetValue<string>()));
Assert.Contains(await _harness.Outgoing(follower.SharedInbox), a => a["type"]!.GetValue<string>() == "Announce");
}
[Fact]
public async Task A_circle_post_goes_only_to_members_and_only_members_may_read_it()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var member = new RemoteActor(_harness.Peer, "member");
var outsider = new RemoteActor(_harness.Peer, "outsider");
var (circleEntity, circle) = await Group(GroupKind.Circle, PostingPolicy.Followers, alice.Id, member.Id);
await _harness.Remote.GetActor(member.Id, refresh: false, token);
var follower = new RemoteActor(_harness.Peer, "follower");
await _harness.FollowedBy(alice, follower);
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "just us", GroupId = circle.Id }, token);
Assert.Equal(PostVisibility.Circle, outcome.Post.Visibility);
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>()));
// 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.Empty(await _harness.Outgoing(follower.SharedInbox));
Assert.Empty((await _harness.Outgoing(member.SharedInbox)).Where(a => a["type"]!.GetValue<string>() == "Announce"));
var authorizer = new SignedFetchAuthorizer(_harness.Remote, new DbEntities());
var path = new Uri(outcome.Post.ObjectURI).AbsolutePath;
var asMember = await authorizer.Requester(member.Get(Harness.Host, path), token);
var asOutsider = await authorizer.Requester(outsider.Get(Harness.Host, path), token);
Assert.True(SignedFetchAuthorizer.MayReadCircle(circleEntity, asMember));
Assert.False(SignedFetchAuthorizer.MayReadCircle(circleEntity, asOutsider));
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.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]
public async Task Only_circle_members_can_post_into_a_circle()
{
var token = TestContext.Current.CancellationToken;
var (aliceRoot, alice) = await _harness.Persona("alice");
var member = new RemoteActor(_harness.Peer, "member");
var outsider = new RemoteActor(_harness.Peer, "outsider");
var (_, circle) = await Group(GroupKind.Circle, PostingPolicy.Followers, alice.Id, member.Id);
await _harness.Deliver(member, "/human-centipede", Create(member, new[] { circle.Uri, circle.Flock }));
await _harness.Deliver(outsider, "/human-centipede", Create(outsider, new[] { circle.Uri, circle.Flock }));
var stored = await DB.Default.Find<Post>().Match(p => p.GroupId == circle.Id).ExecuteAsync(token);
Assert.Equal(member.Id, Assert.Single(stored).ActorURI);
Assert.Equal(PostVisibility.Circle, stored[0].Visibility);
Assert.True(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.PostId == stored[0].ID).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_followed_remote_community_announcing_a_post_puts_it_in_home()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var lemmyCommunity = new RemoteActor(_harness.Peer, "cats", type: "Group");
var poster = new RemoteActor(_harness.Peer, "poster");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = lemmyCommunity.Id }, token);
await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
var create = Create(poster, new[] { lemmyCommunity.Id, Addressing.Public }, lemmyCommunity.Id);
var noteId = create["object"]!["id"]!.GetValue<string>();
_harness.Peer.Serve(new Uri(noteId).AbsolutePath, create["object"]!.ToJsonString());
await _harness.Deliver(lemmyCommunity, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(lemmyCommunity)}/activities/announce/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = lemmyCommunity.Id,
["to"] = new JsonArray(Addressing.Public), ["object"] = create
});
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(token);
Assert.Equal(lemmyCommunity.Id, post.AudienceURI);
Assert.True(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(token));
}
}
}
@@ -1,162 +0,0 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Signing;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace PrivaPub.Tests.Federation
{
public class HttpSignaturesTests
{
const string Host = "privapub.test";
const string KeyId = "https://mastodon.example/users/alice#main-key";
static readonly DateTimeOffset Now = new(2026, 10, 1, 12, 0, 0, TimeSpan.Zero);
static readonly byte[] Body = Encoding.UTF8.GetBytes("""{"type":"Follow","actor":"https://mastodon.example/users/alice"}""");
readonly RSA _key = RSA.Create(2048);
string PublicKey => _key.ExportSubjectPublicKeyInfoPem();
DefaultHttpContext MastodonRequest(string signedTarget = "/peasants/bob/mouth", DateTimeOffset? signedAt = default,
string headers = "(request-target) host date digest content-type", byte[] signedBody = default, string extra = "")
{
var date = (signedAt ?? Now).ToString("r", CultureInfo.InvariantCulture);
var digest = HttpSignatures.Digest(signedBody ?? Body);
var values = new Dictionary<string, string>
{
["(request-target)"] = $"post {signedTarget}",
["host"] = Host,
["date"] = date,
["digest"] = digest,
["content-type"] = "application/activity+json"
};
var signingString = string.Join("\n", headers.Split(' ').Select(h => $"{h}: {values[h]}"));
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
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";
context.Request.Headers["Date"] = date;
context.Request.Headers["Digest"] = digest;
context.Request.Headers["Content-Type"] = "application/activity+json";
context.Request.Headers["Signature"] = $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"{headers}\",signature=\"{signature}\"{extra}";
return context;
}
string Check(HttpContext context, byte[] body = default)
{
var parameters = HttpSignatures.Parse(context.Request.Headers["Signature"].ToString());
var problem = HttpSignatures.CheckRequest(context.Request, parameters, body ?? Body, Now);
if (problem != default)
return problem;
return HttpSignatures.Verify(PublicKey, HttpSignatures.SigningString(context.Request, parameters), parameters.Signature)
? default
: "does not verify";
}
[Fact]
public void Accepts_a_mastodon_style_delivery() =>
Assert.Null(Check(MastodonRequest()));
[Fact]
public void Refuses_a_tampered_body() =>
Assert.Equal("the digest does not match the body", Check(MastodonRequest(), Encoding.UTF8.GetBytes("""{"type":"Delete"}""")));
[Fact]
public void Refuses_a_signature_for_another_inbox() =>
Assert.Equal("does not verify", Check(MastodonRequest(signedTarget: "/peasants/carol/mouth")));
[Fact]
public void Refuses_a_signature_older_than_an_hour() =>
Assert.Equal("the Date header is outside the allowed window", Check(MastodonRequest(signedAt: Now.AddMinutes(-61))));
[Fact]
public void Accepts_a_clock_slightly_ahead() =>
Assert.Null(Check(MastodonRequest(signedAt: Now.AddMinutes(10))));
[Fact]
public void Refuses_a_clock_far_ahead() =>
Assert.Equal("the Date header is outside the allowed window", Check(MastodonRequest(signedAt: Now.AddMinutes(20))));
[Fact]
public void Refuses_a_signature_without_the_request_target() =>
Assert.Equal("(request-target) is not signed", Check(MastodonRequest(headers: "host date digest")));
[Fact]
public void Refuses_a_signature_without_the_host() =>
Assert.Equal("host is not signed", Check(MastodonRequest(headers: "(request-target) date digest")));
[Fact]
public void Refuses_a_body_whose_digest_is_not_signed() =>
Assert.Equal("the digest is not signed", Check(MastodonRequest(headers: "(request-target) host date")));
[Fact]
public void Refuses_an_expired_signature() =>
Assert.Equal("the signature has expired",
Check(MastodonRequest(extra: $",expires=\"{Now.AddMinutes(-20).ToUnixTimeSeconds()}\"")));
// hs2019 leaves the hash to the key: some sign RSA with SHA-512, which rsa-sha256 does not allow; and a SHA-512 digest
[Fact]
public void Accepts_hs2019_hashed_with_sha512_and_a_sha512_digest()
{
var context = new DefaultHttpContext();
context.Request.Method = "POST";
context.Request.Host = new HostString(Host);
context.Request.Path = "/peasants/bob/mouth";
context.Features.Get<IHttpRequestFeature>().RawTarget = "/peasants/bob/mouth";
var date = Now.ToString("r", CultureInfo.InvariantCulture);
var digest = "SHA-512=" + Convert.ToBase64String(SHA512.HashData(Body));
context.Request.Headers["Date"] = date;
context.Request.Headers["Digest"] = digest;
var signingString = $"(request-target): post /peasants/bob/mouth\nhost: {Host}\ndate: {date}\ndigest: {digest}";
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA512, RSASignaturePadding.Pkcs1));
string Signed(string algorithm) => $"keyId=\"{KeyId}\",algorithm=\"{algorithm}\",headers=\"(request-target) host date digest\",signature=\"{signature}\"";
context.Request.Headers["Signature"] = Signed("hs2019");
var parameters = HttpSignatures.Parse(context.Request.Headers["Signature"].ToString());
Assert.Null(HttpSignatures.CheckRequest(context.Request, parameters, Body, Now));
Assert.True(HttpSignatures.Verify(PublicKey, HttpSignatures.SigningString(context.Request, parameters), parameters.Signature, parameters.Algorithm));
context.Request.Headers["Signature"] = Signed("rsa-sha256");
parameters = HttpSignatures.Parse(context.Request.Headers["Signature"].ToString());
Assert.False(HttpSignatures.Verify(PublicKey, HttpSignatures.SigningString(context.Request, parameters), parameters.Signature, parameters.Algorithm));
}
[Fact]
public void Signs_with_the_raw_request_target()
{
var context = MastodonRequest(signedTarget: "/peasants/b%6Fb/mouth");
context.Features.Get<IHttpRequestFeature>().RawTarget = "/peasants/b%6Fb/mouth";
Assert.Null(Check(context));
}
[Fact]
public void Verifies_its_own_outbound_signature()
{
var (privateKey, publicKey) = Keys.NewKeyPair();
var signer = new LocalActor { UserName = "bob", BaseAddress = "https://privapub.test", PrivateKeyPem = privateKey };
using var outbound = new HttpRequestMessage(HttpMethod.Post, "https://mastodon.example/users/alice/inbox");
HttpSignatures.Sign(outbound, signer, Body);
var context = new DefaultHttpContext();
context.Request.Method = "POST";
context.Request.Host = new HostString("mastodon.example");
context.Request.Path = "/users/alice/inbox";
context.Features.Get<IHttpRequestFeature>().RawTarget = "/users/alice/inbox";
foreach (var header in outbound.Headers)
context.Request.Headers[header.Key] = string.Join(", ", header.Value);
var parameters = HttpSignatures.Parse(context.Request.Headers["Signature"].ToString());
Assert.Equal("https://privapub.test/peasants/bob#main-key", parameters.KeyId);
Assert.Null(HttpSignatures.CheckRequest(context.Request, parameters, Body));
Assert.True(HttpSignatures.Verify(publicKey, HttpSignatures.SigningString(context.Request, parameters), parameters.Signature));
}
}
}
@@ -1,77 +0,0 @@
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));
}
}
}
@@ -1,271 +0,0 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class InboundRoutingTests : 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 string NewId(RemoteActor actor, string kind) => $"{Origin(actor)}/{kind}/{Guid.NewGuid():N}";
async Task<Post> LocalPost(string text)
{
var (root, alice) = await _harness.Persona("alice");
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = text }, TestContext.Current.CancellationToken);
return await DB.Default.Find<Post>().Match(p => p.GroupUserId == alice.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken);
}
[Fact]
public async Task A_downvote_counts_and_its_undo_takes_it_back()
{
var token = TestContext.Current.CancellationToken;
var post = await LocalPost("vote on me");
var lemmy = new RemoteActor(_harness.Peer, "voter");
var dislike = new JsonObject { ["id"] = NewId(lemmy, "activities/dislike"), ["type"] = "Dislike", ["actor"] = lemmy.Id, ["object"] = post.ObjectURI };
await _harness.Deliver(lemmy, "/human-centipede", dislike);
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).DownvotesCount);
await _harness.Deliver(lemmy, "/human-centipede", new JsonObject
{
["id"] = NewId(lemmy, "activities/undo"), ["type"] = "Undo", ["actor"] = lemmy.Id, ["object"] = dislike.DeepClone()
});
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).DownvotesCount);
}
[Fact]
public async Task A_delete_that_arrives_first_keeps_the_post_from_coming_back()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
var noteId = NewId(mallory, "notes");
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = NewId(mallory, "activities/delete"), ["type"] = "Delete", ["actor"] = mallory.Id,
["object"] = new JsonObject { ["id"] = noteId, ["type"] = "Tombstone" }
});
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = NewId(mallory, "activities/create"), ["type"] = "Create", ["actor"] = mallory.Id,
["object"] = new JsonObject
{
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = mallory.Id, ["content"] = "<p>late</p>",
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(alice.Uri),
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" })
}
});
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_lemmy_private_message_arrives_as_a_direct_message()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var lemmy = new RemoteActor(_harness.Peer, "pm");
var messageId = NewId(lemmy, "private_message");
await _harness.Deliver(lemmy, "/human-centipede", new JsonObject
{
["id"] = NewId(lemmy, "activities/create"), ["type"] = "Create", ["actor"] = lemmy.Id, ["to"] = new JsonArray(alice.Uri),
["object"] = new JsonObject
{
["id"] = messageId, ["type"] = "ChatMessage", ["attributedTo"] = lemmy.Id, ["to"] = new JsonArray(alice.Uri),
["content"] = "<p>psst</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);
Assert.NotNull(message.ConversationId);
}
// Mbin addresses its private messages to the recipient's profile page (its `url`), not to the actor
[Fact]
public async Task A_chat_message_addressed_to_a_personas_profile_page_reaches_the_persona()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mbin = new RemoteActor(_harness.Peer, "mbuser");
var messageId = NewId(mbin, "f/object");
await _harness.Deliver(mbin, "/human-centipede", new JsonObject
{
["id"] = NewId(mbin, "f/object"), ["type"] = "Create", ["actor"] = mbin.Id, ["to"] = new JsonArray(alice.HtmlUrl), ["cc"] = new JsonArray(),
["object"] = new JsonObject
{
["id"] = messageId, ["type"] = "ChatMessage", ["attributedTo"] = mbin.Id, ["to"] = new JsonArray(alice.HtmlUrl), ["cc"] = new JsonArray(),
["content"] = "<p>an answer</p>", ["mediaType"] = "text/html", ["published"] = DateTime.UtcNow.ToString("O")
}
});
var message = await DB.Default.Find<Post>().Match(p => p.ObjectURI == messageId).ExecuteFirstAsync(token);
Assert.NotNull(message);
Assert.Equal(PostVisibility.Direct, message.Visibility);
var conversation = await DB.Default.Find<DmGroup>().OneAsync(message.ConversationId, token);
Assert.Equal(new[] { alice.Id, mbin.Id }.Order(StringComparer.Ordinal), conversation.Members.Select(m => m.AvatarId).Order(StringComparer.Ordinal));
}
// Lemmy 0.19 and Mbin take a private message only as a ChatMessage: someone who writes to us that way is answered in
// kind, anyone else with a Note
[Fact]
public async Task A_direct_message_answers_in_the_form_its_recipient_writes_in()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var lemmy = new RemoteActor(_harness.Peer, "pm");
var mastodon = new RemoteActor(_harness.Peer, "dm");
async Task<Post> Received(RemoteActor sender, string type)
{
var id = NewId(sender, "messages");
await _harness.Deliver(sender, "/human-centipede", new JsonObject
{
["id"] = NewId(sender, "activities/create"), ["type"] = "Create", ["actor"] = sender.Id, ["to"] = new JsonArray(alice.Uri),
["object"] = new JsonObject
{
["id"] = id, ["type"] = type, ["attributedTo"] = sender.Id, ["to"] = new JsonArray(alice.Uri),
["content"] = "<p>psst</p>", ["published"] = DateTime.UtcNow.ToString("O")
}
});
return await DB.Default.Find<Post>().Match(p => p.ObjectURI == id).ExecuteSingleAsync(token);
}
async Task<JsonObject> Answer(RemoteActor to, Post message)
{
var answer = await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft
{
Text = "psst back", Visibility = PostVisibility.Direct, ConversationId = message.ConversationId
}, token);
Assert.True(answer.Ok, answer.Error);
return (JsonObject)(await _harness.Outgoing(to.Id + "/inbox")).Last(a => a["type"]!.GetValue<string>() == "Create")["object"]!;
}
var chat = await Answer(lemmy, await Received(lemmy, "ChatMessage"));
var note = await Answer(mastodon, await Received(mastodon, "Note"));
Assert.Equal("ChatMessage", chat["type"]!.GetValue<string>());
Assert.Equal(lemmy.Id, Assert.Single(chat["to"]!.AsArray())!.GetValue<string>());
Assert.Null(chat["cc"]);
Assert.DoesNotContain("h-card", chat["content"]!.GetValue<string>());
Assert.Equal("Note", note["type"]!.GetValue<string>());
}
[Fact]
public async Task An_unreachable_key_asks_the_sender_to_retry_instead_of_refusing()
{
var (_, alice) = await _harness.Persona("alice");
var offline = new RemoteActor(_harness.Peer, "offline", origin: "http://127.0.0.1:9");
var result = await _harness.Deliver(offline, "/human-centipede", new JsonObject
{
["id"] = NewId(offline, "follows"), ["type"] = "Follow", ["actor"] = offline.Id, ["object"] = alice.Uri
});
Assert.Equal(503, result.StatusCode);
Assert.Equal(300, result.RetryAfterSeconds);
}
// Forte follows a persona by its profile page, as WebFinger names it, rather than by its actor's id; so does its Undo
[Fact]
public async Task A_follow_of_a_personas_profile_page_follows_the_persona()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var forte = new RemoteActor(_harness.Peer, "forte");
var follow = new JsonObject { ["id"] = NewId(forte, "follow"), ["type"] = "Follow", ["actor"] = forte.Id, ["object"] = alice.HtmlUrl };
Assert.Equal(202, (await _harness.Deliver(forte, "/human-centipede", follow)).StatusCode);
Assert.True(await DB.Default.Find<PrivaPub.Models.Federation.Follower>().Match(f => f.LocalActorId == alice.Id && f.ActorURI == forte.Id && f.IsAccepted).ExecuteAnyAsync(token));
Assert.Contains(await _harness.Outgoing(forte.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Accept");
await _harness.Deliver(forte, "/human-centipede", new JsonObject
{
["id"] = NewId(forte, "undo"), ["type"] = "Undo", ["actor"] = forte.Id,
["object"] = new JsonObject { ["type"] = "Follow", ["actor"] = forte.Id, ["object"] = alice.HtmlUrl }
});
Assert.False(await DB.Default.Find<PrivaPub.Models.Federation.Follower>().Match(f => f.LocalActorId == alice.Id && f.ActorURI == forte.Id).ExecuteAnyAsync(token));
}
// Castopod announces an episode with a note that only links to it: the post takes the episode's sound, title and cover
[Fact]
public async Task A_note_that_only_links_its_authors_episode_carries_the_episodes_sound()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var podcast = new RemoteActor(_harness.Peer, "pod");
await DB.Default.SaveAsync(new PrivaPub.Models.Social.Following
{
AvatarId = alice.Id, TargetActorURI = podcast.Id, TargetInboxURL = podcast.Id + "/inbox", State = PrivaPub.Models.Social.FollowState.Accepted
}, token);
var origin = new Uri(podcast.Id).GetLeftPart(UriPartial.Authority);
var episodePath = $"/episodes/{Guid.NewGuid():N}";
_harness.Peer.Serve(episodePath, new JsonObject
{
["id"] = origin + episodePath, ["type"] = "PodcastEpisode", ["attributedTo"] = podcast.Id, ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"),
["description"] = new JsonObject { ["type"] = "Note", ["content"] = "the meadow", ["mediaType"] = "text/markdown" },
["image"] = new JsonObject { ["type"] = "Image", ["url"] = origin + "/cover.png" },
["audio"] = new JsonObject
{
["id"] = origin + "/one.mp3", ["type"] = "Audio", ["name"] = "Episode one", ["duration"] = 4.0,
["url"] = new JsonObject { ["href"] = origin + "/one.mp3", ["type"] = "Link", ["mediaType"] = "audio/mpeg" }
}
}.ToJsonString());
var noteId = NewId(podcast, "posts");
await _harness.Deliver(podcast, "/human-centipede", new JsonObject
{
["id"] = noteId + "/activity", ["type"] = "Create", ["actor"] = podcast.Id, ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"),
["object"] = new JsonObject
{
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = podcast.Id, ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"),
["content"] = $"<a href=\"{origin + episodePath}\">Episode one</a><br/>", ["published"] = DateTime.UtcNow.ToString("O")
}
});
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(token);
Assert.Equal(origin + "/one.mp3", Assert.Single(post.Media).RemoteURL);
Assert.Equal(("Episode one", origin + "/cover.png", 4.0), (post.Title, post.CoverURL, post.Audio.DurationSeconds));
}
[Fact]
public async Task A_join_of_a_local_post_is_answered_with_ignore()
{
var post = await LocalPost("not an event");
var mobilizon = new RemoteActor(_harness.Peer, "joiner");
await _harness.Deliver(mobilizon, "/human-centipede", new JsonObject
{
["id"] = NewId(mobilizon, "join"), ["type"] = "Join", ["actor"] = mobilizon.Id, ["object"] = post.ObjectURI
});
var answer = Assert.Single(await _harness.Outgoing(mobilizon.Id + "/inbox"));
Assert.Equal("Ignore", answer["type"]!.GetValue<string>());
}
}
}
-569
View File
@@ -1,569 +0,0 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Social;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support;
using System.Security.Cryptography;
using System.Text.Json.Nodes;
using static PrivaPub.Tests.Support.FederatedSeeds;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class InboxGapTests : 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 Follow(RemoteActor follower, string target) =>
new() { ["id"] = NewId(follower, "follows"), ["type"] = "Follow", ["actor"] = follower.Id, ["object"] = target };
Task<ForeignAvatar> Stored(RemoteActor actor) =>
DB.Default.Find<ForeignAvatar>().Match(f => f.ActorURI == actor.Id).ExecuteSingleAsync(TestContext.Current.CancellationToken);
PrivaPub.Models.Statistics.InteractionEvent Processed(string activity) => _harness.Ledger.Of("in").Last(e => e.Activity == activity);
// a follow request nobody answers is sent again after 15 minutes, an hour, 6 hours... (Lemmy 1.0 lost the Accept of a
// follow it took before it started sending to us, and the community's posts were refused as not followed)
[Fact]
public async Task A_follow_request_nobody_answers_is_sent_again_with_a_growing_pause_until_it_is()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var quiet = new RemoteActor(_harness.Peer, "quiet");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = quiet.Id }, token);
var following = await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == quiet.Id).ExecuteSingleAsync(token);
async Task<int> Follows() => (await _harness.Outgoing(quiet.Id + "/inbox")).Count(a => a["type"]!.GetValue<string>() == "Follow");
var made = following.CreatedAt;
Assert.Equal(1, await Follows());
// (the store is shared with other tests' requests: what this one got is counted on its own inbox)
async Task<int> After(TimeSpan wait)
{
var before = await Follows();
await _harness.Follows.ResendPending(made + wait, token);
return await Follows() - before;
}
Assert.Equal(0, await After(TimeSpan.FromMinutes(10)));
Assert.Equal(1, await After(TimeSpan.FromMinutes(16)));
Assert.Equal(0, await After(TimeSpan.FromMinutes(30)));
Assert.Equal(1, await After(TimeSpan.FromMinutes(77)));
Assert.Equal(3, await Follows());
// each one again under its own id (Lemmy never answers an id it has seen), and an answer naming it counts
var sentIds = (await _harness.Outgoing(quiet.Id + "/inbox")).Select(a => a["id"]!.GetValue<string>()).ToList();
Assert.Equal(new[] { following.FollowActivityURI, following.FollowActivityURI + "-again-1", following.FollowActivityURI + "-again-2" }, sentIds);
// a request older than the count is sent again too; an answered one never
await DB.Default.Update<Following>().MatchID(following.ID).Modify(b => b.Unset(f => f.Resent)).Modify(b => b.Unset(f => f.ResentAt)).ExecuteAsync(token);
Assert.Equal(1, await After(TimeSpan.FromDays(1)));
await _harness.Deliver(quiet, "/human-centipede", new JsonObject
{
["id"] = NewId(quiet, "accepts"), ["type"] = "Accept", ["actor"] = quiet.Id, ["object"] = following.FollowActivityURI + "-again-2"
});
Assert.Equal(FollowState.Accepted, (await DB.Default.Find<Following>().OneAsync(following.ID, token)).State);
Assert.Equal(0, await After(TimeSpan.FromDays(30)));
}
// Pixelfed names our post by its page (/@name/<id>, the post's url) in its Like, Announce and their Undo: the post is
// found the same as by its id
// Friendica's activity ids are uniqid(), a prefix and the microsecond: two of its processes answering two follows at
// once gave both Accepts one id, and the second was dropped as a copy of the first, leaving that follow pending
[Fact]
public async Task Two_activities_a_server_gave_one_id_are_both_taken_and_a_true_copy_once()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var dario = new RemoteActor(_harness.Peer, "dario");
var jun = new RemoteActor(_harness.Peer, "jun");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = dario.Id }, token);
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = jun.Id }, token);
Task<Following> Of(RemoteActor target) =>
DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == target.Id).ExecuteSingleAsync(token);
async Task<JsonObject> Accept(RemoteActor by, string id) => new()
{
["id"] = id,
["type"] = "Accept",
["actor"] = by.Id,
["object"] = new JsonObject { ["id"] = (await Of(by)).FollowActivityURI, ["type"] = "Follow", ["actor"] = alice.Uri, ["object"] = by.Id }
};
var shared = NewId(dario, "activity");
var first = await _harness.Deliver(dario, "/human-centipede", await Accept(dario, shared));
var second = await _harness.Deliver(jun, "/human-centipede", await Accept(jun, shared));
var copy = await _harness.Deliver(jun, "/human-centipede", await Accept(jun, shared));
Assert.Equal(("queued", "queued", "duplicate"), (first.Reason, second.Reason, copy.Reason));
Assert.Equal(FollowState.Accepted, (await Of(dario)).State);
Assert.Equal(FollowState.Accepted, (await Of(jun)).State);
}
// Mobilizon: the organiser creates, edits and deletes an event attributed to the group, which announces it. They used
// to be refused as misattributed (400), so the edit was lost and the deletion left the event in place
[Fact]
public async Task An_event_its_organiser_edits_and_deletes_for_a_group_on_the_same_server_follows_that_server()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var group = new RemoteActor(_harness.Peer, "group", type: "Group");
var organiser = new RemoteActor(_harness.Peer, "organiser");
await Follows(alice.Id, group);
var path = $"/events/{Guid.NewGuid():N}";
JsonObject Event(string name) => new()
{
["id"] = _harness.Peer.A + path, ["type"] = "Event", ["name"] = name, ["content"] = "<p>bring bread</p>",
["attributedTo"] = group.Id, ["actor"] = organiser.Id, ["startTime"] = "2026-10-08T18:00:00Z",
["to"] = new JsonArray(PrivaPub.Federation.Objects.Addressing.Public), ["cc"] = new JsonArray(group.Id + "/followers")
};
_harness.Peer.Serve(path, Event("A picnic").ToJsonString());
Task<Post> Stored() => DB.Default.Find<Post>().Match(p => p.ObjectURI == _harness.Peer.A + path).ExecuteFirstAsync(token);
var created = await _harness.Deliver(organiser, "/human-centipede", Create(organiser, Event("A picnic")));
Assert.Equal(202, created.StatusCode);
Assert.Equal(group.Id, (await Stored()).ActorURI);
var moved = Event("A picnic, moved indoors");
moved["updated"] = DateTime.UtcNow.AddMinutes(1).ToString("O");
_harness.Peer.Serve(path, moved.ToJsonString());
await _harness.Deliver(organiser, "/human-centipede", Activity(organiser, "Update", Event("what the activity claims")));
Assert.Equal("A picnic, moved indoors", (await Stored()).Title);
await _harness.Deliver(organiser, "/human-centipede", Activity(organiser, "Delete", JsonValue.Create(_harness.Peer.A + path)!));
Assert.NotNull(await Stored());
_harness.Peer.Answer(path, 410);
await _harness.Deliver(organiser, "/human-centipede", Activity(organiser, "Delete", JsonValue.Create(_harness.Peer.A + path)!));
Assert.Null(await Stored());
// attributed to someone of another server, it is still refused
var elsewhere = new RemoteActor(_harness.Peer, "elsewhere", _harness.Peer.B);
var claimed = Event("Someone else's");
claimed["id"] = _harness.Peer.A + $"/events/{Guid.NewGuid():N}";
claimed["attributedTo"] = elsewhere.Id;
Assert.Equal(400, (await _harness.Deliver(organiser, "/human-centipede", Create(organiser, claimed))).StatusCode);
}
// Funkwhale names its Accept after the Follow it answers (`<our Follow's id>/accept`), on our origin, not its own
[Fact]
public async Task An_answer_named_after_our_follow_is_taken_and_one_naming_someone_elses_is_not()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var channel = new RemoteActor(_harness.Peer, "channel");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = channel.Id }, token);
var following = await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == channel.Id).ExecuteSingleAsync(token);
var elsewhere = await _harness.Deliver(channel, "/human-centipede", new JsonObject
{
["id"] = "https://privapub.test/peasants/somebody/accept", ["type"] = "Accept", ["actor"] = channel.Id, ["object"] = following.FollowActivityURI
});
Assert.Equal(400, elsewhere.StatusCode);
var named = await _harness.Deliver(channel, "/human-centipede", new JsonObject
{
["id"] = following.FollowActivityURI + "/accept", ["type"] = "Accept", ["actor"] = channel.Id,
["object"] = new JsonObject { ["id"] = following.FollowActivityURI, ["type"] = "Follow", ["actor"] = alice.Uri, ["object"] = channel.Id }
});
Assert.Equal(202, named.StatusCode);
Assert.Equal(FollowState.Accepted, (await DB.Default.Find<Following>().OneAsync(following.ID, token)).State);
}
// Funkwhale's channel deletes its uploads in one Delete, their ids in a list as the object's id (and the Delete has
// no id of its own)
[Fact]
public async Task A_delete_naming_several_objects_removes_each()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var channel = new RemoteActor(_harness.Peer, "channel");
await Follows(alice.Id, channel);
var first = PublicNote(channel, "<p>a first track</p>");
var second = PublicNote(channel, "<p>a second track</p>");
await _harness.Deliver(channel, "/human-centipede", Create(channel, first));
await _harness.Deliver(channel, "/human-centipede", Create(channel, second));
Task<bool> Held(JsonObject note) => DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(note)).ExecuteAnyAsync(token);
Assert.True(await Held(first) && await Held(second));
var result = await _harness.Receiver.Receive(channel.Post(Harness.Host, "/human-centipede", new JsonObject
{
["type"] = "Delete", ["actor"] = channel.Id,
["object"] = new JsonObject { ["id"] = new JsonArray(IdOf(first), IdOf(second)), ["type"] = "Audio" }
}), default, token);
Assert.Equal(202, result.StatusCode);
var listed = IdOf(first);
var job = await DB.Default.Find<PrivaPub.Models.Jobs.Job>().Match(j => j.Kind == PrivaPub.Models.Jobs.JobKind.ProcessInbox
&& j.State == PrivaPub.Models.Jobs.JobState.Pending && j.Payload.Contains(listed)).Sort(j => j.CreatedAt, MongoDB.Entities.Order.Descending)
.ExecuteFirstAsync(token);
await _harness.Processor.Handle(job, token);
Assert.False(await Held(first));
Assert.False(await Held(second));
}
[Fact]
public async Task A_like_naming_a_post_by_its_page_counts_and_its_undo_too()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var fan = new RemoteActor(_harness.Peer, "fan");
var post = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "a picture" }, token)).Post;
var page = alice.PostHtmlUrl(post.ID);
Assert.NotEqual(post.ObjectURI, page);
var like = new JsonObject { ["id"] = NewId(fan, "likes"), ["type"] = "Like", ["actor"] = fan.Id, ["object"] = page };
await _harness.Deliver(fan, "/human-centipede", like);
Assert.Equal("accepted", Processed("Like").Outcome);
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
await _harness.Deliver(fan, "/human-centipede", Activity(fan, "Undo", like));
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
// a page that is no post of ours stays as it is
var elsewhere = new JsonObject { ["id"] = NewId(fan, "likes"), ["type"] = "Like", ["actor"] = fan.Id, ["object"] = alice.PostHtmlUrl("000000000000000000000000") };
await _harness.Deliver(fan, "/human-centipede", elsewhere);
Assert.Equal(("dropped", "unknown-object"), (Processed("Like").Outcome, Processed("Like").Reason));
}
[Fact]
public async Task An_actor_update_refreshes_the_account_from_its_origin_even_when_its_updated_is_older()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var bob = new RemoteActor(_harness.Peer, "bob");
await _harness.Deliver(bob, "/human-centipede", Follow(bob, alice.Uri));
var before = await Stored(bob);
using var rotated = RSA.Create(2048);
var document = bob.Document();
document["name"] = "Bob Renamed";
document["summary"] = "<p>a new bio<script>alert(1)</script></p>";
document["publicKey"]!["publicKeyPem"] = rotated.ExportSubjectPublicKeyInfoPem();
_harness.Peer.Serve(new Uri(bob.Id).AbsolutePath, document.ToJsonString());
var embedded = (JsonObject)document.DeepClone();
embedded["name"] = "What the activity claims";
embedded["updated"] = "2001-01-01T00:00:00Z";
var result = await _harness.Deliver(bob, "/human-centipede", Activity(bob, "Update", embedded));
var after = await Stored(bob);
Assert.Equal(202, result.StatusCode);
Assert.Null(before.Name);
Assert.Equal(before.ID, after.ID);
Assert.Equal("Bob Renamed", after.Name);
Assert.Equal("<p>a new bio</p>", after.Biography);
Assert.Equal(bob.KeyId, after.PublicKeyId);
Assert.NotEqual(before.PublicKey, after.PublicKey);
Assert.Equal(rotated.ExportSubjectPublicKeyInfoPem(), after.PublicKey);
Assert.Equal(("accepted", "actor-refresh"), (Processed("Update").Outcome, Processed("Update").Reason));
}
[Fact]
public async Task A_key_moved_to_a_new_id_replaces_the_old_one()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var bob = new RemoteActor(_harness.Peer, "bob");
await _harness.Deliver(bob, "/human-centipede", Follow(bob, alice.Uri));
using var rotated = RSA.Create(2048);
var document = bob.Document();
document["publicKey"] = new JsonObject { ["id"] = bob.Id + "#key-2", ["owner"] = bob.Id, ["publicKeyPem"] = rotated.ExportSubjectPublicKeyInfoPem() };
_harness.Peer.Serve(new Uri(bob.Id).AbsolutePath, document.ToJsonString());
await _harness.Deliver(bob, "/human-centipede", Activity(bob, "Update", JsonValue.Create(bob.Id)!));
var after = await Stored(bob);
Assert.Equal(bob.Id + "#key-2", after.PublicKeyId);
Assert.Equal(rotated.ExportSubjectPublicKeyInfoPem(), after.PublicKey);
}
[Fact]
public async Task Undoing_a_follow_removes_the_follower_by_the_activity_id_or_by_its_object()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var bob = new RemoteActor(_harness.Peer, "bob");
var carol = new RemoteActor(_harness.Peer, "carol");
var bobFollows = Follow(bob, alice.Uri);
await _harness.Deliver(bob, "/human-centipede", bobFollows);
await _harness.Deliver(carol, "/human-centipede", Follow(carol, alice.Uri));
Assert.Equal(2, await DB.Default.CountAsync<Follower>(f => f.LocalActorId == alice.Id, token));
await _harness.Deliver(bob, "/human-centipede", Activity(bob, "Undo", JsonValue.Create(IdOf(bobFollows))!));
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id && f.ActorURI == bob.Id).ExecuteAnyAsync(token));
Assert.Equal(("accepted", "undone"), (Processed("Undo").Outcome, Processed("Undo").Reason));
var forgotten = Follow(carol, alice.Uri);
forgotten["id"] = NewId(carol, "follows");
await _harness.Deliver(carol, "/human-centipede", Activity(carol, "Undo", forgotten));
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id).ExecuteAnyAsync(token));
await _harness.Deliver(carol, "/human-centipede", Activity(carol, "Undo", forgotten));
Assert.Equal(("dropped", "unknown-object"), (Processed("Undo").Outcome, Processed("Undo").Reason));
}
[Fact]
public async Task A_rejected_quote_request_marks_the_quote_rejected_and_only_the_quoted_author_can_reject_it()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var ann = new RemoteActor(_harness.Peer, "ann");
var mallory = new RemoteActor(_harness.Peer, "mallory");
var note = PublicNote(ann, "<p>ask me first</p>", alice.Uri);
note["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" });
note["interactionPolicy"] = new JsonObject { ["canQuote"] = new JsonObject { ["manualApproval"] = new JsonArray(Addressing.Public) } };
await _harness.Deliver(ann, "/human-centipede", Create(ann, note));
var original = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token);
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "may I?", QuotedStatusId = original.ID }, token);
Assert.Equal(QuoteState.Pending, outcome.Post.QuoteState);
var request = Assert.Single(await _harness.Outgoing(ann.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "QuoteRequest");
await _harness.Deliver(mallory, "/human-centipede", Activity(mallory, "Reject", JsonValue.Create(IdOf(request))!));
Assert.Equal(QuoteState.Pending, (await DB.Default.Find<Post>().OneAsync(outcome.Post.ID, token)).QuoteState);
await _harness.Deliver(ann, "/human-centipede", Activity(ann, "Reject", request));
var quoting = await DB.Default.Find<Post>().OneAsync(outcome.Post.ID, token);
Assert.Equal(QuoteState.Rejected, quoting.QuoteState);
Assert.Null(quoting.QuoteAuthorizationURI);
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(original.ID, token)).QuotesCount);
Assert.Equal(("accepted", "quote-answer"), (Processed("Reject").Outcome, Processed("Reject").Reason));
}
[Fact]
public async Task A_vote_a_followed_community_relays_counts_and_its_undo_takes_it_back()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var community = new RemoteActor(_harness.Peer, "cats", type: "Group");
var stranger = new RemoteActor(_harness.Peer, "dogs", type: "Group");
var poster = new RemoteActor(_harness.Peer, "poster");
var voter = new RemoteActor(_harness.Peer, "voter");
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";
page["audience"] = community.Id;
_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 like = new JsonObject { ["id"] = NewId(voter, "likes"), ["type"] = "Like", ["actor"] = voter.Id, ["object"] = post.ObjectURI, ["audience"] = community.Id };
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", like));
var liked = Processed("Announce");
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(voter, "Undo", like)));
var unliked = Processed("Announce");
await _harness.Deliver(stranger, "/human-centipede", Activity(stranger, "Announce", like));
var unfollowed = Processed("Announce");
//Lemmy relays its members' votes inside the community's Announce (G-0003): a voter on the community's own server
//is vouched for by it, and the vote is handled as if the voter had sent it; a community nobody follows is ignored
Assert.Equal("accepted", liked.Outcome);
Assert.Equal("accepted", unliked.Outcome);
Assert.Equal(("dropped", "not-followed"), (unfollowed.Outcome, unfollowed.Reason));
var after = await DB.Default.Find<Post>().OneAsync(post.ID, token);
Assert.Equal(0, after.FavouritesCount);
Assert.False(await DB.Default.Find<Favourite>().Match(f => f.PostId == post.ID).ExecuteAnyAsync(token));
}
// Lemmy sends a vote to the community alone: a vote on our reply in a thread of a community nobody here follows still
// reaches us through it, and only through that community (found by the town's village)
[Fact]
public async Task A_vote_on_our_reply_in_an_unfollowed_communitys_thread_counts_through_that_community_only()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var community = new RemoteActor(_harness.Peer, "cats", type: "Group");
var other = new RemoteActor(_harness.Peer, "dogs", type: "Group");
var poster = new RemoteActor(_harness.Peer, "poster");
var voter = new RemoteActor(_harness.Peer, "voter");
var page = PublicNote(poster, "<p>a thread</p>", community.Id);
page["type"] = "Page";
page["name"] = "a thread";
page["audience"] = community.Id;
_harness.Peer.Serve(new Uri(IdOf(page)).AbsolutePath, page.ToJsonString());
var root = await _harness.RemotePosts.StoreContext(IdOf(page), 0, token);
Assert.Equal(community.Id, root.AudienceURI);
var reply = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "my answer", InReplyTo = root.ID }, token)).Post;
var like = new JsonObject { ["id"] = NewId(voter, "likes"), ["type"] = "Like", ["actor"] = voter.Id, ["object"] = reply.ObjectURI };
await _harness.Deliver(other, "/human-centipede", Activity(other, "Announce", like));
Assert.Equal(("dropped", "not-followed"), (Processed("Announce").Outcome, Processed("Announce").Reason));
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", like));
Assert.Equal("accepted", Processed("Announce").Outcome);
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(reply.ID, token)).FavouritesCount);
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(voter, "Undo", like)));
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(reply.ID, token)).FavouritesCount);
}
// Lemmy's moderators lock a community's post and ban members, and the community relays both inside its Announce
// (G-0006): a locked post takes no reply, a banned persona posts nothing there, until the Undo
[Fact]
public async Task A_community_locks_a_post_and_bans_a_persona_until_it_undoes_either()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var community = new RemoteActor(_harness.Peer, "cats", type: "Group");
var poster = new RemoteActor(_harness.Peer, "poster");
var moderator = new RemoteActor(_harness.Peer, "moderator");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token);
await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
JsonObject Page(string text, bool commentsEnabled = true)
{
var page = PublicNote(poster, $"<p>{text}</p>", community.Id);
page["type"] = "Page";
page["name"] = text;
page["audience"] = community.Id;
page["commentsEnabled"] = commentsEnabled;
_harness.Peer.Serve(new Uri(IdOf(page)).AbsolutePath, page.ToJsonString());
return page;
}
async Task<Post> Announced(JsonObject page)
{
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, page)));
return await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(page)).ExecuteSingleAsync(token);
}
Task<StatusOutcome> Reply(Post post) => _harness.Statuses.Publish(alice, new StatusDraft { Text = "a reply", InReplyTo = post.ID }, token);
var post = await Announced(Page("a thread"));
var lockIt = new JsonObject { ["id"] = NewId(moderator, "locks"), ["type"] = "Lock", ["actor"] = moderator.Id, ["object"] = post.ObjectURI };
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", lockIt));
Assert.Equal(("accepted", "locked"), (Processed("Announce").Outcome, Processed("Announce").Reason));
var refused = await Reply(post);
Assert.Equal((422, "Validation failed: This thread is locked"), (refused.Status, refused.Error));
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(moderator, "Undo", lockIt)));
Assert.Null((await DB.Default.Find<Post>().OneAsync(post.ID, token)).LockedAt);
Assert.True((await Reply(post)).Ok);
// a post its community serves locked arrives locked
Assert.NotNull((await Announced(Page("born locked", commentsEnabled: false))).LockedAt);
var ban = new JsonObject { ["id"] = NewId(moderator, "bans"), ["type"] = "Block", ["actor"] = moderator.Id, ["object"] = alice.Uri, ["target"] = community.Id };
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", ban));
Assert.Equal(("accepted", "banned"), (Processed("Announce").Outcome, Processed("Announce").Reason));
var banned = await Reply(post);
Assert.Equal((422, "Validation failed: This community banned you"), (banned.Status, banned.Error));
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(moderator, "Undo", ban)));
Assert.Equal(("accepted", "unbanned"), (Processed("Announce").Outcome, Processed("Announce").Reason));
Assert.True((await Reply(post)).Ok);
// Lemmy also sends the ban straight to the persona's server, as its moderator's Block: still the community's ban,
// never the moderator's own block of the persona
var direct = new JsonObject { ["id"] = NewId(moderator, "bans"), ["type"] = "Block", ["actor"] = moderator.Id, ["object"] = alice.Uri, ["target"] = community.Id };
await _harness.Deliver(moderator, "/human-centipede", direct);
Assert.Equal(("accepted", "banned"), (Processed("Block").Outcome, Processed("Block").Reason));
Assert.False(await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == alice.Id && b.ActorURI == moderator.Id).ExecuteAnyAsync(token));
Assert.Equal(422, (await Reply(post)).Status);
await _harness.Deliver(moderator, "/human-centipede", Activity(moderator, "Undo", direct));
Assert.True((await Reply(post)).Ok);
var elsewhere = new JsonObject { ["id"] = NewId(moderator, "bans"), ["type"] = "Block", ["actor"] = moderator.Id, ["object"] = poster.Id, ["target"] = community.Id };
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", elsewhere));
Assert.Equal(("dropped", "not-ours"), (Processed("Announce").Outcome, Processed("Announce").Reason));
}
// a voter on another server than the community is believed for the community's own posts, as Lemmy trusts it; for
// anything else only once its vote is fetched from its own origin
[Fact]
public async Task A_relayed_vote_from_another_server_counts_on_the_communitys_posts_and_elsewhere_only_once_fetched()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var community = new RemoteActor(_harness.Peer, "cats", type: "Group");
var poster = new RemoteActor(_harness.Peer, "poster");
var voter = new RemoteActor(_harness.Peer, "voter", origin: _harness.Peer.B);
var forger = new RemoteActor(_harness.Peer, "forger", origin: _harness.Peer.B);
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token);
await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
var page = PublicNote(poster, "<p>a post</p>", community.Id);
page["type"] = "Page";
page["name"] = "a title";
_harness.Peer.Serve(new Uri(IdOf(page)).AbsolutePath, page.ToJsonString());
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, page)));
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(page)).ExecuteSingleAsync(token);
var other = await OwnPost(alice, token);
var like = new JsonObject { ["id"] = NewId(voter, "likes"), ["type"] = "Like", ["actor"] = voter.Id, ["object"] = post.ObjectURI };
var forged = new JsonObject { ["id"] = NewId(forger, "likes"), ["type"] = "Like", ["actor"] = forger.Id, ["object"] = other.ObjectURI };
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", like));
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", forged));
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
Assert.True(await DB.Default.Find<Favourite>().Match(f => f.PostId == post.ID && f.ActorURI == voter.Id).ExecuteAnyAsync(token));
Assert.Equal(("dropped", "fetch-failed"), (Processed("Announce").Outcome, Processed("Announce").Reason));
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(other.ID, token)).FavouritesCount);
}
// a public post of a persona's own, outside any community
static async Task<Post> OwnPost(PrivaPub.Federation.Actors.LocalActor author, CancellationToken token)
{
var post = new Post { GroupUserId = author.Id, AuthorAccountId = author.Id, Text = "not the community's" };
post.ID = (string)post.GenerateNewID();
post.ObjectURI = author.PostUri(post.ID);
await DB.Default.SaveAsync(post, token);
return post;
}
[Fact]
public async Task A_locked_persona_holds_a_follow_until_it_decides_and_answers_with_the_original_follow()
{
var token = TestContext.Current.CancellationToken;
var (_, open) = await _harness.Persona("alice");
await DB.Default.Update<Avatar>().MatchID(open.Id).Modify(a => a.Settings.IsLocked, true).ExecuteAsync(token);
var alice = await _harness.Local.FindById(LocalActorKind.Person, open.Id, token);
var bob = new RemoteActor(_harness.Peer, "bob");
var carol = new RemoteActor(_harness.Peer, "carol");
var bobFollows = Follow(bob, alice.Uri);
var carolFollows = Follow(carol, alice.Uri);
Assert.True(alice.ManuallyApprovesFollowers);
await _harness.Deliver(bob, "/human-centipede", bobFollows);
await _harness.Deliver(carol, "/human-centipede", carolFollows);
var pending = await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id).ExecuteAsync(token);
Assert.Equal(2, pending.Count);
Assert.All(pending, f => Assert.False(f.IsAccepted));
Assert.Equal(("accepted", "pending"), (Processed("Follow").Outcome, Processed("Follow").Reason));
var requests = await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id).ExecuteAsync(token);
Assert.Equal(2, requests.Count);
Assert.All(requests, n => Assert.Equal(NotificationType.FollowRequest, n.Type));
Assert.Empty(await _harness.Outgoing(bob.Id + "/inbox"));
Assert.Empty(await _harness.Delivery.FollowerInboxes(alice, token));
var bobAccount = await Stored(bob);
var carolAccount = await Stored(carol);
Assert.True(await _harness.Follows.Decide(alice, bobAccount.ID, accept: true, token));
Assert.True(await _harness.Follows.Decide(alice, carolAccount.ID, accept: false, token));
Assert.False(await _harness.Follows.Decide(alice, carolAccount.ID, accept: true, token));
var accept = Assert.Single(await _harness.Outgoing(bob.Id + "/inbox"));
Assert.Equal("Accept", accept["type"]!.GetValue<string>());
Assert.Equal(alice.Uri, accept["actor"]!.GetValue<string>());
Assert.Equal(IdOf(bobFollows), IdOf(accept["object"]!));
Assert.Equal(bob.Id, accept["object"]!["actor"]!.GetValue<string>());
var reject = Assert.Single(await _harness.Outgoing(carol.Id + "/inbox"));
Assert.Equal("Reject", reject["type"]!.GetValue<string>());
Assert.Equal(IdOf(carolFollows), IdOf(reject["object"]!));
Assert.True((await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id && f.ActorURI == bob.Id).ExecuteSingleAsync(token)).IsAccepted);
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id && f.ActorURI == carol.Id).ExecuteAnyAsync(token));
Assert.Contains(await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id && n.Type == NotificationType.Follow).ExecuteAsync(token),
n => n.FromAccountId == bobAccount.ID);
Assert.Equal(new[] { bob.Id + "/inbox" }, await _harness.Delivery.FollowerInboxes(alice, token));
}
}
}
@@ -1,360 +0,0 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Entities;
using PrivaPub.Domain.Timelines;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Moderation;
using PrivaPub.Domain.Social;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.Models;
using PrivaPub.StaticServices;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class InboxScenarioTests : IAsyncLifetime
{
const string Host = "privapub.test";
const string Base = "https://" + Host;
Peer _peer;
LocalActorService _local;
InboxReceiver _receiver;
InboxProcessor _processor;
DomainBlocks _blocks;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_peer = await Peer.Start();
var cache = new MemoryCache(new MemoryCacheOptions());
_local = new LocalActorService(new DbEntities(), new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = Base }));
var remote = new RemoteActorService(Peer.Http(cache), _local, cache, new DbEntities());
var queue = new JobQueue();
var delivery = new DeliveryService(new DbEntities(), queue);
var db = new DbEntities();
_blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance);
_receiver = new InboxReceiver(_local, remote, queue, _blocks, NullLogger<InboxReceiver>.Instance);
var remotePosts = new RemotePosts(db, _local, remote, _blocks, queue, new ObjectRecords(queue), new NoPreviews());
var quotes = new QuoteService(db, remote, remotePosts, _local, delivery, new OutboxPublisher(db, _local, delivery));
_processor = new InboxProcessor(remote, new IActivityHandler[]
{
new FollowHandler(db, _local, remote, delivery),
new UndoHandler(db, _local, new Reactions(db, delivery)),
new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), remotePosts, new GroupDistributor(delivery), new ObjectRecords(queue), new PollService(db, _local, delivery, queue), new NoPreviews(), quotes),
new DeleteHandler(db, _local, remote, delivery, new GroupDistributor(delivery), quotes),
new UpdateHandler(db, _local, remote, new GroupDistributor(delivery), new ObjectRecords(queue), quotes)
}, NullLogger<InboxProcessor>.Instance);
}
public async ValueTask DisposeAsync()
{
if (_peer != default)
await _peer.DisposeAsync();
}
async Task<LocalActor> LocalAvatar(string name)
{
var (privateKey, publicKey) = Keys.NewKeyPair();
var avatar = new Avatar { UserName = $"{name}{Guid.NewGuid():N}"[..20], PrivateKey = privateKey, PublicKey = publicKey };
await DB.Default.SaveAsync(avatar, TestContext.Current.CancellationToken);
return _local.FromAvatar(avatar);
}
static JsonObject DirectCreate(RemoteActor author, string to, string context = default, string objectOrigin = default, string attributedTo = default)
{
var id = $"{objectOrigin ?? Origin(author.Id)}/notes/{Guid.NewGuid():N}";
var note = new JsonObject
{
["id"] = id,
["type"] = "Note",
["attributedTo"] = attributedTo ?? author.Id,
["content"] = "<p>psst</p>",
["to"] = new JsonArray(to),
["cc"] = new JsonArray()
};
if (context != default)
note["context"] = context;
return new JsonObject
{
["id"] = $"{Origin(author.Id)}/activities/{Guid.NewGuid():N}",
["type"] = "Create",
["actor"] = author.Id,
["to"] = new JsonArray(to),
["object"] = note
};
}
async Task<InboxResult> Deliver(RemoteActor sender, string path, JsonNode activity)
{
var token = TestContext.Current.CancellationToken;
var result = await _receiver.Receive(sender.Post(Host, path, activity), default, token);
var dedupe = "inbox|" + (activity is JsonObject ? activity["id"]?.GetValue<string>() : default);
var job = await DB.Default.Find<Job>().Match(j => j.DedupeKey == dedupe).ExecuteFirstAsync(token);
if (job != default)
Assert.Equal(JobResult.Done, (await _processor.Handle(job, token)).Result);
return result;
}
static string Origin(string uri) => new Uri(uri).GetLeftPart(UriPartial.Authority);
[Fact]
public async Task A_context_cannot_pull_a_stranger_into_an_existing_conversation()
{
var token = TestContext.Current.CancellationToken;
var alice = await LocalAvatar("alice");
var bob = new RemoteActor(_peer, "bob");
var mallory = new RemoteActor(_peer, "mallory");
var context = $"{_peer.A}/contexts/{Guid.NewGuid():N}";
var first = await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri, context));
var injected = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, context));
Assert.Equal(202, first.StatusCode);
Assert.Equal(202, injected.StatusCode);
var bobDm = await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id && p.Visibility == PostVisibility.Direct).ExecuteSingleAsync(token);
var malloryDm = await DB.Default.Find<Post>().Match(p => p.ActorURI == mallory.Id).ExecuteSingleAsync(token);
Assert.NotEqual(bobDm.ConversationId, malloryDm.ConversationId);
var bobConversation = await DB.Default.Find<DmGroup>().OneAsync(bobDm.ConversationId, token);
Assert.DoesNotContain(bobConversation.Members, m => m.AvatarId == mallory.Id);
}
[Fact]
public async Task Replies_between_the_same_people_land_in_the_same_conversation()
{
var token = TestContext.Current.CancellationToken;
var alice = await LocalAvatar("alice");
var bob = new RemoteActor(_peer, "bob");
await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri));
await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri));
var dms = await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id && p.Visibility == PostVisibility.Direct).ExecuteAsync(token);
Assert.Equal(2, dms.Count);
Assert.Single(dms.Select(d => d.ConversationId).Distinct());
}
[Fact]
public async Task An_activity_id_on_another_origin_is_refused()
{
var token = TestContext.Current.CancellationToken;
var alice = await LocalAvatar("alice");
var mallory = new RemoteActor(_peer, "mallory");
var create = DirectCreate(mallory, alice.Uri);
create["id"] = $"{_peer.B}/activities/{Guid.NewGuid():N}";
var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", create);
Assert.Equal(400, result.StatusCode);
}
[Fact]
public async Task A_note_put_in_someone_elses_mouth_is_refused()
{
var token = TestContext.Current.CancellationToken;
var alice = await LocalAvatar("alice");
var mallory = new RemoteActor(_peer, "mallory");
var victim = new RemoteActor(_peer, "victim", _peer.B);
var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, attributedTo: victim.Id));
Assert.Equal(400, result.StatusCode);
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == victim.Id).ExecuteAnyAsync(token));
// a colleague on mallory's own server is that server's to vouch for: believed only as the server has it, and
// it has no such note
var colleague = new RemoteActor(_peer, "colleague");
result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, attributedTo: colleague.Id));
Assert.Equal(202, result.StatusCode);
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == colleague.Id).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_cross_origin_object_is_fetched_from_its_origin_before_it_is_believed()
{
var token = TestContext.Current.CancellationToken;
var alice = await LocalAvatar("alice");
var mallory = new RemoteActor(_peer, "mallory");
var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, objectOrigin: _peer.B));
Assert.Equal(202, result.StatusCode);
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == mallory.Id).ExecuteAnyAsync(token));
}
static JsonObject PublicCreate(RemoteActor author, string inReplyTo = default, params string[] cc)
{
var id = $"{Origin(author.Id)}/notes/{Guid.NewGuid():N}";
var note = new JsonObject
{
["id"] = id,
["type"] = "Note",
["attributedTo"] = author.Id,
["content"] = "<p>hello</p>",
["to"] = new JsonArray(Addressing.Public),
["cc"] = new JsonArray(cc.Prepend(author.Id + "/followers").Select(c => (JsonNode)c).ToArray())
};
if (inReplyTo != default)
note["inReplyTo"] = inReplyTo;
return new JsonObject
{
["id"] = $"{Origin(author.Id)}/activities/{Guid.NewGuid():N}",
["type"] = "Create",
["actor"] = author.Id,
["object"] = note
};
}
[Fact]
public async Task A_public_reply_to_a_local_post_is_kept_and_counted()
{
var token = TestContext.Current.CancellationToken;
var alice = await LocalAvatar("alice");
var parent = new Post { GroupUserId = alice.Id, AuthorAccountId = alice.Id, ActorURI = alice.Uri, Text = "hi" };
parent.ID = (string)parent.GenerateNewID();
parent.ObjectURI = alice.PostUri(parent.ID);
await DB.Default.SaveAsync(parent, token);
var bob = new RemoteActor(_peer, "bob");
await Deliver(bob, "/human-centipede", PublicCreate(bob, parent.ObjectURI));
var reply = await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token);
Assert.Equal(PostVisibility.Public, reply.Visibility);
Assert.Equal(parent.ID, reply.AnsweringToPostId);
Assert.Equal(alice.Id, reply.InReplyToAccountId);
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(parent.ID, token)).RepliesCount);
}
[Fact]
public async Task A_public_post_nobody_here_asked_for_is_dropped()
{
var token = TestContext.Current.CancellationToken;
var bob = new RemoteActor(_peer, "bob");
await Deliver(bob, "/human-centipede", PublicCreate(bob));
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_mention_is_kept_and_linked_to_the_local_persona()
{
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"]!["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@" + alice.Handle });
await Deliver(bob, "/human-centipede", create);
var post = await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token);
var mention = Assert.Single(post.Mentions);
Assert.True(mention.IsLocal);
Assert.Equal(alice.Id, mention.AccountId);
}
// GoToSocial kept a persona in cc after an edit took the @name out, and Akkoma addresses with to[] alone: the persona it
// is addressed to sees a followers-only post as a silent mention, which no list shows as a mention (found by the town)
[Fact]
public async Task A_followers_only_post_addressed_to_a_persona_without_naming_it_is_a_silent_mention()
{
var token = TestContext.Current.CancellationToken;
var alice = await LocalAvatar("alice");
var bob = new RemoteActor(_peer, "bob");
var create = PublicCreate(bob, default, alice.Uri);
create["object"]!["to"] = bob.Id + "/followers";
create["object"]!["cc"] = alice.Uri;
await Deliver(bob, "/human-centipede", create);
var post = await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token);
Assert.Equal(PostVisibility.FollowersOnly, post.Visibility);
var mention = Assert.Single(post.Mentions);
Assert.True(mention.Silent);
Assert.Equal(alice.Id, mention.AccountId);
Assert.True(await PrivaPub.Domain.Privacy.VisibilityPolicy.CanSee(post, alice.Id, token));
}
[Fact]
public async Task A_suspended_domain_is_dropped_before_its_key_is_fetched()
{
var token = TestContext.Current.CancellationToken;
var alice = await LocalAvatar("alice");
var bob = new RemoteActor(_peer, "bob", _peer.B);
_blocks.Load(new[] { new DomainBlock { Domain = "localhost", Severity = DomainBlockSeverity.Suspend } });
var before = _peer.Requests.Count;
var result = await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri));
Assert.Equal(202, result.StatusCode);
Assert.Equal(before, _peer.Requests.Count);
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_bad_signature_is_a_401()
{
var token = TestContext.Current.CancellationToken;
var alice = await LocalAvatar("alice");
var mallory = new RemoteActor(_peer, "mallory");
var request = mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri));
request.Headers["Signature"] = request.Headers["Signature"].ToString().Replace("signature=\"", "signature=\"AAAA");
Assert.Equal(401, (await _receiver.Receive(request, alice, token)).StatusCode);
}
[Fact]
public async Task Junk_is_a_400_never_a_500()
{
var token = TestContext.Current.CancellationToken;
var alice = await LocalAvatar("alice");
var mallory = new RemoteActor(_peer, "mallory");
foreach (var junk in new JsonNode[] { new JsonArray(1, 2), JsonValue.Create("x"), new JsonObject { ["type"] = "Create" } })
Assert.Equal(400, (await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", junk)).StatusCode);
}
[Fact]
public async Task A_circle_only_takes_follow_requests()
{
var token = TestContext.Current.CancellationToken;
var (privateKey, publicKey) = Keys.NewKeyPair();
var circle = new GroupEntity { UserName = $"circle{Guid.NewGuid():N}"[..20], PrivateKey = privateKey, PublicKey = publicKey };
await DB.Default.SaveAsync(circle, token);
var bob = new RemoteActor(_peer, "bob");
var actor = _local.FromGroup(circle);
var follow = new JsonObject
{
["id"] = $"{bob.Id}/follows/{Guid.NewGuid():N}",
["type"] = "Follow",
["actor"] = bob.Id,
["object"] = actor.Uri
};
Assert.True(actor.IsCircle);
Assert.True(actor.ManuallyApprovesFollowers);
Assert.False(actor.Discoverable);
Assert.Equal(202, (await Deliver(bob, "/human-centipede", follow)).StatusCode);
var request = await DB.Default.Find<Follower>().Match(f => f.LocalActorId == circle.ID).ExecuteSingleAsync(token);
Assert.False(request.IsAccepted);
Assert.DoesNotContain(circle.Members, m => m.AvatarId == bob.Id);
}
}
}
@@ -1,175 +0,0 @@
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));
}
}
}
@@ -1,284 +0,0 @@
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);
}
}
}
@@ -1,225 +0,0 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels.Social;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class InteractionTests : 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<(string Root, Post Post)> LocalPost(string visibility = default)
{
var (root, alice) = await _harness.Persona("alice");
var view = (ViewPost)(await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "like me", Visibility = visibility },
TestContext.Current.CancellationToken)).Data;
return (root, await DB.Default.Find<Post>().OneAsync(view.Id));
}
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
[Fact]
public async Task A_like_counts_once_notifies_and_can_be_undone()
{
var token = TestContext.Current.CancellationToken;
var (_, post) = await LocalPost();
var carol = new RemoteActor(_harness.Peer, "carol");
var like = new JsonObject { ["id"] = $"{Origin(carol)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = carol.Id, ["object"] = post.ObjectURI };
await _harness.Deliver(carol, "/human-centipede", like);
await _harness.Deliver(carol, "/human-centipede", new JsonObject { ["id"] = $"{Origin(carol)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = carol.Id, ["object"] = post.ObjectURI });
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
Assert.Equal(NotificationType.Favourite, (await DB.Default.Find<Notification>().Match(n => n.AvatarId == post.GroupUserId).ExecuteSingleAsync(token)).Type);
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(carol)}/undos/{Guid.NewGuid():N}", ["type"] = "Undo", ["actor"] = carol.Id, ["object"] = like
});
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
}
[Fact]
public async Task A_stranger_cannot_like_a_followers_only_post()
{
var token = TestContext.Current.CancellationToken;
var (_, post) = await LocalPost("followersonly");
var mallory = new RemoteActor(_harness.Peer, "mallory");
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(mallory)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = mallory.Id, ["object"] = post.ObjectURI
});
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
}
// Our followers-only posts are addressed to the followers collection, never to each follower, so a follower is found
// among our followers, not in to/cc. The town found every follower's like, reaction and downvote dropped (G-0001).
[Fact]
public async Task A_follower_likes_reacts_to_and_downvotes_a_followers_only_post()
{
var token = TestContext.Current.CancellationToken;
var (_, post) = await LocalPost("followersonly");
var carol = new RemoteActor(_harness.Peer, "carol");
await _harness.FollowedBy(_harness.Local.FromAvatar(await DB.Default.Find<Avatar>().OneAsync(post.GroupUserId, token)), carol);
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(carol)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = carol.Id, ["object"] = post.ObjectURI
});
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(carol)}/reactions/{Guid.NewGuid():N}", ["type"] = "EmojiReact", ["actor"] = carol.Id, ["object"] = post.ObjectURI,
["content"] = "🔥"
});
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(carol)}/dislikes/{Guid.NewGuid():N}", ["type"] = "Dislike", ["actor"] = carol.Id, ["object"] = post.ObjectURI
});
// the downvote counts, and takes the place of carol's like (one vote each, as Lemmy votes)
var stored = await DB.Default.Find<Post>().OneAsync(post.ID, token);
Assert.Equal(0, stored.FavouritesCount);
Assert.Equal(1, stored.DownvotesCount);
Assert.True(await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID && r.ActorURI == carol.Id && r.Emoji == "🔥").ExecuteAnyAsync(token));
}
[Fact]
public async Task A_vote_turned_the_other_way_replaces_the_first_as_lemmy_sends_it()
{
var token = TestContext.Current.CancellationToken;
var (_, post) = await LocalPost();
var voter = new RemoteActor(_harness.Peer, "voter");
var other = new RemoteActor(_harness.Peer, "other");
JsonObject Vote(RemoteActor actor, string type) => new()
{
["id"] = $"{Origin(actor)}/{type.ToLowerInvariant()}s/{Guid.NewGuid():N}", ["type"] = type, ["actor"] = actor.Id, ["object"] = post.ObjectURI
};
await _harness.Deliver(other, "/human-centipede", Vote(other, "Like"));
await _harness.Deliver(voter, "/human-centipede", Vote(voter, "Dislike"));
await _harness.Deliver(voter, "/human-centipede", Vote(voter, "Like"));
var up = await DB.Default.Find<Post>().OneAsync(post.ID, token);
Assert.Equal((2, 0), (up.FavouritesCount, up.DownvotesCount));
await _harness.Deliver(voter, "/human-centipede", Vote(voter, "Dislike"));
var down = await DB.Default.Find<Post>().OneAsync(post.ID, token);
Assert.Equal((1, 1), (down.FavouritesCount, down.DownvotesCount));
Assert.False(await DB.Default.Find<Favourite>().Match(f => f.PostId == post.ID && f.ActorURI == voter.Id).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_circle_member_likes_a_circle_post_and_an_outsider_cannot()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var carol = new RemoteActor(_harness.Peer, "carol");
var mallory = new RemoteActor(_harness.Peer, "mallory");
var circle = new Group
{
UserName = $"circle{Guid.NewGuid():N}"[..20], Name = "a circle", Kind = GroupKind.Circle, OwnerAvatarId = alice.Id,
Members = { new() { AvatarId = alice.Id }, new() { AvatarId = carol.Id, IsForeign = true } }
};
await DB.Default.SaveAsync(circle, token);
var post = new Post
{
GroupUserId = alice.Id, AuthorAccountId = alice.Id, GroupId = circle.ID, Visibility = PostVisibility.Circle, Text = "only us",
ObjectURI = $"{alice.Uri}/scribbles/{Guid.NewGuid():N}"
};
await DB.Default.SaveAsync(post, token);
foreach (var who in new[] { carol, mallory })
await _harness.Deliver(who, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(who)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = who.Id, ["object"] = post.ObjectURI
});
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
Assert.True(await DB.Default.Find<Favourite>().Match(f => f.PostId == post.ID && f.ActorURI == carol.Id).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_boost_of_a_local_post_counts_notifies_and_can_be_undone()
{
var token = TestContext.Current.CancellationToken;
var (_, post) = await LocalPost();
var carol = new RemoteActor(_harness.Peer, "carol");
var announce = new JsonObject
{
["id"] = $"{Origin(carol)}/announces/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = carol.Id, ["object"] = post.ObjectURI,
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(carol.Id + "/followers")
};
await _harness.Deliver(carol, "/human-centipede", announce);
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).ReblogsCount);
Assert.Equal(NotificationType.Reblog, (await DB.Default.Find<Notification>().Match(n => n.AvatarId == post.GroupUserId).ExecuteSingleAsync(token)).Type);
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(carol)}/undos/{Guid.NewGuid():N}", ["type"] = "Undo", ["actor"] = carol.Id, ["object"] = announce["id"]!.GetValue<string>()
});
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).ReblogsCount);
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == announce["id"]!.GetValue<string>()).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_followed_account_boosting_a_remote_post_puts_it_in_home()
{
var token = TestContext.Current.CancellationToken;
var (bobRoot, bob) = await _harness.Persona("bob");
var carol = new RemoteActor(_harness.Peer, "carol");
var dave = new RemoteActor(_harness.Peer, "dave");
var stranger = new RemoteActor(_harness.Peer, "stranger");
await _harness.Follows.Follow(bobRoot, new FollowForm { AvatarId = bob.Id, Target = carol.Id }, token);
await DB.Default.Update<Following>().Match(f => f.AvatarId == bob.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
var noteId = $"{Origin(dave)}/notes/{Guid.NewGuid():N}";
_harness.Peer.Serve(new Uri(noteId).AbsolutePath, new JsonObject
{
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = dave.Id, ["content"] = "<p>worth sharing</p>", ["to"] = new JsonArray(Addressing.Public)
}.ToJsonString());
await _harness.Deliver(stranger, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(stranger)}/announces/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = stranger.Id, ["object"] = noteId,
["to"] = new JsonArray(Addressing.Public)
});
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteAnyAsync(token));
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(carol)}/announces/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = carol.Id, ["object"] = noteId,
["to"] = new JsonArray(Addressing.Public)
});
var home = (List<ViewPost>)(await _harness.Timelines.Home(bobRoot, bob.Id, default, 20, token)).Data;
var boosted = Assert.Single(home);
Assert.Equal("<p>worth sharing</p>", boosted.ReblogOf.ContentHtml);
Assert.Equal(carol.Id, boosted.AuthorActorURI);
}
}
}
@@ -1,706 +0,0 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Entities;
using OpenIddict.Abstractions;
using PrivaPub.Api.Mastodon.Auth;
using PrivaPub.Domain.Content;
using PrivaPub.Domain.Media;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Objects;
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.Media;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using static PrivaPub.Tests.Support.FederatedSeeds;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class JobHandlerTests : 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();
}
sealed class PeerDescriber : InstanceDescriber
{
readonly string _peer;
public PeerDescriber(IFederationHttp http, string peer) : base(http) => _peer = peer;
protected override string Address(string url) => _peer + new Uri(url).PathAndQuery;
}
[Fact]
public async Task Ancestors_are_fetched_one_job_at_a_time_and_stop_at_the_depth_limit()
{
var token = TestContext.Current.CancellationToken;
var poster = new RemoteActor(_harness.Peer, "poster");
var chain = new List<JsonObject>();
for (var i = 0; i < RemotePosts.MaxDepth + 3; i++)
{
var note = PublicNote(poster, $"<p>number {i}</p>");
if (i > 0)
note["inReplyTo"] = IdOf(chain[^1]);
_harness.Peer.Serve(new Uri(IdOf(note)).AbsolutePath, note.ToJsonString());
chain.Add(note);
}
var leaf = new Post
{
ObjectURI = NewId(poster, "notes"), ActorURI = poster.Id, IsFederatedCopy = true, Visibility = PostVisibility.Public,
InReplyToURI = IdOf(chain[^1]), ContentHtml = "<p>the leaf</p>"
};
await DB.Default.SaveAsync(leaf, token);
await _harness.Queue.Enqueue(JobKind.FetchAncestors, JsonSerializer.Serialize(new AncestorsPayload(leaf.ID, 1)), "127.0.0.1", "ancestors|" + leaf.ID, token);
var handler = new AncestorsJobHandler(_harness.Db, _harness.RemotePosts);
var keys = new List<string> { "ancestors|" + leaf.ID };
var ran = 0;
while (await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.FetchAncestors && j.State == JobState.Pending && keys.Contains(j.DedupeKey))
.ExecuteFirstAsync(token) is { } job)
{
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++;
var uris = chain.Select(IdOf).ToList();
keys = (await DB.Default.Find<Post>().Match(p => uris.Contains(p.ObjectURI)).ExecuteAsync(token)).Select(p => "ancestors|" + p.ID).Append("ancestors|" + leaf.ID).ToList();
Assert.True(ran <= RemotePosts.MaxDepth, "the chain did not stop");
}
var all = chain.Select(IdOf).ToList();
var stored = (await DB.Default.Find<Post>().Match(p => all.Contains(p.ObjectURI)).ExecuteAsync(token)).ToDictionary(p => p.ObjectURI);
Assert.Equal(RemotePosts.MaxDepth, ran);
Assert.Equal(all.Skip(3), stored.Keys.OrderBy(all.IndexOf));
Assert.Equal(stored[all[^1]].ID, (await DB.Default.Find<Post>().OneAsync(leaf.ID, token)).AnsweringToPostId);
for (var i = 4; i < all.Count; i++)
Assert.Equal(stored[all[i - 1]].ID, stored[all[i]].AnsweringToPostId);
Assert.Null(stored[all[3]].AnsweringToPostId);
Assert.Equal(all[2], stored[all[3]].InReplyToURI);
Assert.DoesNotContain(_harness.Peer.Requests, r => r.Path == new Uri(all[2]).AbsolutePath);
}
JsonObject Served(JsonObject document)
{
_harness.Peer.Serve(new Uri(IdOf(document)).AbsolutePath, document.ToJsonString());
return document;
}
static JsonObject Reply(RemoteActor author, string text, JsonObject parent)
{
var note = PublicNote(author, $"<p>{text}</p>");
note["inReplyTo"] = IdOf(parent);
return note;
}
static async Task<Post> Held(JsonObject note)
{
var post = new Post
{
ObjectURI = IdOf(note), ActorURI = note["attributedTo"]!.GetValue<string>(), IsFederatedCopy = true, Visibility = PostVisibility.Public,
ContentHtml = note["content"]!.GetValue<string>()
};
await DB.Default.SaveAsync(post, TestContext.Current.CancellationToken);
return post;
}
// runs the FetchReplies job for the post, and those it queues for this peer's posts, until none is left; how many ran
async Task<int> RunReplies(Post first)
{
var token = TestContext.Current.CancellationToken;
var handler = new RepliesJobHandler(_harness.Db, _harness.Remote, _harness.RemotePosts, _harness.Queue);
var origin = new Uri(first.ObjectURI).GetLeftPart(UriPartial.Authority);
await _harness.Queue.EnqueueMany(new[] { RepliesJobHandler.For(first, 1) }, token);
var ran = 0;
while (true)
{
var mine = new List<Job>();
foreach (var job in await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.FetchReplies && j.State == JobState.Pending).ExecuteAsync(token))
{
var post = await DB.Default.Find<Post>().OneAsync(JsonSerializer.Deserialize<RepliesPayload>(job.Payload)!.PostId, token);
if (post?.ObjectURI.StartsWith(origin) == true)
mine.Add(job);
}
if (mine.Count == 0)
return ran;
foreach (var job in mine)
{
Assert.Equal(JobResult.Done, (await handler.Handle(job, token)).Result);
await DB.Default.Update<Job>().MatchID(job.ID).Modify(j => j.State, JobState.Done).ExecuteAsync(token);
ran++;
}
}
}
static async Task<Dictionary<string, Post>> Stored(params JsonObject[] notes)
{
var uris = notes.Select(IdOf).ToList();
return (await DB.Default.Find<Post>().Match(p => uris.Contains(p.ObjectURI)).ExecuteAsync(TestContext.Current.CancellationToken))
.ToDictionary(p => p.ObjectURI);
}
[Fact]
public async Task A_threads_replies_are_read_from_their_servers_two_levels_down_and_only_public_ones_kept()
{
var poster = new RemoteActor(_harness.Peer, "poster");
var answerer = new RemoteActor(_harness.Peer, "answerer");
var root = PublicNote(poster, "<p>the root</p>");
// what a post carries is as old as the post: the collection is read again by its id
root["replies"] = new JsonObject
{
["id"] = IdOf(root) + "/replies", ["type"] = "Collection", ["first"] = new JsonObject { ["type"] = "CollectionPage", ["items"] = new JsonArray() }
};
Served(root);
var first = Reply(answerer, "first answer", root);
var second = Served(Reply(poster, "second answer", root));
var hidden = Reply(answerer, "for followers", root);
hidden["to"] = new JsonArray(answerer.Id + "/followers");
hidden["cc"] = new JsonArray();
Served(hidden);
var nested = Reply(poster, "nested", first);
var deeper = Served(Reply(answerer, "too deep", nested));
first["replies"] = IdOf(first) + "/replies";
Served(first);
nested["replies"] = IdOf(nested) + "/replies";
Served(nested);
Served(new JsonObject { ["id"] = IdOf(root) + "/replies", ["type"] = "Collection", ["first"] = IdOf(root) + "/replies/1" });
Served(new JsonObject
{
["id"] = IdOf(root) + "/replies/1", ["type"] = "CollectionPage", ["next"] = IdOf(root) + "/replies/2",
["items"] = new JsonArray(first.DeepClone(), IdOf(second))
});
Served(new JsonObject { ["id"] = IdOf(root) + "/replies/2", ["type"] = "CollectionPage", ["items"] = new JsonArray(IdOf(hidden)) });
Served(new JsonObject { ["id"] = IdOf(first) + "/replies", ["type"] = "OrderedCollection", ["orderedItems"] = new JsonArray(IdOf(nested)) });
Served(new JsonObject { ["id"] = IdOf(nested) + "/replies", ["type"] = "OrderedCollection", ["orderedItems"] = new JsonArray(IdOf(deeper)) });
var held = await Held(root);
Assert.Equal(3, await RunReplies(held));
var stored = await Stored(first, second, hidden, nested, deeper);
Assert.Equal(new[] { IdOf(first), IdOf(second), IdOf(nested) }.Order(), stored.Keys.Order());
Assert.Equal(held.ID, stored[IdOf(first)].AnsweringToPostId);
Assert.Equal(held.ID, stored[IdOf(second)].AnsweringToPostId);
Assert.Equal(stored[IdOf(first)].ID, stored[IdOf(nested)].AnsweringToPostId);
Assert.DoesNotContain(_harness.Peer.Requests, r => r.Path == new Uri(IdOf(nested) + "/replies").AbsolutePath);
Assert.All(_harness.Peer.Requests.Where(r => r.Path.EndsWith("/replies")), r => Assert.False(string.IsNullOrEmpty(r.Signature)));
}
[Fact]
public async Task A_threads_context_collection_brings_every_reply_at_once()
{
var poster = new RemoteActor(_harness.Peer, "poster");
var answerer = new RemoteActor(_harness.Peer, "answerer");
var root = PublicNote(poster, "<p>a thread</p>");
var context = NewId(poster, "contexts");
root["context"] = context;
root["replies"] = NewId(poster, "never");
Served(root);
var answer = Served(Reply(answerer, "an answer", root));
var deep = Served(Reply(poster, "an answer to it", answer));
// FEP-f228 lets a context list the activities that made the thread
Served(new JsonObject
{
["id"] = context, ["type"] = "OrderedCollection",
["first"] = new JsonObject { ["type"] = "OrderedCollectionPage", ["orderedItems"] = new JsonArray(IdOf(root), Create(answerer, answer), IdOf(deep)) }
});
var held = await Held(root);
Assert.Equal(1, await RunReplies(held));
var stored = await Stored(answer, deep);
Assert.Equal(held.ID, stored[IdOf(answer)].AnsweringToPostId);
Assert.Equal(stored[IdOf(answer)].ID, stored[IdOf(deep)].AnsweringToPostId);
Assert.DoesNotContain(_harness.Peer.Requests, r => r.Path == new Uri(root["replies"]!.GetValue<string>()).AbsolutePath);
}
async Task<(LocalActor Alice, RemoteActor Mallory, Post Poll)> LocalPoll()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = NewId(mallory, "follows"), ["type"] = "Follow", ["actor"] = mallory.Id, ["object"] = alice.Uri
});
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "which?", Poll = new PollDraft(new[] { "tea", "coffee" }, 3600, false, false) }, token);
Assert.True(outcome.Ok);
return (alice, mallory, outcome.Post);
}
async Task<List<JsonObject>> Updates(RemoteActor to) =>
(await _harness.Outgoing(to.Id + "/inbox")).Where(a => a["type"]!.GetValue<string>() == "Update").ToList();
static int[] Counts(JsonNode question) =>
question["oneOf"]!.AsArray().Select(o => o!["replies"]!["totalItems"]!.GetValue<int>()).ToArray();
[Fact]
public async Task A_poll_refresh_sends_the_new_counts_to_followers_as_an_update()
{
var token = TestContext.Current.CancellationToken;
var (_, mallory, poll) = await LocalPoll();
var (_, bob) = await _harness.Persona("bob");
Assert.True((await _harness.Polls.Vote(bob, poll, new[] { 1 }, token)).Ok);
var job = await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.PollRefresh && j.Payload == poll.ID).ExecuteSingleAsync(token);
Assert.True(job.RunAt > DateTime.UtcNow);
Assert.Empty(await Updates(mallory));
Assert.Equal(JobResult.Done, (await new PollRefreshJob(_harness.Db, _harness.Local, _harness.Outbox).Handle(job, token)).Result);
var question = Assert.Single(await Updates(mallory))["object"]!;
Assert.Equal("Question", question["type"]!.GetValue<string>());
Assert.Equal(new[] { 0, 1 }, Counts(question));
Assert.Null(question["closed"]);
Assert.Null((await DB.Default.Find<Post>().OneAsync(poll.ID, token)).Poll.ClosedAt);
}
[Fact]
public async Task Closing_a_local_poll_tells_its_voters_and_sends_the_closed_question()
{
var token = TestContext.Current.CancellationToken;
var (alice, mallory, poll) = await LocalPoll();
var (_, bob) = await _harness.Persona("bob");
Assert.True((await _harness.Polls.Vote(bob, poll, new[] { 0 }, token)).Ok);
var job = await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.PollClose && j.Payload == poll.ID).ExecuteSingleAsync(token);
Assert.Equal(JobResult.Done, (await new PollCloseJob(_harness.Db, _harness.Local, _harness.Outbox).Handle(job, token)).Result);
var closed = (await DB.Default.Find<Post>().OneAsync(poll.ID, token)).Poll.ClosedAt;
Assert.NotNull(closed);
var question = Assert.Single(await Updates(mallory))["object"]!;
Assert.NotNull(question["closed"]);
Assert.Equal(new[] { 1, 0 }, Counts(question));
var told = await DB.Default.Find<Notification>().Match(n => n.Type == NotificationType.Poll && n.PostId == poll.ID).ExecuteAsync(token);
Assert.Equal(new[] { alice.Id, bob.Id }.Order(), told.Select(n => n.AvatarId).Order());
}
[Fact]
public async Task Closing_a_remote_poll_tells_the_local_voters_and_sends_nothing()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
var question = PublicNote(mallory, "<p>pick</p>", alice.Uri);
question["type"] = "Question";
question["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" });
question["endTime"] = DateTime.UtcNow.AddMinutes(10).ToString("O");
question["oneOf"] = new JsonArray(
new JsonObject { ["type"] = "Note", ["name"] = "red", ["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = 0 } },
new JsonObject { ["type"] = "Note", ["name"] = "blue", ["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = 0 } });
await _harness.Deliver(mallory, "/human-centipede", Create(mallory, question));
var poll = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(question)).ExecuteSingleAsync(token);
Assert.True((await _harness.Polls.Vote(alice, poll, new[] { 1 }, token)).Ok);
var job = await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.PollClose && j.Payload == poll.ID).ExecuteSingleAsync(token);
Assert.Equal(JobResult.Done, (await new PollCloseJob(_harness.Db, _harness.Local, _harness.Outbox).Handle(job, token)).Result);
var told = Assert.Single(await DB.Default.Find<Notification>().Match(n => n.Type == NotificationType.Poll && n.PostId == poll.ID).ExecuteAsync(token));
Assert.Equal(alice.Id, told.AvatarId);
Assert.Equal(poll.AuthorAccountId, told.FromAccountId);
Assert.Empty(await Updates(mallory));
Assert.Null((await DB.Default.Find<Post>().OneAsync(poll.ID, token)).Poll.ClosedAt);
}
[Fact]
public async Task A_server_is_described_from_its_nodeinfo_and_at_most_once_a_week()
{
var token = TestContext.Current.CancellationToken;
var host = $"describe{Guid.NewGuid():N}.example";
var peer = _harness.Peer;
peer.ServeText("/.well-known/nodeinfo", new JsonObject
{
["links"] = new JsonArray(
new JsonObject { ["rel"] = "http://nodeinfo.diaspora.software/ns/schema/2.0", ["href"] = $"https://{host}/nodeinfo/2.0" },
new JsonObject { ["rel"] = "http://nodeinfo.diaspora.software/ns/schema/2.1", ["href"] = $"https://{host}/nodeinfo/2.1" })
}.ToJsonString(), "application/json");
peer.ServeText("/nodeinfo/2.1", new JsonObject
{
["version"] = "2.1",
["software"] = new JsonObject { ["name"] = "gotosocial", ["version"] = "0.20.1" },
["protocols"] = new JsonArray("activitypub", 42),
["openRegistrations"] = false,
["usage"] = new JsonObject { ["users"] = new JsonObject { ["total"] = 3 } },
["metadata"] = new JsonObject { ["nodeName"] = "A small place" }
}.ToJsonString(), "application/json");
var describer = new PeerDescriber(Peer.Http(), peer.A);
async Task Record()
{
var note = NoteParser.Parse(new JsonObject
{
["id"] = $"https://{host}/notes/{Guid.NewGuid():N}", ["type"] = "Note", ["attributedTo"] = $"https://{host}/users/x",
["content"] = "<p>hi</p>", ["to"] = new JsonArray(Addressing.Public)
});
var post = new Post { ObjectURI = note.Id, IsFederatedCopy = true };
await DB.Default.SaveAsync(post, token);
await _harness.Records.Record(note, post, ObjectPath.Fetched, refetched: false, token);
}
Task<List<Job>> Jobs() => DB.Default.Find<Job>().Match(j => j.Kind == JobKind.DescribeInstance && j.Payload == host).ExecuteAsync(token);
await Record();
await Record();
var job = Assert.Single(await Jobs());
Assert.StartsWith($"describe|{host}|", job.DedupeKey);
Assert.Equal(JobResult.Done, (await describer.Handle(job, token)).Result);
var instance = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == host).ExecuteSingleAsync(token);
Assert.Equal(("gotosocial", "0.20.1", "A small place"), (instance.Software, instance.SoftwareVersion, instance.NodeName));
Assert.Equal(new[] { "activitypub" }, instance.Protocols);
Assert.False(instance.OpenRegistrations);
Assert.Equal(3, JsonNode.Parse(instance.NodeInfo)!["usage"]!["users"]!["total"]!.GetValue<int>());
Assert.Null(instance.DescriptionError);
Assert.InRange(instance.DescribedAt!.Value, DateTime.UtcNow.AddMinutes(-1), DateTime.UtcNow.AddSeconds(1));
Assert.DoesNotContain(peer.Requests, r => r.Path == "/nodeinfo/2.0");
await DB.Default.DeleteAsync<Job>(job.ID);
await Record();
Assert.Empty(await Jobs());
await DB.Default.Update<RemoteInstance>().MatchID(instance.ID).Modify(i => i.DescribedAt, DateTime.UtcNow.AddDays(-8)).ExecuteAsync(token);
await Record();
Assert.Single(await Jobs());
}
[Fact]
public async Task A_server_without_usable_nodeinfo_is_described_as_such()
{
var token = TestContext.Current.CancellationToken;
var plain = $"plain{Guid.NewGuid():N}.example";
var describer = new PeerDescriber(Peer.Http(), _harness.Peer.A + "/" + plain);
_harness.Peer.ServeText($"/{plain}/.well-known/nodeinfo", new JsonObject
{
["links"] = new JsonArray(new JsonObject { ["rel"] = "http://nodeinfo.diaspora.software/ns/schema/2.1", ["href"] = $"http://{plain}/nodeinfo/2.1" })
}.ToJsonString(), "application/json");
var silent = $"silent{Guid.NewGuid():N}.example";
await describer.Handle(new Job { Kind = JobKind.DescribeInstance, Payload = plain }, token);
await new PeerDescriber(Peer.Http(), _harness.Peer.A + "/" + silent).Handle(new Job { Kind = JobKind.DescribeInstance, Payload = silent }, token);
Assert.Equal("no NodeInfo", (await DB.Default.Find<RemoteInstance>().Match(i => i.Host == plain).ExecuteSingleAsync(token)).DescriptionError);
Assert.Equal("no NodeInfo", (await DB.Default.Find<RemoteInstance>().Match(i => i.Host == silent).ExecuteSingleAsync(token)).DescriptionError);
}
LinkPreviews Previews() => new(_harness.Db, _harness.Local, Peer.Http(), _harness.Queue,
new StaticOptions<FederationOptions>(new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true }));
async Task<Post> Linking(PostVisibility visibility, string path)
{
var post = new Post
{
ObjectURI = $"https://elsewhere.example/notes/{Guid.NewGuid():N}", IsFederatedCopy = true, Visibility = visibility,
ContentHtml = $"<p>read <a href=\"{_harness.Peer.A}{path}\">this</a></p>"
};
await DB.Default.SaveAsync(post, TestContext.Current.CancellationToken);
_harness.Peer.ServeText(path, """
<html><head><title>Fallback</title>
<meta property="og:title" content="A headline">
<meta property="og:description" content="What it says">
<meta property="og:image" content="/cover.jpg">
</head><body></body></html>
""", "text/html; charset=utf-8");
return post;
}
[Fact]
public async Task A_link_preview_is_built_from_open_graph_for_a_public_post_only()
{
var token = TestContext.Current.CancellationToken;
var publicPath = $"/articles/{Guid.NewGuid():N}";
var privatePath = $"/articles/{Guid.NewGuid():N}";
var open = await Linking(PostVisibility.Public, publicPath);
var followersOnly = await Linking(PostVisibility.FollowersOnly, privatePath);
Assert.Equal(JobResult.Done, (await Previews().Handle(new Job { Kind = JobKind.FetchPreview, Payload = open.ID }, token)).Result);
Assert.Equal(JobResult.Done, (await Previews().Handle(new Job { Kind = JobKind.FetchPreview, Payload = followersOnly.ID }, token)).Result);
var card = (await DB.Default.Find<Post>().OneAsync(open.ID, token)).Link;
Assert.Equal(_harness.Peer.A + publicPath, card.Href);
Assert.Equal(("A headline", "What it says", _harness.Peer.A + "/cover.jpg"), (card.Title, card.Description, card.ImageURL));
Assert.True(await DB.Default.Find<LinkPreview>().Match(p => p.Url == _harness.Peer.A + publicPath).ExecuteAnyAsync(token));
Assert.Null((await DB.Default.Find<Post>().OneAsync(followersOnly.ID, token)).Link);
Assert.DoesNotContain(_harness.Peer.Requests, r => r.Path == privatePath);
Assert.False(await DB.Default.Find<LinkPreview>().Match(p => p.Url == _harness.Peer.A + privatePath).ExecuteAnyAsync(token));
}
[Fact]
public async Task The_janitor_removes_stale_unattached_uploads_and_trims_the_proxy_cache_oldest_first()
{
var token = TestContext.Current.CancellationToken;
var root = Path.Combine(Path.GetTempPath(), $"privapub-janitor-{Guid.NewGuid():N}");
var options = new StaticOptions<MediaOptions>(new MediaOptions { Root = root, ProxyCacheBytes = 800 });
var media = new MediaService(options, _harness.Local, default, NullLogger<MediaService>.Instance);
var seeded = new List<string>();
try
{
MediaAttachment Upload(string name, DateTime created, string postId = default)
{
Directory.CreateDirectory(Path.Combine(root, "files"));
File.WriteAllBytes(Path.Combine(root, "files", name), new byte[] { 1, 2, 3 });
File.WriteAllBytes(Path.Combine(root, "files", "small-" + name), new byte[] { 1 });
return new MediaAttachment { FilePath = Path.Combine("files", name), PreviewPath = Path.Combine("files", "small-" + name), CreatedAt = created, PostId = postId };
}
var stale = Upload("stale.webp", DateTime.UtcNow.AddDays(-2));
var fresh = Upload("fresh.webp", DateTime.UtcNow.AddHours(-1));
var attached = Upload("attached.webp", DateTime.UtcNow.AddDays(-3), "000000000000000000000001");
await DB.Default.SaveAsync(new[] { stale, fresh, attached }, token);
seeded.AddRange(new[] { stale.ID, fresh.ID, attached.ID });
Directory.CreateDirectory(media.ProxyRoot);
FileInfo Cached(string name, int bytes, int minutesAgo)
{
var path = Path.Combine(media.ProxyRoot, name);
File.WriteAllBytes(path, new byte[bytes]);
File.WriteAllText(path + ".type", "image/png");
File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddMinutes(-minutesAgo));
return new FileInfo(path);
}
var oldest = Cached("a", 600, 30);
var older = Cached("b", 400, 20);
var newest = Cached("c", 300, 10);
await new MediaJanitor(media, options, NullLogger<MediaJanitor>.Instance).Sweep(token);
Assert.False(await DB.Default.Find<MediaAttachment>().Match(m => m.ID == stale.ID).ExecuteAnyAsync(token));
Assert.False(File.Exists(Path.Combine(root, stale.FilePath)));
Assert.False(File.Exists(Path.Combine(root, stale.PreviewPath)));
Assert.True(await DB.Default.Find<MediaAttachment>().Match(m => m.ID == fresh.ID).ExecuteAnyAsync(token));
Assert.True(await DB.Default.Find<MediaAttachment>().Match(m => m.ID == attached.ID).ExecuteAnyAsync(token));
Assert.True(File.Exists(Path.Combine(root, fresh.FilePath)));
Assert.False(File.Exists(oldest.FullName));
Assert.False(File.Exists(oldest.FullName + ".type"));
Assert.True(File.Exists(older.FullName));
Assert.True(File.Exists(older.FullName + ".type"));
Assert.True(File.Exists(newest.FullName));
}
finally
{
await DB.Default.DeleteAsync<MediaAttachment>(m => seeded.Contains(m.ID));
foreach (var directory in new[] { root, media.ProxyRoot }.Where(Directory.Exists))
Directory.Delete(directory, recursive: true);
}
}
}
[Trait("Category", "Integration")]
public sealed class OAuthPrunerTests
{
[Fact]
public async Task Old_tokens_and_authorizations_that_are_no_longer_valid_are_pruned_and_the_rest_kept()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var host = await PrivaPubHost.Shared();
var now = DateTimeOffset.UtcNow;
using var scope = host.Services.CreateScope();
var tokens = scope.ServiceProvider.GetRequiredService<IOpenIddictTokenManager>();
var authorizations = scope.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>();
var subject = $"pruned{Guid.NewGuid():N}";
async Task<string> Token(DateTimeOffset created, DateTimeOffset expires, string status) =>
await tokens.GetIdAsync(await tokens.CreateAsync(new OpenIddictTokenDescriptor
{
CreationDate = created, ExpirationDate = expires, Status = status, Subject = subject, Type = OpenIddictConstants.TokenTypeHints.AccessToken
}, token), token);
async Task<string> Authorization(DateTimeOffset created, string status) =>
await authorizations.GetIdAsync(await authorizations.CreateAsync(new OpenIddictAuthorizationDescriptor
{
CreationDate = created, Status = status, Subject = subject, Type = OpenIddictConstants.AuthorizationTypes.Permanent
}, token), token);
var expired = await Token(now.AddDays(-30), now.AddDays(-29), OpenIddictConstants.Statuses.Valid);
var revoked = await Token(now.AddDays(-30), now.AddDays(1), OpenIddictConstants.Statuses.Revoked);
var current = await Token(now.AddMinutes(-5), now.AddHours(1), OpenIddictConstants.Statuses.Valid);
var recentlyRevoked = await Token(now.AddDays(-1), now.AddDays(-1).AddMinutes(1), OpenIddictConstants.Statuses.Revoked);
var oldRevoked = await Authorization(now.AddDays(-30), OpenIddictConstants.Statuses.Revoked);
var oldValid = await Authorization(now.AddDays(-30), OpenIddictConstants.Statuses.Valid);
var (prunedTokens, prunedAuthorizations) = await new OAuthPruner(host.Services, NullLogger<OAuthPruner>.Instance).Prune(now.AddDays(-14), token);
Assert.True(prunedTokens >= 2, $"{prunedTokens} tokens pruned");
Assert.True(prunedAuthorizations >= 1, $"{prunedAuthorizations} authorizations pruned");
//a fresh scope: the managers cache what they created
using var after = host.Services.CreateScope();
tokens = after.ServiceProvider.GetRequiredService<IOpenIddictTokenManager>();
authorizations = after.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>();
Assert.Null(await tokens.FindByIdAsync(expired, token));
Assert.Null(await tokens.FindByIdAsync(revoked, token));
Assert.NotNull(await tokens.FindByIdAsync(current, token));
Assert.NotNull(await tokens.FindByIdAsync(recentlyRevoked, token));
Assert.Null(await authorizations.FindByIdAsync(oldRevoked, token));
Assert.NotNull(await authorizations.FindByIdAsync(oldValid, token));
}
}
[Xunit.Collection(nameof(Exclusive))]
[Trait("Category", "Integration")]
public sealed class DeliveryOutcomeTests : IAsyncLifetime
{
static readonly string[] PeerHosts = { "localhost", "127.0.0.1" };
Harness _harness;
WebApplication _hinting;
string _hintingBase;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
await DB.Default.DeleteAsync<RemoteInstance>(i => PeerHosts.Contains(i.Host));
var builder = WebApplication.CreateSlimBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
_hinting = builder.Build();
//answers /{status}/{retry-after}: the peer cannot send a Retry-After
_hinting.Run(context =>
{
var parts = context.Request.Path.Value!.Trim('/').Split('/');
context.Response.StatusCode = int.Parse(parts[0]);
if (parts.Length > 1)
context.Response.Headers.RetryAfter = parts[1] == "date"
? DateTimeOffset.UtcNow.AddMinutes(10).ToString("r")
: parts[1];
return Task.CompletedTask;
});
await _hinting.StartAsync();
_hintingBase = _hinting.Urls.First();
}
public async ValueTask DisposeAsync()
{
if (_hinting != default)
await _hinting.DisposeAsync();
if (_harness == default)
return;
await _harness.DisposeAsync();
await DB.Default.DeleteAsync<RemoteInstance>(i => PeerHosts.Contains(i.Host));
}
async Task<JobOutcome> Attempt(LocalActor signer, string inbox, int attempts = 1)
{
var token = TestContext.Current.CancellationToken;
var id = $"{Harness.Base}/a/{Guid.NewGuid():N}";
await _harness.Delivery.Enqueue(signer, new[] { inbox }, new JsonObject
{
["id"] = id, ["type"] = "Create", ["actor"] = signer.Uri, ["to"] = new JsonArray(Addressing.Public),
["object"] = new JsonObject { ["type"] = "Note", ["content"] = "hi" }
}, token);
var job = await DB.Default.Find<Job>().Match(j => j.DedupeKey == $"{id}|{inbox}").ExecuteSingleAsync(token);
job.Attempts = attempts;
var handler = new DeliveryJobHandler(_harness.Local, Peer.Http(), new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions())),
NullLogger<DeliveryJobHandler>.Instance);
return await handler.Handle(job, token);
}
[Fact]
public async Task A_delivery_carries_a_valid_digest_and_a_signature_by_the_signer()
{
var (_, alice) = await _harness.Persona("alice");
_harness.Peer.Answer("/ok/inbox", 202);
var outcome = await Attempt(alice, _harness.Peer.A + "/ok/inbox");
Assert.Equal(JobResult.Done, outcome.Result);
var received = Assert.Single(_harness.Peer.Requests, r => r.Path == "/ok/inbox");
Assert.Equal("POST", received.Method);
Assert.Equal(alice.Uri, JsonNode.Parse(received.Body)!["actor"]!.GetValue<string>());
Assert.Equal(HttpSignatures.Digest(Encoding.UTF8.GetBytes(received.Body)), received.Headers["Digest"]);
var signature = HttpSignatures.Parse(received.Signature);
Assert.Equal(alice.KeyId, signature.KeyId);
Assert.Equal(new[] { "(request-target)", "host", "date", "digest" }, signature.Headers);
var signingString = $"(request-target): post /ok/inbox\nhost: {received.Headers["Host"]}\ndate: {received.Headers["Date"]}\ndigest: {received.Headers["Digest"]}";
using var key = RSA.Create();
key.ImportFromPem(alice.PublicKeyPem);
Assert.True(key.VerifyData(Encoding.UTF8.GetBytes(signingString), signature.Signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
}
[Fact]
public async Task Refusals_are_dead_hints_defer_and_failures_retry()
{
var (_, alice) = await _harness.Persona("alice");
_harness.Peer.Answer("/gone/inbox", 410);
_harness.Peer.Answer("/missing/inbox", 404);
_harness.Peer.Answer("/broken/inbox", 500);
var gone = await Attempt(alice, _harness.Peer.A + "/gone/inbox");
var missing = await Attempt(alice, _harness.Peer.A + "/missing/inbox");
var busy = await Attempt(alice, _hintingBase + "/429/120");
var down = await Attempt(alice, _hintingBase + "/503/date");
var unhinted = await Attempt(alice, _hintingBase + "/503");
var broken = await Attempt(alice, _harness.Peer.A + "/broken/inbox");
Assert.Equal(JobResult.Dead, gone.Result);
Assert.StartsWith("410", gone.Error);
Assert.Equal(JobResult.Dead, missing.Result);
Assert.Equal(JobResult.Defer, busy.Result);
Assert.InRange(busy.RetryAt!.Value, DateTime.UtcNow.AddSeconds(100), DateTime.UtcNow.AddSeconds(125));
Assert.Equal(JobResult.Defer, down.Result);
Assert.InRange(down.RetryAt!.Value, DateTime.UtcNow.AddMinutes(9), DateTime.UtcNow.AddMinutes(11));
Assert.Equal(JobResult.Retry, unhinted.Result);
Assert.Equal(JobResult.Retry, broken.Result);
Assert.StartsWith("500", broken.Error);
}
// a server that fails on one activity is up: its 500s are that delivery's to retry, and only a gateway's 502, 503 or
// 504 counts towards taking the host out (Smithereen's 500s on documents it could not read once held every delivery
// to it back for an hour)
[Fact]
public async Task Only_a_gateways_failures_take_the_host_out()
{
var (_, alice) = await _harness.Persona("alice");
_harness.Peer.Answer("/broken/inbox", 500);
_harness.Peer.Answer("/gateway/inbox", 502);
for (var i = 0; i < HostCircuitBreaker.Threshold; i++)
await Attempt(alice, _harness.Peer.A + "/broken/inbox");
var stillTried = await Attempt(alice, _harness.Peer.A + "/broken/inbox");
for (var i = 0; i < HostCircuitBreaker.Threshold; i++)
await Attempt(alice, _harness.Peer.A + "/gateway/inbox");
var heldBack = await Attempt(alice, _harness.Peer.A + "/broken/inbox");
Assert.Equal((JobResult.Retry, "500"), (stillTried.Result, stillTried.Error[..3]));
Assert.Equal((JobResult.Defer, "the host is unavailable"), (heldBack.Result, heldBack.Error));
}
// Mastodon's 422 for two first contacts racing to create one account, and its 409 for a held lock, are retried a
// few times; the third refusal is final like any other
[Fact]
public async Task Conflicts_and_unprocessable_answers_are_retried_twice_then_dead()
{
var (_, alice) = await _harness.Persona("alice");
_harness.Peer.Answer("/racing/inbox", 422);
_harness.Peer.Answer("/locked/inbox", 409);
Assert.Equal(JobResult.Retry, (await Attempt(alice, _harness.Peer.A + "/racing/inbox")).Result);
Assert.Equal(JobResult.Retry, (await Attempt(alice, _harness.Peer.A + "/locked/inbox", attempts: 2)).Result);
var final = await Attempt(alice, _harness.Peer.A + "/racing/inbox", attempts: 3);
Assert.Equal(JobResult.Dead, final.Result);
Assert.StartsWith("422", final.Error);
}
}
}
-106
View File
@@ -1,106 +0,0 @@
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));
}
}
}
@@ -1,220 +0,0 @@
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
public class NoteParserTests
{
const string MastodonNote = """
{
"id": "https://m.example/users/alice/statuses/1",
"type": "Note",
"summary": "spoilers",
"inReplyTo": "https://privapub.test/peasants/bob/posts/abc",
"published": "2026-09-30T10:00:00Z",
"url": "https://m.example/@alice/1",
"attributedTo": "https://m.example/users/alice",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://m.example/users/alice/followers", "https://privapub.test/peasants/bob"],
"sensitive": true,
"conversation": "tag:m.example,2026-09-30:objectId=1:objectType=Conversation",
"context": "https://m.example/contexts/1",
"content": "<p><span class=\"h-card\"><a href=\"https://privapub.test/peasants/bob\" class=\"u-url mention\">@<span>bob</span></a></span> hi <a href=\"https://m.example/tags/Fedi\" class=\"mention hashtag\" rel=\"tag\">#<span>Fedi</span></a><script>x</script></p>",
"contentMap": { "en": "<p>hi</p>" },
"attachment": [{
"type": "Document", "mediaType": "image/png", "url": "https://files.m.example/a.png",
"name": "a cat", "blurhash": "UBL_:rOpGG-oBUNG,qRj2so|=eE1w^n4S5NH", "focalPoint": [0.5, -2.0], "width": 640, "height": 480
}],
"tag": [
{ "type": "Mention", "href": "https://privapub.test/peasants/bob", "name": "@bob@privapub.test" },
{ "type": "Hashtag", "href": "https://m.example/tags/fedi", "name": "#Fedi" },
{ "type": "Emoji", "name": ":blob:", "icon": { "type": "Image", "url": "https://files.m.example/blob.png" } }
]
}
""";
// Castopod's episode: its words in description, its sound in audio, the sound's name for a title
[Fact]
public void A_podcast_episode_is_read_with_its_words_sound_and_cover()
{
var episode = NoteParser.Parse(JsonNode.Parse("""
{"id":"https://castopod.test/@pod/episodes/one","type":"PodcastEpisode","published":"2026-10-06T09:08:17+00:00",
"to":["https://www.w3.org/ns/activitystreams#Public"],"attributedTo":"https://castopod.test/@pod",
"description":{"type":"Note","mediaType":"text/markdown","content":"Listen to the **meadow**","contentMap":{"en":"<p>Listen to the meadow</p>"}},
"image":{"type":"Image","mediaType":"image/png","url":"https://castopod.test/media/cover.png"},
"audio":{"id":"https://castopod.test/audio/one.mp3","type":"Audio","name":"Episode one","size":16791,"duration":4.02,
"url":{"href":"https://castopod.test/audio/one.mp3","type":"Link","mediaType":"audio/mpeg"}}}
"""));
Assert.Equal(("PodcastEpisode", "Episode one", "https://castopod.test/media/cover.png"), (episode.Type, episode.Title, episode.CoverURL));
Assert.Contains("<strong>meadow</strong>", episode.ContentHtml);
var sound = Assert.Single(episode.Attachments);
Assert.Equal(("https://castopod.test/audio/one.mp3", "audio/mpeg"), (sound.RemoteURL, sound.ContentType));
Assert.Equal(4.02, episode.Audio.DurationSeconds);
}
[Fact]
public void Reads_a_mastodon_note()
{
var note = NoteParser.Parse(JsonNode.Parse(MastodonNote));
Assert.Equal("https://m.example/users/alice", note.AttributedTo);
Assert.Equal("spoilers", note.SpoilerText);
Assert.True(note.Sensitive);
Assert.Equal("en", note.Language);
Assert.Equal("https://m.example/@alice/1", note.Url);
Assert.Equal("https://m.example/contexts/1", note.Context);
Assert.DoesNotContain("<script>", note.ContentHtml);
Assert.Contains("u-url mention", note.ContentHtml);
Assert.Equal("https://privapub.test/peasants/bob", Assert.Single(note.Mentions).ActorURI);
Assert.Equal("fedi", Assert.Single(note.Tags));
var media = Assert.Single(note.Attachments);
Assert.Equal("a cat", media.Description);
Assert.Equal(new[] { 0.5f, -1f }, media.Focus);
Assert.Equal(new DateTime(2026, 9, 30, 10, 0, 0, DateTimeKind.Utc), note.Published);
Assert.Null(note.Updated);
}
// GoToSocial writes a lone tag, and lone to and cc, as plain values instead of arrays (captured by the town)
[Fact]
public void Reads_a_gotosocial_reply_whose_only_tag_and_audience_are_single_values()
{
var note = NoteParser.Parse(JsonNode.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "gotosocial",
"followers-only-reply-one-tag.json"))));
var mention = Assert.Single(note.Mentions);
Assert.Equal("https://privapub.test/peasants/flor_pp98", mention.ActorURI);
Assert.Equal(["https://gts.test/users/ines_gt59/followers"], note.To);
Assert.Equal(["https://privapub.test/peasants/flor_pp98"], note.Cc);
}
// PeerTube's video whose comments wait for approval names who may comment (FEP-5624), apart from GoToSocial's policy
[Fact]
public void Reads_who_may_reply_pending_approval()
{
var video = NoteParser.Parse(JsonNode.Parse("""
{"id":"https://peertube.test/videos/watch/1","type":"Video","name":"a video","attributedTo":["https://peertube.test/accounts/pt"],
"canReply":"https://www.w3.org/ns/activitystreams#Public","commentsEnabled":true}
"""));
var open = NoteParser.Parse(JsonNode.Parse("""
{"id":"https://peertube.test/videos/watch/2","type":"Video","name":"an open video","attributedTo":["https://peertube.test/accounts/pt"],
"canReply":null,"commentsPolicy":1}
"""));
var note = NoteParser.Parse(JsonNode.Parse(MastodonNote));
Assert.Equal(["https://www.w3.org/ns/activitystreams#Public"], video.ReplyApprovals.Manual);
Assert.Null(video.ReplyPolicy);
Assert.Null(open.ReplyApprovals);
Assert.Null(note.ReplyApprovals);
}
// Pleroma's edited post carries its earlier versions (formerRepresentations, newest first)
[Fact]
public void Reads_the_earlier_versions_of_a_pleroma_edit()
{
var note = NoteParser.Parse(JsonNode.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "pleroma", "edited.json"))));
var before = Assert.Single(note.FormerRepresentations);
Assert.StartsWith("tomorrow library garden bridge", before.ContentHtml);
Assert.Equal(new DateTime(2026, 10, 5, 0, 49, 58, DateTimeKind.Utc).AddTicks(8_600_710), before.EditedAt);
Assert.Null(before.SpoilerText);
Assert.EndsWith("(edited)", note.ContentHtml);
}
[Fact]
public void Earlier_versions_come_oldest_first_each_read_as_the_post_is()
{
var note = NoteParser.Parse(JsonNode.Parse("""
{"id":"https://pl.example/objects/1","type":"Note","attributedTo":"https://pl.example/users/carol","content":"<p>third</p>",
"published":"2026-10-06T08:00:00Z","updated":"2026-10-06T09:00:00Z",
"formerRepresentations":{"type":"OrderedCollection","totalItems":2,"orderedItems":[
{"type":"Note","content":"<p>second</p>","summary":"careful","published":"2026-10-06T08:00:00Z","updated":"2026-10-06T08:30:00Z"},
{"type":"Note","content":"<p>first<script>x</script></p>","summary":"","published":"2026-10-06T08:00:00Z"}]}}
"""));
Assert.Equal(["<p>first</p>", "<p>second</p>"], note.FormerRepresentations.Select(r => r.ContentHtml));
Assert.Equal([new DateTime(2026, 10, 6, 8, 0, 0, DateTimeKind.Utc), new DateTime(2026, 10, 6, 8, 30, 0, DateTimeKind.Utc)],
note.FormerRepresentations.Select(r => r.EditedAt));
Assert.Equal([(default(string), false), ("careful", true)], note.FormerRepresentations.Select(r => (r.SpoilerText, r.HasContentWarning)));
}
[Fact]
public void Reads_a_lemmy_page_with_a_title()
{
var note = NoteParser.Parse(JsonNode.Parse("""
{
"id": "https://l.example/post/7", "type": "Page", "name": "A title",
"attributedTo": "https://l.example/u/carol", "audience": "https://l.example/c/cats",
"content": "<p>body</p>", "to": ["https://l.example/c/cats", "https://www.w3.org/ns/activitystreams#Public"],
"published": "2026-09-30T10:00:00.123456+00:00"
}
"""));
Assert.Equal("A title", note.Title);
Assert.Equal("https://l.example/c/cats", note.Audience);
Assert.False(note.Sensitive);
}
[Fact]
public void Falls_back_to_misskey_content_and_takes_the_person_from_a_peertube_attribution()
{
var note = NoteParser.Parse(JsonNode.Parse("""
{
"id": "https://k.example/notes/9", "type": "Note",
"attributedTo": [{ "type": "Group", "id": "https://k.example/c/chan" }, { "type": "Person", "id": "https://k.example/users/9" }],
"_misskey_content": "hello <b>there</b>\nnext", "_misskey_quote": "https://k.example/notes/8"
}
"""));
Assert.Equal("https://k.example/users/9", note.AttributedTo);
Assert.Equal("hello &lt;b&gt;there&lt;/b&gt;<br>next", note.ContentHtml);
Assert.Equal("https://k.example/notes/8", note.QuoteUri);
}
[Theory]
[InlineData("Article", false, false)]
[InlineData("Event", false, false)]
[InlineData("Video", false, false)]
[InlineData("Page", false, false)]
[InlineData("Note", false, true)]
[InlineData("Question", false, true)]
[InlineData("Article", true, true)]
public void A_summary_warns_only_on_notes_or_when_marked_sensitive(string type, bool sensitive, bool warns)
{
var note = NoteParser.Parse(JsonNode.Parse($$"""
{
"id": "https://w.example/?p=12", "type": "{{type}}", "name": "Twelve", "summary": "Saturday, 10:00 at the library",
"sensitive": {{(sensitive ? "true" : "false")}}, "attributedTo": "https://w.example/author", "content": "<p>body</p>"
}
"""));
Assert.Equal(warns, note.Sensitive);
Assert.Equal(warns ? "Saturday, 10:00 at the library" : null, note.SpoilerText);
Assert.Equal(warns ? null : "Saturday, 10:00 at the library", note.Excerpt);
}
[Theory]
[InlineData("""{ "id": "https://x.example/1", "type": "Person" }""")]
[InlineData("""{ "id": "not a uri", "type": "Note" }""")]
[InlineData("""[1, 2]""")]
public void Refuses_what_is_not_a_note(string json) =>
Assert.Null(NoteParser.Parse(JsonNode.Parse(json)));
}
public class AddressingTests
{
const string Followers = "https://m.example/users/alice/followers";
[Theory]
[InlineData(new[] { Addressing.Public }, new[] { Followers }, PostVisibility.Public)]
[InlineData(new[] { "as:Public" }, new string[0], PostVisibility.Public)]
[InlineData(new[] { Followers }, new[] { Addressing.Public }, PostVisibility.Unlisted)]
[InlineData(new[] { Followers }, new[] { "https://privapub.test/peasants/bob" }, PostVisibility.FollowersOnly)]
[InlineData(new[] { "https://privapub.test/peasants/bob" }, new string[0], PostVisibility.Direct)]
[InlineData(new[] { "https://m.example/users/mallory/followers" }, new string[0], PostVisibility.Direct)]
public void Classifies_like_mastodon(string[] to, string[] cc, PostVisibility expected) =>
Assert.Equal(expected, Addressing.Classify(to, cc, Followers));
}
}
@@ -1,36 +0,0 @@
using PrivaPub.Federation.Objects;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
public class ObjectRecordsTests
{
[Fact]
public void An_object_larger_than_the_cap_keeps_only_its_hash()
{
var big = new JsonObject { ["content"] = new string('x', ObjectRecords.MaxRawBytes + 1) };
var (text, hash, bytes, truncated) = ObjectRecords.Capture(big);
Assert.Null(text);
Assert.True(truncated);
Assert.True(bytes > ObjectRecords.MaxRawBytes);
Assert.Equal(64, hash.Length);
}
[Fact]
public void A_small_object_is_kept_whole_with_its_hash()
{
var small = new JsonObject { ["type"] = "Note", ["content"] = "hi" };
var (text, hash, bytes, truncated) = ObjectRecords.Capture(small);
Assert.Equal(small.ToJsonString(), text);
Assert.False(truncated);
Assert.Equal(text.Length, bytes);
Assert.Equal(64, hash.Length);
Assert.Equal(default, ObjectRecords.Capture(default));
}
}
}
@@ -1,299 +0,0 @@
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
public class ObjectShapesTests
{
static NoteDocument Parse(string json) => NoteParser.Parse(JsonNode.Parse(json));
[Fact]
public void Keeps_a_peertube_video_with_its_files_poster_captions_and_markdown_description()
{
var note = Parse("""
{
"id": "https://tube.example/videos/watch/1", "type": "Video", "name": "A *talk*", "duration": "PT6299S",
"mediaType": "text/markdown", "content": "**Bold** words", "sensitive": false, "views": 14771, "state": 1,
"isLiveBroadcast": false, "commentsPolicy": 2, "downloadEnabled": true,
"licence": { "identifier": "5", "name": "Attribution - Share Alike" }, "category": { "identifier": "2", "name": "Films" },
"attributedTo": [{ "type": "Person", "id": "https://tube.example/accounts/ann" }, { "type": "Group", "id": "https://tube.example/video-channels/films" }],
"icon": [
{ "type": "Image", "url": "https://tube.example/thumb-280.jpg", "mediaType": "image/jpeg", "width": 280, "height": 157 },
{ "type": "Image", "url": "https://tube.example/thumb-1920.jpg", "mediaType": "image/jpeg", "width": 1920, "height": 1080 },
{ "type": "Image", "url": "https://tube.example/thumb-850.jpg", "mediaType": "image/jpeg", "width": 850, "height": 480 }
],
"subtitleLanguage": [{ "identifier": "en", "url": [{ "mediaType": "text/vtt", "href": "https://tube.example/en.vtt" }] }],
"url": [
{ "type": "Link", "mediaType": "text/html", "href": "https://tube.example/w/abc" },
{ "type": "Link", "mediaType": "video/mp4", "href": "https://tube.example/720.mp4", "height": 720, "width": 1280, "fps": 24, "size": 942586378,
"attachment": [{ "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "audio" }, { "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "video" }] },
{ "type": "Link", "mediaType": "application/x-mpegURL", "href": "https://tube.example/master.m3u8" },
{ "type": "Link", "mediaType": "application/x-bittorrent;x-scheme-handler/magnet", "href": "magnet:?xt=urn:btih:abc" }
]
}
""");
Assert.Equal("https://tube.example/w/abc", note.Url);
Assert.Equal("A *talk*", note.Title);
Assert.Contains("<strong>Bold</strong>", note.ContentHtml);
Assert.Equal("https://tube.example/thumb-850.jpg", note.CoverURL);
Assert.Equal(6299, note.Video.DurationSeconds);
var file = Assert.Single(note.Video.Variants);
Assert.Equal(new[] { "audio", "video" }, file.Streams);
Assert.Equal(942586378, file.Size);
Assert.Equal("https://tube.example/master.m3u8", note.Video.PlaylistURL);
Assert.Equal(2, note.Video.CommentsPolicy);
Assert.Equal("Attribution - Share Alike", note.Video.Licence);
Assert.Equal("https://tube.example/video-channels/films", note.Video.ChannelURI);
Assert.Equal("https://tube.example/en.vtt", Assert.Single(note.Video.Captions).Url);
Assert.Equal("https://tube.example/accounts/ann", note.AttributedTo);
}
[Fact]
public void Keeps_a_mobilizon_event_with_its_time_place_and_online_link()
{
var note = Parse("""
{
"id": "https://events.example/events/9", "type": "Event", "name": "Repair café",
"summary": "<p>Saturday at the library</p>", "content": "<p>Bring broken things.</p>",
"startTime": "2026-10-03T10:00:00+02:00", "endTime": "2026-10-03T13:00:00+02:00", "timezone": "Europe/Rome",
"joinMode": "free", "participantCount": 12, "maximumAttendeeCapacity": 40, "ical:status": "CONFIRMED",
"location": { "type": "Place", "name": "Biblioteca", "latitude": 45.07, "longitude": 7.68,
"address": { "type": "PostalAddress", "streetAddress": "Via Roma 1", "addressLocality": "Torino", "addressCountry": "IT" } },
"attachment": [{ "type": "Link", "name": "Website", "href": "https://meet.example/repair" }],
"attributedTo": "https://events.example/@club"
}
""");
Assert.Null(note.SpoilerText);
Assert.False(note.Sensitive);
Assert.Equal("Saturday at the library", note.Excerpt);
Assert.Equal(new DateTime(2026, 10, 3, 8, 0, 0, DateTimeKind.Utc), note.Event.Start);
Assert.Equal("Via Roma 1, Torino, IT", Assert.Single(note.Event.Places).Address);
Assert.Equal("https://meet.example/repair", note.Event.OnlineURL);
Assert.Equal(40, note.Event.Capacity);
Assert.Equal("CONFIRMED", note.Event.Status);
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]
public void Keeps_a_lemmy_link_post_as_a_link_with_its_thumbnail()
{
var note = Parse("""
{
"id": "https://lemmy.example/post/5", "type": "Page", "name": "A good read", "attributedTo": "https://lemmy.example/u/bo",
"attachment": [{ "type": "Link", "href": "https://news.example/story", "mediaType": "text/html" }],
"image": { "type": "Image", "url": "https://lemmy.example/pictrs/thumb.jpg" },
"language": { "identifier": "en", "name": "English" }
}
""");
Assert.Equal("https://news.example/story", note.Link.Href);
Assert.Equal("https://lemmy.example/pictrs/thumb.jpg", note.Link.ImageURL);
Assert.False(note.Link.FromPublisher);
Assert.Empty(note.Attachments);
}
[Fact]
public void Takes_a_publisher_supplied_preview_from_a_link_attachment()
{
var note = Parse("""
{
"id": "https://m.example/statuses/1", "type": "Note", "attributedTo": "https://m.example/users/a", "content": "<p>look</p>",
"attachment": [{ "type": "Link", "href": "https://news.example/a",
"preview": { "type": "Article", "name": "Headline &amp; more", "summary": "<p>What happened</p>", "image": { "type": "Image", "url": "https://news.example/a.jpg" } } }]
}
""");
Assert.Equal("Headline & more", note.Link.Title);
Assert.Equal("What happened", note.Link.Description);
Assert.Equal("https://news.example/a.jpg", note.Link.ImageURL);
Assert.True(note.Link.FromPublisher);
}
[Fact]
public void Reads_media_in_every_shape_and_infers_what_is_missing()
{
var note = Parse("""
{
"id": "https://bsky.brid.gy/convert/ap/at://did:plc:x/post/1", "type": "Note", "attributedTo": "https://bsky.brid.gy/ap/did:plc:x",
"content": "<p>pics</p>",
"@context": ["https://www.w3.org/ns/activitystreams", { "@language": "de" }],
"attachment": [
{ "type": "Image", "url": "https://cdn.example/a.jpeg", "summary": "alt from summary" },
{ "type": "Document", "url": [{ "type": "Link", "href": "https://cdn.example/clip", "mediaType": "video/mp4" }],
"icon": { "type": "Image", "url": "https://cdn.example/clip.jpg" }, "duration": "PT12.4S", "sensitive": true },
{ "type": "Image", "url": "https://cdn.example/nameless" }
]
}
""");
Assert.Equal("de", note.Language);
Assert.Equal("image/jpeg", note.Attachments[0].ContentType);
Assert.Equal("alt from summary", note.Attachments[0].Description);
Assert.Equal("https://cdn.example/clip", note.Attachments[1].RemoteURL);
Assert.Equal("video/mp4", note.Attachments[1].ContentType);
Assert.Equal("https://cdn.example/clip.jpg", note.Attachments[1].RemotePreviewURL);
Assert.Equal(12.4, note.Attachments[1].DurationSeconds);
Assert.True(note.Attachments[1].Sensitive);
Assert.Equal("image/*", note.Attachments[2].ContentType);
}
[Fact]
public void Keeps_a_funkwhale_track_and_a_loops_poster()
{
var track = Parse("""
{
"id": "https://music.example/federation/music/uploads/1", "type": "Audio", "name": "Song", "attributedTo": "https://music.example/@band",
"summary": "#rock #live", "duration": 241, "position": 3, "album": "https://music.example/albums/2", "license": "https://creativecommons.org/licenses/by/4.0/",
"url": [{ "type": "Link", "mediaType": "text/html", "href": "https://music.example/library/tracks/1" },
{ "type": "Link", "mediaType": "audio/mpeg", "href": "https://music.example/listen/1.mp3", "bitrate": 320000, "size": 9640000 }]
}
""");
var loop = Parse("""
{
"id": "https://loops.example/v/1", "type": "Note", "attributedTo": "https://loops.example/@dancer", "content": "",
"preview": { "type": "Image", "url": { "href": "https://loops.example/poster.jpg", "mediaType": "image/jpeg" } },
"attachment": [{ "type": "Document", "mediaType": "video/mp4", "url": "https://loops.example/v/1.mp4", "width": 720, "height": 1280 }]
}
""");
Assert.Equal("#rock #live", track.Excerpt);
Assert.Null(track.SpoilerText);
Assert.Equal(241, track.Audio.DurationSeconds);
Assert.Equal(3, track.Audio.Position);
Assert.Equal("https://music.example/listen/1.mp3", Assert.Single(track.Audio.Variants).Url);
Assert.Equal("https://loops.example/poster.jpg", loop.CoverURL);
}
[Fact]
public void Keeps_custom_emoji_and_drops_ones_that_could_not_render()
{
var note = Parse("""
{
"id": "https://k.example/notes/1", "type": "Note", "attributedTo": "https://k.example/users/1", "content": ":blobcat: hi :bad:",
"tag": [
{ "type": "Emoji", "name": ":blobcat:", "updated": "2026-01-01T00:00:00Z", "icon": { "type": "Image", "mediaType": "image/png", "url": "https://k.example/emoji/blobcat.png" } },
{ "type": "Emoji", "name": ":blobcat:", "icon": { "type": "Image", "url": "https://k.example/emoji/duplicate.png" } },
{ "type": "Emoji", "name": ":bad name:", "icon": { "type": "Image", "url": "https://k.example/emoji/x.png" } },
{ "type": "Emoji", "name": ":nourl:", "icon": { "type": "Image", "url": "data:image/png;base64,AAAA" } }
]
}
""");
var emoji = Assert.Single(note.Emojis);
Assert.Equal("blobcat", emoji.Shortcode);
Assert.Equal("https://k.example/emoji/blobcat.png", emoji.URL);
}
[Fact]
public void Reads_polls_from_mastodon_misskey_akkoma_and_closed_ones()
{
var mastodon = Parse("""
{ "id": "https://m.example/q/1", "type": "Question", "attributedTo": "https://m.example/u", "content": "?", "endTime": "2026-10-02T00:00:00Z", "votersCount": 7,
"oneOf": [{ "type": "Note", "name": "a", "replies": { "type": "Collection", "totalItems": 4 } }, { "type": "Note", "name": "b", "replies": { "type": "Collection", "totalItems": 3 } }] }
""");
var misskey = Parse("""
{ "id": "https://k.example/q/1", "type": "Question", "attributedTo": "https://k.example/u", "_misskey_content": "?",
"anyOf": [{ "type": "Note", "name": "x", "_misskey_votes": 2 }, { "type": "Note", "name": "y", "_misskey_votes": 5 }] }
""");
var closed = Parse("""
{ "id": "https://g.example/q/1", "type": "Question", "attributedTo": "https://g.example/u", "content": "?", "closed": "2026-09-30T12:00:00Z",
"oneOf": [{ "type": "Note", "name": "p" }, { "type": "Note", "name": "q" }] }
""");
var akkoma = Parse("""
{ "id": "https://a.example/objects/1", "type": "Question", "attributedTo": "https://a.example/users/u", "content": "?", "closed": "2099-01-01T00:00:00.000000Z",
"oneOf": [{ "type": "Note", "name": "p" }, { "type": "Note", "name": "q" }] }
""");
Assert.False(mastodon.Poll.Multiple);
Assert.Equal(new[] { 4, 3 }, mastodon.Poll.Options.Select(o => o.Votes));
Assert.Equal(7, mastodon.Poll.VotersCount);
Assert.True(misskey.Poll.Multiple);
Assert.Equal(new[] { 2, 5 }, misskey.Poll.Options.Select(o => o.Votes));
Assert.Null(misskey.Poll.VotersCount);
Assert.Equal(new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), closed.Poll.ClosedAt);
Assert.Null(akkoma.Poll.ClosedAt);
Assert.Equal(new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc), akkoma.Poll.ExpiresAt);
}
[Fact]
public void Finds_playable_files_inside_an_hls_only_peertube_video_and_picks_one_with_sound()
{
var note = Parse("""
{
"id": "https://tube.example/videos/watch/2", "type": "Video", "name": "HLS only", "attributedTo": "https://tube.example/accounts/ann",
"url": [
{ "type": "Link", "mediaType": "text/html", "href": "https://tube.example/w/def" },
{ "type": "Link", "mediaType": "application/x-mpegURL", "href": "https://tube.example/master.m3u8", "tag": [
{ "type": "Link", "mediaType": "video/mp4", "href": "https://tube.example/1080-fragmented.mp4", "height": 1080,
"attachment": [{ "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "video" }, { "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "audio" }] },
{ "type": "Link", "mediaType": "video/mp4", "href": "https://tube.example/720-fragmented.mp4", "height": 720,
"attachment": [{ "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "video" }] },
{ "type": "Link", "mediaType": "video/mp4", "href": "https://tube.example/480-fragmented.mp4", "height": 480,
"attachment": [{ "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "video" }, { "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "audio" }] }
] }
]
}
""");
Assert.Equal(3, note.Video.Variants.Count);
Assert.Equal("https://tube.example/480-fragmented.mp4", MastodonMapper.BestVideo(note.Video.Variants).Url);
}
[Fact]
public void Normalises_hashtags_like_mastodon() =>
Assert.Equal("fedi".Normalize(System.Text.NormalizationForm.FormKC).ToLowerInvariant(), Assert.Single(Parse("""
{ "id": "https://x.example/1", "type": "Note", "attributedTo": "https://x.example/u", "content": "",
"tag": [{ "type": "Hashtag", "name": "#FEDI" }] }
""").Tags));
}
}
@@ -1,100 +0,0 @@
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);
}
}
}
-138
View File
@@ -1,138 +0,0 @@
using MongoDB.Entities;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Objects;
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
{
[Trait("Category", "Integration")]
public sealed class PollTests : 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 string NewId(RemoteActor actor, string kind) => $"{Origin(actor)}/{kind}/{Guid.NewGuid():N}";
async Task Follows(RemoteActor follower, string target) =>
await _harness.Deliver(follower, "/human-centipede", new JsonObject
{
["id"] = NewId(follower, "follows"), ["type"] = "Follow", ["actor"] = follower.Id, ["object"] = target
});
JsonObject Vote(RemoteActor voter, string question, string owner, string choice) => new()
{
["id"] = NewId(voter, "activities"), ["type"] = "Create", ["actor"] = voter.Id, ["to"] = new JsonArray(owner),
["object"] = new JsonObject
{
["id"] = NewId(voter, "votes"), ["type"] = "Note", ["name"] = choice, ["attributedTo"] = voter.Id,
["inReplyTo"] = question, ["to"] = new JsonArray(owner)
}
};
[Fact]
public async Task A_local_poll_goes_out_as_a_question_and_counts_each_remote_voter_once()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
await Follows(mallory, alice.Uri);
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "which?", Poll = new PollDraft(new[] { "tea", "coffee" }, 3600, false, false) }, token);
Assert.True(outcome.Ok);
var question = (await _harness.Outgoing(mallory.Id + "/inbox")).Single(a => a["type"]!.GetValue<string>() == "Create")["object"]!;
Assert.Equal("Question", question["type"]!.GetValue<string>());
Assert.Equal(new[] { "tea", "coffee" }, question["oneOf"]!.AsArray().Select(o => o!["name"]!.GetValue<string>()));
await _harness.Deliver(mallory, "/human-centipede", Vote(mallory, outcome.Post.ObjectURI, alice.Uri, "coffee"));
await _harness.Deliver(mallory, "/human-centipede", Vote(mallory, outcome.Post.ObjectURI, alice.Uri, "tea"));
var poll = (await DB.Default.Find<Post>().OneAsync(outcome.Post.ID, token)).Poll;
Assert.Equal(new[] { 0, 1 }, poll.Options.Select(o => o.Votes));
Assert.Equal(1, poll.VotersCount);
Assert.False(await DB.Default.Find<Post>().Match(p => p.InReplyToURI == outcome.Post.ObjectURI).ExecuteAnyAsync(token));
Assert.True(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.PollRefresh && j.Payload == outcome.Post.ID).ExecuteAnyAsync(token));
Assert.True(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.PollClose && j.Payload == outcome.Post.ID).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_persona_votes_on_a_remote_poll_and_a_count_refresh_is_not_an_edit()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
var questionId = NewId(mallory, "questions");
JsonObject Question(int first, int second) => new()
{
["id"] = questionId, ["type"] = "Question", ["attributedTo"] = mallory.Id, ["content"] = "<p>pick</p>",
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(alice.Uri),
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" }),
["endTime"] = DateTime.UtcNow.AddDays(1).ToString("O"),
["anyOf"] = new JsonArray(
new JsonObject { ["type"] = "Note", ["name"] = "red", ["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = first } },
new JsonObject { ["type"] = "Note", ["name"] = "blue", ["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = second } })
};
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = NewId(mallory, "activities"), ["type"] = "Create", ["actor"] = mallory.Id, ["object"] = Question(3, 1)
});
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == questionId).ExecuteFirstAsync(token);
Assert.True(post.Poll.Multiple);
Assert.True((await _harness.Polls.Vote(alice, post, new[] { 0, 1 }, token)).Ok);
Assert.False((await _harness.Polls.Vote(alice, post, new[] { 0 }, token)).Ok);
var votes = (await _harness.Outgoing(mallory.Id + "/inbox")).Where(a => a["type"]!.GetValue<string>() == "Create").Select(a => a["object"]!).ToList();
Assert.Equal(new[] { "red", "blue" }, votes.Select(v => v["name"]!.GetValue<string>()).Order().Reverse());
Assert.All(votes, v => Assert.Equal(questionId, v["inReplyTo"]!.GetValue<string>()));
Assert.All(votes, v => Assert.Null(v["published"]));
Assert.Equal(new[] { 0, 1 }, (await _harness.Polls.OwnVotes(post.ID, alice.Id, token)).Order());
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = NewId(mallory, "activities"), ["type"] = "Update", ["actor"] = mallory.Id, ["object"] = Question(9, 5)
});
var refreshed = await DB.Default.Find<Post>().OneAsync(post.ID, token);
Assert.Equal(new[] { 9, 5 }, refreshed.Poll.Options.Select(o => o.Votes));
Assert.Empty(refreshed.Revisions);
Assert.Null(refreshed.EditedAt);
}
[Fact]
public async Task Closing_a_poll_tells_its_voters_and_its_author()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var (_, bob) = await _harness.Persona("bob");
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "quick", Poll = new PollDraft(new[] { "yes", "no" }, 300, false, false) }, token);
Assert.True((await _harness.Polls.Vote(bob, outcome.Post, new[] { 0 }, token)).Ok);
Assert.False((await _harness.Polls.Vote(alice, outcome.Post, new[] { 0 }, token)).Ok);
var job = await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.PollClose && j.Payload == outcome.Post.ID).ExecuteFirstAsync(token);
await new PollCloseJob(_harness.Db, _harness.Local, _harness.Outbox).Handle(job, token);
Assert.NotNull((await DB.Default.Find<Post>().OneAsync(outcome.Post.ID, token)).Poll.ClosedAt);
var told = await DB.Default.Find<Notification>().Match(n => n.Type == NotificationType.Poll && n.PostId == outcome.Post.ID).ExecuteAsync(token);
Assert.Equal(new[] { alice.Id, bob.Id }.Order(), told.Select(n => n.AvatarId).Order());
Assert.False((await _harness.Polls.Vote(bob, await DB.Default.Find<Post>().OneAsync(outcome.Post.ID, token), new[] { 1 }, token)).Ok);
}
}
}
@@ -1,72 +0,0 @@
using MongoDB.Entities;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class ProvenanceTests : 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_delivered_post_keeps_its_raw_object_how_it_came_and_every_edit()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
var origin = new Uri(mallory.Id).GetLeftPart(UriPartial.Authority);
var note = new JsonObject
{
["id"] = $"{origin}/notes/{Guid.NewGuid():N}",
["type"] = "Note",
["attributedTo"] = mallory.Id,
["content"] = "<p>hi</p>",
["_misskey_quote"] = $"{origin}/notes/elsewhere",
["to"] = new JsonArray(Addressing.Public),
["cc"] = new JsonArray(alice.Uri),
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" })
};
var create = new JsonObject { ["id"] = $"{origin}/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = mallory.Id, ["object"] = note.DeepClone() };
await _harness.Deliver(mallory, "/human-centipede", create);
var edited = note.DeepClone().AsObject();
edited["content"] = "<p>hi again</p>";
edited["updated"] = DateTime.UtcNow.ToString("O");
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = $"{origin}/activities/{Guid.NewGuid():N}", ["type"] = "Update", ["actor"] = mallory.Id, ["object"] = edited
});
var record = await DB.Default.Find<ObjectRecord>().Match(r => r.ObjectURI == note["id"]!.GetValue<string>()).ExecuteFirstAsync(token);
Assert.NotNull(record);
Assert.Equal(ObjectPath.Delivered, record.Path);
Assert.False(record.Refetched);
Assert.Equal("shared", record.Inbox);
Assert.Equal(mallory.KeyId, record.KeyId);
Assert.Equal(create["id"]!.GetValue<string>(), record.ActivityId);
Assert.Contains("digest", record.SignedHeaders);
Assert.Equal("<p>hi</p>", JsonNode.Parse(record.Raw)!["content"]!.GetValue<string>());
Assert.Contains("legacy-quote", ObjectFeatures.Detect(JsonNode.Parse(record.Raw)!.AsObject()));
Assert.Equal("<p>hi again</p>", JsonNode.Parse(Assert.Single(record.Revisions).Raw)!["content"]!.GetValue<string>());
Assert.True(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.DescribeInstance && j.Payload == record.Host).ExecuteAnyAsync(token));
}
}
}
-221
View File
@@ -1,221 +0,0 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class QuoteTests : 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 string NewId(RemoteActor actor, string kind) => $"{Origin(actor)}/{kind}/{Guid.NewGuid():N}";
async Task<Post> Delivered(RemoteActor author, string mentioned, Action<JsonObject> shape = default)
{
var note = new JsonObject
{
["id"] = NewId(author, "notes"), ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = "<p>hello</p>",
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(mentioned),
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = mentioned, ["name"] = "@someone" })
};
shape?.Invoke(note);
await _harness.Deliver(author, "/human-centipede", new JsonObject { ["id"] = NewId(author, "activities"), ["type"] = "Create", ["actor"] = author.Id, ["object"] = note });
return await DB.Default.Find<Post>().Match(p => p.ObjectURI == note["id"]!.GetValue<string>()).ExecuteFirstAsync(TestContext.Current.CancellationToken);
}
string Stamp(RemoteActor quotedAuthor, string quoting, string quoted)
{
var path = $"/stamps/{Guid.NewGuid():N}";
var id = Origin(quotedAuthor) + path;
_harness.Peer.Serve(path, new JsonObject
{
["@context"] = "https://www.w3.org/ns/activitystreams", ["id"] = id, ["type"] = "QuoteAuthorization",
["attributedTo"] = quotedAuthor.Id, ["interactingObject"] = quoting, ["interactionTarget"] = quoted
}.ToJsonString());
return id;
}
[Fact]
public async Task A_misskey_quote_of_a_public_post_is_shown_and_counted()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var ann = new RemoteActor(_harness.Peer, "ann");
var kitty = new RemoteActor(_harness.Peer, "kitty");
var original = await Delivered(ann, alice.Uri);
var quoting = await Delivered(kitty, alice.Uri, n =>
{
n["_misskey_quote"] = original.ObjectURI;
n["content"] = $"<p>look<span class=\"quote-inline\"><br>RE: <a href=\"{original.ObjectURI}\">{original.ObjectURI}</a></span></p>";
});
Assert.Equal(QuoteState.Accepted, quoting.QuoteState);
Assert.Equal(original.ID, quoting.QuotedPostId);
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(original.ID, token)).QuotesCount);
var status = await new PrivaPub.Api.Mastodon.Mappers.MastodonMapper(_harness.Db, _harness.Local).Status(quoting, alice.Id, token);
Assert.Equal("accepted", status.Quote.State);
Assert.Equal(original.ID, status.Quote.QuotedStatus.Id);
Assert.DoesNotContain("RE:", status.Content);
}
[Fact]
public async Task A_stamped_quote_is_accepted_a_forged_one_is_not_and_revoking_takes_it_back()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var ann = new RemoteActor(_harness.Peer, "ann");
var bob = new RemoteActor(_harness.Peer, "bob");
var original = await Delivered(ann, alice.Uri);
var quotingId = NewId(bob, "notes");
var stamp = Stamp(ann, quotingId, original.ObjectURI);
var forged = Stamp(ann, NewId(bob, "notes"), original.ObjectURI);
var quoting = await Delivered(bob, alice.Uri, n => { n["id"] = quotingId; n["quote"] = original.ObjectURI; n["quoteAuthorization"] = stamp; });
var cheat = await Delivered(bob, alice.Uri, n => { n["quote"] = original.ObjectURI; n["quoteAuthorization"] = forged; });
var waiting = await Delivered(bob, alice.Uri, n => n["quote"] = original.ObjectURI);
Assert.Equal(QuoteState.Accepted, quoting.QuoteState);
Assert.Equal(QuoteState.Unauthorized, cheat.QuoteState);
Assert.Equal(QuoteState.Pending, waiting.QuoteState);
await _harness.Deliver(ann, "/human-centipede", new JsonObject
{
["id"] = NewId(ann, "activities"), ["type"] = "Delete", ["actor"] = ann.Id, ["object"] = stamp
});
Assert.Equal(QuoteState.Revoked, (await DB.Default.Find<Post>().OneAsync(quoting.ID, token)).QuoteState);
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(original.ID, token)).QuotesCount);
}
[Fact]
public async Task A_persona_asks_before_quoting_and_is_stamped_when_accepted()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var ann = new RemoteActor(_harness.Peer, "ann");
var original = await Delivered(ann, alice.Uri, n => n["interactionPolicy"] = new JsonObject
{
["canQuote"] = new JsonObject { ["automaticApproval"] = new JsonArray(Addressing.Public) }
});
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "this", QuotedStatusId = original.ID }, token);
Assert.Equal(QuoteState.Pending, outcome.Post.QuoteState);
var request = Assert.Single(await _harness.Outgoing(ann.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "QuoteRequest");
Assert.Equal(original.ObjectURI, request["object"]!.GetValue<string>());
Assert.Equal(original.ObjectURI, request["instrument"]!["quote"]!.GetValue<string>());
await _harness.Deliver(ann, "/human-centipede", new JsonObject
{
["id"] = NewId(ann, "activities"), ["type"] = "Accept", ["actor"] = ann.Id,
["object"] = request["id"]!.GetValue<string>(), ["result"] = Stamp(ann, outcome.Post.ObjectURI, original.ObjectURI)
});
var quoting = await DB.Default.Find<Post>().OneAsync(outcome.Post.ID, token);
Assert.Equal(QuoteState.Accepted, quoting.QuoteState);
Assert.NotNull(quoting.QuoteAuthorizationURI);
Assert.Contains(await _harness.Outgoing(ann.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Update"
&& a["object"]!["quoteAuthorization"]?.GetValue<string>() == quoting.QuoteAuthorizationURI);
}
[Fact]
public async Task A_legacy_post_is_quoted_without_asking_and_a_persona_that_allows_nobody_cannot_be_quoted()
{
var token = TestContext.Current.CancellationToken;
var (aliceRoot, alice) = await _harness.Persona("alice");
var (bobRoot, bob) = await _harness.Persona("bob");
var kitty = new RemoteActor(_harness.Peer, "kitty");
var legacy = await Delivered(kitty, alice.Uri);
bob.Settings.QuotePolicy = PrivaPub.Models.User.QuotePolicies.Nobody;
var bobs = (await _harness.Statuses.Publish(bob, new StatusDraft { Text = "mine" }, token)).Post;
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "nice", QuotedStatusId = legacy.ID }, token);
Assert.Equal(QuoteState.Accepted, outcome.Post.QuoteState);
Assert.False(outcome.Post.QuoteByConsent);
Assert.False((await _harness.Statuses.Publish(alice, new StatusDraft { Text = "and this", QuotedStatusId = bobs.ID }, token)).Ok);
Assert.True((await _harness.Statuses.Publish(bob, new StatusDraft { Text = "me again", QuotedStatusId = bobs.ID }, token)).Ok);
}
JsonObject Request(RemoteActor quoter, string quotedUri, string quotingId) => new()
{
["id"] = NewId(quoter, "activities"), ["type"] = "QuoteRequest", ["actor"] = quoter.Id, ["object"] = quotedUri,
["instrument"] = new JsonObject { ["id"] = quotingId, ["type"] = "Note", ["attributedTo"] = quoter.Id, ["quote"] = quotedUri, ["content"] = "<p>so true</p>" }
};
[Fact]
public async Task Anyone_may_quote_a_persona_by_default_and_the_licence_can_be_revoked()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
await _harness.Deliver(mallory, "/human-centipede", new JsonObject { ["id"] = NewId(mallory, "follows"), ["type"] = "Follow", ["actor"] = mallory.Id, ["object"] = alice.Uri });
var original = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "quote me" }, token)).Post;
var created = (await _harness.Outgoing(mallory.Id + "/inbox")).Single(a => a["type"]!.GetValue<string>() == "Create")["object"]!;
Assert.Equal(Addressing.Public, created["interactionPolicy"]!["canQuote"]!["automaticApproval"]![0]!.GetValue<string>());
var quotingId = NewId(mallory, "notes");
await _harness.Deliver(mallory, "/human-centipede", Request(mallory, original.ObjectURI, quotingId));
var accept = Assert.Single(await _harness.Outgoing(mallory.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Accept" && a["result"] != null);
var licence = accept["result"]!.GetValue<string>();
Assert.StartsWith(alice.Uri + "/parrot-licences/", licence);
var quoting = await Delivered(mallory, alice.Uri, n => { n["id"] = quotingId; n["quote"] = original.ObjectURI; n["quoteAuthorization"] = licence; });
Assert.Equal(QuoteState.Accepted, quoting.QuoteState);
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(original.ID, token)).QuotesCount);
Assert.True(await _harness.Quotes.RevokeLicence(original, quoting, token));
Assert.Equal(QuoteState.Revoked, (await DB.Default.Find<Post>().OneAsync(quoting.ID, token)).QuoteState);
Assert.Contains(await _harness.Outgoing(mallory.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Delete" && a["object"]!.GetValue<string>() == licence);
}
[Fact]
public async Task A_followers_only_persona_turns_a_stranger_down()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
alice.Settings.QuotePolicy = PrivaPub.Models.User.QuotePolicies.Followers;
var original = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "friends only quoting" }, token)).Post;
var stranger = new RemoteActor(_harness.Peer, "stranger");
await _harness.Deliver(stranger, "/human-centipede", Request(stranger, original.ObjectURI, NewId(stranger, "notes")));
Assert.Equal("Reject", Assert.Single(await _harness.Outgoing(stranger.Id + "/inbox"))["type"]!.GetValue<string>());
}
[Fact]
public async Task One_persona_quotes_another_with_a_licence()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var (_, bob) = await _harness.Persona("bob");
var original = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "public thought" }, token)).Post;
var outcome = await _harness.Statuses.Publish(bob, new StatusDraft { Text = "agreed", QuotedStatusId = original.ID }, token);
Assert.True(outcome.Ok);
Assert.Equal(QuoteState.Accepted, outcome.Post.QuoteState);
Assert.StartsWith(alice.Uri + "/parrot-licences/", outcome.Post.QuoteAuthorizationURI);
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(original.ID, token)).QuotesCount);
}
}
}
-122
View File
@@ -1,122 +0,0 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.Domain.Social;
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
{
public class ReactionNamesTests
{
[Theory]
[InlineData(":blobcat@k.example:", "blobcat")]
[InlineData("👍🏽", "👍🏽")]
[InlineData("❤️", "❤️")]
[InlineData("❤", "❤️")]
[InlineData("✌", "✌️")]
[InlineData("🔥\uFE0F", "🔥")]
[InlineData("nope", null)]
[InlineData("👍👍", null)]
[InlineData(":unknown:", null)]
public void Normalises_what_peers_send(string content, string expected) =>
Assert.Equal(expected, Reactions.Normalise(content, new List<CustomEmoji> { new() { Shortcode = "blobcat", URL = "https://k.example/e.png" } })?.Name);
}
[Trait("Category", "Integration")]
public sealed class ReactionTests : 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 string NewId(RemoteActor actor, string kind) => $"{Origin(actor)}/{kind}/{Guid.NewGuid():N}";
async Task<Post> LocalPost()
{
var (root, alice) = await _harness.Persona("alice");
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "react to me" }, TestContext.Current.CancellationToken);
return await DB.Default.Find<Post>().Match(p => p.GroupUserId == alice.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken);
}
[Fact]
public async Task Misskey_and_pleroma_reactions_are_kept_apart_from_favourites()
{
var token = TestContext.Current.CancellationToken;
var post = await LocalPost();
var misskey = new RemoteActor(_harness.Peer, "kitty");
var pleroma = new RemoteActor(_harness.Peer, "pleroma");
await _harness.Deliver(misskey, "/human-centipede", new JsonObject
{
["id"] = NewId(misskey, "likes"), ["type"] = "Like", ["actor"] = misskey.Id, ["object"] = post.ObjectURI, ["content"] = ":blobcat:",
["_misskey_reaction"] = ":blobcat:",
["tag"] = new JsonArray(new JsonObject { ["type"] = "Emoji", ["name"] = ":blobcat:", ["icon"] = new JsonObject { ["type"] = "Image", ["url"] = $"{Origin(misskey)}/emoji/blobcat.png" } })
});
await _harness.Deliver(misskey, "/human-centipede", new JsonObject
{
["id"] = NewId(misskey, "likes"), ["type"] = "Like", ["actor"] = misskey.Id, ["object"] = post.ObjectURI, ["content"] = "❤"
});
var fire = new JsonObject { ["id"] = NewId(pleroma, "activities"), ["type"] = "EmojiReact", ["actor"] = pleroma.Id, ["object"] = post.ObjectURI, ["content"] = "🔥" };
await _harness.Deliver(pleroma, "/human-centipede", fire);
var kept = await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID).ExecuteAsync(token);
Assert.Equal(new HashSet<string> { "blobcat", "🔥" }, kept.Select(r => r.Emoji).ToHashSet());
Assert.Equal($"{Origin(misskey)}/emoji/blobcat.png", kept.Single(r => r.Emoji == "blobcat").EmojiURL);
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
Assert.True(await DB.Default.Find<Notification>().Match(n => n.Type == NotificationType.Reaction && n.Emoji == "🔥").ExecuteAnyAsync(token));
await _harness.Deliver(pleroma, "/human-centipede", new JsonObject
{
["id"] = NewId(pleroma, "activities"), ["type"] = "Undo", ["actor"] = pleroma.Id, ["object"] = fire.DeepClone()
});
Assert.Equal("blobcat", Assert.Single(await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID).ExecuteAsync(token)).Emoji);
}
[Fact]
public async Task A_persona_reacts_to_a_remote_post_and_takes_it_back()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
var noteId = NewId(mallory, "notes");
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = NewId(mallory, "activities"), ["type"] = "Create", ["actor"] = mallory.Id,
["object"] = new JsonObject
{
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = mallory.Id, ["content"] = "<p>hi</p>",
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(alice.Uri),
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" })
}
});
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(token);
Assert.True(await _harness.Reactions.React(alice, post, "🎉", token));
Assert.False(await _harness.Reactions.React(alice, post, "not an emoji", token));
Assert.True(await _harness.Reactions.Unreact(alice, post, "🎉", token));
var sent = await _harness.Outgoing(mallory.Id + "/inbox");
var react = Assert.Single(sent, a => a["type"]!.GetValue<string>() == "EmojiReact");
Assert.Equal("🎉", react["content"]!.GetValue<string>());
Assert.Equal(noteId, react["object"]!.GetValue<string>());
Assert.Equal(react["id"]!.GetValue<string>(), Assert.Single(sent, a => a["type"]!.GetValue<string>() == "Undo")["object"]!["id"]!.GetValue<string>());
}
}
}
-143
View File
@@ -1,143 +0,0 @@
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));
}
}
}
@@ -1,254 +0,0 @@
using Microsoft.Extensions.Caching.Memory;
using PrivaPub.Federation.Actors;
using PrivaPub.Models;
using PrivaPub.StaticServices;
using PrivaPub.Tests.Support;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class RemoteActorServiceTests : IAsyncLifetime
{
Peer _peer;
RemoteActorService _service;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_peer = await Peer.Start();
var cache = new MemoryCache(new MemoryCacheOptions());
var local = new LocalActorService(new DbEntities(),
new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = "https://privapub.test" }));
_service = new RemoteActorService(Peer.Http(cache), local, cache, new DbEntities());
}
public async ValueTask DisposeAsync()
{
if (_peer != default)
await _peer.DisposeAsync();
}
[Fact]
public async Task A_stored_actor_remembers_the_extensions_its_document_uses()
{
var bob = new RemoteActor(_peer, "bob");
var stored = await _service.GetActor(bob.Id, refresh: true, TestContext.Current.CancellationToken);
Assert.Contains("key:rsa-2048", stored.Features);
}
static string Actor(string id, string keyId = default, string owner = default, string pem = default, string type = "Person") =>
new JsonObject
{
["id"] = id,
["type"] = type,
["preferredUsername"] = id[(id.LastIndexOf('/') + 1)..],
["inbox"] = id + "/inbox",
["publicKey"] = new JsonObject
{
["id"] = keyId ?? id + "#main-key",
["owner"] = owner ?? id,
["publicKeyPem"] = pem ?? Keys.NewKeyPair().PublicKeyPem
}
}.ToJsonString();
string Unique(string name) => $"/users/{name}{Guid.NewGuid():N}";
[Fact]
public async Task Accepts_an_actor_whose_document_names_its_own_address()
{
var path = Unique("alice");
_peer.Serve(path, Actor("{A}" + path));
var actor = await _service.GetActorByKeyId($"{_peer.A}{path}#main-key", refresh: false, TestContext.Current.CancellationToken);
Assert.NotNull(actor);
Assert.Equal(_peer.A + path, actor.ActorURI);
Assert.Equal($"{_peer.A}{path}#main-key", actor.PublicKeyId);
}
[Fact]
public async Task Refuses_a_document_that_claims_another_origin()
{
var victim = Unique("bob");
var mallory = Unique("mallory");
_peer.Serve(mallory, Actor("{B}" + victim));
var actor = await _service.GetActor(_peer.A + mallory, refresh: false, TestContext.Current.CancellationToken);
var byKey = await _service.GetActorByKeyId($"{_peer.B}{victim}#main-key", refresh: false, TestContext.Current.CancellationToken);
Assert.Null(actor);
Assert.Null(byKey);
}
[Fact]
public async Task Refuses_a_key_owned_by_someone_else()
{
var eve = Unique("eve");
_peer.Serve(eve, Actor("{A}" + eve, owner: "{A}/users/alice"));
Assert.Null(await _service.GetActorByKeyId($"{_peer.A}{eve}#main-key", refresh: false, TestContext.Current.CancellationToken));
}
[Fact]
public async Task Refuses_a_key_document_whose_owner_is_on_another_origin()
{
var alice = Unique("alice");
var key = $"/keys/{Guid.NewGuid():N}";
_peer.Serve(alice, Actor("{B}" + alice));
_peer.Serve(key, new JsonObject { ["id"] = "{A}" + key, ["owner"] = "{B}" + alice, ["publicKeyPem"] = Keys.NewKeyPair().PublicKeyPem }.ToJsonString());
Assert.Null(await _service.GetActorByKeyId(_peer.A + key, refresh: false, TestContext.Current.CancellationToken));
}
[Fact]
public async Task Refuses_a_key_the_actor_does_not_list()
{
var alice = Unique("alice");
_peer.Serve(alice, Actor("{A}" + alice));
Assert.Null(await _service.GetActorByKeyId($"{_peer.A}{alice}#other-key", refresh: false, TestContext.Current.CancellationToken));
}
[Fact]
public async Task Follows_a_same_origin_alias_to_the_actor_it_names()
{
var alice = Unique("alice");
var alias = $"/@alias{Guid.NewGuid():N}";
_peer.Serve(alice, Actor("{A}" + alice));
_peer.Serve(alias, Actor("{A}" + alice));
var actor = await _service.GetActor(_peer.A + alias, refresh: false, TestContext.Current.CancellationToken);
Assert.NotNull(actor);
Assert.Equal(_peer.A + alice, actor.ActorURI);
}
[Fact]
public async Task Resolves_a_gotosocial_style_key_address()
{
var gts = Unique("gts");
var keyPath = gts + "/main-key";
var pem = Keys.NewKeyPair().PublicKeyPem;
_peer.Serve(gts, Actor("{A}" + gts, keyId: "{A}" + keyPath, pem: pem));
_peer.Serve(keyPath, Actor("{A}" + gts, keyId: "{A}" + keyPath, pem: pem));
var actor = await _service.GetActorByKeyId(_peer.A + keyPath, refresh: false, TestContext.Current.CancellationToken);
Assert.NotNull(actor);
Assert.Equal(_peer.A + gts, actor.ActorURI);
Assert.Equal(pem, actor.PublicKey);
}
[Fact]
public async Task A_rotated_key_replaces_the_stored_one_but_only_once_per_interval()
{
var alice = Unique("alice");
var keyId = $"{_peer.A}{alice}#main-key";
var first = Keys.NewKeyPair().PublicKeyPem;
var second = Keys.NewKeyPair().PublicKeyPem;
var third = Keys.NewKeyPair().PublicKeyPem;
_peer.Serve(alice, Actor("{A}" + alice, pem: first));
var token = TestContext.Current.CancellationToken;
Assert.Equal(first, (await _service.GetActorByKeyId(keyId, refresh: false, token)).PublicKey);
_peer.Serve(alice, Actor("{A}" + alice, pem: second));
Assert.Equal(first, (await _service.GetActorByKeyId(keyId, refresh: true, token)).PublicKey);
var fresh = new RemoteActorService(Peer.Http(), new LocalActorService(new DbEntities(),
new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = "https://privapub.test" })),
new MemoryCache(new MemoryCacheOptions()), new DbEntities());
Assert.Equal(second, (await fresh.GetActorByKeyId(keyId, refresh: true, token)).PublicKey);
_peer.Serve(alice, Actor("{A}" + alice, pem: third));
Assert.Equal(second, (await fresh.GetActorByKeyId(keyId, refresh: true, token)).PublicKey);
Assert.Single(await new DbEntities().ForeignAvatars.Match(a => a.ActorURI == _peer.A + alice).ExecuteAsync(token));
}
[Fact]
public async Task Signs_every_fetch()
{
var alice = Unique("alice");
_peer.Serve(alice, Actor("{A}" + alice));
await _service.GetActor(_peer.A + alice, refresh: false, TestContext.Current.CancellationToken);
var request = Assert.Single(_peer.Requests, r => r.Path == alice);
Assert.Contains("keyId=\"https://privapub.test/peasants/privapub#main-key\"", request.Signature);
}
}
public class ActorDocumentTests
{
[Fact]
public void Reads_a_mastodon_actor()
{
var document = ActorDocument.Parse(JsonDocument.Parse("""
{
"@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"],
"id": "https://mastodon.example/users/alice",
"type": "Person",
"preferredUsername": "alice",
"name": "Alice",
"inbox": "https://mastodon.example/users/alice/inbox",
"outbox": "https://mastodon.example/users/alice/outbox",
"url": "https://mastodon.example/@alice",
"discoverable": false,
"endpoints": { "sharedInbox": "https://mastodon.example/inbox" },
"icon": { "type": "Image", "url": "https://files.mastodon.example/a.png" },
"publicKey": {
"id": "https://mastodon.example/users/alice#main-key",
"owner": "https://mastodon.example/users/alice",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIB\n-----END PUBLIC KEY-----\n"
}
}
""").RootElement);
Assert.Equal("alice", document.PreferredUsername);
Assert.Equal("https://mastodon.example/inbox", document.SharedInbox);
Assert.Equal("https://files.mastodon.example/a.png", document.Icon);
Assert.False(document.Discoverable);
Assert.NotNull(document.Key("https://mastodon.example/users/alice#main-key"));
}
[Fact]
public void Keeps_only_keys_owned_by_the_actor_on_its_origin()
{
var document = ActorDocument.Parse(JsonDocument.Parse("""
{
"id": "https://a.example/users/alice",
"type": "Person",
"publicKey": [
{ "id": "https://a.example/users/alice#main-key", "owner": "https://a.example/users/alice", "publicKeyPem": "x" },
{ "id": "https://b.example/keys/1", "owner": "https://a.example/users/alice", "publicKeyPem": "y" },
{ "id": "https://a.example/users/alice#other", "owner": "https://a.example/users/bob", "publicKeyPem": "z" },
{ "id": "https://a.example/users/alice#unowned", "publicKeyPem": "w" }
]
}
""").RootElement);
Assert.Equal("https://a.example/users/alice#main-key", Assert.Single(document.Keys).Id);
}
[Fact]
public void Refuses_an_inbox_on_another_origin()
{
var document = ActorDocument.Parse(JsonDocument.Parse("""
{ "id": "https://a.example/users/alice", "type": "Person", "inbox": "https://b.example/inbox" }
""").RootElement);
Assert.Null(document.Inbox);
}
[Theory]
[InlineData("Note")]
[InlineData("Tombstone")]
[InlineData("Collection")]
public void Refuses_non_actor_types(string type) =>
Assert.Null(ActorDocument.Parse(JsonDocument.Parse($$"""{ "id": "https://a.example/x", "type": "{{type}}" }""").RootElement));
}
}
@@ -1,38 +0,0 @@
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
namespace PrivaPub.Tests.Federation
{
public class RemoteEditsTests
{
static readonly DateTime Published = new(2026, 10, 3, 10, 40, 1, DateTimeKind.Utc);
static Post Stored(DateTime? editedAt = default) => new() { CreationDate = Published, EditedAt = editedAt, ContentHtml = "<p>before</p>" };
[Fact]
public void A_newer_update_is_an_edit_and_an_update_without_a_date_is_a_refresh()
{
Assert.True(RemoteEdits.IsEdit(new NoteDocument { Updated = Published.AddSeconds(1), ContentHtml = "<p>before</p>" }, Stored()));
Assert.False(RemoteEdits.IsEdit(new NoteDocument { ContentHtml = "<p>after</p>" }, Stored()));
Assert.False(RemoteEdits.IsEdit(new NoteDocument { Updated = Published.AddSeconds(-1), ContentHtml = "<p>after</p>" }, Stored()));
}
[Fact]
public void An_edit_in_the_second_the_post_was_published_counts_when_the_text_changed()
{
Assert.True(RemoteEdits.IsEdit(new NoteDocument { Updated = Published, ContentHtml = "<p>after</p>" }, Stored()));
Assert.True(RemoteEdits.IsEdit(new NoteDocument { Updated = Published, ContentHtml = "<p>before</p>", SpoilerText = "now with a warning" }, Stored()));
Assert.False(RemoteEdits.IsEdit(new NoteDocument { Updated = Published, ContentHtml = "<p>before</p>" }, Stored()));
}
[Fact]
public void After_a_first_edit_only_a_newer_one_counts()
{
var edited = Published.AddMinutes(1);
Assert.False(RemoteEdits.IsEdit(new NoteDocument { Updated = edited, ContentHtml = "<p>again</p>" }, Stored(edited)));
Assert.True(RemoteEdits.IsEdit(new NoteDocument { Updated = edited.AddSeconds(1), ContentHtml = "<p>again</p>" }, Stored(edited)));
}
}
}
@@ -1,117 +0,0 @@
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));
}
}
}
-103
View File
@@ -1,103 +0,0 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
public class RendererTests
{
static readonly LocalActor Alice = new()
{
Id = "a1",
Kind = LocalActorKind.Person,
UserName = "alice",
Name = "Alice",
Summary = "I **like** <b>cats</b>",
PublicKeyPem = Keys.NewKeyPair().PublicKeyPem,
Published = new DateTime(2026, 9, 30, 17, 45, 12, DateTimeKind.Utc),
Fields = new Dictionary<string, string> { ["site"] = "https://alice.example", ["mood"] = "<i>ok</i>" },
BaseAddress = "https://privapub.test"
};
[Fact]
public void An_actor_looks_like_a_mastodon_actor_with_our_route_names()
{
var actor = ActivityPubRenderer.Actor(Alice);
Assert.Equal("https://privapub.test/peasants/alice", actor["id"]!.GetValue<string>());
Assert.Equal("https://privapub.test/@alice", actor["url"]!.GetValue<string>());
Assert.Equal("https://privapub.test/peasants/alice/mouth", actor["inbox"]!.GetValue<string>());
Assert.Equal("https://privapub.test/peasants/alice/anus", actor["outbox"]!.GetValue<string>());
Assert.Equal("https://privapub.test/peasants/alice/groupies", actor["followers"]!.GetValue<string>());
Assert.Equal("https://privapub.test/peasants/alice/stalking", actor["following"]!.GetValue<string>());
Assert.Equal("https://privapub.test/human-centipede", actor["endpoints"]!["sharedInbox"]!.GetValue<string>());
Assert.Equal("2026-09-30T00:00:00Z", actor["published"]!.GetValue<string>());
Assert.Equal("alice@privapub.test", actor["webfinger"]!.GetValue<string>());
Assert.False(actor["indexable"]!.GetValue<bool>());
Assert.Equal("<p>I <strong>like</strong> &lt;b&gt;cats&lt;/b&gt;</p>", actor["summary"]!.GetValue<string>());
var fields = actor["attachment"]!.AsArray();
Assert.Contains("rel=\"nofollow noopener noreferrer me\"", fields[0]!["value"]!.GetValue<string>());
Assert.Equal("&lt;i&gt;ok&lt;/i&gt;", fields[1]!["value"]!.GetValue<string>());
Assert.Equal("https://privapub.test/peasants/alice#main-key", actor["publicKey"]!["id"]!.GetValue<string>());
Assert.Equal(actor["id"]!.GetValue<string>(), actor["publicKey"]!["owner"]!.GetValue<string>());
}
[Fact]
public void A_titled_post_with_a_warning_keeps_the_title_visible_on_mastodon()
{
var post = new Post
{
ID = "p1",
Title = "On <cats>",
ContentHtml = "<p>body</p>",
HasContentWarning = true,
Language = "en",
Mentions = new() { new PostMention { ActorURI = "https://m.example/users/bob", Handle = "@bob@m.example" } },
Tags = new() { "cats" },
CreationDate = new DateTime(2026, 9, 30, 17, 45, 12, 345, DateTimeKind.Utc)
};
var note = ActivityPubRenderer.Note(post, Alice, default, "https://m.example/users/bob/statuses/1");
Assert.Equal("https://privapub.test/peasants/alice/scribbles/p1", note["id"]!.GetValue<string>());
Assert.Equal("https://privapub.test/@alice/p1", note["url"]!.GetValue<string>());
Assert.Equal("<p><strong>On &lt;cats&gt;</strong></p><p>body</p>", note["content"]!.GetValue<string>());
Assert.Equal("On <cats>", note["summary"]!.GetValue<string>());
Assert.True(note["sensitive"]!.GetValue<bool>());
Assert.Equal("2026-09-30T17:45:12.345Z", note["published"]!.GetValue<string>());
Assert.Equal(new[] { ActivityPubRenderer.Public }, note["to"]!.AsArray().Select(n => n!.GetValue<string>()));
Assert.Equal(new[] { Alice.Followers, "https://m.example/users/bob" }, note["cc"]!.AsArray().Select(n => n!.GetValue<string>()));
var tags = note["tag"]!.AsArray();
Assert.Equal("@bob@m.example", tags[0]!["name"]!.GetValue<string>());
Assert.Equal("https://privapub.test/tags/cats", tags[1]!["href"]!.GetValue<string>());
Assert.Equal("https://m.example/users/bob/statuses/1", note["inReplyTo"]!.GetValue<string>());
Assert.NotNull(note["contentMap"]!["en"]);
}
[Fact]
public void A_warning_without_a_title_or_spoiler_gets_a_generic_summary()
{
var note = ActivityPubRenderer.Note(new Post { ID = "p2", ContentHtml = "<p>x</p>", HasContentWarning = true }, Alice, default, default);
Assert.Equal(ActivityPubRenderer.ContentWarning, note["summary"]!.GetValue<string>());
}
[Fact]
public void A_direct_note_is_addressed_to_its_recipients_only()
{
var post = new Post { ID = "p3", ContentHtml = "<p>psst</p>", Visibility = PostVisibility.Direct };
var note = ActivityPubRenderer.DirectNote(post, Alice, new[] { ("https://m.example/users/bob", "bob@m.example") },
Alice.ConversationUri("c1"));
Assert.Equal(new[] { "https://m.example/users/bob" }, note["to"]!.AsArray().Select(n => n!.GetValue<string>()));
Assert.Empty(note["cc"]!.AsArray());
Assert.Equal("https://privapub.test/peasants/alice/whispers/c1", note["context"]!.GetValue<string>());
Assert.StartsWith("<p><span class=\"h-card\"", note["content"]!.GetValue<string>());
Assert.Equal("Mention", note["tag"]![0]!["type"]!.GetValue<string>());
}
}
}
@@ -1,114 +0,0 @@
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));
}
}
}
@@ -1,64 +0,0 @@
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));
}
}
}
@@ -1,250 +0,0 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Signing;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
// Lemmy takes a report only from a person or a service, about one post or comment, addressed to its community (owner
// decision 2026-10-06): a report about a post in a community on a Lemmy leaves from the server's reporter, one per post.
// Alone in its collection: the server rows it writes for the peer's hosts decide for whoever reports there.
[Trait("Category", "Integration")]
[Xunit.Collection(nameof(Exclusive))]
public sealed class ServiceReportTests : IAsyncLifetime
{
static readonly string[] PeerHosts = { "localhost", "127.0.0.1" };
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
await DB.Default.DeleteAsync<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 Payload, JsonObject Body)>> Queued() =>
(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver && j.CreatedAt >= DateTime.UtcNow.AddMinutes(-5)).ExecuteAsync(Token))
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
.Select(p => (p, JsonNode.Parse(p.Body)!.AsObject()))
.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.Payload.Inbox != community.SharedInbox && q.Body.ToJsonString().Contains(post.ObjectURI));
Assert.DoesNotContain(alice.UserName, flag.ToJsonString());
Assert.DoesNotContain(alice.Id, flag.ToJsonString());
var queued = Assert.Single(await Queued(), q => q.Body["actor"]!.GetValue<string>() == reporter.Uri && q.Body["object"]!.GetValue<string>() == post.ObjectURI);
Assert.Equal(LocalActorKind.Reporter, queued.Payload.SignerKind);
}
[Fact]
public async Task The_reporter_signs_its_flag_with_its_own_key()
{
var (_, alice) = await _harness.Persona("alice");
var (community, poster) = await OnLemmy();
var post = await Held(poster, community.Id);
var reporter = await _harness.Local.GetReporterActor(Token);
var instance = await _harness.Local.GetInstanceActor(Token);
_harness.Peer.Answer("/inbox", 202);
await _harness.Reports.File(alice, (await Known(poster)).ID, new[] { post.ID }, default, "spam", forward: true, Token);
var job = await DB.Default.Find<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));
}
}
}
@@ -1,50 +0,0 @@
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"));
}
}
}
Loaded 100 of 351 files, more files were not shown because too many files have changed in this diff. Show more