Compare commits

...
23 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 125414df2c emoji.sh: read a status's emojis, and reactions named as strings
Build / Build (push) Successful in 1m7s
The post check handed the whole status to has_code instead of its emojis, and Misskey lists its reactions by name
(':code@host:'), which has_code took for objects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-18 10:57:49 +02:00
thepraandClaude Opus 5.5 5020c031a3 instance.sh: the staff's log may hold acts that name nobody
Since R6 an act on one of the acting login's own personas is logged without its name, and every persona of the pasture
is the root's, so the scenario's log check takes an entry with no name too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-18 10:30:28 +02:00
thepraandClaude Opus 5.5 048eb15bb7 R7's emoji id is /grimaces/{shortcode}, the owner's name
The federated id of a local custom emoji (LocalEmojis.Path) was the plan's /emojis/{shortcode} awaiting the owner; it is
/grimaces/{shortcode}, in the style of the other routes, and freezes once it deploys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-18 10:29:53 +02:00
thepraandClaude Opus 5.5 9b792042e6 R9 Web Push, then the honest version: VAPID key, push subscriptions, encrypted pushes, 4.5.0 advertised
The server's VAPID key (PushKeys, made on first need, in Mongo) is the instance API's vapid.public_key and every app's
vapid_key. /api/v1/push/subscription (POST, GET, PUT, DELETE) keeps one subscription per access token, so per persona;
every notification kept (never a filtered one) is queued as a SendPush job for the subscriptions whose alert and policy
admit it, and sent through the `push` client (the safe handler, https only) encrypted as RFC 8291 aes128gcm or the
aesgcm draft with an ES256 VAPID JWT. 404/410 delete the subscription, 429/5xx/timeouts retry, other answers drop the
push. Subscriptions end with their tokens (revocation, sessions ended, suspension, deletion, a restore); backups hold
neither keys nor subscriptions. The walk of every Mastodon 4.3-4.5 client route (docs/MASTODON-API.md) added
conversations/:id/unread, domain_blocks/preview, v2 notifications clear and PUT policy, /oauth/userinfo and stubs, so both
instance APIs now say 4.5.0 (compatible; PrivaPub <release>). The pasture gets a push sink (pushsink.test) and
scenarios/push.sh in batch A.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-18 09:18:26 +02:00
thepraandClaude Opus 5.5 87529bca49 R8 the Mastodon API made real: featured tags, endorsements, notes, notify and languages, suggestions, the direct stream
Featured tags per persona (/api/v1/featured_tags, suggestions, tags/:name/feature, accounts' featured tags, max 10),
listed at /tattoos as Hashtags and sent as Add/Remove{Hashtag} to followers on the featured collection, as Mastodon
sends them; remote accounts' tags received and read from featuredTags. Endorsements (pin/endorse) and private notes
(PersonaNote) kept per persona and never federated; the relationship's notifying, languages, endorsed and note, with
follow's notify (status notifications) and languages (home filter). Reading preferences set on /clientapi. Suggestions
v1/v2 with dismissals and staff picks, never the persona or a sibling. conversation events on the direct stream.
translate, annual reports and the admin API answer. New collections backed up and removed with their persona.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-18 08:38:11 +02:00
thepraandClaude Opus 5.5 d08fbabaa5 R7 custom emoji: the server's own, added by administrators, sent on posts, profiles and reactions
LocalEmoji, a new collection (unique shortcode whatever its case, never renamed: the shortcode is its id elsewhere),
managed on /clientapi/admin/emoji/list|insert|update|delete and kept in the staff's log (emoji.insert, .update,
.delete). Its picture goes through IMediaService.EmojiImage: PNG, GIF, WebP or JPEG up to 256 KB, a PNG within 256 px
with alpha kept (an animated GIF or WebP stays an animated GIF, with a still PNG), keep=none, shrunk until it fits
GoToSocial's 100 KiB, held by ServerUse "emoji:<shortcode>" so the janitor and the audit leave it. LocalEmojis keeps the
offered ones in memory, reloaded on every change: GET /api/v1/custom_emojis is real; local posts (words, title, warning,
poll options, each revision) and profiles keep the emoji they name; Notes, Questions, actors and EmojiReacts carry
Mastodon's Emoji tag with its id, served as an Emoji at /emojis/{shortcode} (410 once deleted; the path awaits the
owner's confirmation); a persona reacts with one (Pleroma's reactions, announcements). A restore keeps the live emoji
as it keeps the settings, and trashes server pictures nothing holds. tools/pasture/scenarios/emoji.sh joins batch A.

Verified: built, and run locally against a scratch database (uploads of every type, animation kept, metadata gone,
refusals, rendering, reactions, history, announcements, delete and revive, backup and restore, media audit). The
pasture scenario and the sweep were not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 05:57:25 +02:00
thepraandClaude Opus 5.5 9f68ca3ed0 R6 moderation: roles, silence and suspension, bans that write nothing on the personas, deletions, report actions
Roles (user, moderator, administrator) from the CLI and /clientapi/admin/roots/{id}/role, never one's own nor the last
administrator. Silence keeps an account's posts out of public timelines, hashtags, trends, search and relays while its
followers still get them; suspension refuses and revokes a local persona's tokens, answers 404 for its posts and
collections, holds back every delivery and tells other servers nothing new, its actor unchanged; a suspended remote
account's activities are dropped and nothing goes to its own inbox (a shared one still serves its server). A banned
login's personas are held back in memory alone (Restrictions), so nothing pairs them in stored data. Personas and groups
are deleted one at a time (PersonaRemoval, GroupRemoval), by their login or an administrator with the password. Reports
resolve with an action and a note; posts are taken down, a remote copy hidden. Warnings reach the persona as Mastodon's
moderation_warning. Every staff act goes to the staff's log. Migration _020 limits posts from servers already silenced;
a restore keeps the moderation made since the backup.

Verified: interop.sh moderation gts mastodon with SecureMode, 194 passed; restore.sh, 25 passed, now with a suspension,
a silence and a takedown made after the backup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 05:23:45 +02:00
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
388 changed files with 11811 additions and 29020 deletions

No files matched your search

-3
View File
@@ -14,6 +14,3 @@ jobs:
- name: Build - name: Build
run: dotnet build PrivaPub.sln -c Release 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
+13 -23
View File
@@ -27,9 +27,6 @@ jobs:
echo "BUILD_REF=${GITHUB_REF_NAME:-master}" >> "$GITHUB_ENV" echo "BUILD_REF=${GITHUB_REF_NAME:-master}" >> "$GITHUB_ENV"
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$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 - name: Publish
run: | run: |
rm -rf "$GITHUB_WORKSPACE/publish" rm -rf "$GITHUB_WORKSPACE/publish"
@@ -106,11 +103,18 @@ jobs:
[ "$served" = "$BUILD_COMMIT" ] || { echo "::error::served build is '$served', expected '$BUILD_COMMIT'"; exit 1; } [ "$served" = "$BUILD_COMMIT" ] || { echo "::error::served build is '$served', expected '$BUILD_COMMIT'"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$PUBLIC_URL/peasants/privapub") code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$PUBLIC_URL/peasants/privapub")
[ "$code" = "200" ] || { echo "::error::the instance actor answered $code"; exit 1; } [ "$code" = "200" ] || { echo "::error::the instance actor answered $code"; exit 1; }
# SecureMode (owner decision 2026-10-04): an unsigned reader gets 401 from a persona, a browser the public page # SecureMode (owner decision 2026-10-04): an unsigned reader gets 401 before any lookup, so a name no persona can have
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$PUBLIC_URL/peasants/thepra") # (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" = "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") directory=$(curl -fsS "$PUBLIC_URL/api/v1/directory?local=true&limit=1") || { echo "::error::the directory did not answer"; exit 1; }
[ "$code" = "302" ] || { echo "::error::a browser asking for a persona got $code, not a redirect"; 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=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/.well-known/nodeinfo")
[ "$code" = "200" ] || { echo "::error::nodeinfo answered $code"; exit 1; } [ "$code" = "200" ] || { echo "::error::nodeinfo answered $code"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/swagger/index.html") code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/swagger/index.html")
@@ -128,22 +132,8 @@ jobs:
|| { echo "::error::/stargazing does not say the crawler is on"; exit 1; } || { echo "::error::/stargazing does not say the crawler is on"; exit 1; }
echo "::notice::serving $served" 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, # The deploy no longer signs in (owner decision 2026-10-17): it kept a login of its own, deploy-smoke, whose @thepra
# and gives its root a new password on every deploy, so no secret is stored and nobody has to create anything. # is the administrator's now. Its checks are the anonymous ones above.
- 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 server fetches DB-IP Lite itself (GeoUpdater) about 30 s after it starts and then daily; the deploy only checks. # 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 - name: Geolocation databases are current
+1
View File
@@ -407,6 +407,7 @@ PrivaPub/media-store-incoming/
PrivaPub/media-store-backups/ PrivaPub/media-store-backups/
tools/pasture/.publish/ tools/pasture/.publish/
tools/pasture/.flood/ tools/pasture/.flood/
tools/pasture/.pushsink/
.claude/worktrees/ .claude/worktrees/
tools/pasture/.ca/ tools/pasture/.ca/
tools/pasture/.state/ tools/pasture/.state/
+608 -92
View File
@@ -18,17 +18,23 @@ Its defining idea: **one private login owns several public personas.**
Root and avatar are linked only through `RootToAvatar`. 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. - **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 - **`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.** - **`DmGroup` is a direct-message conversation.**
- **`privapub` is the instance actor** (type Application). It signs fetches no persona should be tied to. - **`privapub` is the instance actor** (type Application). It signs fetches no persona should be tied to.
- **`privapub_reports` is the reporter** (type Service, `LocalActorKind.Reporter`). It carries reports to Lemmy, which - **`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 takes none from an Application, and to PieFed and Mbin, and names nobody.
(`LocalActor.IsServerActor`): never followed, mentioned or shown as accounts. - **`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: Privacy features in the model:
- location-ranged posts (`Post.Location`, `RangeKm`), to become local-only and never federated; - location-ranged posts (`Post.Location`, `RangeKm`), local-only and never federated;
- contacts a persona can share (`Avatar.SharedPersonalContacts`); - contacts a persona can share (`Avatar.SharedPersonalContacts`) and the root's notes (`RootUserNote`, `PersonalNote`)
- private notes (`RootUserNote`, `PersonalNote`). exist only as model classes, with no endpoint. A persona's private note on an account is `PersonaNote` (R8), its own:
`RootUserNote` is keyed by the root and would show a note made as one persona to its siblings, so it is never used for
it.
The intended client is the Mastodon client API (Tusky, Elk, Phanpy, Ivory), where each avatar logs in as its own The intended client is the Mastodon client API (Tusky, Elk, Phanpy, Ivory), where each avatar logs in as its own
account. The private `/clientapi` covers what Mastodon can't express. `thepra/decePubClient` account. The private `/clientapi` covers what Mastodon can't express. `thepra/decePubClient`
@@ -52,10 +58,19 @@ actor-scoped route gets a name in the same spirit, agreed with the owner, and a
| Activities | `/peasants/{name}/grunts/{id}` (`create-{postId}` resolves) | | Activities | `/peasants/{name}/grunts/{id}` (`create-{postId}` resolves) |
| DM context | `/peasants/{name}/whispers/{id}` | | DM context | `/peasants/{name}/whispers/{id}` |
| Quote permission (FEP-044f `QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{id}` | | Quote permission (FEP-044f `QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{id}` |
| Featured (pinned posts) | `/peasants/{name}/trophies` |
| Featured tags (an `OrderedCollection` of `Hashtag`s, R8) | `/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` | | Token refresh | `/clientapi/user/sniff/again` |
| A custom emoji (`Emoji`, R7) | `/grimaces/{shortcode}` (`LocalEmojis.Path`, the one place it is written) |
Agreed for later phases: `/gossip`, `/drool`, `/echoes` (replies, likes, shares), `/trophies` (featured), `/tattoos` All of these have federated, so they are frozen. `/grimaces/{shortcode}` is a custom emoji's id, named by the owner
(featured tags), `/flock` and `/wardens` (group members and moderators). (2026-10-18) in the style of the others; it freezes once R7 deploys, and every emoji id elsewhere names it. 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: Routes other software looks up by name stay conventional:
- `/.well-known/*` and `/nodeinfo/*`; - `/.well-known/*` and `/nodeinfo/*`;
@@ -70,18 +85,29 @@ PrivaPub.sln
PrivaPub/ ASP.NET Core Web API, net10.0 PrivaPub/ ASP.NET Core Web API, net10.0
Program.cs host, Mongo init (GUIDs Standard), pipeline, /build.json Program.cs host, Mongo init (GUIDs Standard), pipeline, /build.json
Middleware/SocialPubConfigurations.cs every DI registration (auth, federation, services, swagger, CORS) Middleware/SocialPubConfigurations.cs every DI registration (auth, federation, services, swagger, CORS)
Controllers/ClientToServer/ /clientapi/*: RootUser (signup/login/invitations/recovery), PrivateAvatar, Controllers/ClientToServer/ /clientapi/*: RootUser (signup, setup, login, invitations, recovery), PrivateAvatar,
Group, Post (posts + DMs), Admin, Data Group, Post (posts + DMs), Social, Timeline, Data, PersonaArchive, Moderator (reports,
accounts and posts moderated, the staff's log), Admin (logins: roles, bans, deletions,
two-factor resets), DomainBlock, Statistics, Backup, InstanceAdmin (the server's
settings, thumbnail, announcements, relays), EmojiAdmin (the server's custom emoji)
Infrastructure/ Infrastructure/
Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out
Jobs/ JobQueue (leases), JobWorker, Backoff, HostCircuitBreaker Jobs/ JobQueue (leases), JobWorker, Backoff, HostCircuitBreaker
Push/ Web Push (R9): Vapid (the server's key, ES256 JWTs), WebPushCrypto (aes128gcm, aesgcm),
WebPushJob (SendPush, through the `push` client)
Ids/ PrivacyIds (day-only ids for personas and groups, arrival-ordered ids for remote posts) 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 Data/ Indexes (created at start), EntityMaps.Warm, Migrations/_NNN_*.cs
Cli/ AdminCommands (`PrivaPub admin promote|demote <root>`) Cli/ AdminCommands (`PrivaPub admin promote|demote|role|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 RateLimiting.cs accounts (per client address) and inbox (per sending origin) policies
RegistrationOptions.cs who may make a login (`Registrations:Mode`)
Federation/ Federation/
Controllers/ PeasantsController (actor, outbox, followers, following, posts, inboxes), Controllers/ PeasantsController (actor, outbox, followers, following, posts, inboxes),
WellKnownController (webfinger, nodeinfo), UsersController (redirect) WellKnownController (webfinger, nodeinfo), UsersController (redirect),
EmojisController (a custom emoji's `Emoji` at /grimaces/{shortcode})
Actors/ LocalActorService (LocalActor, Keys, ReservedName), RemoteActorService Actors/ LocalActorService (LocalActor, Keys, ReservedName), RemoteActorService
(authoritative fetch, key verification, WebFinger), ActorDocument (parser) (authoritative fetch, key verification, WebFinger), ActorDocument (parser)
Objects/ Origin, ActivityJson, NoteParser, Addressing, ContentSanitizer Objects/ Origin, ActivityJson, NoteParser, Addressing, ContentSanitizer
@@ -96,13 +122,23 @@ PrivaPub/ ASP.NET Core Web API, net10.0
ReplyRelay (third-party replies to a persona's posts passed on to its followers) ReplyRelay (third-party replies to a persona's posts passed on to its followers)
Outbox/ OutboxPublisher (who a post goes to), DeliveryService (queues jobs) + DeliveryJobHandler Outbox/ OutboxPublisher (who a post goes to), DeliveryService (queues jobs) + DeliveryJobHandler
Rendering/ ActivityPubRenderer (Mastodon @context, actors, notes, collections) Rendering/ ActivityPubRenderer (Mastodon @context, actors, notes, collections)
Crawler/ Crawler, PeerHosts, RobotsTxt (the opt-in crawler behind `/stargazing`)
Relays/ Relays (the instance actor's relay subscriptions)
Domain/ Domain/
Content/ ContentRenderer (Markdown or plain text → HTML with h-card mentions and hashtags) Content/ ContentRenderer (Markdown or plain text → HTML with h-card mentions and hashtags;
Social/ FollowService (local in-process, remote Follow/Accept), Notifications Document: the server's own texts), LocalEmojis (the server's custom emoji in memory)
Instance/ InstanceSettings (the server's own settings in memory), MediaLimitsFromSettings
Moderation/ ModerationLog (the staff's log, AdminAction), AccountModeration (silence, suspend, posts
removed), Restrictions (who is held back, in memory), ModerationFlags (posts' flags again)
Social/ FollowService (local in-process, remote Follow/Accept), Notifications, Announcements,
WebPush (who is pushed what; WebPushDispatcher on Notifications.Told),
FeaturedTags (a persona's featured hashtags, sent and received), Suggestions (who to follow)
Timelines/ Fanout (TimelineEntry rows, Mastodon's home rules), TimelineService Timelines/ Fanout (TimelineEntry rows, Mastodon's home rules), TimelineService
Privacy/ VisibilityPolicy (IsPublic expression, CanSee) Privacy/ VisibilityPolicy (IsPublic expression, CanSee)
Relationships/ RelationshipService (blocks, mutes, account domain blocks; Hidden), ReportService Relationships/ RelationshipService (blocks, mutes, account domain blocks; Hidden), ReportService
Media/ MediaService (libvips, ffmpeg remux, blurhash), MediaProxy, MediaJanitor Media/ MediaService (libvips, ffmpeg, 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); 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; 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 Participations: a persona's Join and Leave of a remote event, and the organiser's answer
@@ -110,31 +146,31 @@ PrivaPub/ ASP.NET Core Web API, net10.0
Auth/ OpenIddict setup (keys in Mongo), MastodonScopes, TokenController, OAuthPruner Auth/ OpenIddict setup (keys in Mongo), MastodonScopes, TokenController, OAuthPruner
Infrastructure/ MastodonController (avatar context, scopes, errors, Link), MastodonParams, MastodonJson, Page Infrastructure/ MastodonController (avatar context, scopes, errors, Link), MastodonParams, MastodonJson, Page
Entities/ Mappers/ Mastodon entities; MastodonMapper (Account, Status), AccountSearch 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, custom emojis, featured tags, suggestions, Web Push (`PushController`), OAuth
userinfo; the stubs are `StubsController` in `SearchController.cs`
Web/Pages/ Razor: /@{user}, /@{user}/{id} (public posts only, strict CSP, noindex); Web/Pages/ Razor: /@{user}, /@{user}/{id} (public posts only, strict CSP, noindex);
OAuth/: /oauth/login (root password), /oauth/authorize (choose persona, consent) OAuth/: /oauth/login (root password), /oauth/authorize (choose persona, consent)
Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, … Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, RootRemoval,
Models/ Mongo entities: User/, Group/, Post/, Federation/, Jobs/, AppConfiguration PersonaRemoval and GroupRemoval (deleting a login, a persona, a group), …
Models/ Mongo entities: User/, Group/, Post/, Federation/, Jobs/, AppConfiguration, ServerSetup,
ServerSettings, AdminAction
StaticServices/ DbEntities (Find<T> accessors), AuthTokenManager (JWT), PasswordHasher StaticServices/ DbEntities (Find<T> accessors), AuthTokenManager (JWT), PasswordHasher
Data/InitDb.cs first-run seeding (languages) Data/InitDb.cs first-run seeding (languages)
PrivaPub.ClientModels/ DTOs + validation resources shared with clients 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 deploy/ nginx vhost, systemd units (privapub, privapub-mongod), max/setup.sh
.gitea/workflows/ build.yml (push) · deploy.yml (tag v*) .gitea/workflows/ build.yml (push) · deploy.yml (tag v*)
docs/ROADMAP.md decisions + phased plan docs/ROADMAP.md decisions + phased plan
``` ```
The roadmap moves code towards `Infrastructure/`, `Federation/`, `Domain/`, `Api/{ClientApi,Mastodon}` and `Web/`, The planned move of `/clientapi` into `Api/ClientApi` was dropped (2026-10-17): it stays in `Controllers/ClientToServer/`
one pure-move commit at a time. and `Services/`.
## Commands ## Commands
```bash ```bash
dotnet build PrivaPub.sln -c Release 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 \ cd PrivaPub && ASPNETCORE_ENVIRONMENT=Development \
Kestrel__Endpoints__Http__Url=http://127.0.0.1:6970 Kestrel__Endpoints__Http__Protocols=Http1AndHttp2 \ 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 \ AppConfiguration__BackendBaseAddress=http://127.0.0.1:6970 MongoSettings__Database=PrivaPubTest \
@@ -146,27 +182,400 @@ curl-able. Outbound fetches only go to https DNS names resolving to public addre
`Federation__AllowPrivateNetworks=true`, `Federation__AllowPlainHttp=true` and `Federation__AcceptAnyCertificate=true`, `Federation__AllowPrivateNetworks=true`, `Federation__AllowPlainHttp=true` and `Federation__AcceptAnyCertificate=true`,
which startup refuses in Production. 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 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 the live service. It is the way back when setup is closed and no administrator can sign in; promoting is how an admin is
an admin is made (signing up as "admin" grants nothing): made (signing up as "admin" grants nothing):
```bash ```bash
cd /var/www/privapub.thepra.dev cd /var/www/privapub.thepra.dev
echo '<password>' | ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin create-root <login> --admin # password on stdin 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 promote|demote <root>
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin smoke <persona> # the deploy's: prints "deploy-smoke <new password>" ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin role <root> <user|moderator|admin> # promote/demote are admin/user
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 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, role, create-root, move-persona, delete-root, reset-totp) and `restore` is kept in the staff's
log as the command line's (`cli`). The command line may leave the server without an administrator (it says so); the
client's role route never does.
## 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, or since R6 on a post of it, a group it owns or a report about it, 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, the CLI's acts and, since R6, every act of moderation
(below). `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.
## Moderation (completion program R6)
- **Roles.** A login is a user, a moderator (also a user) or an administrator (all three): `RootUser.SetRole` writes its
`Policies` (`SetAdmin` is a wrapper), and JwtEvents reads them from the database on every request. Moderators handle
reports, silence and suspend personas and accounts elsewhere, and remove posts; never logins (roles, bans, deletions,
two-factor), settings, emoji, relays, backups or statistics, which are the administrators'.
- **What an act does** (`Domain/Moderation/AccountModeration.cs`, following the `AuthorGone` pattern): the account keeps
when it was silenced or suspended (`Avatar`/`ForeignAvatar.SilencedAt`, `SuspendedAt`, `AccountState`, the staff's
`ModerationNote`), and its posts carry `Post.AuthorLimited` or `Post.AuthorSuspended`, set on what it already wrote
and on what arrives or is written later (`RemotePosts.Build`, `AnnounceHandler`, `StatusService`, archive imports), so
a query needs no join. `Restrictions` (static, in memory, reloaded after every act and when five minutes old) holds
the suspended personas, the silenced ones, and the suspended accounts elsewhere with their own inboxes.
- **Silenced** (a persona, an account elsewhere, or a server: a domain block of `Silence` limits every post of its
accounts and subdomains, on arrival and when the block is made or lifted, `AccountModeration.DomainChanged`):
`VisibilityPolicy.IsListed` keeps its posts out of the public and hashtag timelines and streams, trends, followed
hashtags, status search (but what the reader met itself), the `/tags` page and the relays; its account out of the
directory, trends and search by a part of its name (its exact name still finds it). Its followers still get it, and
anyone may still open it; the Mastodon account says `limited`.
- **Suspended, a persona** (owner decision 2026-10-17, **tells other servers nothing new**): its Mastodon tokens are
revoked and refused (`MastodonController`, 401; `TokenController` and `/oauth/authorize` give it none), its streams
close, `/clientapi` acts as it nowhere (posts, follows, groups, profile edits, archive imports; its archive may still
be exported), it delivers nothing (`DeliveryService.HeldBack`, at enqueueing and again at sending; the one exception
is the Delete of itself when it is deleted), it takes no follower (`FollowHandler` leaves the request unanswered),
its scheduled posts wait (up to a month), `IsShown` hides its posts from everyone, the Mastodon mapper shows its
account to nobody (404 for the account, its statuses, lookup), and `PeasantsController` answers 404 for everything
of it but its actor and its inbox, signed or not; the actor, WebFinger and the server's counts stay exactly as they
were. Unsuspending gives everything back but the revoked tokens. **It is warned** with Mastodon's
`moderation_warning` notification (`Notifications.Warn`: from itself, as Mastodon's are, `Notification.WarningAction`
and the moderator's `Warning` text), which it reads once it can sign in again; meanwhile its login sees it
`Suspended` in `/clientapi/avatar/private/list`. Silencing warns it too, and a removed post warns its author
(`delete_statuses`).
- **Suspended, an account elsewhere:** its posts are hidden as above, `InboxProcessor` drops what it sends (but a
`Delete` or an `Undo`, which only take away what we hold), and nothing is addressed to it or delivered to its own
inbox (its server's shared inbox still serves the others there). It is told nothing.
- **A banned login** (`/clientapi/admin/ban/users`): its sessions end and `Restrictions` holds its personas back as
suspended ones (`Counted.HeldBackPersonas`, which is `BarredPersonas` plus the suspended personas), **without writing
anything on any of them**: flagging every persona of a login at once would pair them in stored data a moderator or a
backup reads. Where posts are read one at a time `VisibilityPolicy.Shown` asks `Restrictions` (`Barred`); lists leave
them to the Mastodon mapper, which shows no account of theirs and so none of their posts. Unbanning gives them back.
- **A post removed:** a post here is deleted as its author deletes it (`StatusService.Remove`: emptied, a Delete to its
audience, unless its author is suspended); a copy of a post elsewhere gets `Post.ModeratedAt`, hidden everywhere by
`IsShown` and never taken in again (its address stays known).
- **Deleting** a persona (`Services/PersonaRemoval.cs`) or a group (`Services/GroupRemoval.cs`) deletes everything
public it had, as `RootRemoval` always did for a login's personas (and now does through PersonaRemoval): its
tokens end, a persona's groups go with it, a Delete of the actor goes to its followers, the accounts it follows and
a group's members elsewhere, its posts are emptied, its name stays reserved and its documents answer 410.
- **Routes**, every one kept in the staff's log (`persona.silence|unsilence|suspend|unsuspend|delete`,
`account.silence|unsilence|suspend|unsuspend`, `post.remove`, `report.resolve` with its action in the note,
`group.delete`, `root.role|ban|unban|delete|reset_totp`); an entry about a persona of the acting login, a post of it, a
group it owns or a report about it is kept without who acted:
- moderators (`ModeratorController`): `GET /clientapi/moderator/accounts?q=` (a name, `user@host` or an actor's address:
personas here, never their logins, and accounts elsewhere), `GET /accounts/moderated?offset=&limit=`,
`GET /accounts/{id}` (`ViewModeratedAccount`, `ViewModeratedAccountsPage`), `POST /accounts/moderate`
(`ModerateAccountForm`: `silence`, `unsilence`, `suspend`, `unsuspend`, a `Note` for the staff, a `Warning` for the
persona), `POST /posts/remove` (`RemovePostForm`), `POST /reports/{id}/resolve` (an optional `ResolveReportForm`:
`none`, `silence`, `suspend` or `remove_posts`, the act first, so one that fails leaves the report open; kept as
`Report.Resolution` and `ResolutionNote`, which `ViewReport` shows);
- administrators (`AdminController`, `Authorize(IsAdmin)`): `GET /clientapi/admin/roots?q=&before=&limit=` and
`/roots/{id}` (`ViewAdminRoot`: the one place a login is shown with its personas and the groups they own; `q` matches
a login's name or a persona's), `POST /roots/{id}/role` (`RootRoleForm`; never one's own, 409, and never the last
administrator: a demotion that leaves none is undone, 409), `POST /roots/{id}/totp/reset` (as `admin reset-totp`;
never one's own), `POST /personas/{id}/delete` and `/groups/{id}/delete` (`AdminPasswordForm`: the administrator's own
password, 403 when wrong), and the older `/ban/users`, `/unban/users`, `DELETE /remove/users`, now logged;
- the login itself: `POST /clientapi/avatar/private/delete` (`DeletePersonaForm`, its password) and the group's owner
`POST /clientapi/group/delete` (`DeleteGroupForm`, the login's password). Neither is a staff act, so neither is logged.
- **Data:** migration `_020` and every restore run `ModerationFlags.Recompute` (the posts' flags and the accounts'
`AccountState` from their moderation and the silenced servers); `ProtectiveMerge` keeps P's silences and suspensions,
here and elsewhere, and the posts taken down, before it does; indexes on `SilencedAt` and `SuspendedAt` of both account
collections and a partial one on `Post.ModeratedAt`. No new collection, so backups hold it all as they did.
## The Mastodon API made real (completion program R8)
- **Every row is one persona's** (`Models/Social/Featuring.cs`, new collections): `FeaturedTag`, `Endorsement`,
`PersonaNote` and `SuggestionDismissal` are keyed by the persona (`AvatarId`), never by its root, with unique indexes on
(persona, name or account); `RemoteFeaturedTag` is an account elsewhere's (by `ActorURI`). `Following` gained `Notify`
and `Languages` (null: every language), `ForeignAvatar` `FeaturedTagsURL`, `AvatarSettings` `ExpandMedia`
(`default`, `show_all`, `hide_all`) and `ExpandSpoilers`, `NotificationType` `Status` (last, as enum members are stored
as ints).
- **Featured tags** (`Domain/Social/FeaturedTags.cs`, `FeaturedTagsController`, `TagsController`): `GET`/`POST
/api/v1/featured_tags` (`name`; 422 for a name that is no hashtag or past `FeaturedTags.MaxFeatured`, 10, which both
instance APIs advertise as `max_featured_tags`; one featured already is answered as it is), `DELETE
/api/v1/featured_tags/{id}` (`{}`, 404), `GET /api/v1/featured_tags/suggestions` (Mastodon's `Tag`s: the tags of the
persona's last 200 posts it does not feature, the most used first, ten), `POST /api/v1/tags/{name}/feature|unfeature`
(the `Tag`, with Mastodon 4.4's `featuring`, which every `Tag` now carries for a signed-in persona), `GET
/api/v1/accounts/{id}/featured_tags` (anonymous too: a persona's own, an account elsewhere's from `RemoteFeaturedTag`, a
community none, a suspended account 404). Mastodon's `FeaturedTag`: `statuses_count` a string and `last_status_at` a
day, counted from the account's public and unlisted posts held here with the tag. They are public, so they federate:
`/tattoos` lists them as `Hashtag`s, and featuring one sends `Add{Hashtag}`, unfeaturing `Remove{Hashtag}`, to the
persona's followers, **with its `featured` collection (`/trophies`) as the target**, as Mastodon sends them
(`AddHashtagSerializer`) and the only target Mastodon takes a Hashtag from. Received: `FeaturedHandler` takes a `Hashtag`
added to or removed from an account's `featured` or `featuredTags` (an object with no id, read before anything takes its
`href` for a post), and `AccountCountsJob` reads the `featuredTags` collection (its own server only, three pages, 20 tags
at most) with the account's counts.
- **Endorsements** (`AccountsController`): `POST /api/v1/accounts/{id}/pin|unpin` (Mastodon's old names) and
`/endorse|/unendorse` (4.4) answer the relationship; only an account the persona follows (422 otherwise, as Mastodon
says it), 500 at most. `GET /api/v1/endorsements` lists the persona's own, newest first, those it still follows (paged
by `Link`); `GET /api/v1/accounts/{id}/endorsements` answers them to the persona itself and `[]` for every other account:
**endorsements stay private** (the owner's default), never federated as featured accounts. Unfollowing drops the
endorsement, as on Mastodon; a `Move` carries the endorsement and the note over to the new account.
- **Private notes:** `POST /api/v1/accounts/{id}/note` (`comment`, 2000 characters at most, 422 beyond; empty takes it
away) answers the relationship, whose `note` is the persona's own `PersonaNote`. Never federated.
- **The relationship** fills `notifying` and `languages` from the follow, `endorsed` (only while it follows) and `note`.
`POST /api/v1/accounts/{id}/follow` takes `notify` and `languages[]` (each changed only when given, as on Mastodon; an
empty list is every language). `Fanout` keeps a post in another language out of that follower's home (a post whose
language is unknown comes in; languages compare by their primary subtag) and gives a follower with `notify` a `status`
notification for the account's own posts and its replies to itself, never its boosts, once the post reached that home
(Mastodon's `FeedInsertWorker`). Archives carry both in `following_accounts.csv`'s own columns.
- **Preferences:** `GET /api/v1/preferences` answers `reading:expand:media` and `reading:expand:spoilers` from the
persona's settings, which Mastodon's API sets nowhere, so `GET`/`POST /clientapi/avatar/private/preferences`
(`ViewPersonaPreferences`, `PersonaPreferencesForm`, the login's own persona; 404 for any other, 422 for another mode)
sets them.
- **Suggestions** (`Domain/Social/Suggestions.cs`, `SuggestionsController`): `GET /api/v1/suggestions` (accounts) and
`/api/v2/suggestions` (`source`, `sources`, `account`), `limit` 40 (80 at most); `DELETE /api/v1/suggestions/{account_id}`
(`{}`) dismisses one for that persona for good. In order: the staff's picks (`ServerSettings.StaffPicks`, `staff` /
`featured`), past interactions (accounts the persona favourited, boosted or answered lately, `past_interactions`, which
4.3's `sources` has no word for), then global: accounts followed by at least three personas here (`most_followed`) and
personas here whose public posts of the last 30 days were favourited and boosted most (`most_interactions`),
discoverable ones only, worked out at most every ten minutes. **Never the persona itself and never a sibling persona of
its login** (`RootToAvatar` read server-side only: suggesting a sibling would pair them the moment the persona followed
it), nor what it follows, asked to follow, blocked, muted, was blocked by (here or elsewhere), dismissed or hides by
server, nor an account held back (`Counted.HeldBackPersonas`), suspended, silenced or shown `limited`; a community only
as a staff pick.
- **Staff picks** (`InstanceAdminController`, administrators, checked against the database): `GET
/clientapi/admin/staff-picks`, `POST /staff-picks/insert` (`StaffPickForm`: a persona here or a person elsewhere by its
Mastodon id; 404 for no such account, 409 for one picked already, 422 past 40), `POST /staff-picks/delete?accountId=`;
each answers `List<ViewStaffPick>`, which `ViewServerSettings.StaffPicks` shows too (`IsShown` false for one deleted,
suspended or silenced since). They are kept in the settings but never changed by the settings form, so an older page
saving the form keeps them. Logged as `staff_pick.insert` and `staff_pick.delete` (target `persona` or `account`, so an
entry about a persona of the acting login keeps no actor).
- **familiar_followers stays empty by design:** which accounts the persona follows also follow another would tell who
follows whom among the personas here; every account answers `accounts: []`.
- **The direct stream:** `StreamingController` takes `stream=direct` (and the WebSocket's `subscribe` to `direct`); a
direct message saved here or arriving (`StatusService`, `CreateHandler`, after `ConversationStates.Posted`) tells the
`direct` streams of the conversation's personas a `conversation` event (`Fanout.Conversation`), mapped per persona as
`/api/v1/conversations` maps it.
- **Stubs that answer:** `POST /api/v1/statuses/{id}/translate` 422 (translation is cut), `GET /api/v1/annual_reports`
`{"annual_reports":[],"accounts":[],"statuses":[]}`, every verb under `/api/v1/admin/**` (and `GET`/`POST` under
`/api/v2/admin/**`) 403 `{"error":"This action is not allowed"}`, signed in or not (moderation stays on `/clientapi`).
- **Removal, backups, archives:** `PersonaRemoval` deletes the persona's featured tags, endorsements, notes and
dismissals, and what other personas kept about it (their endorsements of it, notes on it, dismissals of it); the Delete
of the actor tells other servers. Every new collection is in every backup (none is in `ServerBackup.Excluded`: they are
personas' own data, the remote featured tags a cache like `RemoteFeatured`); a restore keeps them as the backup has them,
and `ProtectiveMerge` deletes those of a persona deleted since. Mastodon's account archive has no featured tags, notes or
endorsements, so the persona archive leaves them out.
## Web Push (completion program R9)
- **The key** (`Models/PushKeys.cs`, `Infrastructure/Push/Vapid.cs`): one P-256 VAPID key pair (RFC 8292), made the first
time something needs it and kept in Mongo (one document, id `vapid`; two processes at once keep the first one saved).
Its public key, the uncompressed point in URL-safe base64 with its padding (as Mastodon writes `VAPID_PUBLIC_KEY`), is
v2's `configuration.vapid.public_key`, an app's `vapid_key` (`POST /api/v1/apps`, `apps/verify_credentials`) and every
subscription's `server_key`.
- **Subscriptions** (`Models/Social/PushSubscription.cs`, a new collection, unique on `TokenId`): one per Mastodon access
token, as on Mastodon, so one persona's (the token's subject) and never its login's. Each holds the endpoint, the
client's `p256dh` and `auth` as it sent them, `standard`, the alerts turned on, the policy, and the token itself, which
the payload carries back. The alerts are Mastodon 4.5's names (`WebPush.Alerts`: `mention`, `quote`, `status`,
`reblog`, `follow`, `follow_request`, `favourite`, `poll`, `update`, `quoted_update`, `severed_relationships`,
`moderation_warning`, `annual_report`, `admin.sign_up`, `admin.report`) and Pleroma's `pleroma:emoji_reaction`; those
PrivaPub never notifies (the admin ones, `quoted_update`, `severed_relationships`, `annual_report`) are kept and never fire.
- **Routes** (`Api/Mastodon/Controllers/PushController.cs`, the `push` scope, a persona's token; Mastodon's parameters as
a form or JSON):
- `POST /api/v1/push/subscription`: `subscription[endpoint]` (an https URL; plain http only where
`Federation:AllowPlainHttp`, which production refuses), `subscription[keys][p256dh]` (a point on P-256),
`subscription[keys][auth]` (16 bytes), `subscription[standard]`, `data[alerts][<alert>]`, `data[policy]` (`all` by
default, `followed`, `follower`, `none`). It replaces the token's subscription and answers it; 400 without an
endpoint, 422 for an endpoint that is no https URL, keys that are no Web Push keys, or another policy.
- `GET`: the token's subscription, or 404 `Record not found`.
- `PUT`: `data[alerts]` and `data[policy]` replace what it had, as Mastodon's update does (an alert left out is off, a
policy left out is `all`); 404 without one.
- `DELETE`: `{}`, whether there was one or not.
- The answer is Mastodon's `WebPushSubscription`: `id` (a string), `endpoint`, `standard`, `alerts` (every alert, true
or false), `server_key`, `policy`.
- **What is pushed** (`Domain/Social/WebPush.cs`): `Notifications.Add` and `Notifications.Warn` tell `Notifications.Told`
of each notification they keep, never one the notification policy filtered or dropped nor one blocks or mutes hid.
`WebPushDispatcher` (a hosted service: the command line pushes nothing) hangs there and queues one `SendPush` job
(`JobKind.SendPush`, the last member; dedupe `push|<subscription>|<notification>`, its host the endpoint's) for each
subscription of that persona whose alert for the type is on and whose policy admits the sender: `followed`, an account
the persona follows; `follower`, one that follows it; the persona itself (its poll ended, the staff's warning) under
every policy but `none`. Nothing for a persona `Restrictions` holds back (suspended, its login banned).
- **Sending** (`Infrastructure/Push/WebPushJob.cs`). Everything is read again when the job runs: the subscription, its
persona (not held back, not deleted), its token (valid, unexpired, its subject the subscription's persona, or the
subscription goes), the notification (still the persona's, still pushable, still shown to it by
`MastodonMapper.Notifications`). The payload is Mastodon's JSON, at most 3993 bytes (`WebPushCrypto.MaxPayload`):
- `access_token`, the subscription's own token, never another's; `preferred_locale`, the persona's default posting
language, else the server's first; `notification_id`, PrivaPub's id (a string); `notification_type`, its Mastodon
name;
- `icon`, the sender's avatar through the media proxy; `title`, Mastodon's English subjects ("You were mentioned by
…", "… favorited your post", …); `body`, the post's warning, else its words, else the sender's bio, without tags, 140
characters at most.
- **Encryption** (`WebPushCrypto`, System.Security.Cryptography only: ECDiffieHellman on P-256, HKDF-SHA-256, AesGcm),
a fresh key pair and salt per message. A `standard` subscription gets RFC 8291's `aes128gcm` (RFC 8188's header:
salt, record size 4096, our public key as the key id; one record, delimiter 0x02) with `Authorization: vapid
t=<jwt>, k=<key>`. Any other gets the draft Mastodon still sends by default, `aesgcm` (two bytes of padding length
first) with `Encryption: salt=…`, `Crypto-Key: dh=<our key>;p256ecdsa=<the VAPID key>` and `Authorization: WebPush
<jwt>`. Both carry `Content-Type: application/octet-stream`, `TTL: 172800` and `Urgency: normal`, as Mastodon's do.
- **The JWT** (`VapidKey.Token`): ES256, `aud` the endpoint's origin, `exp` twelve hours ahead (RFC 8292 allows a
day), `sub` `mailto:` the contact email the administrator gave, else the server's address (`mailto:privapub@<host>`
for a test server on plain http). Never a persona.
- **Answers:** 2xx is done; 404 and 410 delete the subscription; 408, 429, a 5xx, a timeout or a broken connection retry
on the jobs' backoff (six attempts, about forty minutes); any other answer, a redirect, or an endpoint that does not
resolve to a public address drops the push, logged by the push service's host alone. Four run at a time, four per host.
- **decePub:** the first-party client's exchanged persona tokens may hold `push` too (`PersonaExchange.FirstPartyScopes`),
so it can subscribe like any other client.
- **Lifecycle:** a subscription goes with its token: `/oauth/revoke` (an OpenIddict handler after the revocation),
`RootSessions.Revoke` (a password changed or recovered, two-factor turned on, signing out everywhere, a ban, a deleted
login), `PersonaRemoval`, a suspension (`AccountModeration`), `admin move-persona`, and any token found unusable when a
push is sent. A new subscription made with the same token replaces it.
- **Backups:** `PushSubscription` and `PushKeys` are never in a backup (`ServerBackup.Excluded`). A restore leaves the
live key as it is, so the key the push services know never changes, and deletes every subscription with the tokens
(`ProtectiveMerge`); clients subscribe again once signed in.
- **The rest of the honest version** (`docs/MASTODON-API.md`, the walk of every Mastodon 4.3, 4.4 and 4.5 client route),
built with it:
- `POST /api/v1/conversations/:id/unread` (`ConversationState.MarkedUnread`, until it is read);
- `GET /api/v1/domain_blocks/preview` (`following_count` and `followers_count` the block would take, counted as
`BlockDomain` takes them);
- `POST /api/v2/notifications/clear` and `PUT /api/v2/notifications/policy`;
- `GET`/`POST /oauth/userinfo` (the `profile` scope: `iss`, `sub` the actor, `name`, `preferred_username`, `profile`,
`picture`; `userinfo_endpoint` in the server's metadata) and `verify_credentials` for a `profile` token
(`Scope("read:accounts|profile")`: a scope written `a|b` is either);
- stubs: `GET /api/v1/annual_reports/:year` (none) and `POST …/:year/read` (`{}`), `POST /api/v1/accounts` (403, as
Mastodon with registrations closed: they are cut), `POST /api/v1/emails/confirmations` (403) and
`GET /api/v1/emails/check_confirmation` (`true`), `GET /api/v1/peers/search` (`[]`), and 4.4's
`GET /api/v1_alpha/async_refreshes/:id` (404: PrivaPub never sends `Mastodon-Async-Refresh`).
Both instance APIs then say `4.5.0 (compatible; PrivaPub <release>)` (Mastodon invariant 6).
## Custom emoji (completion program R7)
- **The server's own emoji** (`Models/Post/LocalEmoji.cs`, a collection of its own): a shortcode (`[a-zA-Z0-9_]{2,64}`,
Mastodon's rule, `Constants.ShortcodeRegex`; unique whatever its case through `Key`, the lower-cased shortcode, while
texts match it exactly), a category, `VisibleInPicker`, `Disabled` (kept, but offered nowhere and taken into nothing
new), the picture (`MediaId`, `Url`, `StaticUrl`, `ContentType`), `CreatedAt`, `UpdatedAt` (any change),
`ImageUpdatedAt` (the picture's: the `updated` that federates) and `DeletedAt`. **It is never renamed**: its shortcode
is its id elsewhere, so an update names the emoji by it, and a new name is a new emoji. Deleted, the row stays, so its
id answers 410; adding the same shortcode again (written the same way) brings the row back with a new picture.
- **In memory** (`Domain/Content/LocalEmojis.cs`, static, as `Restrictions` is): the emoji offered now (neither disabled
nor deleted), read at start, after every change and when five minutes old. Whatever renders or matches emoji reads it,
never the database.
- **Where they are taken:** a local post keeps the emoji its words, title, warning and poll options name when it is
published or edited (`StatusService`, also archive imports; `Post.Emojis`, and each `PostRevision` its own), a persona
those its name, bio and fields name when its profile is saved (`update_credentials`, `/clientapi`'s avatar insert and
update, archive imports; `Avatar.Emojis`, `LocalActor.Emojis`). Only this server's, only those offered: `:name:` with no
letter or digit right before or after it, as Mastodon scans, at most 64 an object. Remote shortcodes are never ours.
- **What is rendered is what they are now** (`LocalEmojis.Of`): a Note, a Question and an actor carry an `Emoji` tag per
kept emoji still offered, with its current picture (an actor only when it has one); the Mastodon API's `emojis` of a
local status, poll, account and history entry come from the same list. An emoji disabled or deleted since is left out,
a new picture shows (its `updated` moves, so servers holding the old one fetch it again). No `Update` is sent when an
emoji changes (Mastodon sends none either).
- **The `Emoji` sent** (`LocalEmojis.Tag`, Mastodon's shape; GoToSocial takes none without its `id`):
`{"id": "{base}/grimaces/{shortcode}", "type": "Emoji", "name": ":shortcode:", "updated": <ImageUpdatedAt, ms>,
"icon": {"type": "Image", "mediaType": "image/png" | "image/gif", "url": <the picture>}}`, and the same with the
`@context` at its id (`Federation/Controllers/EmojisController.cs`, `application/activity+json`, unsigned under
SecureMode too, since an emoji tells nothing about anyone; a disabled one 404, a deleted one 410 with a Tombstone; a
browser is sent to the picture). Statistics count its fetches as served `emoji`.
- **Reactions:** a persona reacts with one of ours as `:shortcode:`, `:shortcode@this-host:` or the bare shortcode
(`Reactions.React`, before the post's own emoji, so `:code:` is ours and `:code@host:` another server's); the
`EmojiReact` carries `content` `:shortcode:` and the full tag, which Misskey and Akkoma register before they count it.
Announcements take our emoji as reactions too (Mastodon's `name`, the shortcode, with `url` and `static_url`), and list
those their text uses in `emojis`. A new picture moves the `EmojiURL` of the reactions and notifications made with it.
- **The Mastodon API:** `GET /api/v1/custom_emojis` (anonymous) lists, as Mastodon does, those offered and in the picker,
by shortcode (`shortcode`, `url`, `static_url`, `visible_in_picker`, `category`), from memory.
- **Administrator routes** (`EmojiAdminController`, `[Authorize(Policy = Policies.IsAdmin)]` and checked again against
the database; every act in the staff's log as `emoji.insert`, `emoji.update` (its note names what changed) or
`emoji.delete`, target kind `emoji`, labelled `:shortcode:`):
- `GET /clientapi/admin/emoji/list`: `List<ViewLocalEmoji>`, disabled ones too, deleted ones never;
- `POST /clientapi/admin/emoji/insert`: multipart, `LocalEmojiForm` (`shortcode`, `category`, `visibleInPicker`,
`disabled`) and the picture as `image` (or `file`): 200 `ViewLocalEmoji`, 400 for the form, 409 for a shortcode taken
(whatever its case), 422 for the picture;
- `POST /clientapi/admin/emoji/update`: the same form, the emoji as it should be (its category none when empty), a new
picture optional: 200, 400, 404 for no such emoji, 422;
- `POST /clientapi/admin/emoji/delete?shortcode=`: 200, 404.
- **Its picture** (`IMediaService.EmojiImage`, see Media invariants): held by `ServerUse = "emoji:<shortcode>"` like the
thumbnail (the janitor and the quota leave it, `admin media audit` keeps the pictures of the emoji not deleted, disabled
ones too); a replaced picture and a deleted emoji's are trashed.
- **Backups and restores:** `LocalEmoji` is in every backup. A restore keeps the live emoji as it keeps the settings
(`ProtectiveMerge`: P's rows): one deleted since the backup stays deleted (its id answers 410, its picture stays in the
trash), one added or changed since stays as it is; then a picture of the server's that neither the settings nor an
emoji holds goes to the trash.
## Federation invariants ## Federation invariants
1. **Every outbound request goes through `IFederationHttp`.** Its handler resolves the name itself and connects only 1. **Every outbound request goes through `IFederationHttp`.** Its handler resolves the name itself and connects only
to public addresses; redirects are followed by hand (three at most, each re-checked); bodies are capped at 1 MB; to public addresses; redirects are followed by hand (three at most, each re-checked); bodies are capped at 1 MB;
only JSON media types are read; a refused URL is not asked again for five minutes. Never create another only JSON media types are read; a refused URL is not asked again for five minutes. Never create another
`HttpClient` for federation. The only other outbound traffic is SMTP and `GeoUpdater`'s monthly DB-IP Lite download `HttpClient` for federation. The only other outbound traffic is SMTP, `GeoUpdater`'s monthly DB-IP Lite download
(its own `geo` client, a fixed HTTPS host, size-capped, the file checked before it is swapped in). (its own `geo` client, a fixed HTTPS host, size-capped, the file checked before it is swapped in), and Web Push (R9):
the named `push` client, on `SafeHttpHandlerFactory`'s handler (it resolves the name itself and connects only to
public addresses, with federation's test-network exceptions; no redirects, cookies or proxy), https only (plain http
only where `Federation:AllowPlainHttp`), 5 s to connect and 15 s a request. It POSTs encrypted payloads to the
endpoints clients subscribed with, and reads nothing back but the status.
2. **Every fetch is signed by the instance actor** (`privapub`), never by a persona; deliveries are signed by the acting 2. **Every fetch is signed by the instance actor** (`privapub`), never by a persona; deliveries are signed by the acting
avatar or group. Both are draft-cavage rsa-sha256 over `(request-target) host date` (+ `digest` on bodies). Inbound, avatar or group. Both are draft-cavage rsa-sha256 over `(request-target) host date` (+ `digest` on bodies). Inbound,
an RFC 9421 signature (`Signature-Input`) is verified too, its target against our public address. A persona's own an RFC 9421 signature (`Signature-Input`) is verified too, its target against our public address. A persona's own
@@ -232,13 +641,17 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
11. **Every number about posts and users goes through `Domain/Privacy/Counted`** (owner decision 2026-10-04: one 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 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); `status_count` all count Mastodon's way (not deleted, not a DM, boosts included, circle and located posts too);
users exclude personas of banned or deleted roots; `replies_count` counts only public and unlisted replies; a users exclude personas of banned or deleted roots (a suspended persona still counts, and its posts: a count tells
nothing new of it); `replies_count` counts only public and unlisted replies; a
remote account that deletes itself takes its likes, votes, reactions, boosts and replies out of every count remote account that deletes itself takes its likes, votes, reactions, boosts and replies out of every count
(`GoneActors`). Follower and following counts are public, their members never are (`hide_collections` is always (`GoneActors`). Follower and following counts are public, their members never are (`hide_collections` is always
true). true).
12. **Every "may anyone see this" goes through `VisibilityPolicy.IsPublic`;** a persona-specific read uses `CanSee`, and 12. **Every "may anyone see this" goes through `VisibilityPolicy.IsPublic`;** a persona-specific read uses `CanSee`, and
any other read of stored posts filters by `IsShown`. All three hide deleted posts and the posts of a remote account any other read of stored posts filters by `IsShown`. All three hide deleted posts, the posts of a remote account
that deleted itself (`Post.AuthorGone`: kept, hidden everywhere, owner decision). that deleted itself (`Post.AuthorGone`: kept, hidden everywhere, owner decision), of a suspended author
(`AuthorSuspended`) and those the staff took down (`ModeratedAt`); a post read one at a time (`Shown`) is also hidden
when its persona's login is banned (`Restrictions`). Where strangers browse (public and hashtag timelines, trends,
search, relays) `IsListed` leaves out a silenced author's posts (`AuthorLimited`, R6).
13. **Remote content is stored only when someone here asked for it:** a persona follows the author, is addressed or 13. **Remote content is stored only when someone here asked for it:** a persona follows the author, is addressed or
mentioned, it replies to a local post, or it is addressed to a community the author follows; a public parent is mentioned, it replies to a local post, or it is addressed to a community the author follows; a public parent is
fetched as context. Followers-only is detected by the author's stored `followers` URL. fetched as context. Followers-only is detected by the author's stored `followers` URL.
@@ -270,17 +683,23 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
deletion, session stamp). The issued token is the same kind as the authorization code flow's, so revocation and deletion, session stamp). The issued token is the same kind as the authorization code flow's, so revocation and
`RootSessions` cover it. `RootSessions` cover it.
2. `/api/*` authenticates with OpenIddict validation, everything else with the old JWT (`PrivaPub` policy scheme). 2. `/api/*` authenticates with OpenIddict validation, everything else with the old JWT (`PrivaPub` policy scheme).
Every `/api` request re-checks that the persona's root is neither banned nor deleted (`MastodonController`). Every `/api` request re-checks that the persona's root is neither banned nor deleted and the persona neither deleted
nor suspended (`MastodonController`); a persona's token that can no longer act answers 401 everywhere, as a revoked
one does on Mastodon (R6).
3. Read parameters through `Params` (query, form and JSON merged Rails-style), never MVC binding. A value type read 3. Read parameters through `Params` (query, form and JSON merged Rails-style), never MVC binding. A value type read
from a conditional must say `(int?)null`, not `default`: that bug once made every list one item long. from a conditional must say `(int?)null`, not `default`: that bug once made every list one item long.
4. Answer with `Json(...)` (snake_case, explicit nulls) or `Error(status, message)`; page lists with `Page` and `Link`. 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` 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 error that leaves without a body (a challenge, a 404 from routing, a 429) gets Mastodon's `{"error": ...}` from
`UseMastodonErrorBodies`. `NeverFiveHundredTests` walks every route with junk ids, anonymously, as a persona and `UseMastodonErrorBodies`.
with a junk token. 6. **Advertise `4.5.0 (compatible; PrivaPub <release>)`** in both instance APIs (`InstanceController.Version`, since R9;
6. Advertise `4.2.0 (compatible; PrivaPub)` until streaming and Web Push exist too; `api_versions.mastodon = 7` stays, the release is the build's tag without its `v`, else its ref or commit), with `api_versions.mastodon = 7`, 4.5's own.
because clients show quotes from it, so everything a 4.3+ client calls because of it must answer: grouped The number is the walk's (`docs/MASTODON-API.md`): every client route of Mastodon 4.3, 4.4 and 4.5 is served or
notifications (`/api/v2/notifications`, its unread count, groups, accounts and dismiss) and the notification policy. answers as a named stub, and none a client relies on is missing. A route Mastodon adds, or one PrivaPub stops serving,
goes there first, and the version follows the walk, never a wish; `tools/smoke/mastodon-api.sh` checks it on every
deploy. Clients branch on both numbers, so everything a 4.3+ client calls must answer: grouped notifications
(`/api/v2/notifications`, its unread count, groups, accounts, dismiss and clear), quotes, Web Push, streaming and the
notification policy.
The policy is real (`Domain/Social/NotificationPolicies.cs`, applied in `Notifications.Add`): accepted by default, The policy is real (`Domain/Social/NotificationPolicies.cs`, applied in `Notifications.Add`): accepted by default,
it filters (into a `NotificationRequest` per account, out of every list and count unless `include_filtered`) or drops it filters (into a `NotificationRequest` per account, out of every list and count unless `include_filtered`) or drops
what strangers, accounts that do not follow the persona (or only for three days), accounts newer than 30 days, what strangers, accounts that do not follow the persona (or only for three days), accounts newer than 30 days,
@@ -288,14 +707,22 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
(`NotificationPermission`). (`NotificationPermission`).
7. **What a Mastodon `Status` cannot say goes in `Status.privapub`** (`PrivaPubStatus`): object type, title, excerpt, 7. **What a Mastodon `Status` cannot say goes in `Status.privapub`** (`PrivaPubStatus`): object type, title, excerpt,
cover, the author's source, link, video, audio and event details, a remote post's own place (Pixelfed's `location`, 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 through the proxy; only page links (`link.url`, an event's online link) point at the remote site, because following
one is the reader's choice. one is the reader's choice.
8. **What a persona keeps through the API is that persona's** (R8): featured tags (public, federated), endorsements and
private notes (shown to it alone, never federated), dismissed suggestions. Suggestions never offer the persona itself
or a sibling persona of its login, and `familiar_followers` answers no one for every account, since it would tell who
follows whom here.
9. **A Web Push subscription is its token's** (R9): one per access token, read, changed and deleted only with that token,
pushed only its persona's notifications, and its payload carries only that token. It ends when the token does.
## Media invariants ## Media invariants
1. **No upload keeps its metadata.** Images are re-encoded by libvips with `keep=none`; audio and video are remuxed with 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. `-map_metadata -1`.
2. **An image is checked before it is decoded.** 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 - **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. constructor blocks every other loader), so an SVG, PDF or TIFF claiming another type never loads.
@@ -305,6 +732,13 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
sRGB (`thumbnail`). sRGB (`thumbnail`).
- **GIFs:** an animated GIF becomes a looping silent H.264 mp4 typed `gifv`, as on Mastodon (ffmpeg with libx264; - **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. `PostMedia.Kind` says so), and a still GIF is an image.
- **A custom emoji's picture** (R7, `EmojiImage`): PNG, GIF, WebP or JPEG up to 256 KB, fitted within 256 px with its
transparency kept, written by libvips with `keep=none`: a still picture as a PNG, an animated GIF or WebP as an
animated GIF (libvips' `thumbnail` with every page, then `gifsave`: a PNG would lose the animation, and GIF is the
animated picture every peer takes as an emoji) with a still PNG of its first frame for `static_url`. It must end at
most 100 KiB, GoToSocial's default for another server's emoji (the strictest peer; Mastodon's is 256 KB): a still
picture is tried as it is, then with a palette, then at 192, 128, 96 and 64 px; an animation at those sides; one that
never fits is refused (422).
- **Types:** HEIC and HEIF are not accepted, since the bundled libvips has no HEVC decoder. - **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 - **Load:** processing runs `Media:Concurrency` at a time, and uploads have their own rate limit (`uploads`, per
credential). credential).
@@ -331,8 +765,10 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
- `MediaJanitor` deletes them a day later (`TrashGrace`). - `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 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) refuses an upload over it with 422; the `/clientapi/user/storage`; `Media:QuotaBytesPerRoot` (0, none, by default, or the administrator's override in the
administrator's statistics overview has the totals, the proxy cache and the trash. 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, `emoji:<shortcode>` for a custom
emoji's) belong to nobody and are held while the settings name them, or while their emoji is not deleted.
Nothing is deleted because it looks unused. `PrivaPub admin media audit [--fix]` compares disk and database: with 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 `--fix` (as www-data) it gives pictures shown from before their rows a row, and trashes media of deleted posts or
@@ -375,10 +811,36 @@ 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 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` 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). 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` - **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 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 and the accounts it follows, the personas' posts are emptied, and `/peasants/{name}`, its inbox and WebFinger answer
410 (`LocalActorService.Gone`) while the names stay reserved. The root keeps only a `deleted-{id}` name. 410 (`LocalActorService.Gone`) while the names stay reserved. The root keeps only a `deleted-{id}` name. One persona
or one group is deleted the same way (`PersonaRemoval`, `GroupRemoval`, R6), by its login or owner with the password,
or by an administrator with theirs.
- **Moderation never pairs a login with its personas** (R6): a ban is held in memory (`Restrictions`) and never written
on the personas or their posts; moderators see personas and accounts, never logins (`ViewModeratedAccount`), and only
administrators read a login next to its personas (`ViewAdminRoot`); the staff's log follows R5's rule.
- **Never derive a public name from the root username.** Invitation sign-up takes `AvatarUserName` and refuses one - **Never derive a public name from the root username.** Invitation sign-up takes `AvatarUserName` and refuses one
equal to the login. equal to the login.
- **One username space:** personas, groups and the instance reserve their name in `ReservedName` (unique index) before - **One username space:** personas, groups and the instance reserve their name in `ReservedName` (unique index) before
@@ -388,7 +850,9 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
- **An actor's `published` is `PublishedOn`**, a random whole day up to two weeks before creation, so personas made the - **An actor's `published` is `PublishedOn`**, a random whole day up to two weeks before creation, so personas made the
same day do not share a date. New persona and group ids carry that same day (`GenerateNewID`), because local clients same day do not share a date. New persona and group ids carry that same day (`GenerateNewID`), because local clients
see ids. Never emit `CreatedAt`. see ids. Never emit `CreatedAt`.
- **Per-avatar state stays per avatar:** blocks, mutes, notifications, follows. Nothing may relate sibling avatars. - **Per-avatar state stays per avatar:** blocks, mutes, notifications, follows, and since R8 featured tags,
endorsements, private notes (`PersonaNote`, never `RootUserNote`) and dismissed suggestions. Nothing may relate sibling
avatars: suggestions never offer a sibling, and familiar followers stay empty.
- **Blocks federate** (owner decision, 2026-10-01): a block is sent as `Block` from the blocking avatar, an unblock as - **Blocks federate** (owner decision, 2026-10-01): a block is sent as `Block` from the blocking avatar, an unblock as
`Undo{Block}`. Reports still leave as `Flag` from the server's own actors (the instance actor, or the reporter for `Undo{Block}`. Reports still leave as `Flag` from the server's own actors (the instance actor, or the reporter for
communities on Lemmy, PieFed and Mbin), never from the reporting avatar. communities on Lemmy, PieFed and Mbin), never from the reporting avatar.
@@ -396,6 +860,12 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
decided in `docs/ROADMAP.md` ("Owner decisions on what PrivaPub reveals"). Anything new that tells another server decided in `docs/ROADMAP.md` ("Owner decisions on what PrivaPub reveals"). Anything new that tells another server
something about an avatar gets the same treatment: ask, then record it there. something about an avatar gets the same treatment: ask, then record it there.
- **Location-ranged posts never federate.** - **Location-ranged posts never federate.**
- **Web Push tells a push service when, never what** (R9). Every payload is encrypted to the subscribing client's own
keys (RFC 8291, or the aesgcm draft), so the push service (Google's, Mozilla's, Apple's, a UnifiedPush distributor)
learns only that this server has something for that endpoint, when, and how big: never the words, the sender or the
persona. The VAPID JWT names the push service's origin and the server's contact (the contact email, or the server's
address), never a persona. Subscriptions are per token, so per persona: none receives a sibling's notifications, and
nothing stored pairs two personas of one login.
- **Statistics name servers, never people** (owner decisions on statistics, 2026-10-03). Every interaction is recorded - **Statistics name servers, never people** (owner decisions on statistics, 2026-10-03). Every interaction is recorded
through `IInteractionLedger` (`Infrastructure/Statistics`) as an `InteractionEvent` (90 days), but an event never through `IInteractionLedger` (`Infrastructure/Statistics`) as an `InteractionEvent` (90 days), but an event never
holds a root, persona or group id, an activity id, an inbox URL, a remote actor URI or a sender IP: holds a root, persona or group id, an activity id, an inbox URL, a remote actor URI or a sender IP:
@@ -497,8 +967,10 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
backup rotates out), copies where a link can't be made. `--db-only` lists them instead. 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), **Never in a backup** (`ServerBackup.Excluded`): `InteractionSalt` (owner rule), `Job` and `Delivery` (work in flight),
`EmailRecovery`, `openiddict.tokens` and `.authorizations` (sessions), `MaintenanceLock`, and `AppConfiguration` `EmailRecovery` and `EmailVerification`, `openiddict.tokens` and `.authorizations` (sessions), `MaintenanceLock`, `ServerSetup` (a restore
(its SMTP password; it is made again from appsettings at boot). One backup or restore runs at a time never opens setup again), `AppConfiguration` (its SMTP password; it is made again from appsettings at boot),
`PushSubscription` (sessions' too, R9) and `PushKeys` (the VAPID key: a restore keeps the live one). 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 (`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; 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 rotation keeps 7 daily and 4 weekly nightly backups, 3 pre-deploy, 3 pre-restore, and manual and uploaded ones until
@@ -516,10 +988,16 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
4. **the protective merge from P: a restore never undoes a protective act.** Followers and follows are P's; blocks, 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 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 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; what their deletion takes away; P's moderation of a persona or an account elsewhere (silenced, suspended), the posts
the staff took down, and a root's password, e-mail, ban and policies stay, and every post's moderation flags are
worked out again (`ModerationFlags`);
roots, personas and groups made since become tombstones (deleted, names kept), local posts made since answer 410, 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; media made or trashed since go to the trash (but the server's own pictures, `ServerUse`, though one of those that
5. every session ends (a new `SessionStamp` for each root, persona tokens deleted), circuits close, and a nothing holds any more goes there too); the staff's log (`AdminAction`) is the union, so a restore never loses an
entry, its own request included, and the server's settings and custom emoji (`LocalEmoji`, R7) are P's: the live
settings and emoji outlive a restore, and an emoji deleted since stays deleted;
5. every session ends (a new `SessionStamp` for each root, persona tokens deleted, and every Web Push subscription with
them, R9: clients subscribe again, to the same VAPID key), circuits close, and a
`RestoreRecord` (never in a backup) tells what happened (`admin restore --status`). `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 Each attempt redoes everything. Refused before anything changed, it is abandoned and recorded, and the server boots as
@@ -582,37 +1060,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 + - 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. 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 There is no test project (owner decision 2026-10-17: `PrivaPub.Tests` was removed, with the CI and deploy steps that
without `PRIVAPUB_TEST_MONGOD=1`. CI (`build.yml` and `deploy.yml`) runs all of them through ran it). Don't add one back unless asked. Verify by building, running locally, and exercising:
`tools/ci/with-test-mongod.sh`, which starts a throwaway mongod on a random localhost port and deletes it afterwards.
Like production's, it is a one-member replica set (`rs0`, reached with `directConnection=true`), so backups' snapshot
reads are what the tests exercise (`TopologyTests`).
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:
- the client API (sign up, create an avatar, a group, a post); - the client API (sign up, create an avatar, a group, a post);
- the ActivityPub endpoints with curl and `Accept: application/activity+json`. - the ActivityPub endpoints with curl and `Accept: application/activity+json`.
@@ -621,11 +1072,12 @@ Interop is checked against real servers, starting with the workstation's own pas
```bash ```bash
DOTNET=~/.dotnet/dotnet tools/pasture/run.sh up [gts mastodon ...] # podman: PrivaPub + the named peers (default gts) + Mongo, behind Caddy 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/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 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) - **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 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. 1.0 reader refused every document carrying it.
- **Layout:** `lib/pasture.sh` (network, Caddy, Mongo, PrivaPub), `peers/<name>.sh` (`<name>_up`, plus `peers/shared.sh` - **Layout:** `lib/pasture.sh` (network, Caddy, Mongo, PrivaPub), `peers/<name>.sh` (`<name>_up`, plus `peers/shared.sh`
@@ -678,7 +1130,7 @@ tools/pasture/run.sh down # removes e
read from its `user_relationships`. `akkoma_token <user>` signs one of its users in. 45 checks. read from its `user_relationships`. `akkoma_token <user>` signs one of its users in. 45 checks.
- **Lemmy (1.0.0-beta.2):** the backend alone on the shared Postgres, its admin made by `setup` in the generated - **Lemmy (1.0.0-beta.2):** the backend alone on the shared Postgres, its admin made by `setup` in the generated
`config.hjson`. It trusts Caddy's CA through `SSL_CERT_FILE` and reaches the network through `config.hjson`. It trusts Caddy's CA through `SSL_CERT_FILE` and reaches the network through
`DANGER_FEDERATION_ALLOW_LOCAL_IP=1`; 0.19 cannot join (its rustls trusts only its bundled roots). Its API is `DANGER_FEDERATION_ALLOW_LOCAL_IP=1`; 0.19'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 `/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 activity at `warn` (`LEMMY_LOG` sets `RUST_LOG`); the reason is in the 400's body. It answers our community's echo of
its own activity and every bare `Announce{object}` 400 by design, and the echo is still needed (see its own activity and every bare `Announce{object}` 400 by design, and the echo is still needed (see
@@ -765,6 +1217,69 @@ 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 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 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. 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).
- **Moderation (`scenarios/moderation.sh`, needs gts and mastodon; after instance in batch A, whose log check expects
the administrator's acts alone):** a second login made a moderator by `create-root` and the role route (its own role
refused, 409; a moderator refused the logins, 403; two-factor reset); a silenced persona's post out of the public,
local and hashtag timelines and search, still in a follower's home here and on Mastodon, its account `limited`, the
persona warned; a suspended persona's token 401, its account, statuses, post and lookup 404, its actor 200 and its
outbox and post 404 to a signed GET (Mastodon's `Request` through `rails runner`, since SecureMode answers unsigned
ones 401), OAuth no longer offering it, Mastodon's copy of the account untouched, then back whole (a new token) and
warned; gtsuser suspended: its post and account gone here, what it sends meanwhile dropped (Mongo), a DM to it never
sent, then its earlier post back and new ones arriving; gts.test silenced through a domain block and back; a banned
login's personas 401 and 404, a Mastodon follow left waiting (`follow_requests`), nothing written on either persona,
back once unbanned; a persona deleted by the administrator (password checked, moderators refused) gone on Mastodon and
GoToSocial, one deleted by its own login, groups deleted by their owner and by the administrator (410); posts removed
directly and through a report gone on Mastodon, the author warned, a GoToSocial copy taken down here; the staff's log
listing every act, the report's action in its note, no login entry naming a persona, none shown to the moderator, and
no address.
- **Custom emoji (`scenarios/emoji.sh`, needs gts, mastodon, misskey and akkoma; after moderation in batch A):** the
administrator adds `:pasture_<run>:` (409 for it again, a persona refused the list); `/api/v1/custom_emojis` lists it
and `/grimaces/{shortcode}` serves the `Emoji` unsigned; the four peers follow emoji_alice, whose post using it shows it
in the status's `emojis` on Mastodon, GoToSocial (by URI), Misskey (the note's `emojis`) and Akkoma; her display name
shows it on Mastodon; her `EmojiReact` with it reaches Misskey and Akkoma as that emoji; deleted, it leaves the list,
its id answers 410 and posts here carry it no more; the staff's log keeps the insert and the delete.
- **Featured tags and the API made real (`scenarios/featured.sh`, needs mastodon; after emoji in batch A):** mastouser
follows feat_alice, who features a tag: Mastodon's `featured_tags` of her account shows it (her `Add{Hashtag}`, the
`/tattoos` collection serving it), unfeaturing takes it away there; mastouser features a tag, which feat_alice reads
(following him) through `/api/v1/accounts/:id/featured_tags`; following mastouser with `notify=true` gives feat_alice a
`status` notification for his next post; her SSE and WebSocket `direct` streams tell a `conversation` event for his
DM; her endorsement of him and her note on him are hers alone (feat_bob, a sibling persona of the same login, sees
neither, and `GET /api/v1/accounts/:id/endorsements` answers nobody else), and feat_bob is never suggested to her; the
stubs (translate 422, annual reports, the admin API 403) answer.
- **Web Push (`scenarios/push.sh`, needs mastodon and pushsink; after featured in batch A):** push_alice subscribes with
the sink's keys; mastouser's mention reaches the sink as aes128gcm, its VAPID JWT by the instance API's key for the
sink's origin with TTL and Urgency, and decrypts to a `mention` with her own access token and her notification's id;
the mention alert turned off stops it while his favourite is pushed; `policy=followed` stops him until she follows him;
`standard=false` is aesgcm (Encryption, Crypto-Key, `WebPush`) and decrypts too, and the new subscription replaces the
old; push_bob, a sibling persona of the same login, never gets hers nor she his; DELETE stops the pushes; the sink
answering 410 removes the subscription; a token revoked at `/oauth/revoke` is pushed nothing; no subscription is left.
Both instance APIs say 4.5.0 and give the key an app registration gives.
- **pushsink (`peers/pushsink.sh`, `pushsink/pushsink.cs`, as `pushsink.test`):** a Web Push service of the pasture's
own, a .NET file-based app published once into `.pushsink` like flood. It makes each subscription's keys itself, checks
what a push service checks (the VAPID JWT: ES256 by the server's key, the instance API's or the one the scenario gave
it; `aud` its own origin; `exp` ahead and at most a day; a `mailto:` or `https:` `sub`; TTL), decrypts `aes128gcm` and
`aesgcm` with its own code, not PrivaPub's, and keeps what it took. Its API, behind Caddy: `POST /subscriptions`
(`{"server_key"}` optional) answers `{id, endpoint, keys: {p256dh, auth}}`; `GET /subscriptions/{id}`;
`POST /subscriptions/{id}/answer` (`{"status": 410, "times": 1}`, for the next pushes); `GET` and `DELETE
/subscriptions/{id}/received` (each push: when, the status answered, `ok` and the errors, headers, the JWT's claims, the
decrypted payload); `POST /push/{id}` is the push service (201, 403 for a VAPID failure, 400 for the rest, 404 for no
such subscription); `/health`. PrivaPub reaches it through Caddy as every peer, since its `push` client honours
`appsettings.Pasture.json`'s Federation exceptions; nothing new is configured. In memory only.
- **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, - **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 `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 `.env` the image rewrites at each start, `spark fediverse:broadcast` looping in the container. The podcast `@pod` is
@@ -910,26 +1425,27 @@ tools/pasture/town.sh backlog --write # docs/INTEROP-BACKLOG.md
audience only; a Misskey heart reaction arrives as a Like. 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 - 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`. `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 - decePubClient is served as `https://decepub.test` (`peers/decepub.sh`) once published with the Pasture environment
it as `https://decepub.test` (`peers/decepub.sh`) and write their cells to `out/client.jsonl`. (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 After the pasture, verify.funfedi.dev and the owner's GoToSocial at social.arasaka.software. **Ask before acting from
the owner's GoToSocial account.** the owner's GoToSocial account.**
## Deploy ## Deploy
- **CI/CD:** push to `master` runs `build.yml` (build + tests) on the instance-wide `build` runner. A `v*` tag runs - **CI/CD:** push to `master` runs `build.yml` (a build) on the instance-wide `build` runner. A `v*` tag runs
`deploy.yml`: tests, self-contained linux-x64 publish, a snapshot of the site to `/var/backups/privapub.thepra.dev`, `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 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), 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, 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, Swagger 404, inbox junk 400, unsigned 401) and `tools/smoke/mastodon-api.sh` (app registration with its `vapid_key`,
discovery, instance, public timeline). Then it checks that what production should be running is running: client credentials, discovery, both instance APIs with the advertised version and the VAPID key, public timeline).
Then it checks that what production should be running is running:
- NodeInfo and the instance API agree on registrations (closed, invitations only); - NodeInfo and the instance API agree on registrations (closed, invitations only);
- `/stargazing` says the crawler is on; - `/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` - nothing signs in (owner decision 2026-10-17): the deploy's own login, `deploy-smoke`, gave its @thepra to the
runs the real OAuth flow (the pasture's `privapub_token` uses the same script), the signed-in API is checked, the administrator and was deleted, and the signed-in check went with it;
persona must be undiscoverable, and the token is revoked;
- the DB-IP Lite databases, which the server fetches itself (`GeoUpdater`), exist and are at most 40 days old. - 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 - **The box:** Max (`nuvola.xyz`). Unit `privapub` runs as www-data from `/var/www/privapub.thepra.dev` with
`ASPNETCORE_ENVIRONMENT=Production`. `ASPNETCORE_ENVIRONMENT=Production`.
+125 -23
View File
@@ -65,8 +65,8 @@ Checked both ways, where the peer has the feature:
- communities and circles; - communities and circles;
- blocks and unfollows. - 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 `docs/INTEROP.md` has each peer's evidence and what is still expected to fail. The platforms not in the pasture are
covered by unit tests written in their documents' shape. read from documents in their published shape (`docs/INTEROP.md` records each one); there is no test project.
## Supported FEPs ## Supported FEPs
@@ -98,10 +98,21 @@ covered by unit tests written in their documents' shape.
(sent and honoured; see "Followers synchronisation") (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): - [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") 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
- [FEP-9098: Custom emojis](https://codeberg.org/fediverse/fep/src/branch/main/fep/9098/fep-9098.md): received, and this
server's own sent on posts, polls, actors and reactions, each served at its id (see "Custom emoji")
Planned (see `docs/ROADMAP.md`, phases P7 and P8, and the per-platform notes in `docs/INTEROP.md`): FEP-9098 (custom Planned (see `docs/ROADMAP.md`, the completion program, and the per-platform notes in `docs/INTEROP.md`): FEP-8967 link
emoji), FEP-7888 and FEP-f228 (threads), FEP-7628 (Move), FEP-8967 (link attachments sent (R10).
attachments), FEP-ae0c (relays).
## Actors ## Actors
@@ -121,6 +132,8 @@ actor document, a collection, NodeInfo or a delivery relates two avatars of the
| Direct-message context | `/peasants/{name}/whispers/{id}` | | Direct-message context | `/peasants/{name}/whispers/{id}` |
| A persona's wall (FEP-400e) | `/peasants/{name}/graffiti` | | A persona's wall (FEP-400e) | `/peasants/{name}/graffiti` |
| Followers on the asking server (FEP-8fcf) | `/peasants/{name}/groupies/roll-call` | | Followers on the asking server (FEP-8fcf) | `/peasants/{name}/groupies/roll-call` |
| Featured (pinned) posts, featured hashtags | `/peasants/{name}/trophies`, `/peasants/{name}/tattoos` |
| A custom emoji of this server (`Emoji`) | `/grimaces/{shortcode}` |
| Profile and post pages | `/@{name}`, `/@{name}/{id}` | | Profile and post pages | `/@{name}`, `/@{name}/{id}` |
The names are the project's own and are stable; resolve actors through WebFinger, not by guessing a path. The names are the project's own and are stable; resolve actors through WebFinger, not by guessing a path.
@@ -128,14 +141,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. - The key is `{actor}#main-key`, RSA 2048, served as SPKI PEM with `owner` set to the actor.
- `published` on an actor is a whole day, chosen at random up to two weeks before the account was made, so two - `published` on an actor is a whole day, chosen at random up to two weeks before the account was made, so two
personas made on the same day do not share a date. `indexable` is `false` unless the persona turns it on, and 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 - 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 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. 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 - 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 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. 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 ## Groups
@@ -167,7 +185,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 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, 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 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 ## Activities
@@ -185,17 +210,26 @@ Received:
| `Like` | counted and notified, on posts the liker could see | | `Like` | counted and notified, on posts the liker could see |
| `Dislike` | counted as a downvote (Lemmy, PieFed, Mbin, Friendica); `Undo` takes it back | | `Dislike` | counted as a downvote (Lemmy, PieFed, Mbin, Friendica); `Undo` takes it back |
| `EmojiReact`, `Like` with an emoji `content` | an emoji reaction (FEP-c0e0; Pleroma, Akkoma, Iceshrimp.NET, Misskey, Sharkey), Unicode or a custom emoji from its `tag`; a `Like` whose content is ❤ stays a favourite; `Undo` takes it back | | `EmojiReact`, `Like` with an emoji `content` | an emoji reaction (FEP-c0e0; Pleroma, Akkoma, Iceshrimp.NET, Misskey, Sharkey), Unicode or a custom emoji from its `tag`; a `Like` whose content is ❤ stays a favourite; `Undo` takes it back |
| `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) | | `Join` | answered with `Ignore`: PrivaPub hosts no events of its own yet (FEP-8a8e) |
| `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin | | `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin |
| `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back, though a new post its server publishes under that id after the deletion (Gancio numbers events from the last one kept) is kept | | `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back, though a new post its server publishes under that id after the deletion (Gancio numbers events from the last one kept) is kept |
| `Flag` | becomes a report for this server's moderators | | `Flag` | becomes a report for this server's moderators |
| `Move` | an account moving: believed as Mastodon believes it, when the account sends it about itself and the new account, read again from its server, names it in `alsoKnownAs`. The old account then shows where it went (`moved`), and, as Mastodon does it (owner decision 2026-10-05), the personas following it follow the new one instead (a Follow to its server, an Undo to the old), in the same lists; a mute or a block of the old account carries over | | `Move` | an account moving: believed as Mastodon believes it, when the account sends it about itself and the new account, read again from its server, names it in `alsoKnownAs`. The old account then shows where it went (`moved`), and, as Mastodon does it (owner decision 2026-10-05), the personas following it follow the new one instead (a Follow to its server, an Undo to the old), in the same lists; a mute or a block of the old account carries over |
| `Add`/`Remove` of a `Hashtag` on the actor's `featured` or `featuredTags` | the account features a hashtag on its profile, or stops (Mastodon sends them to `featured`); shown as its featured tags (`GET /api/v1/accounts/:id/featured_tags`). Its `featuredTags` collection itself is read with its counts, at most once a day (20 tags at most) |
| `Add`/`Remove` on the actor's `featured` | the account pins or unpins one of its own posts (fetched from its server when not held); inside a community's announce, the community features a post made in it. Its profile shows them first (`pinned=true`). The collection itself is read with the account's counts, at most once a day. On the account's wall (`sm:wall`), see "Walls". Any other target (a community's moderators) is dropped; one on the account's own server that PrivaPub does not know has its document read again first, at most hourly | | `Add`/`Remove` on the actor's `featured` | the account pins or unpins one of its own posts (fetched from its server when not held); inside a community's announce, the community features a post made in it. Its profile shows them first (`pinned=true`). The collection itself is read with the account's counts, at most once a day. On the account's wall (`sm:wall`), see "Walls". Any other target (a community's moderators) is dropped; one on the account's own server that PrivaPub does not know has its document read again first, at most hourly |
| `Block` of a persona | the follows between them end, the blocker's posts and notifications are hidden from the persona, nothing of the persona's is addressed to the blocker, and the relationship says `blocked_by`; `Undo{Block}` lifts it | | `Block` of a persona | the follows between them end, the blocker's posts and notifications are hidden from the persona, nothing of the persona's is addressed to the blocker, and the relationship says `blocked_by`; `Undo{Block}` lifts it |
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll votes, `EmojiReact` and its `Undo`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`, Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll votes, `EmojiReact` and its `Undo`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
`Reject{Follow}`, `Like`, `Announce` and their `Undo`, `Flag`, `Join` and `Leave` of a remote event, and the replies to a `Reject{Follow}`, `Like`, `Dislike`, `Announce` and their `Undo`, `Flag`, `Join` and `Leave` of a remote event,
persona's posts passed on to its followers. A deleted post answers 410 with a `Tombstone`. `Add`/`Remove` of a pinned post or a featured `Hashtag`, 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`. - **Attachments** are `Document`s with `mediaType`, `name` (alt text), `blurhash`, `focalPoint`, `width` and `height`.
Uploaded files have all metadata removed. Uploaded files have all metadata removed.
@@ -224,10 +258,25 @@ persona's posts passed on to its followers. A deleted post answers 410 with a `T
post on its wall. The wall lists the persona's public posts that start a thread and what was written on it, newest post on its wall. The wall lists the persona's public posts that start a thread and what was written on it, newest
first. An account elsewhere that tells its followers here, with `Add{Note}` on its own wall, of a post written there first. An account elsewhere that tells its followers here, with `Add{Note}` on its own wall, of a post written there
has it shown to them as on its wall (`privapub.wall` on the status); a `Remove` there takes it away. has it shown to them as on its wall (`privapub.wall` on the status); a `Remove` there takes it away.
- **Pinned posts** are the actor's `featured` collection (`/trophies`); `featuredTags` is `/tattoos`. A pin or an unpin - **Pinned posts** are the actor's `featured` collection (`/trophies`). A pin or an unpin is told to the post's audience
is told to the post's audience as `Add` or `Remove` on `featured`, as Mastodon tells it. as `Add` or `Remove` on `featured`, as Mastodon tells it.
- **Featured hashtags** are the actor's `featuredTags` collection (`/tattoos`): an `OrderedCollection` of `Hashtag`s, in
the order the persona featured them (ten at most), each `{"type": "Hashtag", "href": "<this server>/tags/<tag>", "name":
"#Tag"}`. Featuring one sends its followers
```json
{"id": "…/peasants/alice/grunts/feature-tag-…", "type": "Add", "actor": "…/peasants/alice",
"object": {"type": "Hashtag", "href": "https://privapub.thepra.dev/tags/gardening", "name": "#Gardening"},
"target": "…/peasants/alice/trophies", "to": ["…#Public"], "cc": ["…/peasants/alice/groupies"]}
```
and unfeaturing it the same `Remove`. The target is the `featured` collection, not `featuredTags`: that is what Mastodon
sends and the only target it takes a Hashtag from (it tells a pin from a tag by the object's type).
- **What is never sent:** a persona's endorsements of other accounts (Mastodon's account pins and 4.4's endorsements) stay
private, never published as featured accounts or a collection; its private notes on accounts and its dismissed follow
suggestions are its own. No collection lists who follows whom (familiar followers are never shown either).
- **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it - **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it
followed); an unblock sends `Undo{Block}`. followed); an unblock sends `Undo{Block}`. 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. - **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 - 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`. persona's words in `content`.
@@ -313,6 +362,23 @@ and naming the post answered, lets it out to its audience with `replyApproval`;
(`rejected`). An empty `canReply` refuses every reply (422). (`rejected`). An empty `canReply` refuses every reply (422).
**Custom emoji** (`Emoji` tags) are read on posts, display names, bios and profile fields, at most 64 per object. **Custom emoji** (`Emoji` tags) are read on posts, display names, bios and profile fields, at most 64 per object.
This server's own (its administrator adds them; shortcodes of letters, digits and underscores) are sent the same way, as
Mastodon sends them, on a post or poll whose words, title, warning or options name them as `:shortcode:`, on an actor
whose name, bio or fields do, and on an `EmojiReact`:
```json
{"id": "https://privapub.thepra.dev/grimaces/blobcat", "type": "Emoji", "name": ":blobcat:",
"updated": "2026-10-17T12:00:00.000Z",
"icon": {"type": "Image", "mediaType": "image/png", "url": "https://privapub.thepra.dev/media/files/2026/10/….png"}}
```
The picture is a PNG of at most 256 px a side, or an animated GIF, and at most 100 KiB (GoToSocial's limit for another
server's emoji). Its `id` serves the same object with its `@context` (`application/activity+json`, signed or not, since
an emoji tells nothing about anyone); a deleted emoji answers 410 with a `Tombstone`, and one added again under the same
shortcode comes back at the same id with a newer `updated`. A post or an actor rendered again carries the emoji as they
are then: one deleted or disabled since is left out, and a new picture has a newer `updated`. No activity is sent when
an emoji changes. A reaction with one of them has `content` `:shortcode:` and that tag.
**Profiles** keep their header, profile fields, `manuallyApprovesFollowers`, `published`, `movedTo`, `indexable`, **Profiles** keep their header, profile fields, `manuallyApprovesFollowers`, `published`, `movedTo`, `indexable`,
`memorial` and avatar and header descriptions. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show `memorial` and avatar and header descriptions. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show
`name`. A content warning without its own text uses the title, or "Content warning". `name`. A content warning without its own text uses the title, or "Content warning".
@@ -337,10 +403,11 @@ days and shared by every account on the server. Images are served to clients onl
## Relays ## Relays
PrivaPub reads from the relays `Federation:Relays` names (by their actor's address, or their inbox's), none by default. PrivaPub reads from the relays `Federation:Relays` names, and those its administrator adds in the server's settings (by
Its instance actor follows `Public` at each, as Mastodon subscribes (`Federation/Relays/Relays.cs`, a minute after start their actor's address, or their inbox's), none by default. Its instance actor follows `Public` at each, as Mastodon
and every six hours: an unanswered or refused subscription is asked again a day later, and a relay no longer named gets subscribes (`Federation/Relays/Relays.cs`, a minute after start, every six hours and at once when the administrator adds
the `Undo`). What an accepted relay passes on comes to the federated timeline, never to anyone's home: a public post it 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 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 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 persona's public post outside any group, its edit and its deletion also go to the relays that accepted us (owner decision
@@ -348,6 +415,31 @@ persona's public post outside any group, its edit and its deletion also go to th
only with an LD signature or an FEP-8b32 proof: PrivaPub's carry a proof, so they reach Mastodon through both kinds of only with an LD signature or an FEP-8b32 proof: PrivaPub's carry a proof, so they reach Mastodon through both kinds of
relay (checked live). relay (checked live).
## Moderation
What this server's staff does to an account tells other servers nothing new (owner decision 2026-10-17): no `Flag`,
`Block`, `Update` or `Delete` is sent for a silence or a suspension, and none is undone when it is lifted.
- **A persona silenced here** stays out of this server's public and hashtag timelines, trends, directory and search,
and its posts no longer go to the relays. Everything else is as before: its actor, its collections, and every
delivery to its followers and to the accounts it addresses.
- **A persona suspended here** (by the staff, or with its login banned) delivers nothing, takes no new follower (a
`Follow` is left unanswered, as one waiting for a locked account's approval), and its outbox, followers, following,
featured collections, posts, activities, replies, contexts, wall and quote permissions answer 404, signed or not. Its
actor document, its inbox (202, as ever) and WebFinger stay exactly as they were, so lifting the suspension serves
everything again without telling anyone it happened. A post of it removed meanwhile is deleted here only. The one thing
it may still send is the `Delete` of itself, if it is deleted.
- **An account elsewhere suspended here:** what it sends is dropped after the 202, but a `Delete` or an `Undo`; nothing
is addressed to it, and nothing goes to its own inbox (its server's shared inbox still serves the others there).
Silenced, its posts stay out of the public timelines here. Its server hears of neither.
- **A server silenced** (a domain block of `silence`) has its accounts' posts kept out of the public timelines here, as a
silenced account's are; a suspended one is refused at the inbox, as before.
- **A post removed** by the staff: one of ours is deleted as its author would delete it (a `Delete` to its audience); a
copy of one from elsewhere is hidden here and never taken in again, and its server is not told.
- **A persona or a group deleted** (by its login, its owner or an administrator): a `Delete` of the actor to its
followers, the accounts it follows and a group's members elsewhere; its documents answer 410 from then on, and its name
is never given again.
## Server descriptions and the crawler ## Server descriptions and the crawler
PrivaPub keeps statistics about servers, never about accounts (see `/stargazing` on the server). PrivaPub keeps statistics about servers, never about accounts (see `/stargazing` on the server).
@@ -364,6 +456,14 @@ PrivaPub keeps statistics about servers, never about accounts (see `/stargazing`
- **Opting out:** in robots.txt, the crawler obeys the group `PrivaPub-Stargazer`, then `PrivaPub`, then `*`. A - **Opting out:** in robots.txt, the crawler obeys the group `PrivaPub-Stargazer`, then `PrivaPub`, then `*`. A
robots.txt that answers with a server error or times out also keeps it out, as do this server's domain blocks. robots.txt that answers with a server error or times out also keeps it out, as do this server's domain blocks.
## Web Push
Not federation, but traffic an operator may see: when a client signed in as a persona subscribes to Web Push (Mastodon's
`/api/v1/push/subscription`), PrivaPub POSTs its notifications to the push service the client named, encrypted to the
client (RFC 8291 `aes128gcm`, or the older `aesgcm` draft Mastodon still sends) and carrying a VAPID JWT (RFC 8292) signed
by the server's own P-256 key, the one `/api/v2/instance` gives as `configuration.vapid.public_key`. No HTTP signature
and no ActivityPub; https endpoints on public addresses only, no redirects. The push service learns when, never what.
## Local-only posts ## Local-only posts
Posts with a location (shown to nearby users of this server) never leave the server, in any form. Posts with a location (shown to nearby users of this server) never leave the server, in any form.
@@ -392,12 +492,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 - **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three
redirects and read at most 1 MB. redirects and read at most 1 MB.
- **Threads.** A reply's missing parents are fetched, up to 10 levels. When someone here opens a public remote thread, - **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 its replies are read from their servers, at most once an hour: the thread's conversation container (FEP-171b's
one, else its `replies` (PeerTube's `comments`) and theirs, two levels down; 5 pages and 100 posts at most. Only `contextHistory`: the posts its owner's `Add`s brought in) or its `context` collection (FEP-7888) if it has one, else
public and unlisted replies are kept, each fetched from its own origin. 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 - **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 Mastodon's authorized fetch; the server's own actors (the instance actor `/peasants/privapub`, the reporter
`/peasants/privapub_reports`) are the exception, since their keys are needed first. `/peasants/privapub_reports` and the reader of feeds `/peasants/privapub_feeds`) are the exception, since their keys
are needed first, and so are its custom emoji at `/grimaces/{shortcode}`.
A browser asking for HTML is redirected to the public page instead. 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 - **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 for, or from the instance actor of a server where someone they were for lives, and to nobody else (404). Once deleted
@@ -423,7 +526,6 @@ Posts with a location (shown to nearby users of this server) never leave the ser
## Known limitations ## Known limitations
- Our own posts carry no custom emoji: emoji are received and shown, not offered.
- Remote media is fetched through this server's proxy when a local client displays it. Video and audio are streamed - Remote media is fetched through this server's proxy when a local client displays it. Video and audio are streamed
through it with byte ranges passed on, so a PeerTube file is never fetched whole for one viewer. through it with byte ranges passed on, so a PeerTube file is never fetched whole for one viewer.
- Collections expose counts, not members: `/groupies` and `/stalking` give the same totals as the Mastodon API's - Collections expose counts, not members: `/groupies` and `/stalking` give the same totals as the Mastodon API's
@@ -12,6 +12,9 @@ namespace PrivaPub.ClientModels.Admin
public bool Suspend { get; set; } = true; public bool Suspend { get; set; } = true;
public bool RejectMedia { get; set; } 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))] [StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string PublicComment { get; set; } public string PublicComment { get; set; }
@@ -28,6 +31,8 @@ namespace PrivaPub.ClientModels.Admin
public bool RejectMedia { get; set; } public bool RejectMedia { get; set; }
public string PublicComment { get; set; } public string PublicComment { get; set; }
public string PrivateComment { get; set; } public string PrivateComment { get; set; }
public bool Published { get; set; } = false;
public bool Obfuscate { get; set; } = false;
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
} }
} }
+159
View File
@@ -0,0 +1,159 @@
using PrivaPub.ClientModels.Resources;
using System.ComponentModel.DataAnnotations;
namespace PrivaPub.ClientModels.Admin
{
// The staff's acts on accounts and posts (completion program R6). Moderators silence and suspend personas here and
// accounts elsewhere, remove posts and resolve reports with one of those acts; administrators also change logins
// (roles, bans, deletions, two-factor sign-in). Every act is kept in the staff's log.
public static class ModerationActions
{
public const string None = "none";
public const string Silence = "silence";
public const string Unsilence = "unsilence";
public const string Suspend = "suspend";
public const string Unsuspend = "unsuspend";
public const string RemovePosts = "remove_posts";//a report's resolution only: the posts it names
public static bool IsAccountAction(string action) => action is Silence or Unsilence or Suspend or Unsuspend;
public static bool IsResolution(string action) => action is None or Silence or Suspend or RemovePosts;
}
// POST /clientapi/moderator/accounts/moderate: a persona here (its id) or an account elsewhere (its id here)
public class ModerateAccountForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string AccountId { get; set; }
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(16, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Action { get; set; }//ModerationActions: silence, unsilence, suspend, unsuspend
// the staff's own words: kept in the log and on the account, never shown to it
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Note { get; set; }
// what a persona here is told with its warning (Mastodon's moderation_warning); nothing reaches an account elsewhere
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Warning { get; set; }
}
// POST /clientapi/moderator/posts/remove: a post here is deleted as its author would (the deletion federates), a copy of
// a post elsewhere is taken down here only
public class RemovePostForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string PostId { get; set; }
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Note { get; set; }
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Warning { get; set; }
}
// POST /clientapi/moderator/reports/{id}/resolve: the body is optional; without one, or with action none, the report is
// only marked resolved
public class ResolveReportForm
{
[StringLength(16, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Action { get; set; } = ModerationActions.None;//none, silence, suspend, remove_posts
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Note { get; set; }
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Warning { get; set; }
}
// an account as moderators see it: a persona here (never its login) or an account elsewhere, and what the staff did to it
public class ViewModeratedAccount
{
public string Id { get; set; }
public bool IsLocal { get; set; } = false;
public string UserName { get; set; }
public string DisplayName { get; set; }
public string Handle { get; set; }//user@host
public string Url { get; set; }//its actor's id
public string Domain { get; set; }
public DateTime? SilencedAt { get; set; }
public DateTime? SuspendedAt { get; set; }
public bool DomainSilenced { get; set; } = false;//an account elsewhere whose server is silenced: limited without its own silence
public bool DomainSuspended { get; set; } = false;
public string Note { get; set; }//the staff's last note on it
}
// GET /clientapi/moderator/accounts?q= and /clientapi/moderator/accounts/moderated?offset=&limit=: Next is the offset of
// the next page, null on the last
public class ViewModeratedAccountsPage
{
public List<ViewModeratedAccount> Items { get; set; } = [];
public int? Next { get; set; }
}
// a login as administrators see it (GET /clientapi/admin/roots), with its personas: only administrators ever read a login
// next to its personas
public class ViewAdminRoot
{
public string Id { get; set; }
public string UserName { get; set; }
public string Role { get; set; }//user, moderator, admin
public bool IsBanned { get; set; } = false;
public bool HasEmail { get; set; } = false;
public bool IsEmailValidated { get; set; } = false;
public bool HasTotp { get; set; } = false;
public bool IsYou { get; set; } = false;
public DateTime CreatedAt { get; set; }
public List<ViewAdminPersona> Personas { get; set; } = [];
}
public class ViewAdminPersona
{
public string Id { get; set; }
public string UserName { get; set; }
public string DisplayName { get; set; }
public string Handle { get; set; }
public DateTime? SilencedAt { get; set; }
public DateTime? SuspendedAt { get; set; }
public List<ViewAdminGroup> Groups { get; set; } = [];//the groups it owns
}
public class ViewAdminGroup
{
public string Id { get; set; }
public string UserName { get; set; }
public string Name { get; set; }
public bool IsCommunity { get; set; } = false;
public int MembersCount { get; set; } = 0;
}
// newest first; Next is the `before` of the next page, null on the last
public class ViewAdminRootsPage
{
public List<ViewAdminRoot> Items { get; set; } = [];
public string Next { get; set; }
}
// POST /clientapi/admin/roots/{id}/role
public class RootRoleForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(16, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Role { get; set; }//user, moderator, admin
}
// what can't be undone asks for the administrator's own password: deleting a persona or a group
public class AdminPasswordForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
public string Password { get; set; }
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Note { 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;
}
}
@@ -0,0 +1,45 @@
using PrivaPub.ClientModels.Resources;
using System.ComponentModel.DataAnnotations;
namespace PrivaPub.ClientModels.Admin
{
// one of the server's custom emoji as the administrator's page shows it (/clientapi/admin/emoji/list, completion program
// R7): disabled ones too, deleted ones never
public class ViewLocalEmoji
{
public string Id { get; set; }
public string Shortcode { get; set; }
public string Category { get; set; }
public bool VisibleInPicker { get; set; } = true;
public bool Disabled { get; set; } = false;
public string Url { get; set; }//its picture: a PNG, or an animated GIF
public string StaticUrl { get; set; }//a still PNG of it
public string ContentType { get; set; }
public int? Width { get; set; }
public int? Height { get; set; }
public long Size { get; set; } = 0;
public string Uri { get; set; }//its id on other servers (/grimaces/{shortcode}): why a shortcode never changes
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTime ImageUpdatedAt { get; set; }
}
// POST /clientapi/admin/emoji/insert and /emoji/update, as multipart/form-data with the picture as `image` (PNG, GIF,
// WebP or JPEG, 256 KB at most; required to insert, optional to update). The shortcode names the emoji: an update
// changes the emoji with that shortcode, and an emoji is never renamed (its shortcode is its id elsewhere); an update
// sends the emoji as it should be, its category (none when empty), whether it is in the picker and whether it is
// disabled.
public class LocalEmojiForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
RegularExpression(Constants.ShortcodeRegex, ErrorMessageResourceName = "InvalidShortcode", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Shortcode { get; set; }
[StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Category { get; set; }
public bool VisibleInPicker { get; set; } = true;
public bool Disabled { get; set; } = false;//kept, but offered nowhere and taken into no new post
}
}
@@ -11,10 +11,16 @@ namespace PrivaPub.ClientModels.Admin
public List<string> PostIds { get; set; } = new(); public List<string> PostIds { get; set; } = new();
public List<string> ObjectURIs { get; set; } = new(); public List<string> ObjectURIs { get; set; } = new();
public string Category { get; set; } 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 string Comment { get; set; }
public bool Forwarded { get; set; } public bool Forwarded { get; set; }
public bool IsResolved { get; set; } public bool IsResolved { get; set; }
public string Resolution { get; set; }//what resolving it did: none, silence, suspend, remove_posts (null before R6)
public string ResolutionNote { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public DateTime? ResolvedAt { 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,188 @@
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 List<ViewStaffPick> StaffPicks { get; set; } = [];//the accounts suggested first to every persona (R8), their own routes
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; }
}
// an account the administrators suggest to every persona here, first (R8: Mastodon's staff picks, `featured` among a
// suggestion's sources): a persona of this server or an account elsewhere, by its Mastodon account id. Added and removed
// on their own routes (/clientapi/admin/staff-picks), never through the settings form, so an older page saving the form
// never drops them.
public class ViewStaffPick
{
public string AccountId { get; set; }
public bool IsLocal { get; set; } = false;
public string Handle { get; set; }//user@host
public string Name { get; set; }
public bool IsShown { get; set; } = true;//false: deleted, suspended or silenced since, so suggested to nobody
}
public class StaffPickForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(64, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
public string AccountId { 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
}
}
@@ -96,4 +96,96 @@ namespace PrivaPub.ClientModels.Admin
public string CrawlError { get; set; } public string CrawlError { get; set; }
public string Software { 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; }
}
} }
+2
View File
@@ -10,5 +10,7 @@
public const string PasswordRegex = @"^(?=.*)(?=.*[a-z])(?=.*[A-Z])(?!.*\s).*$"; public const string PasswordRegex = @"^(?=.*)(?=.*[a-z])(?=.*[A-Z])(?!.*\s).*$";
public const string UserNameRegex = "^[a-z0-9_]+$"; public const string UserNameRegex = "^[a-z0-9_]+$";
// a custom emoji's shortcode: Mastodon's rule (letters, digits and underscores, two at least), 64 at most
public const string ShortcodeRegex = "^[a-zA-Z0-9_]{2,64}$";
} }
} }
@@ -17,6 +17,19 @@ namespace PrivaPub.ClientModels.Group
public string InvitationPassword { get; set; } public string InvitationPassword { get; set; }
} }
// the group's owner deletes it (POST /clientapi/group/delete), with the login's password: its members are told, and its
// name stays taken
public class DeleteGroupForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
public string AvatarId { get; set; }
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
public string GroupId { get; set; }
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
public string Password { get; set; }
}
public class GroupMembershipForm public class GroupMembershipForm
{ {
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))] [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
+5
View File
@@ -34,5 +34,10 @@ namespace PrivaPub.ClientModels
public bool ThemeIsDarkMode { get; set; } = false; public bool ThemeIsDarkMode { get; set; } = false;
public string InvitationPassword { get; set; } 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; }
} }
} }
@@ -114,6 +114,15 @@ namespace PrivaPub.ClientModels.Resources {
} }
} }
/// <summary>
/// Looks up a localized string similar to Use only letters, digits and underscores, from 2 to 64 of them..
/// </summary>
public static string InvalidShortcode {
get {
return ResourceManager.GetString("InvalidShortcode", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Invalid password, upper-case, lower-case and number characters required.. /// Looks up a localized string similar to Invalid password, upper-case, lower-case and number characters required..
/// </summary> /// </summary>
@@ -135,6 +135,9 @@
<data name="InvalidUserName" xml:space="preserve"> <data name="InvalidUserName" xml:space="preserve">
<value>Usa solo lettere minuscole, cifre e trattini bassi.</value> <value>Usa solo lettere minuscole, cifre e trattini bassi.</value>
</data> </data>
<data name="InvalidShortcode" xml:space="preserve">
<value>Usa solo lettere, cifre e trattini bassi, da 2 a 64.</value>
</data>
<data name="InvalidPassword" xml:space="preserve"> <data name="InvalidPassword" xml:space="preserve">
<value>Password non valida, deve avere almeno un carattere grande, un carattere piccolo ed un numero.</value> <value>Password non valida, deve avere almeno un carattere grande, un carattere piccolo ed un numero.</value>
</data> </data>
@@ -135,6 +135,9 @@
<data name="InvalidUserName" xml:space="preserve"> <data name="InvalidUserName" xml:space="preserve">
<value>Use only lower-case letters, digits and underscores.</value> <value>Use only lower-case letters, digits and underscores.</value>
</data> </data>
<data name="InvalidShortcode" xml:space="preserve">
<value>Use only letters, digits and underscores, from 2 to 64 of them.</value>
</data>
<data name="InvalidPassword" xml:space="preserve"> <data name="InvalidPassword" xml:space="preserve">
<value>Invalid password, upper-case, lower-case and number characters required.</value> <value>Invalid password, upper-case, lower-case and number characters required.</value>
</data> </data>
@@ -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> /// <summary>
/// Looks up a localized string similar to Description. /// Looks up a localized string similar to Description.
/// </summary> /// </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> /// <summary>
/// Looks up a localized string similar to Email. /// Looks up a localized string similar to Email.
/// </summary> /// </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> /// <summary>
/// Looks up a localized string similar to Groups. /// Looks up a localized string similar to Groups.
/// </summary> /// </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> /// <summary>
/// Looks up a localized string similar to Invitation code. /// Looks up a localized string similar to Invitation code.
/// </summary> /// </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> /// <summary>
/// Looks up a localized string similar to Invitation password. /// Looks up a localized string similar to Invitation password.
/// </summary> /// </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> /// <summary>
/// Looks up a localized string similar to Language id. /// Looks up a localized string similar to Language id.
/// </summary> /// </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> /// <summary>
/// Looks up a localized string similar to New password. /// Looks up a localized string similar to New password.
/// </summary> /// </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> /// <summary>
/// Looks up a localized string similar to Password. /// Looks up a localized string similar to Password.
/// </summary> /// </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> /// <summary>
/// Looks up a localized string similar to Prefer system theme. /// Looks up a localized string similar to Prefer system theme.
/// </summary> /// </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> /// <summary>
/// Looks up a localized string similar to Dark mode. /// Looks up a localized string similar to Dark mode.
/// </summary> /// </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> /// <summary>
/// Looks up a localized string similar to Title. /// Looks up a localized string similar to Title.
/// </summary> /// </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> /// <summary>
/// Looks up a localized string similar to Username. /// Looks up a localized string similar to Username.
/// </summary> /// </summary>
@@ -659,23 +236,5 @@ namespace PrivaPub.ClientModels.Resources {
return ResourceManager.GetString("Username", resourceCulture); 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"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </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"> <data name="Description" xml:space="preserve">
<value>Описание</value> <value>Описание</value>
</data> </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"> <data name="Email" xml:space="preserve">
<value>Имейл</value> <value>Имейл</value>
</data> </data>
<data name="Emoji" xml:space="preserve"> <data name="Emoji" xml:space="preserve">
<value>Емотикони</value> <value>Емотикони</value>
</data> </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"> <data name="Groups" xml:space="preserve">
<value>Групи</value> <value>Групи</value>
</data> </data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>Да включите първия отговор в предварителния преглед на поканата? (за да се включите в дискусията по-ангажиращо)</value>
</data>
<data name="InvitationCode" xml:space="preserve"> <data name="InvitationCode" xml:space="preserve">
<value>Код на поканата</value> <value>Код на поканата</value>
</data> </data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Код или връзка за покана</value>
</data>
<data name="InvitationPassword" xml:space="preserve"> <data name="InvitationPassword" xml:space="preserve">
<value>Парола за покана</value> <value>Парола за покана</value>
</data> </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"> <data name="LanguageId" xml:space="preserve">
<value>Идентификатор на езика</value> <value>Идентификатор на езика</value>
</data> </data>
<data name="Name" xml:space="preserve"> <data name="Name" xml:space="preserve">
<value>Име</value> <value>Име</value>
</data> </data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Активиране на известията?(полезно за получаване на актуализации на приложението)</value>
</data>
<data name="NewPassword" xml:space="preserve"> <data name="NewPassword" xml:space="preserve">
<value>Нова парола</value> <value>Нова парола</value>
</data> </data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve"> <data name="OldPassword" xml:space="preserve">
<value>Стара парола</value> <value>Стара парола</value>
</data> </data>
<data name="OneTime" xml:space="preserve">
<value>Само веднъж</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>Парола</value> <value>Парола</value>
</data> </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"> <data name="PreferSystemTheming" xml:space="preserve">
<value>Предпочитайте темата на системата</value> <value>Предпочитайте темата на системата</value>
</data> </data>
<data name="RecoveryCode" xml:space="preserve"> <data name="RecoveryCode" xml:space="preserve">
<value>Код за възстановяване</value> <value>Код за възстановяване</value>
</data> </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"> <data name="ThemeIsDarkMode" xml:space="preserve">
<value>Тъмен режим</value> <value>Тъмен режим</value>
</data> </data>
<data name="Ticks" xml:space="preserve">
<value>Кърлежи</value>
</data>
<data name="Title" xml:space="preserve"> <data name="Title" xml:space="preserve">
<value>Заглавие</value> <value>Заглавие</value>
</data> </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"> <data name="Username" xml:space="preserve">
<value>Потребителско име</value> <value>Потребителско име</value>
</data> </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> </root>
@@ -117,123 +117,30 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </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"> <data name="Description" xml:space="preserve">
<value>Popis</value> <value>Popis</value>
</data> </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"> <data name="Email" xml:space="preserve">
<value>E-mail</value> <value>E-mail</value>
</data> </data>
<data name="Emoji" xml:space="preserve"> <data name="Emoji" xml:space="preserve">
<value>Emoji</value> <value>Emoji</value>
</data> </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"> <data name="Groups" xml:space="preserve">
<value>Skupiny</value> <value>Skupiny</value>
</data> </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"> <data name="InvitationCode" xml:space="preserve">
<value>Kód pozvánky</value> <value>Kód pozvánky</value>
</data> </data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Pozvánkový kód nebo odkaz</value>
</data>
<data name="InvitationPassword" xml:space="preserve"> <data name="InvitationPassword" xml:space="preserve">
<value>Heslo pozvánky</value> <value>Heslo pozvánky</value>
</data> </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"> <data name="LanguageId" xml:space="preserve">
<value>Identifikace jazyka</value> <value>Identifikace jazyka</value>
</data> </data>
<data name="Name" xml:space="preserve"> <data name="Name" xml:space="preserve">
<value>Název</value> <value>Název</value>
</data> </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"> <data name="NewPassword" xml:space="preserve">
<value>Nové heslo</value> <value>Nové heslo</value>
</data> </data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve"> <data name="OldPassword" xml:space="preserve">
<value>Staré heslo</value> <value>Staré heslo</value>
</data> </data>
<data name="OneTime" xml:space="preserve">
<value>Pouze jednou</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>Heslo</value> <value>Heslo</value>
</data> </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"> <data name="PreferSystemTheming" xml:space="preserve">
<value>Preferovat systémové téma</value> <value>Preferovat systémové téma</value>
</data> </data>
<data name="RecoveryCode" xml:space="preserve"> <data name="RecoveryCode" xml:space="preserve">
<value>Kód obnovy</value> <value>Kód obnovy</value>
</data> </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"> <data name="ThemeIsDarkMode" xml:space="preserve">
<value>Tmavý režim</value> <value>Tmavý režim</value>
</data> </data>
<data name="Ticks" xml:space="preserve">
<value>Klíšťata</value>
</data>
<data name="Title" xml:space="preserve"> <data name="Title" xml:space="preserve">
<value>Název</value> <value>Název</value>
</data> </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"> <data name="Username" xml:space="preserve">
<value>Uživatelské jméno</value> <value>Uživatelské jméno</value>
</data> </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> </root>
@@ -117,123 +117,30 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </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"> <data name="Description" xml:space="preserve">
<value>Beskrivelse</value> <value>Beskrivelse</value>
</data> </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"> <data name="Email" xml:space="preserve">
<value>E-mail</value> <value>E-mail</value>
</data> </data>
<data name="Emoji" xml:space="preserve"> <data name="Emoji" xml:space="preserve">
<value>Emoji</value> <value>Emoji</value>
</data> </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"> <data name="Groups" xml:space="preserve">
<value>Grupper</value> <value>Grupper</value>
</data> </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"> <data name="InvitationCode" xml:space="preserve">
<value>Kode for indbydelse</value> <value>Kode for indbydelse</value>
</data> </data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Indbydelseskode eller link</value>
</data>
<data name="InvitationPassword" xml:space="preserve"> <data name="InvitationPassword" xml:space="preserve">
<value>Adgangskode til invitationen</value> <value>Adgangskode til invitationen</value>
</data> </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"> <data name="LanguageId" xml:space="preserve">
<value>Sprog-id</value> <value>Sprog-id</value>
</data> </data>
<data name="Name" xml:space="preserve"> <data name="Name" xml:space="preserve">
<value>Navn</value> <value>Navn</value>
</data> </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"> <data name="NewPassword" xml:space="preserve">
<value>Ny adgangskode</value> <value>Ny adgangskode</value>
</data> </data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve"> <data name="OldPassword" xml:space="preserve">
<value>Gammel adgangskode</value> <value>Gammel adgangskode</value>
</data> </data>
<data name="OneTime" xml:space="preserve">
<value>Kun én gang</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>Adgangskode</value> <value>Adgangskode</value>
</data> </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"> <data name="PreferSystemTheming" xml:space="preserve">
<value>Foretrækker systemtema</value> <value>Foretrækker systemtema</value>
</data> </data>
<data name="RecoveryCode" xml:space="preserve"> <data name="RecoveryCode" xml:space="preserve">
<value>Kode for inddrivelse</value> <value>Kode for inddrivelse</value>
</data> </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"> <data name="ThemeIsDarkMode" xml:space="preserve">
<value>Mørk tilstand</value> <value>Mørk tilstand</value>
</data> </data>
<data name="Ticks" xml:space="preserve">
<value>Flåter</value>
</data>
<data name="Title" xml:space="preserve"> <data name="Title" xml:space="preserve">
<value>Titel</value> <value>Titel</value>
</data> </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"> <data name="Username" xml:space="preserve">
<value>Brugernavn</value> <value>Brugernavn</value>
</data> </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> </root>
@@ -117,123 +117,30 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </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"> <data name="Description" xml:space="preserve">
<value>Beschreibung</value> <value>Beschreibung</value>
</data> </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"> <data name="Email" xml:space="preserve">
<value>E-Mail</value> <value>E-Mail</value>
</data> </data>
<data name="Emoji" xml:space="preserve"> <data name="Emoji" xml:space="preserve">
<value>Emoji</value> <value>Emoji</value>
</data> </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"> <data name="Groups" xml:space="preserve">
<value>Gruppen</value> <value>Gruppen</value>
</data> </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"> <data name="InvitationCode" xml:space="preserve">
<value>Einladungs-Code</value> <value>Einladungs-Code</value>
</data> </data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Einladungscode oder Link</value>
</data>
<data name="InvitationPassword" xml:space="preserve"> <data name="InvitationPassword" xml:space="preserve">
<value>Passwort für die Einladung</value> <value>Passwort für die Einladung</value>
</data> </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"> <data name="LanguageId" xml:space="preserve">
<value>Sprache id</value> <value>Sprache id</value>
</data> </data>
<data name="Name" xml:space="preserve"> <data name="Name" xml:space="preserve">
<value>Name</value> <value>Name</value>
</data> </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"> <data name="NewPassword" xml:space="preserve">
<value>Neues Passwort</value> <value>Neues Passwort</value>
</data> </data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve"> <data name="OldPassword" xml:space="preserve">
<value>Altes Passwort</value> <value>Altes Passwort</value>
</data> </data>
<data name="OneTime" xml:space="preserve">
<value>Nur einmalig</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>Passwort</value> <value>Passwort</value>
</data> </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"> <data name="PreferSystemTheming" xml:space="preserve">
<value>Systemthema bevorzugen</value> <value>Systemthema bevorzugen</value>
</data> </data>
<data name="RecoveryCode" xml:space="preserve"> <data name="RecoveryCode" xml:space="preserve">
<value>Wiederherstellungscode</value> <value>Wiederherstellungscode</value>
</data> </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"> <data name="ThemeIsDarkMode" xml:space="preserve">
<value>Dunkler Modus</value> <value>Dunkler Modus</value>
</data> </data>
<data name="Ticks" xml:space="preserve">
<value>Zecken</value>
</data>
<data name="Title" xml:space="preserve"> <data name="Title" xml:space="preserve">
<value>Titel</value> <value>Titel</value>
</data> </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"> <data name="Username" xml:space="preserve">
<value>Benutzername</value> <value>Benutzername</value>
</data> </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> </root>
@@ -117,123 +117,30 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </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"> <data name="Description" xml:space="preserve">
<value>Περιγραφή</value> <value>Περιγραφή</value>
</data> </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"> <data name="Email" xml:space="preserve">
<value>Ηλεκτρονικό ταχυδρομείο</value> <value>Ηλεκτρονικό ταχυδρομείο</value>
</data> </data>
<data name="Emoji" xml:space="preserve"> <data name="Emoji" xml:space="preserve">
<value>Emoji</value> <value>Emoji</value>
</data> </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"> <data name="Groups" xml:space="preserve">
<value>Ομάδες</value> <value>Ομάδες</value>
</data> </data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>Να συμπεριλάβετε την πρώτη απάντηση στην προεπισκόπηση της πρόσκλησης; (καθιστώντας την πιο ελκυστική για να συμμετάσχετε στη συζήτηση)</value>
</data>
<data name="InvitationCode" xml:space="preserve"> <data name="InvitationCode" xml:space="preserve">
<value>Κωδικός πρόσκλησης</value> <value>Κωδικός πρόσκλησης</value>
</data> </data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Κωδικός πρόσκλησης ή σύνδεσμος</value>
</data>
<data name="InvitationPassword" xml:space="preserve"> <data name="InvitationPassword" xml:space="preserve">
<value>Κωδικός πρόσκλησης</value> <value>Κωδικός πρόσκλησης</value>
</data> </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"> <data name="LanguageId" xml:space="preserve">
<value>Id γλώσσας</value> <value>Id γλώσσας</value>
</data> </data>
<data name="Name" xml:space="preserve"> <data name="Name" xml:space="preserve">
<value>Όνομα</value> <value>Όνομα</value>
</data> </data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Ενεργοποίηση ειδοποιήσεων; (χρήσιμο για να λαμβάνετε ενημερώσεις εφαρμογών)</value>
</data>
<data name="NewPassword" xml:space="preserve"> <data name="NewPassword" xml:space="preserve">
<value>Νέος κωδικός πρόσβασης</value> <value>Νέος κωδικός πρόσβασης</value>
</data> </data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve"> <data name="OldPassword" xml:space="preserve">
<value>Παλαιός κωδικός πρόσβασης</value> <value>Παλαιός κωδικός πρόσβασης</value>
</data> </data>
<data name="OneTime" xml:space="preserve">
<value>Μόνο μία φορά</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>Κωδικός πρόσβασης</value> <value>Κωδικός πρόσβασης</value>
</data> </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"> <data name="PreferSystemTheming" xml:space="preserve">
<value>Προτιμήστε το θέμα του συστήματος</value> <value>Προτιμήστε το θέμα του συστήματος</value>
</data> </data>
<data name="RecoveryCode" xml:space="preserve"> <data name="RecoveryCode" xml:space="preserve">
<value>Κωδικός ανάκτησης</value> <value>Κωδικός ανάκτησης</value>
</data> </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"> <data name="ThemeIsDarkMode" xml:space="preserve">
<value>Σκοτεινή λειτουργία</value> <value>Σκοτεινή λειτουργία</value>
</data> </data>
<data name="Ticks" xml:space="preserve">
<value>Κρότωνες</value>
</data>
<data name="Title" xml:space="preserve"> <data name="Title" xml:space="preserve">
<value>Τίτλος</value> <value>Τίτλος</value>
</data> </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"> <data name="Username" xml:space="preserve">
<value>Όνομα χρήστη</value> <value>Όνομα χρήστη</value>
</data> </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> </root>
@@ -117,123 +117,30 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </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"> <data name="Description" xml:space="preserve">
<value>Descripción</value> <value>Descripción</value>
</data> </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"> <data name="Email" xml:space="preserve">
<value>Envíe un correo electrónico a</value> <value>Envíe un correo electrónico a</value>
</data> </data>
<data name="Emoji" xml:space="preserve"> <data name="Emoji" xml:space="preserve">
<value>Emoji</value> <value>Emoji</value>
</data> </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"> <data name="Groups" xml:space="preserve">
<value>Grupos</value> <value>Grupos</value>
</data> </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"> <data name="InvitationCode" xml:space="preserve">
<value>Código de invitación</value> <value>Código de invitación</value>
</data> </data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Código o enlace de invitación</value>
</data>
<data name="InvitationPassword" xml:space="preserve"> <data name="InvitationPassword" xml:space="preserve">
<value>Contraseña de la invitación</value> <value>Contraseña de la invitación</value>
</data> </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"> <data name="LanguageId" xml:space="preserve">
<value>Id. de idioma</value> <value>Id. de idioma</value>
</data> </data>
<data name="Name" xml:space="preserve"> <data name="Name" xml:space="preserve">
<value>Nombre</value> <value>Nombre</value>
</data> </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"> <data name="NewPassword" xml:space="preserve">
<value>Nueva contraseña</value> <value>Nueva contraseña</value>
</data> </data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve"> <data name="OldPassword" xml:space="preserve">
<value>Contraseña antigua</value> <value>Contraseña antigua</value>
</data> </data>
<data name="OneTime" xml:space="preserve">
<value>Una sola vez</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>Contraseña</value> <value>Contraseña</value>
</data> </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"> <data name="PreferSystemTheming" xml:space="preserve">
<value>Prefiero el tema del sistema</value> <value>Prefiero el tema del sistema</value>
</data> </data>
<data name="RecoveryCode" xml:space="preserve"> <data name="RecoveryCode" xml:space="preserve">
<value>Código de recuperación</value> <value>Código de recuperación</value>
</data> </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"> <data name="ThemeIsDarkMode" xml:space="preserve">
<value>Modo oscuro</value> <value>Modo oscuro</value>
</data> </data>
<data name="Ticks" xml:space="preserve">
<value>Garrapatas</value>
</data>
<data name="Title" xml:space="preserve"> <data name="Title" xml:space="preserve">
<value>Título</value> <value>Título</value>
</data> </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"> <data name="Username" xml:space="preserve">
<value>Nombre de usuario</value> <value>Nombre de usuario</value>
</data> </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> </root>
@@ -117,123 +117,30 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </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"> <data name="Description" xml:space="preserve">
<value>Kirjeldus</value> <value>Kirjeldus</value>
</data> </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"> <data name="Email" xml:space="preserve">
<value>E-post</value> <value>E-post</value>
</data> </data>
<data name="Emoji" xml:space="preserve"> <data name="Emoji" xml:space="preserve">
<value>Emoji</value> <value>Emoji</value>
</data> </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"> <data name="Groups" xml:space="preserve">
<value>Rühmad</value> <value>Rühmad</value>
</data> </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"> <data name="InvitationCode" xml:space="preserve">
<value>Kutse kood</value> <value>Kutse kood</value>
</data> </data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Kutse kood või link</value>
</data>
<data name="InvitationPassword" xml:space="preserve"> <data name="InvitationPassword" xml:space="preserve">
<value>Kutse parool</value> <value>Kutse parool</value>
</data> </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"> <data name="LanguageId" xml:space="preserve">
<value>Keele id</value> <value>Keele id</value>
</data> </data>
<data name="Name" xml:space="preserve"> <data name="Name" xml:space="preserve">
<value>Nimi</value> <value>Nimi</value>
</data> </data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Teavituste lubamine?(kasulik, et saada rakenduse uuendusi)</value>
</data>
<data name="NewPassword" xml:space="preserve"> <data name="NewPassword" xml:space="preserve">
<value>Uus parool</value> <value>Uus parool</value>
</data> </data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve"> <data name="OldPassword" xml:space="preserve">
<value>Vana parool</value> <value>Vana parool</value>
</data> </data>
<data name="OneTime" xml:space="preserve">
<value>Ainult üks kord</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>Parool</value> <value>Parool</value>
</data> </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"> <data name="PreferSystemTheming" xml:space="preserve">
<value>Eelistage süsteemi teemat</value> <value>Eelistage süsteemi teemat</value>
</data> </data>
<data name="RecoveryCode" xml:space="preserve"> <data name="RecoveryCode" xml:space="preserve">
<value>Taastamiskood</value> <value>Taastamiskood</value>
</data> </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"> <data name="ThemeIsDarkMode" xml:space="preserve">
<value>Tume režiim</value> <value>Tume režiim</value>
</data> </data>
<data name="Ticks" xml:space="preserve">
<value>Puugid</value>
</data>
<data name="Title" xml:space="preserve"> <data name="Title" xml:space="preserve">
<value>Pealkiri</value> <value>Pealkiri</value>
</data> </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"> <data name="Username" xml:space="preserve">
<value>Kasutajanimi</value> <value>Kasutajanimi</value>
</data> </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> </root>
@@ -117,123 +117,30 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </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"> <data name="Description" xml:space="preserve">
<value>Kuvaus</value> <value>Kuvaus</value>
</data> </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"> <data name="Email" xml:space="preserve">
<value>Sähköposti</value> <value>Sähköposti</value>
</data> </data>
<data name="Emoji" xml:space="preserve"> <data name="Emoji" xml:space="preserve">
<value>Emoji</value> <value>Emoji</value>
</data> </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"> <data name="Groups" xml:space="preserve">
<value>Ryhmät</value> <value>Ryhmät</value>
</data> </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"> <data name="InvitationCode" xml:space="preserve">
<value>Kutsun koodi</value> <value>Kutsun koodi</value>
</data> </data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Kutsukoodi tai linkki</value>
</data>
<data name="InvitationPassword" xml:space="preserve"> <data name="InvitationPassword" xml:space="preserve">
<value>Kutsun salasana</value> <value>Kutsun salasana</value>
</data> </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"> <data name="LanguageId" xml:space="preserve">
<value>Kielitunnus</value> <value>Kielitunnus</value>
</data> </data>
<data name="Name" xml:space="preserve"> <data name="Name" xml:space="preserve">
<value>Nimi</value> <value>Nimi</value>
</data> </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"> <data name="NewPassword" xml:space="preserve">
<value>Uusi salasana</value> <value>Uusi salasana</value>
</data> </data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve"> <data name="OldPassword" xml:space="preserve">
<value>Vanha salasana</value> <value>Vanha salasana</value>
</data> </data>
<data name="OneTime" xml:space="preserve">
<value>Vain kerran</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>Salasana</value> <value>Salasana</value>
</data> </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"> <data name="PreferSystemTheming" xml:space="preserve">
<value>Suositeltava järjestelmäteema</value> <value>Suositeltava järjestelmäteema</value>
</data> </data>
<data name="RecoveryCode" xml:space="preserve"> <data name="RecoveryCode" xml:space="preserve">
<value>Palautuskoodi</value> <value>Palautuskoodi</value>
</data> </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"> <data name="ThemeIsDarkMode" xml:space="preserve">
<value>Tumma tila</value> <value>Tumma tila</value>
</data> </data>
<data name="Ticks" xml:space="preserve">
<value>Punkit</value>
</data>
<data name="Title" xml:space="preserve"> <data name="Title" xml:space="preserve">
<value>Otsikko</value> <value>Otsikko</value>
</data> </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"> <data name="Username" xml:space="preserve">
<value>Käyttäjätunnus</value> <value>Käyttäjätunnus</value>
</data> </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> </root>
@@ -117,123 +117,30 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </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"> <data name="Description" xml:space="preserve">
<value>Description</value> <value>Description</value>
</data> </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"> <data name="Email" xml:space="preserve">
<value>Courriel</value> <value>Courriel</value>
</data> </data>
<data name="Emoji" xml:space="preserve"> <data name="Emoji" xml:space="preserve">
<value>Emoji</value> <value>Emoji</value>
</data> </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"> <data name="Groups" xml:space="preserve">
<value>Groupes</value> <value>Groupes</value>
</data> </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"> <data name="InvitationCode" xml:space="preserve">
<value>Code d'invitation</value> <value>Code d'invitation</value>
</data> </data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Code ou lien d'invitation</value>
</data>
<data name="InvitationPassword" xml:space="preserve"> <data name="InvitationPassword" xml:space="preserve">
<value>Mot de passe de l'invitation</value> <value>Mot de passe de l'invitation</value>
</data> </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"> <data name="LanguageId" xml:space="preserve">
<value>Identifiant de langue</value> <value>Identifiant de langue</value>
</data> </data>
<data name="Name" xml:space="preserve"> <data name="Name" xml:space="preserve">
<value>Nom</value> <value>Nom</value>
</data> </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"> <data name="NewPassword" xml:space="preserve">
<value>Nouveau mot de passe</value> <value>Nouveau mot de passe</value>
</data> </data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve"> <data name="OldPassword" xml:space="preserve">
<value>Ancien mot de passe</value> <value>Ancien mot de passe</value>
</data> </data>
<data name="OneTime" xml:space="preserve">
<value>En une seule fois</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>Mot de passe</value> <value>Mot de passe</value>
</data> </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"> <data name="PreferSystemTheming" xml:space="preserve">
<value>Préférer le thème du système</value> <value>Préférer le thème du système</value>
</data> </data>
<data name="RecoveryCode" xml:space="preserve"> <data name="RecoveryCode" xml:space="preserve">
<value>Code de récupération</value> <value>Code de récupération</value>
</data> </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"> <data name="ThemeIsDarkMode" xml:space="preserve">
<value>Mode foncé</value> <value>Mode foncé</value>
</data> </data>
<data name="Ticks" xml:space="preserve">
<value>Tiques</value>
</data>
<data name="Title" xml:space="preserve"> <data name="Title" xml:space="preserve">
<value>Titre</value> <value>Titre</value>
</data> </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"> <data name="Username" xml:space="preserve">
<value>Nom d'utilisateur</value> <value>Nom d'utilisateur</value>
</data> </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> </root>
@@ -117,123 +117,30 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </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"> <data name="Description" xml:space="preserve">
<value>Leírás</value> <value>Leírás</value>
</data> </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"> <data name="Email" xml:space="preserve">
<value>E-mail</value> <value>E-mail</value>
</data> </data>
<data name="Emoji" xml:space="preserve"> <data name="Emoji" xml:space="preserve">
<value>Emoji</value> <value>Emoji</value>
</data> </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"> <data name="Groups" xml:space="preserve">
<value>Csoportok</value> <value>Csoportok</value>
</data> </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"> <data name="InvitationCode" xml:space="preserve">
<value>Meghívó kód</value> <value>Meghívó kód</value>
</data> </data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Meghívó kód vagy link</value>
</data>
<data name="InvitationPassword" xml:space="preserve"> <data name="InvitationPassword" xml:space="preserve">
<value>Meghívó jelszó</value> <value>Meghívó jelszó</value>
</data> </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"> <data name="LanguageId" xml:space="preserve">
<value>Nyelvi azonosító</value> <value>Nyelvi azonosító</value>
</data> </data>
<data name="Name" xml:space="preserve"> <data name="Name" xml:space="preserve">
<value>Név</value> <value>Név</value>
</data> </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"> <data name="NewPassword" xml:space="preserve">
<value>Új jelszó</value> <value>Új jelszó</value>
</data> </data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve"> <data name="OldPassword" xml:space="preserve">
<value>Régi jelszó</value> <value>Régi jelszó</value>
</data> </data>
<data name="OneTime" xml:space="preserve">
<value>Csak egyszeri alkalom</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>Jelszó</value> <value>Jelszó</value>
</data> </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"> <data name="PreferSystemTheming" xml:space="preserve">
<value>A rendszer témájának előnyben részesítése</value> <value>A rendszer témájának előnyben részesítése</value>
</data> </data>
<data name="RecoveryCode" xml:space="preserve"> <data name="RecoveryCode" xml:space="preserve">
<value>Helyreállítási kód</value> <value>Helyreállítási kód</value>
</data> </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"> <data name="ThemeIsDarkMode" xml:space="preserve">
<value>Sötét üzemmód</value> <value>Sötét üzemmód</value>
</data> </data>
<data name="Ticks" xml:space="preserve">
<value>Kullancsok</value>
</data>
<data name="Title" xml:space="preserve"> <data name="Title" xml:space="preserve">
<value>Cím</value> <value>Cím</value>
</data> </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"> <data name="Username" xml:space="preserve">
<value>Felhasználónév</value> <value>Felhasználónév</value>
</data> </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> </root>
@@ -117,123 +117,30 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </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"> <data name="Description" xml:space="preserve">
<value>Descrizione</value> <value>Descrizione</value>
</data> </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"> <data name="Email" xml:space="preserve">
<value>Email</value> <value>Email</value>
</data> </data>
<data name="Emoji" xml:space="preserve"> <data name="Emoji" xml:space="preserve">
<value>Emoji</value> <value>Emoji</value>
</data> </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"> <data name="Groups" xml:space="preserve">
<value>Gruppi</value> <value>Gruppi</value>
</data> </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"> <data name="InvitationCode" xml:space="preserve">
<value>Codice d'invito</value> <value>Codice d'invito</value>
</data> </data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Collegamento o codice di invito</value>
</data>
<data name="InvitationPassword" xml:space="preserve"> <data name="InvitationPassword" xml:space="preserve">
<value>Password dell'invito</value> <value>Password dell'invito</value>
</data> </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"> <data name="LanguageId" xml:space="preserve">
<value>ID lingua</value> <value>ID lingua</value>
</data> </data>
<data name="Name" xml:space="preserve"> <data name="Name" xml:space="preserve">
<value>Nome</value> <value>Nome</value>
</data> </data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Abilitare le notifiche?(utili per percepire aggiornamenti)</value>
</data>
<data name="NewPassword" xml:space="preserve"> <data name="NewPassword" xml:space="preserve">
<value>Nuova password</value> <value>Nuova password</value>
</data> </data>
@@ -243,78 +150,24 @@
<data name="OldPassword" xml:space="preserve"> <data name="OldPassword" xml:space="preserve">
<value>Attuale password</value> <value>Attuale password</value>
</data> </data>
<data name="OneTime" xml:space="preserve">
<value>Paga una volta</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>Password</value> <value>Password</value>
</data> </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"> <data name="PreferSystemTheming" xml:space="preserve">
<value>Preferire il tema di sistema</value> <value>Preferire il tema di sistema</value>
</data> </data>
<data name="RecoveryCode" xml:space="preserve"> <data name="RecoveryCode" xml:space="preserve">
<value>Codice di recupero</value> <value>Codice di recupero</value>
</data> </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"> <data name="ThemeIsDarkMode" xml:space="preserve">
<value>Modalità scura</value> <value>Modalità scura</value>
</data> </data>
<data name="Ticks" xml:space="preserve">
<value>Zecche</value>
</data>
<data name="Title" xml:space="preserve"> <data name="Title" xml:space="preserve">
<value>Titolo</value> <value>Titolo</value>
</data> </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"> <data name="Username" xml:space="preserve">
<value>Soprannome</value> <value>Soprannome</value>
</data> </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"> <data name="AvatarId" xml:space="preserve">
<value>Avatar</value> <value>Avatar</value>
</data> </data>
@@ -117,123 +117,30 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </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"> <data name="Description" xml:space="preserve">
<value>商品説明</value> <value>商品説明</value>
</data> </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"> <data name="Email" xml:space="preserve">
<value>電子メール</value> <value>電子メール</value>
</data> </data>
<data name="Emoji" xml:space="preserve"> <data name="Emoji" xml:space="preserve">
<value>絵文字</value> <value>絵文字</value>
</data> </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"> <data name="Groups" xml:space="preserve">
<value>グループ</value> <value>グループ</value>
</data> </data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>招待状のプレビューに最初の回答を含める?(議論への参加をより魅力的にするために)</value>
</data>
<data name="InvitationCode" xml:space="preserve"> <data name="InvitationCode" xml:space="preserve">
<value>招待状コード</value> <value>招待状コード</value>
</data> </data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>招待コードまたはリンク</value>
</data>
<data name="InvitationPassword" xml:space="preserve"> <data name="InvitationPassword" xml:space="preserve">
<value>招待状のパスワード</value> <value>招待状のパスワード</value>
</data> </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"> <data name="LanguageId" xml:space="preserve">
<value>言語ID</value> <value>言語ID</value>
</data> </data>
<data name="Name" xml:space="preserve"> <data name="Name" xml:space="preserve">
<value>名前</value> <value>名前</value>
</data> </data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>通知を有効にしますか?(アプリの更新情報を受け取るのに便利です)</value>
</data>
<data name="NewPassword" xml:space="preserve"> <data name="NewPassword" xml:space="preserve">
<value>新しいパスワード</value> <value>新しいパスワード</value>
</data> </data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve"> <data name="OldPassword" xml:space="preserve">
<value>古いパスワード</value> <value>古いパスワード</value>
</data> </data>
<data name="OneTime" xml:space="preserve">
<value>1回限り</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>パスワード</value> <value>パスワード</value>
</data> </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"> <data name="PreferSystemTheming" xml:space="preserve">
<value>システムテーマを優先する</value> <value>システムテーマを優先する</value>
</data> </data>
<data name="RecoveryCode" xml:space="preserve"> <data name="RecoveryCode" xml:space="preserve">
<value>リカバリーコード</value> <value>リカバリーコード</value>
</data> </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"> <data name="ThemeIsDarkMode" xml:space="preserve">
<value>ダークモード</value> <value>ダークモード</value>
</data> </data>
<data name="Ticks" xml:space="preserve">
<value>ダニ</value>
</data>
<data name="Title" xml:space="preserve"> <data name="Title" xml:space="preserve">
<value>タイトル</value> <value>タイトル</value>
</data> </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"> <data name="Username" xml:space="preserve">
<value>ユーザー名</value> <value>ユーザー名</value>
</data> </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> </root>
@@ -117,123 +117,30 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </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"> <data name="Description" xml:space="preserve">
<value>Aprašymas</value> <value>Aprašymas</value>
</data> </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"> <data name="Email" xml:space="preserve">
<value>El. paštas</value> <value>El. paštas</value>
</data> </data>
<data name="Emoji" xml:space="preserve"> <data name="Emoji" xml:space="preserve">
<value>Emoji</value> <value>Emoji</value>
</data> </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"> <data name="Groups" xml:space="preserve">
<value>Grupės</value> <value>Grupės</value>
</data> </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"> <data name="InvitationCode" xml:space="preserve">
<value>Kvietimo kodas</value> <value>Kvietimo kodas</value>
</data> </data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Kvietimo kodas arba nuoroda</value>
</data>
<data name="InvitationPassword" xml:space="preserve"> <data name="InvitationPassword" xml:space="preserve">
<value>Kvietimo slaptažodis</value> <value>Kvietimo slaptažodis</value>
</data> </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"> <data name="LanguageId" xml:space="preserve">
<value>Kalbos ID</value> <value>Kalbos ID</value>
</data> </data>
<data name="Name" xml:space="preserve"> <data name="Name" xml:space="preserve">
<value>Pavadinimas</value> <value>Pavadinimas</value>
</data> </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"> <data name="NewPassword" xml:space="preserve">
<value>Naujas slaptažodis</value> <value>Naujas slaptažodis</value>
</data> </data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve"> <data name="OldPassword" xml:space="preserve">
<value>Senasis slaptažodis</value> <value>Senasis slaptažodis</value>
</data> </data>
<data name="OneTime" xml:space="preserve">
<value>Tik vieną kartą</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>Slaptažodis</value> <value>Slaptažodis</value>
</data> </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"> <data name="PreferSystemTheming" xml:space="preserve">
<value>Pirmenybė sistemos temai</value> <value>Pirmenybė sistemos temai</value>
</data> </data>
<data name="RecoveryCode" xml:space="preserve"> <data name="RecoveryCode" xml:space="preserve">
<value>Atkūrimo kodas</value> <value>Atkūrimo kodas</value>
</data> </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"> <data name="ThemeIsDarkMode" xml:space="preserve">
<value>Tamsus režimas</value> <value>Tamsus režimas</value>
</data> </data>
<data name="Ticks" xml:space="preserve">
<value>Erkės</value>
</data>
<data name="Title" xml:space="preserve"> <data name="Title" xml:space="preserve">
<value>Pavadinimas</value> <value>Pavadinimas</value>
</data> </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"> <data name="Username" xml:space="preserve">
<value>Vartotojo vardas</value> <value>Vartotojo vardas</value>
</data> </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> </root>
@@ -117,123 +117,30 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </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"> <data name="Description" xml:space="preserve">
<value>Apraksts</value> <value>Apraksts</value>
</data> </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"> <data name="Email" xml:space="preserve">
<value>E-pasts</value> <value>E-pasts</value>
</data> </data>
<data name="Emoji" xml:space="preserve"> <data name="Emoji" xml:space="preserve">
<value>Emocijas</value> <value>Emocijas</value>
</data> </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"> <data name="Groups" xml:space="preserve">
<value>Grupas</value> <value>Grupas</value>
</data> </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"> <data name="InvitationCode" xml:space="preserve">
<value>Uzaicinājuma kods</value> <value>Uzaicinājuma kods</value>
</data> </data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Uzaicinājuma kods vai saite</value>
</data>
<data name="InvitationPassword" xml:space="preserve"> <data name="InvitationPassword" xml:space="preserve">
<value>Uzaicinājuma parole</value> <value>Uzaicinājuma parole</value>
</data> </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"> <data name="LanguageId" xml:space="preserve">
<value>Valodas identifikators</value> <value>Valodas identifikators</value>
</data> </data>
<data name="Name" xml:space="preserve"> <data name="Name" xml:space="preserve">
<value>Nosaukums</value> <value>Nosaukums</value>
</data> </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"> <data name="NewPassword" xml:space="preserve">
<value>Jauna parole</value> <value>Jauna parole</value>
</data> </data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve"> <data name="OldPassword" xml:space="preserve">
<value>Vecā parole</value> <value>Vecā parole</value>
</data> </data>
<data name="OneTime" xml:space="preserve">
<value>Tikai vienu reizi</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>Parole</value> <value>Parole</value>
</data> </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"> <data name="PreferSystemTheming" xml:space="preserve">
<value>Dodiet priekšroku sistēmas tēmai</value> <value>Dodiet priekšroku sistēmas tēmai</value>
</data> </data>
<data name="RecoveryCode" xml:space="preserve"> <data name="RecoveryCode" xml:space="preserve">
<value>Atgūšanas kods</value> <value>Atgūšanas kods</value>
</data> </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"> <data name="ThemeIsDarkMode" xml:space="preserve">
<value>Tumšais režīms</value> <value>Tumšais režīms</value>
</data> </data>
<data name="Ticks" xml:space="preserve">
<value>Ērces</value>
</data>
<data name="Title" xml:space="preserve"> <data name="Title" xml:space="preserve">
<value>Nosaukums</value> <value>Nosaukums</value>
</data> </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"> <data name="Username" xml:space="preserve">
<value>Lietotājvārds</value> <value>Lietotājvārds</value>
</data> </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> </root>
@@ -117,123 +117,30 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </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"> <data name="Description" xml:space="preserve">
<value>Beschrijving</value> <value>Beschrijving</value>
</data> </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"> <data name="Email" xml:space="preserve">
<value>E-mail</value> <value>E-mail</value>
</data> </data>
<data name="Emoji" xml:space="preserve"> <data name="Emoji" xml:space="preserve">
<value>Emoji</value> <value>Emoji</value>
</data> </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"> <data name="Groups" xml:space="preserve">
<value>Groepen</value> <value>Groepen</value>
</data> </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"> <data name="InvitationCode" xml:space="preserve">
<value>Uitnodiging code</value> <value>Uitnodiging code</value>
</data> </data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Uitnodigingscode of link</value>
</data>
<data name="InvitationPassword" xml:space="preserve"> <data name="InvitationPassword" xml:space="preserve">
<value>Uitnodiging wachtwoord</value> <value>Uitnodiging wachtwoord</value>
</data> </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"> <data name="LanguageId" xml:space="preserve">
<value>Taal id</value> <value>Taal id</value>
</data> </data>
<data name="Name" xml:space="preserve"> <data name="Name" xml:space="preserve">
<value>Naam</value> <value>Naam</value>
</data> </data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Meldingen inschakelen?(handig om app updates te ontvangen)</value>
</data>
<data name="NewPassword" xml:space="preserve"> <data name="NewPassword" xml:space="preserve">
<value>Nieuw wachtwoord</value> <value>Nieuw wachtwoord</value>
</data> </data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve"> <data name="OldPassword" xml:space="preserve">
<value>Oud wachtwoord</value> <value>Oud wachtwoord</value>
</data> </data>
<data name="OneTime" xml:space="preserve">
<value>Eenmalig</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>Wachtwoord</value> <value>Wachtwoord</value>
</data> </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"> <data name="PreferSystemTheming" xml:space="preserve">
<value>Voorkeur systeemthema</value> <value>Voorkeur systeemthema</value>
</data> </data>
<data name="RecoveryCode" xml:space="preserve"> <data name="RecoveryCode" xml:space="preserve">
<value>Herstelcode</value> <value>Herstelcode</value>
</data> </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"> <data name="ThemeIsDarkMode" xml:space="preserve">
<value>Donkere modus</value> <value>Donkere modus</value>
</data> </data>
<data name="Ticks" xml:space="preserve">
<value>Teken</value>
</data>
<data name="Title" xml:space="preserve"> <data name="Title" xml:space="preserve">
<value>Titel</value> <value>Titel</value>
</data> </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"> <data name="Username" xml:space="preserve">
<value>Gebruikersnaam</value> <value>Gebruikersnaam</value>
</data> </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> </root>
@@ -117,123 +117,30 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </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"> <data name="Description" xml:space="preserve">
<value>Opis</value> <value>Opis</value>
</data> </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"> <data name="Email" xml:space="preserve">
<value>E-mail</value> <value>E-mail</value>
</data> </data>
<data name="Emoji" xml:space="preserve"> <data name="Emoji" xml:space="preserve">
<value>Emoji</value> <value>Emoji</value>
</data> </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"> <data name="Groups" xml:space="preserve">
<value>Grupy</value> <value>Grupy</value>
</data> </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"> <data name="InvitationCode" xml:space="preserve">
<value>Kod zaproszenia</value> <value>Kod zaproszenia</value>
</data> </data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Kod lub link do zaproszenia</value>
</data>
<data name="InvitationPassword" xml:space="preserve"> <data name="InvitationPassword" xml:space="preserve">
<value>Hasło do zaproszenia</value> <value>Hasło do zaproszenia</value>
</data> </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"> <data name="LanguageId" xml:space="preserve">
<value>Język id</value> <value>Język id</value>
</data> </data>
<data name="Name" xml:space="preserve"> <data name="Name" xml:space="preserve">
<value>Nazwa</value> <value>Nazwa</value>
</data> </data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Włączyć powiadomienia? (przydatne do otrzymywania aktualizacji aplikacji)</value>
</data>
<data name="NewPassword" xml:space="preserve"> <data name="NewPassword" xml:space="preserve">
<value>Nowe hasło</value> <value>Nowe hasło</value>
</data> </data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve"> <data name="OldPassword" xml:space="preserve">
<value>Stare hasło</value> <value>Stare hasło</value>
</data> </data>
<data name="OneTime" xml:space="preserve">
<value>Tylko jeden raz</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>Hasło</value> <value>Hasło</value>
</data> </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"> <data name="PreferSystemTheming" xml:space="preserve">
<value>Preferowany motyw systemowy</value> <value>Preferowany motyw systemowy</value>
</data> </data>
<data name="RecoveryCode" xml:space="preserve"> <data name="RecoveryCode" xml:space="preserve">
<value>Kod odzyskiwania</value> <value>Kod odzyskiwania</value>
</data> </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"> <data name="ThemeIsDarkMode" xml:space="preserve">
<value>Tryb ciemny</value> <value>Tryb ciemny</value>
</data> </data>
<data name="Ticks" xml:space="preserve">
<value>Kleszcze</value>
</data>
<data name="Title" xml:space="preserve"> <data name="Title" xml:space="preserve">
<value>Tytuł</value> <value>Tytuł</value>
</data> </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"> <data name="Username" xml:space="preserve">
<value>Nazwa użytkownika</value> <value>Nazwa użytkownika</value>
</data> </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> </root>
@@ -117,123 +117,30 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </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"> <data name="Description" xml:space="preserve">
<value>Descrição</value> <value>Descrição</value>
</data> </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"> <data name="Email" xml:space="preserve">
<value>Email</value> <value>Email</value>
</data> </data>
<data name="Emoji" xml:space="preserve"> <data name="Emoji" xml:space="preserve">
<value>Emoji</value> <value>Emoji</value>
</data> </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"> <data name="Groups" xml:space="preserve">
<value>Grupos</value> <value>Grupos</value>
</data> </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"> <data name="InvitationCode" xml:space="preserve">
<value>Código de convite</value> <value>Código de convite</value>
</data> </data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Código do convite ou link</value>
</data>
<data name="InvitationPassword" xml:space="preserve"> <data name="InvitationPassword" xml:space="preserve">
<value>Senha do convite</value> <value>Senha do convite</value>
</data> </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"> <data name="LanguageId" xml:space="preserve">
<value>Identificação do idioma</value> <value>Identificação do idioma</value>
</data> </data>
<data name="Name" xml:space="preserve"> <data name="Name" xml:space="preserve">
<value>Nome</value> <value>Nome</value>
</data> </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"> <data name="NewPassword" xml:space="preserve">
<value>Nova senha</value> <value>Nova senha</value>
</data> </data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve"> <data name="OldPassword" xml:space="preserve">
<value>Senha antiga</value> <value>Senha antiga</value>
</data> </data>
<data name="OneTime" xml:space="preserve">
<value>Só uma vez</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>Senha</value> <value>Senha</value>
</data> </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"> <data name="PreferSystemTheming" xml:space="preserve">
<value>Tema do sistema de preferências</value> <value>Tema do sistema de preferências</value>
</data> </data>
<data name="RecoveryCode" xml:space="preserve"> <data name="RecoveryCode" xml:space="preserve">
<value>Código de recuperação</value> <value>Código de recuperação</value>
</data> </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"> <data name="ThemeIsDarkMode" xml:space="preserve">
<value>Modo escuro</value> <value>Modo escuro</value>
</data> </data>
<data name="Ticks" xml:space="preserve">
<value>Carraças</value>
</data>
<data name="Title" xml:space="preserve"> <data name="Title" xml:space="preserve">
<value>Título</value> <value>Título</value>
</data> </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"> <data name="Username" xml:space="preserve">
<value>Nome de usuário</value> <value>Nome de usuário</value>
</data> </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> </root>
@@ -117,123 +117,30 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </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"> <data name="Description" xml:space="preserve">
<value>Description</value> <value>Description</value>
</data> </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"> <data name="Email" xml:space="preserve">
<value>Email</value> <value>Email</value>
</data> </data>
<data name="Emoji" xml:space="preserve"> <data name="Emoji" xml:space="preserve">
<value>Emoji</value> <value>Emoji</value>
</data> </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"> <data name="Groups" xml:space="preserve">
<value>Groups</value> <value>Groups</value>
</data> </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"> <data name="InvitationCode" xml:space="preserve">
<value>Invitation code</value> <value>Invitation code</value>
</data> </data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Invitation code or link</value>
</data>
<data name="InvitationPassword" xml:space="preserve"> <data name="InvitationPassword" xml:space="preserve">
<value>Invitation password</value> <value>Invitation password</value>
</data> </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"> <data name="LanguageId" xml:space="preserve">
<value>Language id</value> <value>Language id</value>
</data> </data>
<data name="Name" xml:space="preserve"> <data name="Name" xml:space="preserve">
<value>Name</value> <value>Name</value>
</data> </data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Enable notifications?(useful to receive app updates)</value>
</data>
<data name="NewPassword" xml:space="preserve"> <data name="NewPassword" xml:space="preserve">
<value>New password</value> <value>New password</value>
</data> </data>
@@ -243,78 +150,24 @@
<data name="OldPassword" xml:space="preserve"> <data name="OldPassword" xml:space="preserve">
<value>Old password</value> <value>Old password</value>
</data> </data>
<data name="OneTime" xml:space="preserve">
<value>One time only</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>Password</value> <value>Password</value>
</data> </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"> <data name="PreferSystemTheming" xml:space="preserve">
<value>Prefer system theme</value> <value>Prefer system theme</value>
</data> </data>
<data name="RecoveryCode" xml:space="preserve"> <data name="RecoveryCode" xml:space="preserve">
<value>Recovery code</value> <value>Recovery code</value>
</data> </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"> <data name="ThemeIsDarkMode" xml:space="preserve">
<value>Dark mode</value> <value>Dark mode</value>
</data> </data>
<data name="Ticks" xml:space="preserve">
<value>Ticks</value>
</data>
<data name="Title" xml:space="preserve"> <data name="Title" xml:space="preserve">
<value>Title</value> <value>Title</value>
</data> </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"> <data name="Username" xml:space="preserve">
<value>Username</value> <value>Username</value>
</data> </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"> <data name="AvatarId" xml:space="preserve">
<value>Avatar</value> <value>Avatar</value>
</data> </data>
@@ -117,123 +117,30 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </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"> <data name="Description" xml:space="preserve">
<value>Descriere</value> <value>Descriere</value>
</data> </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"> <data name="Email" xml:space="preserve">
<value>Email</value> <value>Email</value>
</data> </data>
<data name="Emoji" xml:space="preserve"> <data name="Emoji" xml:space="preserve">
<value>Emoji</value> <value>Emoji</value>
</data> </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"> <data name="Groups" xml:space="preserve">
<value>Grupuri</value> <value>Grupuri</value>
</data> </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"> <data name="InvitationCode" xml:space="preserve">
<value>Codul de invitație</value> <value>Codul de invitație</value>
</data> </data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Cod sau link de invitație</value>
</data>
<data name="InvitationPassword" xml:space="preserve"> <data name="InvitationPassword" xml:space="preserve">
<value>Parola de invitație</value> <value>Parola de invitație</value>
</data> </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"> <data name="LanguageId" xml:space="preserve">
<value>ID-ul limbii</value> <value>ID-ul limbii</value>
</data> </data>
<data name="Name" xml:space="preserve"> <data name="Name" xml:space="preserve">
<value>Nume</value> <value>Nume</value>
</data> </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"> <data name="NewPassword" xml:space="preserve">
<value>Parolă nouă</value> <value>Parolă nouă</value>
</data> </data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve"> <data name="OldPassword" xml:space="preserve">
<value>Parolă veche</value> <value>Parolă veche</value>
</data> </data>
<data name="OneTime" xml:space="preserve">
<value>O singură dată</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>Parola</value> <value>Parola</value>
</data> </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"> <data name="PreferSystemTheming" xml:space="preserve">
<value>Preferați tema de sistem</value> <value>Preferați tema de sistem</value>
</data> </data>
<data name="RecoveryCode" xml:space="preserve"> <data name="RecoveryCode" xml:space="preserve">
<value>Cod de recuperare</value> <value>Cod de recuperare</value>
</data> </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"> <data name="ThemeIsDarkMode" xml:space="preserve">
<value>Mod întunecat</value> <value>Mod întunecat</value>
</data> </data>
<data name="Ticks" xml:space="preserve">
<value>Căpușe</value>
</data>
<data name="Title" xml:space="preserve"> <data name="Title" xml:space="preserve">
<value>Titlu</value> <value>Titlu</value>
</data> </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"> <data name="Username" xml:space="preserve">
<value>Nume utilizator</value> <value>Nume utilizator</value>
</data> </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> </root>
@@ -117,123 +117,30 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </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"> <data name="Description" xml:space="preserve">
<value>Описание</value> <value>Описание</value>
</data> </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"> <data name="Email" xml:space="preserve">
<value>Электронная почта</value> <value>Электронная почта</value>
</data> </data>
<data name="Emoji" xml:space="preserve"> <data name="Emoji" xml:space="preserve">
<value>Emoji</value> <value>Emoji</value>
</data> </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"> <data name="Groups" xml:space="preserve">
<value>Группы</value> <value>Группы</value>
</data> </data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>Включить первый ответ в предварительный просмотр приглашения? (что делает его более привлекательным для участия в обсуждении)</value>
</data>
<data name="InvitationCode" xml:space="preserve"> <data name="InvitationCode" xml:space="preserve">
<value>Код приглашения</value> <value>Код приглашения</value>
</data> </data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Код приглашения или ссылка</value>
</data>
<data name="InvitationPassword" xml:space="preserve"> <data name="InvitationPassword" xml:space="preserve">
<value>Пароль приглашения</value> <value>Пароль приглашения</value>
</data> </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"> <data name="LanguageId" xml:space="preserve">
<value>Языковой идентификатор</value> <value>Языковой идентификатор</value>
</data> </data>
<data name="Name" xml:space="preserve"> <data name="Name" xml:space="preserve">
<value>Имя</value> <value>Имя</value>
</data> </data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Включить уведомления? (полезно для получения обновлений приложения)</value>
</data>
<data name="NewPassword" xml:space="preserve"> <data name="NewPassword" xml:space="preserve">
<value>Новый пароль</value> <value>Новый пароль</value>
</data> </data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve"> <data name="OldPassword" xml:space="preserve">
<value>Старый пароль</value> <value>Старый пароль</value>
</data> </data>
<data name="OneTime" xml:space="preserve">
<value>Только один раз</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>Пароль</value> <value>Пароль</value>
</data> </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"> <data name="PreferSystemTheming" xml:space="preserve">
<value>Предпочитайте системную тему</value> <value>Предпочитайте системную тему</value>
</data> </data>
<data name="RecoveryCode" xml:space="preserve"> <data name="RecoveryCode" xml:space="preserve">
<value>Код восстановления</value> <value>Код восстановления</value>
</data> </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"> <data name="ThemeIsDarkMode" xml:space="preserve">
<value>Темный режим</value> <value>Темный режим</value>
</data> </data>
<data name="Ticks" xml:space="preserve">
<value>Клещи</value>
</data>
<data name="Title" xml:space="preserve"> <data name="Title" xml:space="preserve">
<value>Название</value> <value>Название</value>
</data> </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"> <data name="Username" xml:space="preserve">
<value>Имя пользователя</value> <value>Имя пользователя</value>
</data> </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> </root>
@@ -117,123 +117,30 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </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"> <data name="Description" xml:space="preserve">
<value>Popis</value> <value>Popis</value>
</data> </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"> <data name="Email" xml:space="preserve">
<value>E-mail</value> <value>E-mail</value>
</data> </data>
<data name="Emoji" xml:space="preserve"> <data name="Emoji" xml:space="preserve">
<value>Emoji</value> <value>Emoji</value>
</data> </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"> <data name="Groups" xml:space="preserve">
<value>Skupiny</value> <value>Skupiny</value>
</data> </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"> <data name="InvitationCode" xml:space="preserve">
<value>Pozývací kód</value> <value>Pozývací kód</value>
</data> </data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Pozývací kód alebo odkaz</value>
</data>
<data name="InvitationPassword" xml:space="preserve"> <data name="InvitationPassword" xml:space="preserve">
<value>Heslo pozvánky</value> <value>Heslo pozvánky</value>
</data> </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"> <data name="LanguageId" xml:space="preserve">
<value>Identifikátor jazyka</value> <value>Identifikátor jazyka</value>
</data> </data>
<data name="Name" xml:space="preserve"> <data name="Name" xml:space="preserve">
<value>Názov</value> <value>Názov</value>
</data> </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"> <data name="NewPassword" xml:space="preserve">
<value>Nové heslo</value> <value>Nové heslo</value>
</data> </data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve"> <data name="OldPassword" xml:space="preserve">
<value>Staré heslo</value> <value>Staré heslo</value>
</data> </data>
<data name="OneTime" xml:space="preserve">
<value>Iba jedenkrát</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>Heslo</value> <value>Heslo</value>
</data> </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"> <data name="PreferSystemTheming" xml:space="preserve">
<value>Uprednostniť tému systému</value> <value>Uprednostniť tému systému</value>
</data> </data>
<data name="RecoveryCode" xml:space="preserve"> <data name="RecoveryCode" xml:space="preserve">
<value>Kód obnovy</value> <value>Kód obnovy</value>
</data> </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"> <data name="ThemeIsDarkMode" xml:space="preserve">
<value>Tmavý režim</value> <value>Tmavý režim</value>
</data> </data>
<data name="Ticks" xml:space="preserve">
<value>Kliešte</value>
</data>
<data name="Title" xml:space="preserve"> <data name="Title" xml:space="preserve">
<value>Názov</value> <value>Názov</value>
</data> </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"> <data name="Username" xml:space="preserve">
<value>Používateľské meno</value> <value>Používateľské meno</value>
</data> </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> </root>
@@ -117,123 +117,30 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </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"> <data name="Description" xml:space="preserve">
<value>Opis</value> <value>Opis</value>
</data> </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"> <data name="Email" xml:space="preserve">
<value>E-pošta</value> <value>E-pošta</value>
</data> </data>
<data name="Emoji" xml:space="preserve"> <data name="Emoji" xml:space="preserve">
<value>Emoji</value> <value>Emoji</value>
</data> </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"> <data name="Groups" xml:space="preserve">
<value>Skupine</value> <value>Skupine</value>
</data> </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"> <data name="InvitationCode" xml:space="preserve">
<value>Koda vabila</value> <value>Koda vabila</value>
</data> </data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Koda vabila ali povezava</value>
</data>
<data name="InvitationPassword" xml:space="preserve"> <data name="InvitationPassword" xml:space="preserve">
<value>Geslo za povabilo</value> <value>Geslo za povabilo</value>
</data> </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"> <data name="LanguageId" xml:space="preserve">
<value>Id jezika</value> <value>Id jezika</value>
</data> </data>
<data name="Name" xml:space="preserve"> <data name="Name" xml:space="preserve">
<value>Ime</value> <value>Ime</value>
</data> </data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>Omogočite obvestila? (koristno za prejemanje posodobitev aplikacije)</value>
</data>
<data name="NewPassword" xml:space="preserve"> <data name="NewPassword" xml:space="preserve">
<value>Novo geslo</value> <value>Novo geslo</value>
</data> </data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve"> <data name="OldPassword" xml:space="preserve">
<value>Staro geslo</value> <value>Staro geslo</value>
</data> </data>
<data name="OneTime" xml:space="preserve">
<value>Samo enkrat</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>Geslo</value> <value>Geslo</value>
</data> </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"> <data name="PreferSystemTheming" xml:space="preserve">
<value>Prednost sistemske teme</value> <value>Prednost sistemske teme</value>
</data> </data>
<data name="RecoveryCode" xml:space="preserve"> <data name="RecoveryCode" xml:space="preserve">
<value>Koda za obnovitev</value> <value>Koda za obnovitev</value>
</data> </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"> <data name="ThemeIsDarkMode" xml:space="preserve">
<value>Temni način</value> <value>Temni način</value>
</data> </data>
<data name="Ticks" xml:space="preserve">
<value>Klopi</value>
</data>
<data name="Title" xml:space="preserve"> <data name="Title" xml:space="preserve">
<value>Naslov</value> <value>Naslov</value>
</data> </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"> <data name="Username" xml:space="preserve">
<value>Uporabniško ime</value> <value>Uporabniško ime</value>
</data> </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> </root>
@@ -117,123 +117,30 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </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"> <data name="Description" xml:space="preserve">
<value>Beskrivning</value> <value>Beskrivning</value>
</data> </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"> <data name="Email" xml:space="preserve">
<value>E-post</value> <value>E-post</value>
</data> </data>
<data name="Emoji" xml:space="preserve"> <data name="Emoji" xml:space="preserve">
<value>Emoji</value> <value>Emoji</value>
</data> </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"> <data name="Groups" xml:space="preserve">
<value>Grupper</value> <value>Grupper</value>
</data> </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"> <data name="InvitationCode" xml:space="preserve">
<value>Kod för inbjudan</value> <value>Kod för inbjudan</value>
</data> </data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>Inbjudningskod eller länk</value>
</data>
<data name="InvitationPassword" xml:space="preserve"> <data name="InvitationPassword" xml:space="preserve">
<value>Lösenord för inbjudan</value> <value>Lösenord för inbjudan</value>
</data> </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"> <data name="LanguageId" xml:space="preserve">
<value>Språk-id</value> <value>Språk-id</value>
</data> </data>
<data name="Name" xml:space="preserve"> <data name="Name" xml:space="preserve">
<value>Namn</value> <value>Namn</value>
</data> </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"> <data name="NewPassword" xml:space="preserve">
<value>Nytt lösenord</value> <value>Nytt lösenord</value>
</data> </data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve"> <data name="OldPassword" xml:space="preserve">
<value>Gammalt lösenord</value> <value>Gammalt lösenord</value>
</data> </data>
<data name="OneTime" xml:space="preserve">
<value>Endast en gång</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>Lösenord</value> <value>Lösenord</value>
</data> </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"> <data name="PreferSystemTheming" xml:space="preserve">
<value>Föredrar systemtema</value> <value>Föredrar systemtema</value>
</data> </data>
<data name="RecoveryCode" xml:space="preserve"> <data name="RecoveryCode" xml:space="preserve">
<value>Kod för återvinning</value> <value>Kod för återvinning</value>
</data> </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"> <data name="ThemeIsDarkMode" xml:space="preserve">
<value>Mörkt läge</value> <value>Mörkt läge</value>
</data> </data>
<data name="Ticks" xml:space="preserve">
<value>Fästingar</value>
</data>
<data name="Title" xml:space="preserve"> <data name="Title" xml:space="preserve">
<value>Titel</value> <value>Titel</value>
</data> </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"> <data name="Username" xml:space="preserve">
<value>Användarnamn</value> <value>Användarnamn</value>
</data> </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> </root>
@@ -117,123 +117,30 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </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"> <data name="Description" xml:space="preserve">
<value>描述</value> <value>描述</value>
</data> </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"> <data name="Email" xml:space="preserve">
<value>电子邮件</value> <value>电子邮件</value>
</data> </data>
<data name="Emoji" xml:space="preserve"> <data name="Emoji" xml:space="preserve">
<value>表情符号</value> <value>表情符号</value>
</data> </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"> <data name="Groups" xml:space="preserve">
<value>群体</value> <value>群体</value>
</data> </data>
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
<value>在邀请预览中包括第一个答案?(使其更加吸引人加入讨论)</value>
</data>
<data name="InvitationCode" xml:space="preserve"> <data name="InvitationCode" xml:space="preserve">
<value>邀请函代码</value> <value>邀请函代码</value>
</data> </data>
<data name="InvitationCodeLink" xml:space="preserve">
<value>邀请函代码或链接</value>
</data>
<data name="InvitationPassword" xml:space="preserve"> <data name="InvitationPassword" xml:space="preserve">
<value>邀请函密码</value> <value>邀请函密码</value>
</data> </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"> <data name="LanguageId" xml:space="preserve">
<value>语言标识</value> <value>语言标识</value>
</data> </data>
<data name="Name" xml:space="preserve"> <data name="Name" xml:space="preserve">
<value>命名</value> <value>命名</value>
</data> </data>
<data name="NativeNotificationsEnabled" xml:space="preserve">
<value>启用通知?(对接收应用程序的更新很有用)</value>
</data>
<data name="NewPassword" xml:space="preserve"> <data name="NewPassword" xml:space="preserve">
<value>新密码</value> <value>新密码</value>
</data> </data>
@@ -243,76 +150,22 @@
<data name="OldPassword" xml:space="preserve"> <data name="OldPassword" xml:space="preserve">
<value>旧密码</value> <value>旧密码</value>
</data> </data>
<data name="OneTime" xml:space="preserve">
<value>仅限一次</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>密码</value> <value>密码</value>
</data> </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"> <data name="PreferSystemTheming" xml:space="preserve">
<value>倾向于系统主题</value> <value>倾向于系统主题</value>
</data> </data>
<data name="RecoveryCode" xml:space="preserve"> <data name="RecoveryCode" xml:space="preserve">
<value>恢复代码</value> <value>恢复代码</value>
</data> </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"> <data name="ThemeIsDarkMode" xml:space="preserve">
<value>黑暗模式</value> <value>黑暗模式</value>
</data> </data>
<data name="Ticks" xml:space="preserve">
<value>蜱虫</value>
</data>
<data name="Title" xml:space="preserve"> <data name="Title" xml:space="preserve">
<value>标题</value> <value>标题</value>
</data> </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"> <data name="Username" xml:space="preserve">
<value>帐号</value> <value>帐号</value>
</data> </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> </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; }
}
}
@@ -0,0 +1,29 @@
using PrivaPub.ClientModels.Resources;
using System.ComponentModel.DataAnnotations;
namespace PrivaPub.ClientModels.User.Avatar
{
// How a persona's clients show what others post (R8): Mastodon's reading:expand:media ("default" hides media marked
// sensitive, "show_all", "hide_all") and reading:expand:spoilers, which GET /api/v1/preferences answers. Mastodon's API
// sets neither, so they are set here: GET and POST /clientapi/avatar/private/preferences, for the login's own persona.
public class ViewPersonaPreferences
{
public string AvatarId { get; set; }
public string ExpandMedia { get; set; }
public bool ExpandSpoilers { get; set; } = false;
}
public class PersonaPreferencesForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = nameof(AvatarId), ResourceType = typeof(FieldsNameResource))]
public string AvatarId { get; set; }
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(16, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
public string ExpandMedia { get; set; }//"default", "show_all" or "hide_all"
public bool ExpandSpoilers { get; set; } = false;
}
}
@@ -0,0 +1,19 @@
using PrivaPub.ClientModels.Resources;
using System.ComponentModel.DataAnnotations;
namespace PrivaPub.ClientModels.User
{
// A login deletes one of its own personas (POST /clientapi/avatar/private/delete), and with it the groups the persona
// owns, with its password again, as it deletes itself.
public class DeletePersonaForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "AvatarId", ResourceType = typeof(FieldsNameResource))]
public string AvatarId { get; set; }
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
public string Password { get; set; }
}
}
+1 -1
View File
@@ -2,6 +2,6 @@
{ {
public class UsersIds public class UsersIds
{ {
public IList<string> UserIdList { get; set; } = Array.Empty<string>(); public List<string> UserIdList { 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;
}
}
@@ -21,5 +21,8 @@ namespace PrivaPub.ClientModels.User
public bool ThemeIsDarkMode { get; set; } = false; public bool ThemeIsDarkMode { get; set; } = false;
public bool ThemeIsLightGray { get; set; } = false; public bool ThemeIsLightGray { get; set; } = false;
public bool ThemeIsDarkGray { 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"));
}
}
-160
View File
@@ -1,160 +0,0 @@
using PrivaPub.Models.Federation;
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", false, 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, false, 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);
}
// a login over its quota uploads nothing more, whichever persona tries
[Fact]
public async Task A_login_over_its_quota_uploads_nothing_more()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var (_, sibling) = await _harness.Persona("sibling", root);
var quota = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = _harness.Media.Root, QuotaBytesPerRoot = 3000 }),
_harness.Local, default, Microsoft.Extensions.Logging.Abstractions.NullLogger<MediaService>.Instance);
// noise: its PNG is about as big as its pixels, so two of them are over the quota and one is not
static byte[] Noise()
{
var pixels = new byte[30 * 20 * 3];
Random.Shared.NextBytes(pixels);
using var image = NetVips.Image.NewFromMemory(pixels, 30, 20, 3, NetVips.Enums.BandFormat.Uchar);
return image.WriteToBuffer(".png");
}
Assert.True((await quota.Upload(alice, Upload(Noise(), "image/png"), default, default, false, token)).Ok);
var full = await quota.Upload(sibling, Upload(Noise(), "image/png"), default, default, false, token);
Assert.False(full.Ok);
Assert.Contains("storage is full", full.Error);
}
// nothing of a suspended server, or of one whose media are rejected, is proxied; blocking one purges what was cached
[Fact]
public async Task A_blocked_servers_media_are_not_proxied_and_their_cache_goes()
{
var token = TestContext.Current.CancellationToken;
var path = $"/files/{Guid.NewGuid():N}.png";
_harness.Peer.ServeFile(path, Png(10, 10), "image/png");
var remote = _harness.Peer.A + path;
var host = new Uri(remote).Host;
var blocks = new Blocks();
var proxy = new MediaProxy(_harness.Local, Peer.Http(), _harness.Media, new StaticOptions<MediaOptions>(new MediaOptions()), blocks);
Assert.Equal(ProxyOutcome.Cached, (await proxy.Download(remote, token)).Outcome);
blocks.Blocked[host] = new DomainBlock { Domain = host, Severity = DomainBlockSeverity.Silence, RejectMedia = true };
Assert.True(proxy.Refuses(remote));
Assert.True(proxy.Purge(host) >= 1);
Assert.Equal(default, proxy.Cached(remote));
blocks.Blocked[host] = new DomainBlock { Domain = host, Severity = DomainBlockSeverity.Silence };
Assert.False(proxy.Refuses(remote));
blocks.Blocked[host] = new DomainBlock { Domain = host, Severity = DomainBlockSeverity.Suspend };
Assert.True(proxy.Refuses(remote));
}
sealed class Blocks : PrivaPub.Federation.Moderation.IDomainBlocks
{
public Dictionary<string, DomainBlock> Blocked { get; } = new();
public DomainBlock Find(string host) => Blocked.GetValueOrDefault(host);
public bool IsSuspended(string host) => Find(host)?.Severity == DomainBlockSeverity.Suspend;
public Task Reload(CancellationToken token) => Task.CompletedTask;
}
}
}
@@ -1,103 +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");
}
// SVG, PDF and the other formats libvips could read never load from an upload, whatever it claims to be
[Fact]
public void Only_the_upload_formats_are_ever_loaded()
{
var svg = "<svg xmlns='http://www.w3.org/2000/svg' width='20000' height='20000'><rect width='10' height='10'/></svg>"u8.ToArray();
Assert.Throws<VipsException>(() => MediaService.Inspect(svg));
Assert.Throws<VipsException>(() => MediaService.Inspect("%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF"u8.ToArray()));
using var colour = (Image.Black(8, 8, bands: 3) + 100).Cast(Enums.BandFormat.Uchar);
foreach (var format in new[] { ".jpg", ".png", ".gif", ".webp" })
Assert.Equal(8, MediaService.Inspect(colour.WriteToBuffer(format)).Width);
Assert.Throws<VipsException>(() => MediaService.Inspect(colour.WriteToBuffer(".tif")));
}
// the header tells how big an image would decode before anything is decoded
[Fact]
public void The_header_says_how_big_an_image_would_decode()
{
using var huge = Image.Black(8000, 6000);
var header = MediaService.Inspect(huge.WriteToBuffer(".png"));
Assert.Equal(48_000_000, header.Pixels);
Assert.False(header.Animated);
using var frame = (Image.Black(40, 30, bands: 3) + 60).Cast(Enums.BandFormat.Uchar);
using var frames = Image.Arrayjoin(new[] { frame, frame + 80, frame + 160 }, across: 1).Cast(Enums.BandFormat.Uchar);
using var paged = frames.Mutate(m => m.Set(GValue.GIntType, "page-height", 30));
var gif = MediaService.Inspect(paged.WriteToBuffer(".gif"));
Assert.True(gif.Animated);
Assert.Equal(3, gif.Pages);
Assert.Equal(40 * 30 * 3, gif.Pixels);
}
[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);
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);
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);
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]);
}
}
}
-79
View File
@@ -1,79 +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);
// nor reaches a home, its author's included: the Mastodon API looks past it, so there it could not be acted on
Assert.False(await DB.Default.Find<PrivaPub.Models.Social.TimelineEntry>().Match(e => e.PostId == post.ID).ExecuteAnyAsync(token));
}
[Fact]
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,714 +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);
// never posted for a day: trashed, its files out of what /media/files serves at once
Assert.NotNull((await DB.Default.Find<MediaAttachment>().OneAsync(stale.ID, token)).TrashedAt);
Assert.False(File.Exists(Path.Combine(root, stale.FilePath)));
Assert.False(File.Exists(Path.Combine(root, stale.PreviewPath)));
Assert.True(File.Exists(Path.Combine(media.TrashRoot, stale.FilePath)));
// and once its grace has passed, deleted with its row
await DB.Default.Update<MediaAttachment>().MatchID(stale.ID).Modify(m => m.TrashedAt, DateTime.UtcNow - MediaJanitor.TrashGrace - TimeSpan.FromMinutes(1)).ExecuteAsync(token);
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(media.TrashRoot, stale.FilePath)));
Assert.False(File.Exists(Path.Combine(media.TrashRoot, 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)));
}
}
}
Loaded 100 of 388 files, more files were not shown because too many files have changed in this diff. Show more