Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:28:33 +02:00
278 changed files with 3617 additions and 29326 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
+11 -7
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")
+172 -65
View File
@@ -18,7 +18,7 @@ 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
@@ -30,9 +30,9 @@ Its defining idea: **one private login owns several public personas.**
shown as accounts. 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 private notes (`RootUserNote`, `PersonalNote`)
- private notes (`RootUserNote`, `PersonalNote`). exist only as model classes, with no endpoint.
The intended client is the Mastodon client API (Tusky, Elk, Phanpy, Ivory), where each avatar logs in as its own 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`
@@ -56,10 +56,17 @@ actor-scoped route gets a name in the same spirit, agreed with the owner, and a
| Activities | `/peasants/{name}/grunts/{id}` (`create-{postId}` resolves) | | 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 | `/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` |
Agreed for later phases: `/gossip`, `/drool`, `/echoes` (replies, likes, shares), `/trophies` (featured), `/tattoos` All of these have federated, so they are frozen. Agreed for later: `/drool` and `/echoes` (likes, shares). `/gossip` was
(featured tags), `/flock` and `/wardens` (group members and moderators). 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/*`;
@@ -74,15 +81,22 @@ 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,
the staff's log), Admin (logins), DomainBlock, Statistics, Backup, InstanceAdmin (the
server's settings, thumbnail, announcements, relays)
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
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|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)
@@ -100,13 +114,20 @@ PrivaPub/ ASP.NET Core Web API, net10.0
ReplyRelay (third-party replies to a persona's posts passed on to its followers) 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)
Instance/ InstanceSettings (the server's own settings in memory), MediaLimitsFromSettings
Moderation/ ModerationLog (the staff's log, AdminAction)
Social/ FollowService (local in-process, remote Follow/Accept), Notifications, Announcements
Timelines/ Fanout (TimelineEntry rows, Mastodon's home rules), TimelineService 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
@@ -114,31 +135,29 @@ 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; 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, …
Models/ Mongo entities: User/, Group/, Post/, Federation/, Jobs/, AppConfiguration Models/ Mongo entities: User/, Group/, Post/, Federation/, Jobs/, AppConfiguration, ServerSetup,
ServerSettings, AdminAction
StaticServices/ DbEntities (Find<T> accessors), AuthTokenManager (JWT), PasswordHasher 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 \
@@ -171,10 +190,84 @@ echo '<password>' | ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin create-ro
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin promote|demote <root> ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin promote|demote <root>
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin move-persona <persona> <root> # its link moves, its Mastodon tokens end 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 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, create-root, move-persona, delete-root, reset-totp) and `restore` is kept in the staff's log as
the command line's (`cli`).
## The server's own settings (completion program R5)
- **One document** (`Models/ServerSettings.cs`, fixed id `settings`), read through the `IInstanceSettings` singleton
(`Domain/Instance/InstanceSettings.cs`): `Current` is never null and never changed in place; `Update(change, byRootId)`
changes the stored document (or the defaults), saves it and swaps it in; it is read at start (after migrations), after
each save and every five minutes (`InstanceSettingsRefresher`), so an edit made elsewhere takes. **The defaults are what
PrivaPub served before, word for word** (title "PrivaPub", `ServerSettings.DefaultDescription`, the privacy policy of
2026-10-04 as Markdown that renders to the old paragraph, `/media/missing-header.png`), so nothing changes until an
administrator saves. Registrations stay configuration-only (`Registrations:Mode`, owner decision; the deploy checks it).
- **Where each fact comes from now:**
- the title: both instance APIs' `title`, NodeInfo `metadata.nodeName`, the instance actor's `name`,
`ProvenanceController.DescribeSelf`, the web pages' footer (`_Layout`), the `/oauth/login` heading and the emails'
From name (`Mailer`);
- the short description: v1 `short_description`/`description`, v2 `description`, NodeInfo `nodeDescription`, and the
instance actor's `summary` once it is not the default (until then the actor's own sentence);
- the description (Markdown): `/api/v1/instance/extended_description` (dated when it last changed, today until then);
- contact email and contact persona (`ContactAccountId`, a local persona the administrator chooses, publicly tied to
running the server; shown while it exists and its login may act): v1 `email`/`contact_account`, v2 `contact`;
- the thumbnail: v1 `thumbnail`, v2 `thumbnail.url`;
- the rules (`ServerRule`: a GUID id, text, hint, position; soft-deleted, so old reports keep their meaning): both
instance APIs' `rules` and `/api/v1/instance/rules`, those not deleted, in order;
- the privacy policy and the terms of service (Markdown): `/api/v1/instance/privacy_policy` and Mastodon 4.4's
`/api/v1/instance/terms_of_service[/{yyyy-MM-dd}]` (one version kept; 404 while there are none).
The server's texts are rendered by `ContentRenderer.Document` (Markdig with raw HTML off, then `ContentSanitizer`;
nothing is looked up).
- **Administrator routes** (`InstanceAdminController`, `[Authorize(Policy = Policies.IsAdmin)]` and checked again against
the database): `GET`/`POST /clientapi/admin/settings` (`ViewServerSettings` / `ServerSettingsForm`: a rule named by its
id keeps it, a new one gets one, one left out is deleted; a description's or policy's date moves only when its text
does), `POST`/`DELETE /clientapi/admin/settings/thumbnail` (multipart `file`), `/clientapi/admin/announcements/
list|insert|update|delete`, `/clientapi/admin/relays`, `/relays/insert`, `/relays/delete?address=`.
- **The thumbnail** goes through `IMediaService.ServerImage` (checked before it is decoded, libvips, `keep=none`, fitted
within 1200×630) into a `MediaAttachment` nobody owns, held by `ServerUse = "thumbnail"`: the janitor's never-posted
sweep leaves such rows alone, `admin media audit` keeps the one the settings name (and trashes any other), no quota
counts them, and a replaced or removed thumbnail is trashed.
- **Media limits** (`ServerSettings.Media`: quota, image and video sizes, duration): `MediaLimitsFromSettings`
(`IPostConfigureOptions<MediaOptions>`) applies them over `Media:*` within hard caps (images 40 MB, video 99 MB, which
nginx's 100m and `MediaController`'s request limit allow, four hours), and `MediaLimitsChangeSource` makes every
`IOptionsMonitor<MediaOptions>` reader (uploads, the quota, the instance API, `/clientapi/user/storage`) see a save at
once.
- **Rules in reports:** `POST /api/v1/reports` takes `rule_ids[]` (kept when the rule exists, deleted ones too, since the
dialog may have listed them; a report naming rules without a category is a `violation`, and only a violation names
rules, as on Mastodon); `Report.RuleIds`, the entity's `rule_ids`, and the moderators' `ViewReport.RuleIds` and `Rules`
(their texts). Rules never federate.
- **Announcements** (`Domain/Social/Announcements.cs`, `Api/Mastodon/Controllers/AnnouncementsController.cs`): written in
Markdown by an administrator, shown while published and not ended; `GET /api/v1/announcements` (anonymous: without
`read` and `me`), `POST …/{id}/dismiss`, `PUT`/`DELETE …/{id}/reactions/{name}` (one unicode emoji, normalised as a
post's reactions are, eight different ones at most; anything else 422). Dismissals (`AnnouncementRead`) and reactions
(`AnnouncementReaction`) are kept per persona, never federated, and go with a deleted persona (`RootRemoval`). Every
persona's `user` stream hears `announcement`, `announcement.reaction` and `announcement.delete` (`Streams.ToUsers`,
mapped per persona in `StreamingController.Payload`).
- **Public domain blocks:** `DomainBlock.Published` and `Obfuscate` (off by default: publishing a block tells the blocked
server, and everyone). `/api/v1/instance/domain_blocks` lists only published ones, as Mastodon does (`domain`, partly
starred when obfuscated, `digest` the SHA-256 of the domain, `severity`, `comment` the public comment).
- **Relays** are `Federation:Relays` ∪ `ServerSettings.Relays` (`IRelays.Named`). One `Reconcile` runs at a time; the
page's insert and delete start one at once in the background (resolving a relay is a fetch) and answer 202. A
configured relay can't be removed from the page (409). A subscription no longer named gets no `Undo` while the same
relay actor is still named under another address.
- **Activity:** `/api/v1/instance/activity` answers `[]` unless `PublishActivity` (off by default); then twelve weeks,
this one first, counted by `Counted.Weekly` (local posts as `LocalPosts` counts them, by their ids; logins made by
`RootUser.CreatedAt`; `logins` "0", since sign-ins are not kept), worked out at most hourly. Personas are never counted
by their creation (their dates are spread on purpose).
- **The staff's log** (`Models/AdminAction.cs`, `Domain/Moderation/ModerationLog.cs`): who (a root id, or `cli`), the action,
the target's kind, id and label, the report, a note; never an IP address, and never a root next to its own personas
(an act on a persona the acting root owns is kept without who acted; a root's entry names none of its personas). It
keeps domain blocks, backups (create, delete, restore), settings saves (the fields changed, never their values),
rules, the thumbnail, announcements, relays, report resolutions and the CLI's acts. `GET
/clientapi/moderator/log?before=&limit=` (`ViewAdminActionsPage`, newest first, by id): administrators read all of it
with login names; a moderator who isn't one reads what isn't about a login, named only when it acted itself or it was
the command line. Writing never fails the act it records.
## Federation invariants ## Federation invariants
@@ -292,8 +385,7 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
4. Answer with `Json(...)` (snake_case, explicit nulls) or `Error(status, message)`; page lists with `Page` and `Link`. 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.2.0 (compatible; PrivaPub)` until streaming and Web Push exist too; `api_versions.mastodon = 7` stays, 6. Advertise `4.2.0 (compatible; PrivaPub)` until streaming and Web Push exist too; `api_versions.mastodon = 7` stays,
because clients show quotes from it, so everything a 4.3+ client calls because of it must answer: grouped because clients show quotes from it, so everything a 4.3+ client calls because of it must answer: grouped
notifications (`/api/v2/notifications`, its unread count, groups, accounts and dismiss) and the notification policy. notifications (`/api/v2/notifications`, its unread count, groups, accounts and dismiss) and the notification policy.
@@ -313,7 +405,7 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
## 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.
@@ -349,8 +441,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) belong to nobody and are held while
the settings name them.
Nothing is deleted because it looks unused. `PrivaPub admin media audit [--fix]` compares disk and database: with 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
@@ -393,6 +487,27 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
queues a `SendRecovery` job, found or not; `RecoveryJob` sends the email and keeps only a SHA-256 of the code, for an 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
@@ -515,7 +630,7 @@ 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`, `ServerSetup` (a restore `EmailRecovery` and `EmailVerification`, `openiddict.tokens` and `.authorizations` (sessions), `MaintenanceLock`, `ServerSetup` (a restore
never opens setup again), and `AppConfiguration` (its SMTP password; it is made again from appsettings at boot). One never opens setup again), and `AppConfiguration` (its SMTP password; it is made again from appsettings at boot). One
backup or restore runs at a time 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
@@ -537,7 +652,9 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
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 and a root's password, e-mail, ban and policies stay;
roots, personas and groups made since become tombstones (deleted, names kept), local posts made since answer 410, 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`); the staff's log
(`AdminAction`) is the union, so a restore never loses an entry, its own request included, and the server's settings
are P's: the live settings outlive a restore;
5. every session ends (a new `SessionStamp` for each root, persona tokens deleted), circuits close, and a 5. every session ends (a new `SessionStamp` for each root, persona tokens deleted), circuits close, and a
`RestoreRecord` (never in a backup) tells what happened (`admin restore --status`). `RestoreRecord` (never in a backup) tells what happened (`admin restore --status`).
@@ -601,37 +718,10 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
- Libraries chosen for the roadmap: HtmlSanitizer, Markdig (`DisableHtml`), NSign (RFC 9421 inbound), OpenIddict + - 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`.
@@ -640,11 +730,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`
@@ -697,7 +788,7 @@ tools/pasture/run.sh down # removes e
read from its `user_relationships`. `akkoma_token <user>` signs one of its users in. 45 checks. 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
@@ -784,6 +875,21 @@ tools/pasture/run.sh down # removes e
announced by the other), that alice's public post goes to the relays (and through both to Mastodon: Activity-Relay's 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).
- **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
@@ -929,16 +1035,17 @@ 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,
+26 -12
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,19 @@ 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
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-9098
emoji), FEP-7888 and FEP-f228 (threads), FEP-7628 (Move), FEP-8967 (link custom emoji offered (R7) and FEP-8967 link attachments sent (R10).
attachments), FEP-ae0c (relays).
## Actors ## Actors
@@ -128,9 +137,11 @@ 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
@@ -245,7 +256,9 @@ replies to a persona's posts passed on to its followers. A deleted post answers
- **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`); `featuredTags` is `/tattoos`. A pin or an unpin
is told to the post's audience as `Add` or `Remove` on `featured`, as Mastodon tells it. is told to the post's audience as `Add` or `Remove` on `featured`, as Mastodon tells it.
- **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it - **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it
followed); an unblock sends `Undo{Block}`. followed); an unblock sends `Undo{Block}`. 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`.
@@ -355,10 +368,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
@@ -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; }
} }
} }
@@ -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;
}
}
@@ -11,6 +11,8 @@ 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; }
@@ -0,0 +1,167 @@
using PrivaPub.ClientModels.Resources;
using System.ComponentModel.DataAnnotations;
namespace PrivaPub.ClientModels.Admin
{
// The server's own settings as the administrator's page shows them (GET /clientapi/admin/settings): what the instance
// API, NodeInfo, the instance actor, the web pages and the emails say of the server, its rules, its relays, its media
// limits next to the configured ones, and whether its weekly activity is published. Registrations are read-only: they
// stay in the configuration (Registrations:Mode).
public class ViewServerSettings
{
public string Title { get; set; }
public string ShortDescription { get; set; }
public string Description { get; set; }//Markdown
public DateTime? DescriptionUpdatedAt { get; set; }
public string ContactEmail { get; set; }
public string ContactAccountId { get; set; }
public ViewContactAccount ContactAccount { get; set; }//null when none is set, or it is gone
public string ThumbnailUrl { get; set; }//what the instance API shows (the default picture when none was uploaded)
public bool ThumbnailIsDefault { get; set; } = true;
public string PrivacyPolicy { get; set; }//Markdown
public DateTime? PrivacyPolicyUpdatedAt { get; set; }
public string TermsOfService { get; set; }//Markdown; null: none
public DateTime? TermsEffectiveAt { get; set; }
public List<ViewServerRule> Rules { get; set; } = [];//deleted ones too (IsDeleted), as old reports name them
public List<ViewRelay> Relays { get; set; } = [];
public ViewMediaLimits Media { get; set; } = new();
public string RegistrationMode { get; set; }//"Invitations" or "Open", from the configuration
public bool PublishActivity { get; set; } = false;
public DateTime? UpdatedAt { get; set; }//null: never saved, every value is the default
public string UpdatedBy { get; set; }//the administrator's login, or "cli"
}
public class ViewContactAccount
{
public string Id { get; set; }
public string UserName { get; set; }
public string Name { get; set; }
public string Handle { get; set; }//user@host
}
public class ViewServerRule
{
public string Id { get; set; }
public string Text { get; set; }
public string Hint { get; set; }
public int Position { get; set; } = 0;
public bool IsDeleted { get; set; } = false;
public DateTime? DeletedAt { get; set; }
}
// the administrator's overrides (null: the configured value holds), what the configuration says, and the hard caps
public class ViewMediaLimits
{
public long? QuotaBytesPerRoot { get; set; }
public long? MaxImageBytes { get; set; }
public long? MaxVideoBytes { get; set; }
public int? MaxSeconds { get; set; }
public long ConfiguredQuotaBytesPerRoot { get; set; } = 0;//0: no quota
public long ConfiguredMaxImageBytes { get; set; } = 0;
public long ConfiguredMaxVideoBytes { get; set; } = 0;
public int ConfiguredMaxSeconds { get; set; } = 0;
public long EffectiveQuotaBytesPerRoot { get; set; } = 0;
public long EffectiveMaxImageBytes { get; set; } = 0;
public long EffectiveMaxVideoBytes { get; set; } = 0;
public int EffectiveMaxSeconds { get; set; } = 0;
public long HardMaxImageBytes { get; set; } = ServerMediaLimitsForm.HardMaxImageBytes;
public long HardMaxVideoBytes { get; set; } = ServerMediaLimitsForm.HardMaxVideoBytes;
public int HardMaxSeconds { get; set; } = ServerMediaLimitsForm.HardMaxSeconds;
}
// POST /clientapi/admin/settings: everything the page edits at once (relays and the thumbnail have their own routes).
// A rule keeps its id when the form names it; one without an id is new; one left out is deleted (kept, marked).
public class ServerSettingsForm
{
public const int MaxRules = 50;
public const int MaxText = 20000;
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(64, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "Title", ResourceType = typeof(FieldsNameResource))]
public string Title { get; set; }
[StringLength(500, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
public string ShortDescription { get; set; }
[StringLength(MaxText, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "Description", ResourceType = typeof(FieldsNameResource))]
public string Description { get; set; }
[EmailAddress(ErrorMessageResourceName = "InvalidEmail", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(254, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "Email", ResourceType = typeof(FieldsNameResource))]
public string ContactEmail { get; set; }
[StringLength(64, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
public string ContactAccountId { get; set; }//a local persona's account id (its Mastodon id), or none
[StringLength(MaxText, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
public string PrivacyPolicy { get; set; }
[StringLength(MaxText, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
public string TermsOfService { get; set; }//empty: none
public DateTime? TermsEffectiveAt { get; set; }//a date; today when terms are given without one
[MaxLength(MaxRules, ErrorMessageResourceName = "MaxLengthArray", ErrorMessageResourceType = typeof(ErrorsResource))]
public List<ServerRuleForm> Rules { get; set; } = [];
public ServerMediaLimitsForm Media { get; set; } = new();
public bool PublishActivity { get; set; } = false;
}
public class ServerRuleForm
{
[StringLength(64, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Id { get; set; }//an existing rule's id, or none for a new rule
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(500, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Text { get; set; }
[StringLength(1000, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Hint { get; set; }
}
// null keeps the configured value
public class ServerMediaLimitsForm
{
public const long HardMaxImageBytes = 40L * 1024 * 1024;
public const long HardMaxVideoBytes = 99L * 1024 * 1024;
public const int HardMaxSeconds = 4 * 3600;
[Range(0L, long.MaxValue, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
public long? QuotaBytesPerRoot { get; set; }//0: no quota
[Range(1L, HardMaxImageBytes, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
public long? MaxImageBytes { get; set; }
[Range(1L, HardMaxVideoBytes, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
public long? MaxVideoBytes { get; set; }
[Range(1, HardMaxSeconds, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
public int? MaxSeconds { get; set; }
}
// a relay the instance actor subscribes to: named by the configuration (Federation:Relays, removed only there) or by
// the settings (added and removed from the page)
public class ViewRelay
{
public string Address { get; set; }
public string Source { get; set; }//"configuration" or "settings"
public string State { get; set; }//"pending", "accepted", "rejected", or "none" before its first request
public string ActorURI { get; set; }
public DateTime? RequestedAt { get; set; }
public DateTime? AnsweredAt { get; set; }
}
public class RelayForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(2048, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Address { get; set; }//the relay's actor (https://relay.example/actor) or its inbox
}
}
@@ -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; }
}
} }
+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; }
} }
} }
@@ -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>
@@ -0,0 +1,94 @@
using PrivaPub.ClientModels.Resources;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace PrivaPub.ClientModels.User
{
// The login's email address, set only with its password and only once a code sent to it comes back
// (/clientapi/user/email, /email/verify).
public class EmailChangeForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
EmailAddress(ErrorMessageResourceName = "InvalidEmail", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(254, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "Email", ResourceType = typeof(FieldsNameResource))]
public string Email { get; set; }
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
DataType(DataType.Password),
PasswordPropertyText(true),
StringLength(Constants.MaxPasswordLength, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
public string Password { get; set; }
}
public class EmailCodeForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(16, MinimumLength = 6, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "RecoveryCode", ResourceType = typeof(FieldsNameResource))]
public string Code { get; set; }
}
// what only the login's password may do (removing its email address)
public class PasswordForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
DataType(DataType.Password),
PasswordPropertyText(true),
StringLength(Constants.MaxPasswordLength, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
public string Password { get; set; }
}
public class ViewAccount
{
public string UserName { get; set; }
public string Email { get; set; }
public bool IsEmailValidated { get; set; } = false;
public string PendingEmail { get; set; }//waiting for its code
public DateTime CreatedAt { get; set; }
public bool TwoFactorEnabled { get; set; } = false;
public int RecoveryCodesLeft { get; set; } = 0;
}
// a code from the authenticator app (six digits) or a recovery code
public class TotpCodeForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(32, MinimumLength = 6, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "RecoveryCode", ResourceType = typeof(FieldsNameResource))]
public string Code { get; set; }
}
public class TotpDisableForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
DataType(DataType.Password),
PasswordPropertyText(true),
StringLength(Constants.MaxPasswordLength, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
public string Password { get; set; }
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(32, MinimumLength = 6, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "RecoveryCode", ResourceType = typeof(FieldsNameResource))]
public string Code { get; set; }
}
// what an authenticator app needs: the secret, as text and as the otpauth:// URI its QR code holds
public class ViewTotpSetup
{
public string Secret { get; set; }
public string OtpAuthUri { get; set; }
public string QrDataUri { get; set; }//an SVG image, as a data: URI
}
// shown once: the recovery codes, and this device's new session (turning two-factor on ends every other one)
public class ViewTotpEnabled
{
public List<string> RecoveryCodes { get; set; } = [];
public JwtUser Session { get; set; }
}
}
+1 -1
View File
@@ -2,6 +2,6 @@
{ {
public class UsersIds public class UsersIds
{ {
public IList<string> UserIdList { get; set; } = Array.Empty<string>(); public List<string> UserIdList { get; set; } = [];
} }
} }
@@ -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,95 +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<LocalActor> GetReaderActor(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,115 +0,0 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
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 CommunityModerationTests : 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(string id) => new Uri(id).GetLeftPart(UriPartial.Authority);
// a Lemmy community on the peer's first origin whose moderators collection lists the given accounts
RemoteActor Community(params RemoteActor[] moderators)
{
var community = new RemoteActor(_harness.Peer, "comm", type: "Group");
var document = community.Document();
document["attributedTo"] = community.Id + "/moderators";
_harness.Peer.Serve($"/users/{community.Name}", document.ToJsonString());
_harness.Peer.Serve($"/users/{community.Name}/moderators", new JsonObject
{
["id"] = community.Id + "/moderators", ["type"] = "OrderedCollection",
["orderedItems"] = new JsonArray(moderators.Select(m => (JsonNode)m.Id).ToArray())
}.ToJsonString());
return community;
}
async Task<(Post Post, PrivaPub.Federation.Actors.LocalActor Alice)> PostIn(RemoteActor community)
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "in the community" }, token);
var post = await DB.Default.Find<Post>().Match(p => p.GroupUserId == alice.Id).ExecuteFirstAsync(token);
await DB.Default.Update<Post>().MatchID(post.ID).Modify(p => p.AudienceURI, community.Id).ExecuteAsync(token);
await _harness.Remote.GetActor(community.Id, refresh: true, token);
return (post, alice);
}
JsonObject Warn(RemoteActor moderator, RemoteActor community, string persona, string post) => new()
{
["id"] = $"{Origin(moderator.Id)}/activities/warn/{Guid.NewGuid():N}", ["type"] = "Warn", ["actor"] = moderator.Id,
["to"] = new JsonArray(persona), ["object"] = post, ["summary"] = "please keep it civil", ["audience"] = community.Id
};
[Fact]
public async Task A_warning_from_the_communitys_moderators_reaches_the_persona_and_a_strangers_does_not()
{
var token = TestContext.Current.CancellationToken;
var listed = new RemoteActor(_harness.Peer, "listedmod", origin: _harness.Peer.B);
var stranger = new RemoteActor(_harness.Peer, "stranger", origin: _harness.Peer.B);
var community = Community(listed);
var admin = new RemoteActor(_harness.Peer, "admin");
var (post, alice) = await PostIn(community);
await _harness.Deliver(stranger, "/human-centipede", Warn(stranger, community, alice.Uri, post.ObjectURI));
Assert.False(await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id && n.Type == NotificationType.ModerationWarning).ExecuteAnyAsync(token));
await _harness.Deliver(listed, "/human-centipede", Warn(listed, community, alice.Uri, post.ObjectURI));
var warning = await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id && n.Type == NotificationType.ModerationWarning).ExecuteFirstAsync(token);
Assert.Equal("please keep it civil", warning.Text);
Assert.Equal(post.ID, warning.PostId);
var adminWarning = Warn(admin, community, alice.Uri, post.ObjectURI);
adminWarning["summary"] = "from the server's admin";
await DB.Default.DeleteAsync<Notification>(n => n.AvatarId == alice.Id);
await _harness.Deliver(admin, "/human-centipede", adminWarning);
Assert.Equal("from the server's admin", (await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id && n.Type == NotificationType.ModerationWarning).ExecuteFirstAsync(token)).Text);
}
[Fact]
public async Task A_reports_resolution_is_kept_only_from_the_server_holding_what_was_reported()
{
var token = TestContext.Current.CancellationToken;
var community = Community();
var (post, _) = await PostIn(community);
var report = new Report { PostIds = new() { post.ID }, Comment = "spam" };
await DB.Default.SaveAsync(report, token);
var flag = $"{_harness.Local.BaseAddress}/peasants/privapub_reports/grunts/flag-{report.ID}-1";
JsonObject Resolve(RemoteActor by) => new()
{
["id"] = $"{Origin(by.Id)}/activities/resolve/{Guid.NewGuid():N}", ["type"] = "Resolve", ["actor"] = by.Id, ["to"] = new JsonArray(community.Id),
["object"] = new JsonObject { ["id"] = flag, ["type"] = "Flag", ["actor"] = $"{_harness.Local.BaseAddress}/peasants/privapub_reports", ["object"] = post.ObjectURI }
};
var elsewhere = new RemoteActor(_harness.Peer, "elsewhere", origin: _harness.Peer.B);
var moderator = new RemoteActor(_harness.Peer, "moderator");
await _harness.Deliver(elsewhere, "/human-centipede", Resolve(elsewhere));
Assert.Null((await DB.Default.Find<Report>().OneAsync(report.ID, token)).RemoteResolvedAt);
await _harness.Deliver(moderator, "/human-centipede", Resolve(moderator));
var resolved = await DB.Default.Find<Report>().OneAsync(report.ID, token);
Assert.NotNull(resolved.RemoteResolvedAt);
Assert.Equal(moderator.Id, resolved.RemoteResolvedBy);
Assert.False(resolved.IsResolved);
}
}
}
@@ -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));
}
}
}
-68
View File
@@ -1,68 +0,0 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
public class FlairTests
{
[Fact]
public void A_lemmy_posts_flair_is_described_by_its_community()
{
using var group = JsonDocument.Parse("""
{
"id": "https://lemmy.example/c/tenforward", "type": "Group", "preferredUsername": "tenforward", "inbox": "https://lemmy.example/c/tenforward/inbox",
"attributedTo": "https://lemmy.example/c/tenforward/moderators",
"tag": [{ "type": "CommunityPostTag", "id": "https://lemmy.example/c/tenforward/tag/news", "preferredUsername": "news",
"name": "News", "content": "What happened", "color": "color03" }]
}
""");
var community = ActorDocument.Parse(group.RootElement);
var page = NoteParser.Parse(JsonNode.Parse("""
{
"id": "https://lemmy.example/post/1", "type": "Page", "name": "Big news", "attributedTo": "https://lemmy.example/u/bo",
"tag": [{ "type": "CommunityPostTag", "id": "https://lemmy.example/c/tenforward/tag/news", "preferredUsername": "news" },
{ "type": "Mention", "href": "https://lemmy.example/c/tenforward", "name": "@tenforward@lemmy.example" }]
}
"""));
Assert.Equal("https://lemmy.example/c/tenforward/moderators", community.Moderators);
var flair = Assert.Single(ObjectShapes.Described(page.Flairs, community.Flairs));
Assert.Equal("News", flair.Name);
Assert.Equal("news", flair.Slug);
Assert.Equal("color03", flair.Color);
Assert.Equal("What happened", flair.Description);
}
[Fact]
public void Reads_piefeds_flairs_in_both_dialects_and_its_older_community_list()
{
var post = NoteParser.Parse(JsonNode.Parse("""
{
"id": "https://piefed.example/post/9", "type": "Page", "name": "A question", "attributedTo": "https://piefed.example/u/pf",
"tag": [{ "type": "lemmy:CommunityTag", "id": "https://piefed.example/c/ask/tag/3", "display_name": "Solved",
"text_color": "#ffffff", "background_color": "#2e7d32", "blur_images": false },
{ "type": "Hashtag", "href": "https://piefed.example/tag/q", "name": "#q" }]
}
"""));
using var group = JsonDocument.Parse("""
{
"id": "https://piefed.example/c/ask", "type": "Group", "preferredUsername": "ask", "inbox": "https://piefed.example/c/ask/inbox",
"lemmy:tagsForPosts": [{ "type": "lemmy:CommunityTag", "id": "https://piefed.example/c/ask/tag/4", "display_name": "Spoiler",
"text_color": "#000", "background_color": "red; background: url(x)", "blur_images": true }]
}
""");
var solved = Assert.Single(post.Flairs);
Assert.Equal("Solved", solved.Name);
Assert.Equal("#2e7d32", solved.BackgroundColor);
Assert.Equal("q", Assert.Single(post.Tags));
var spoiler = Assert.Single(ActorDocument.Parse(group.RootElement).Flairs);
Assert.True(spoiler.BlurImages);
Assert.Equal("#000", spoiler.TextColor);
Assert.Null(spoiler.BackgroundColor);
}
}
}
@@ -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>());
}
}
}
-611
View File
@@ -1,611 +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);
}
// PieFed moves a post to another community, and the community it leaves relays the Move: the post's thread goes with it
[Fact]
public async Task A_post_moved_to_another_community_takes_its_thread_along()
{
var token = TestContext.Current.CancellationToken;
var (root, 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");
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);
async Task<Post> Announced(JsonObject note)
{
note["audience"] = community.Id;
_harness.Peer.Serve(new Uri(IdOf(note)).AbsolutePath, note.ToJsonString());
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, note)));
return await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token);
}
var page = PublicNote(poster, "<p>misplaced</p>", community.Id);
page["type"] = "Page";
page["name"] = "misplaced";
var post = await Announced(page);
var comment = PublicNote(poster, "<p>a comment</p>", community.Id);
comment["inReplyTo"] = IdOf(page);
var answer = await Announced(comment);
var move = new JsonObject
{
["id"] = NewId(poster, "moves"), ["type"] = "Move", ["actor"] = poster.Id, ["object"] = post.ObjectURI, ["origin"] = community.Id, ["target"] = other.Id
};
// only the community it leaves speaks for it
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", new JsonObject
{
["id"] = NewId(poster, "moves"), ["type"] = "Move", ["actor"] = poster.Id, ["object"] = post.ObjectURI, ["origin"] = other.Id, ["target"] = community.Id
}));
Assert.Equal(("dropped", "unsupported"), (Processed("Announce").Outcome, Processed("Announce").Reason));
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", move));
Assert.Equal(("accepted", "moved"), (Processed("Announce").Outcome, Processed("Announce").Reason));
Assert.Equal(other.Id, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).AudienceURI);
Assert.Equal(other.Id, (await DB.Default.Find<Post>().OneAsync(answer.ID, token)).AudienceURI);
}
// 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,811 +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_conversation_container_is_read_before_the_context_and_only_its_added_posts_taken()
{
var owner = new RemoteActor(_harness.Peer, "owner");
var answerer = new RemoteActor(_harness.Peer, "answerer");
var root = PublicNote(owner, "<p>a conversation</p>");
var history = NewId(owner, "conversations");
root["contextHistory"] = history;
root["context"] = NewId(owner, "never");
Served(root);
var embedded = Served(Reply(answerer, "added whole", root));
var referenced = Served(Reply(owner, "added by reference", embedded));
var liked = Served(Reply(answerer, "only liked", root));
var target = new JsonObject { ["type"] = "OrderedCollection", ["id"] = history, ["attributedTo"] = owner.Id };
JsonObject Add(JsonNode activity) => new()
{
["id"] = NewId(owner, "activities"), ["type"] = "Add", ["actor"] = owner.Id, ["object"] = activity, ["target"] = target.DeepClone()
};
var create = Served(Create(owner, referenced));
create["object"] = IdOf(referenced);
Served(create);
var byReference = Served(Add(IdOf(create)));
Served(new JsonObject
{
["id"] = history, ["type"] = "OrderedCollection", ["attributedTo"] = owner.Id, ["collectionOf"] = "Activity",
["orderedItems"] = new JsonArray(Create(owner, root), Add(Create(answerer, embedded)), IdOf(byReference), Add(Activity(answerer, "Like", JsonValue.Create(IdOf(liked)))))
});
var held = await Held(root);
Assert.Equal(1, await RunReplies(held));
var stored = await Stored(embedded, referenced, liked);
Assert.Equal(new[] { IdOf(embedded), IdOf(referenced) }.Order(), stored.Keys.Order());
Assert.Equal(held.ID, stored[IdOf(embedded)].AnsweringToPostId);
Assert.Equal(stored[IdOf(embedded)].ID, stored[IdOf(referenced)].AnsweringToPostId);
Assert.DoesNotContain(_harness.Peer.Requests, r => r.Path == new Uri(root["context"]!.GetValue<string>()).AbsolutePath);
}
[Fact]
public async Task An_unchanged_context_is_not_read_again_while_it_counts_the_thread()
{
var token = TestContext.Current.CancellationToken;
var poster = new RemoteActor(_harness.Peer, "poster");
var answerer = new RemoteActor(_harness.Peer, "answerer");
var root = PublicNote(poster, "<p>a topic</p>");
var context = NewId(poster, "topics");
var path = new Uri(context).AbsolutePath;
root["context"] = context;
Served(root);
var first = Served(Reply(answerer, "first", root));
var later = Served(Reply(poster, "later", root));
// NodeBB's: every post inline, counted, and an ETag without quotes
JsonObject Thread(params JsonObject[] notes) => new()
{
["id"] = context, ["type"] = "OrderedCollection", ["totalItems"] = notes.Length,
["orderedItems"] = new JsonArray(notes.Select(n => (JsonNode)IdOf(n)).ToArray())
};
_harness.Peer.ServeTagged(path, Thread(root, first).ToJsonString(), "4f930186eb");
var held = await Held(root);
var handler = new RepliesJobHandler(_harness.Db, _harness.Remote, _harness.RemotePosts, _harness.Queue);
await handler.Handle(RepliesJobHandler.For(held, 1), token);
Assert.Equal("4f930186eb", (await DB.Default.Find<ThreadCollection>().Match(c => c.URI == context).ExecuteFirstAsync(token)).ETag);
// the same version: a 304, and nothing read
_harness.Peer.ServeTagged(path, Thread(root, first, later).ToJsonString(), "4f930186eb");
await handler.Handle(RepliesJobHandler.For(held, 1), token);
Assert.Equal("4f930186eb", _harness.Peer.Requests.Last(r => r.Path == path).Headers["If-None-Match"]);
Assert.Single(await Stored(first, later));
// a new one
_harness.Peer.ServeTagged(path, Thread(root, first, later).ToJsonString(), "5a0b");
await handler.Handle(RepliesJobHandler.For(held, 1), token);
Assert.Equal(2, (await Stored(first, later)).Count);
Assert.Equal("5a0b", (await DB.Default.Find<ThreadCollection>().Match(c => c.URI == context).ExecuteFirstAsync(token)).ETag);
}
[Fact]
public async Task A_paged_contexts_etag_is_not_kept_since_its_first_page_hides_what_is_added_later()
{
var token = TestContext.Current.CancellationToken;
var poster = new RemoteActor(_harness.Peer, "poster");
var root = PublicNote(poster, "<p>a long thread</p>");
var context = NewId(poster, "contexts");
root["context"] = context;
Served(root);
var answer = Served(Reply(poster, "on the second page", root));
_harness.Peer.ServeTagged(new Uri(context).AbsolutePath, new JsonObject
{
["id"] = context, ["type"] = "Collection",
["first"] = new JsonObject { ["type"] = "CollectionPage", ["items"] = new JsonArray(IdOf(root)), ["next"] = context + "/2" }
}.ToJsonString(), "W/\"first\"");
Served(new JsonObject { ["id"] = context + "/2", ["type"] = "CollectionPage", ["items"] = new JsonArray(IdOf(answer)) });
var held = await Held(root);
await new RepliesJobHandler(_harness.Db, _harness.Remote, _harness.RemotePosts, _harness.Queue).Handle(RepliesJobHandler.For(held, 1), token);
Assert.Single(await Stored(answer));
Assert.False(await DB.Default.Find<ThreadCollection>().Match(c => c.URI == context && c.ETag != null).ExecuteAnyAsync(token));
}
[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)));
}
}
}
@@ -1,117 +0,0 @@
using Microsoft.Extensions.Caching.Memory;
using MongoDB.Entities;
using PrivaPub.Domain.Relationships;
using PrivaPub.Federation.Actors;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
// an account's handle: user@host, or the domain its `webfinger` names (FEP-2c59; Mastodon's LOCAL_DOMAIN apart from
// its WEB_DOMAIN) once that domain's WebFinger says the handle is this account
[Trait("Category", "Integration")]
public sealed class RemoteHandleTests : IAsyncLifetime
{
Harness _harness;
RemoteActorService _remote;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
_remote = new RemoteActorService(Peer.Http(), _harness.Local, new MemoryCache(new MemoryCacheOptions()), _harness.Db,
new StaticOptions<FederationOptions>(new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true }));
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
// the actor at the peer's first address, its handle at the second
RemoteActor Actor(string name) => Serve(new RemoteActor(_harness.Peer, name));
RemoteActor Serve(RemoteActor actor, string preferredUsername = default)
{
var document = actor.Document();
if (preferredUsername != default)
document["preferredUsername"] = preferredUsername;
document["webfinger"] = $"acct:{preferredUsername ?? actor.Name}@{new Uri(_harness.Peer.B).Authority}";
_harness.Peer.Serve(new Uri(actor.Id).AbsolutePath, document.ToJsonString());
return actor;
}
void WebFinger(string self) => _harness.Peer.ServeText("/.well-known/webfinger", new JsonObject
{
["links"] = new JsonArray(new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = self })
}.ToJsonString(), "application/jrd+json");
[Fact]
public async Task A_handle_on_another_domain_is_kept_once_its_webfinger_agrees()
{
var actor = Actor("split");
WebFinger(actor.Id);
var stored = await _remote.GetActor(actor.Id, refresh: true, TestContext.Current.CancellationToken);
Assert.Equal(new Uri(_harness.Peer.B).Authority, stored.Domain);
}
[Fact]
public async Task A_handle_its_webfinger_does_not_confirm_stays_on_the_actors_host()
{
var actor = Actor("claims");
WebFinger("http://localhost/users/someone-else");
var stored = await _remote.GetActor(actor.Id, refresh: true, TestContext.Current.CancellationToken);
Assert.Equal(new Uri(actor.Id).Authority, stored.Domain);
}
[Fact]
public async Task A_renamed_account_is_asked_again()
{
var actor = Actor("renames");
WebFinger(actor.Id);
await _remote.GetActor(actor.Id, refresh: true, TestContext.Current.CancellationToken);
Serve(actor, "renamed");
WebFinger("http://localhost/users/someone-else");
// (another cache: the first fetch holds the actor back for a while)
var later = new RemoteActorService(Peer.Http(), _harness.Local, new MemoryCache(new MemoryCacheOptions()), _harness.Db,
new StaticOptions<FederationOptions>(new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true }));
var stored = await later.GetActor(actor.Id, refresh: true, TestContext.Current.CancellationToken);
Assert.Equal("renamed", stored.UserName);
Assert.Equal(new Uri(actor.Id).Authority, stored.Domain);
}
[Fact]
public async Task A_persona_blocking_the_handles_server_hides_the_account()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var split = Actor("split");
WebFinger(split.Id);
await _remote.GetActor(split.Id, refresh: true, token);
var claims = Actor("claims");
WebFinger("http://localhost/users/someone-else");
await new RemoteActorService(Peer.Http(), _harness.Local, new MemoryCache(new MemoryCacheOptions()), _harness.Db,
new StaticOptions<FederationOptions>(new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true }))
.GetActor(claims.Id, refresh: true, token);
await DB.Default.SaveAsync(new AccountDomainBlock { AvatarId = alice.Id, Domain = new Uri(_harness.Peer.B).Host }, token);
Assert.Equal([split.Id], await Hidden.AuthorsHiddenFrom(alice.Id, new[] { split.Id, claims.Id }, forNotifications: false, token));
Assert.Contains(alice.Id, await Hidden.RecipientsHiding(new[] { alice.Id }, split.Id, token));
Assert.Empty(await Hidden.RecipientsHiding(new[] { alice.Id }, claims.Id, token));
}
}
}
-103
View File
@@ -1,103 +0,0 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
public class RendererTests
{
static readonly LocalActor Alice = new()
{
Id = "a1",
Kind = LocalActorKind.Person,
UserName = "alice",
Name = "Alice",
Summary = "I **like** <b>cats</b>",
PublicKeyPem = Keys.NewKeyPair().PublicKeyPem,
Published = new DateTime(2026, 9, 30, 17, 45, 12, DateTimeKind.Utc),
Fields = new Dictionary<string, string> { ["site"] = "https://alice.example", ["mood"] = "<i>ok</i>" },
BaseAddress = "https://privapub.test"
};
[Fact]
public void An_actor_looks_like_a_mastodon_actor_with_our_route_names()
{
var actor = ActivityPubRenderer.Actor(Alice);
Assert.Equal("https://privapub.test/peasants/alice", actor["id"]!.GetValue<string>());
Assert.Equal("https://privapub.test/@alice", actor["url"]!.GetValue<string>());
Assert.Equal("https://privapub.test/peasants/alice/mouth", actor["inbox"]!.GetValue<string>());
Assert.Equal("https://privapub.test/peasants/alice/anus", actor["outbox"]!.GetValue<string>());
Assert.Equal("https://privapub.test/peasants/alice/groupies", actor["followers"]!.GetValue<string>());
Assert.Equal("https://privapub.test/peasants/alice/stalking", actor["following"]!.GetValue<string>());
Assert.Equal("https://privapub.test/human-centipede", actor["endpoints"]!["sharedInbox"]!.GetValue<string>());
Assert.Equal("2026-09-30T00:00:00Z", actor["published"]!.GetValue<string>());
Assert.Equal("alice@privapub.test", actor["webfinger"]!.GetValue<string>());
Assert.False(actor["indexable"]!.GetValue<bool>());
Assert.Equal("<p>I <strong>like</strong> &lt;b&gt;cats&lt;/b&gt;</p>", actor["summary"]!.GetValue<string>());
var fields = actor["attachment"]!.AsArray();
Assert.Contains("rel=\"nofollow noopener noreferrer me\"", fields[0]!["value"]!.GetValue<string>());
Assert.Equal("&lt;i&gt;ok&lt;/i&gt;", fields[1]!["value"]!.GetValue<string>());
Assert.Equal("https://privapub.test/peasants/alice#main-key", actor["publicKey"]!["id"]!.GetValue<string>());
Assert.Equal(actor["id"]!.GetValue<string>(), actor["publicKey"]!["owner"]!.GetValue<string>());
}
[Fact]
public void A_titled_post_with_a_warning_keeps_the_title_visible_on_mastodon()
{
var post = new Post
{
ID = "p1",
Title = "On <cats>",
ContentHtml = "<p>body</p>",
HasContentWarning = true,
Language = "en",
Mentions = new() { new PostMention { ActorURI = "https://m.example/users/bob", Handle = "@bob@m.example" } },
Tags = new() { "cats" },
CreationDate = new DateTime(2026, 9, 30, 17, 45, 12, 345, DateTimeKind.Utc)
};
var note = ActivityPubRenderer.Note(post, Alice, default, "https://m.example/users/bob/statuses/1");
Assert.Equal("https://privapub.test/peasants/alice/scribbles/p1", note["id"]!.GetValue<string>());
Assert.Equal("https://privapub.test/@alice/p1", note["url"]!.GetValue<string>());
Assert.Equal("<p><strong>On &lt;cats&gt;</strong></p><p>body</p>", note["content"]!.GetValue<string>());
Assert.Equal("On <cats>", note["summary"]!.GetValue<string>());
Assert.True(note["sensitive"]!.GetValue<bool>());
Assert.Equal("2026-09-30T17:45:12.345Z", note["published"]!.GetValue<string>());
Assert.Equal(new[] { ActivityPubRenderer.Public }, note["to"]!.AsArray().Select(n => n!.GetValue<string>()));
Assert.Equal(new[] { Alice.Followers, "https://m.example/users/bob" }, note["cc"]!.AsArray().Select(n => n!.GetValue<string>()));
var tags = note["tag"]!.AsArray();
Assert.Equal("@bob@m.example", tags[0]!["name"]!.GetValue<string>());
Assert.Equal("https://privapub.test/tags/cats", tags[1]!["href"]!.GetValue<string>());
Assert.Equal("https://m.example/users/bob/statuses/1", note["inReplyTo"]!.GetValue<string>());
Assert.NotNull(note["contentMap"]!["en"]);
}
[Fact]
public void A_warning_without_a_title_or_spoiler_gets_a_generic_summary()
{
var note = ActivityPubRenderer.Note(new Post { ID = "p2", ContentHtml = "<p>x</p>", HasContentWarning = true }, Alice, default, default);
Assert.Equal(ActivityPubRenderer.ContentWarning, note["summary"]!.GetValue<string>());
}
[Fact]
public void A_direct_note_is_addressed_to_its_recipients_only()
{
var post = new Post { ID = "p3", ContentHtml = "<p>psst</p>", Visibility = PostVisibility.Direct };
var note = ActivityPubRenderer.DirectNote(post, Alice, new[] { ("https://m.example/users/bob", "bob@m.example") },
Alice.ConversationUri("c1"));
Assert.Equal(new[] { "https://m.example/users/bob" }, note["to"]!.AsArray().Select(n => n!.GetValue<string>()));
Assert.Empty(note["cc"]!.AsArray());
Assert.Equal("https://privapub.test/peasants/alice/whispers/c1", note["context"]!.GetValue<string>());
Assert.StartsWith("<p><span class=\"h-card\"", note["content"]!.GetValue<string>());
Assert.Equal("Mention", note["tag"]![0]!["type"]!.GetValue<string>());
}
}
}
@@ -1,114 +0,0 @@
using MongoDB.Entities;
using PrivaPub.Domain.Statuses;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
using static PrivaPub.Tests.Support.FederatedSeeds;
namespace PrivaPub.Tests.Federation
{
// Inbox forwarding of replies (owner decision 2026-10-05): a reply to a persona's post reaches the persona's followers,
// who are not among those its author's server sends it to
[Trait("Category", "Integration")]
public sealed class ReplyRelayTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
// alice's post; bob replies from his server, fan follows alice from another, pal from bob's
async Task<(PrivaPub.Federation.Actors.LocalActor Alice, Post Post, RemoteActor Bob, RemoteActor Fan, RemoteActor Pal)> Thread()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var bob = new RemoteActor(_harness.Peer, "bob");
var fan = new RemoteActor(_harness.Peer, "fan", _harness.Peer.B);
var pal = new RemoteActor(_harness.Peer, "pal");
await _harness.FollowedBy(alice, fan);
await _harness.FollowedBy(alice, pal);
var post = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "what do you think?" }, token)).Post;
return (alice, post, bob, fan, pal);
}
JsonObject Reply(RemoteActor bob, Post post, string visibility = "public")
{
var reply = PublicNote(bob, "<p>I think so</p>", post.ActorURI);
reply["inReplyTo"] = post.ObjectURI;
if (visibility == "followers")
{
reply["to"] = new JsonArray(bob.Id + "/followers");
reply["cc"] = new JsonArray(post.ActorURI);
}
return reply;
}
Task<List<JsonObject>> At(RemoteActor follower) => _harness.Outgoing(follower.SharedInbox);
[Fact]
public async Task A_public_reply_to_a_personas_post_reaches_its_followers_as_it_came_and_not_its_authors_server()
{
var (_, post, bob, fan, pal) = await Thread();
var create = Create(bob, Reply(bob, post));
create["signature"] = new JsonObject { ["type"] = "RsaSignature2017", ["signatureValue"] = "kept as it came" };
await _harness.Deliver(bob, "/human-centipede", create);
var relayed = Assert.Single(await At(fan), a => a["id"]!.GetValue<string>() == IdOf(create));
Assert.Equal(bob.Id, relayed["actor"]!.GetValue<string>());
Assert.Equal("kept as it came", relayed["signature"]!["signatureValue"]!.GetValue<string>());
Assert.DoesNotContain(await At(pal), a => a["id"]!.GetValue<string>() == IdOf(create));
}
[Fact]
public async Task Its_edit_and_deletion_follow_it_and_a_followers_only_reply_stays_where_it_was_sent()
{
var (_, post, bob, fan, _) = await Thread();
var reply = Reply(bob, post);
await _harness.Deliver(bob, "/human-centipede", Create(bob, reply));
var edited = (JsonObject)reply.DeepClone();
edited["content"] = "<p>I think so, on second thought</p>";
edited["updated"] = DateTime.UtcNow.AddMinutes(1).ToString("O");
var update = Activity(bob, "Update", edited);
await _harness.Deliver(bob, "/human-centipede", update);
var delete = Activity(bob, "Delete", JsonValue.Create(IdOf(reply))!);
await _harness.Deliver(bob, "/human-centipede", delete);
var relayed = (await At(fan)).Select(a => a["id"]!.GetValue<string>()).ToList();
Assert.Contains(IdOf(update), relayed);
Assert.Contains(IdOf(delete), relayed);
var quiet = Create(bob, Reply(bob, post, "followers"));
await _harness.Deliver(bob, "/human-centipede", quiet);
Assert.DoesNotContain(await At(fan), a => a["id"]!.GetValue<string>() == IdOf(quiet));
}
[Fact]
public async Task A_reply_that_reached_us_forwarded_is_not_passed_on_again()
{
var (_, post, bob, fan, _) = await Thread();
var other = new RemoteActor(_harness.Peer, "other", _harness.Peer.B);
var reply = Reply(bob, post);
_harness.Peer.Serve(new Uri(IdOf(reply)).AbsolutePath, reply.ToJsonString());
var create = Create(bob, reply);
var result = await _harness.Deliver(other, "/human-centipede", create);
Assert.Equal("forwarded", result.Reason);
Assert.True(await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(reply)).ExecuteAnyAsync(TestContext.Current.CancellationToken));
Assert.DoesNotContain(await At(fan), a => a["id"]!.GetValue<string>() == IdOf(create));
}
}
}
@@ -1,64 +0,0 @@
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
using static PrivaPub.Tests.Support.FederatedSeeds;
namespace PrivaPub.Tests.Federation
{
// A deleted remote post is never brought back by its own Create, however late it comes; an id its server gives a new
// post afterwards (Gancio numbers an event after the last one it keeps) is that new post
[Trait("Category", "Integration")]
public sealed class ReusedIdTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
[Fact]
public async Task A_deleted_posts_create_stays_dropped_and_a_new_post_under_its_id_is_kept()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var carol = new RemoteActor(_harness.Peer, "carol");
await Follows(alice.Id, carol);
var first = PublicNote(carol, "<p>the first event</p>");
first["published"] = DateTime.UtcNow.AddMinutes(-10).ToString("O");
await _harness.Deliver(carol, "/human-centipede", Create(carol, first));
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = NewId(carol, "deletes"), ["type"] = "Delete", ["actor"] = carol.Id,
["object"] = new JsonObject { ["id"] = IdOf(first), ["type"] = "Tombstone" }
});
Assert.True(await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == IdOf(first)).ExecuteAnyAsync(token));
// the first Create again, late
await _harness.Deliver(carol, "/human-centipede", Create(carol, (JsonObject)first.DeepClone()));
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(first) && !p.DeletedAt.HasValue).ExecuteAnyAsync(token));
var second = PublicNote(carol, "<p>a new event, numbered as the first</p>");
second["id"] = IdOf(first);
second["published"] = DateTime.UtcNow.ToString("O");
await _harness.Deliver(carol, "/human-centipede", Create(carol, second));
var kept = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(first) && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
Assert.Contains("a new event", kept?.ContentHtml);
Assert.False(await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == IdOf(first)).ExecuteAnyAsync(token));
}
}
}
@@ -1,252 +0,0 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Signing;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
// Lemmy takes a report only from a person or a service, about one post or comment, addressed to its community (owner
// decision 2026-10-06): a report about a post in a community on a Lemmy leaves from the server's reporter, one per post.
// Alone in its collection: the server rows it writes for the peer's hosts decide for whoever reports there.
[Trait("Category", "Integration")]
[Xunit.Collection(nameof(Exclusive))]
public sealed class ServiceReportTests : IAsyncLifetime
{
static readonly string[] PeerHosts = { "localhost", "127.0.0.1" };
// (a test sees only the deliveries queued since it began: the others' share the database)
readonly DateTime _started = DateTime.UtcNow;
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
await DB.Default.DeleteAsync<RemoteInstance>(i => PeerHosts.Contains(i.Host));
}
public async ValueTask DisposeAsync()
{
if (_harness == default)
return;
await DB.Default.DeleteAsync<RemoteInstance>(i => PeerHosts.Contains(i.Host));
await _harness.DisposeAsync();
}
static CancellationToken Token => TestContext.Current.CancellationToken;
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
// what NodeInfo said the server at that host runs
static async Task Runs(string host, string software)
{
await DB.Default.DeleteAsync<RemoteInstance>(i => i.Host == host);
await DB.Default.SaveAsync(new RemoteInstance { Host = host, Software = software, SoftwareVersion = "1.0.0" }, Token);
}
async Task<ForeignAvatar> Known(RemoteActor actor) => await _harness.Remote.GetActor(actor.Id, refresh: false, Token);
static async Task<Post> Held(RemoteActor author, string community = default, Post parent = default)
{
var post = new Post
{
ObjectURI = $"{Origin(author)}/post/{Guid.NewGuid():N}",
ActorURI = author.Id,
AudienceURI = community,
AnsweringToPostId = parent?.ID,
ContentHtml = "<p>reported</p>"
};
await DB.Default.SaveAsync(post, Token);
return post;
}
async Task<List<DeliveryPayload>> Queued() =>
(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver && j.CreatedAt >= _started).ExecuteAsync(Token))
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
.ToList();
// a community on a Lemmy, or on a server that takes reports as Lemmy does (the peer as localhost), and one of its posters there
async Task<(RemoteActor Community, RemoteActor Poster)> OnLemmy(string software = "lemmy")
{
var community = new RemoteActor(_harness.Peer, "cats", _harness.Peer.B, type: "Group", sharedInbox: true);
var poster = new RemoteActor(_harness.Peer, "poster", _harness.Peer.B);
await Known(community);
await Known(poster);
await Runs("localhost", software);
return (community, poster);
}
// PieFed and Mbin take it too, proven live (2026-10-06); NodeInfo names Lemmy as "lemmy" and kbin's heir as "mbin"
[Theory]
[InlineData("lemmy")]
[InlineData("Lemmy")]
[InlineData("piefed")]
[InlineData("mbin")]
public async Task A_post_in_a_lemmy_community_is_reported_to_its_community_by_the_reporter_and_nowhere_else(string software)
{
var (_, alice) = await _harness.Persona("alice");
var (community, poster) = await OnLemmy(software);
var post = await Held(poster, community.Id);
var reporter = await _harness.Local.GetReporterActor(Token);
var report = await _harness.Reports.File(alice, (await Known(poster)).ID, new[] { post.ID }, " a slur in the title ", "violation", forward: true, Token);
Assert.True(report.Forwarded);
var flag = Assert.Single(await _harness.Outgoing(community.SharedInbox));
Assert.Equal("Flag", flag["type"]!.GetValue<string>());
Assert.Equal(reporter.Uri, flag["actor"]!.GetValue<string>());
Assert.EndsWith("/peasants/privapub_reports", reporter.Uri);
Assert.StartsWith(reporter.Uri + "/", flag["id"]!.GetValue<string>());
Assert.Equal(new[] { community.Id }, flag["to"]!.AsArray().Select(t => t!.GetValue<string>()));
Assert.Equal(community.Id, flag["audience"]!.GetValue<string>());
Assert.Equal(post.ObjectURI, flag["object"]!.GetValue<string>());
Assert.Equal("a slur in the title", flag["summary"]!.GetValue<string>());
Assert.Equal("a slur in the title", flag["content"]!.GetValue<string>());
Assert.Empty(await _harness.Outgoing(poster.Id + "/inbox"));
Assert.DoesNotContain(await Queued(), q => q.Inbox != community.SharedInbox && q.Body.Contains(post.ObjectURI));
Assert.DoesNotContain(alice.UserName, flag.ToJsonString());
Assert.DoesNotContain(alice.Id, flag.ToJsonString());
var queued = Assert.Single(await Queued(), q => q.Inbox == community.SharedInbox);
Assert.Equal(LocalActorKind.Reporter, queued.SignerKind);
Assert.Equal(reporter.Id, queued.SignerId);
}
[Fact]
public async Task The_reporter_signs_its_flag_with_its_own_key()
{
var (_, alice) = await _harness.Persona("alice");
var (community, poster) = await OnLemmy();
var post = await Held(poster, community.Id);
var reporter = await _harness.Local.GetReporterActor(Token);
var instance = await _harness.Local.GetInstanceActor(Token);
_harness.Peer.Answer("/inbox", 202);
await _harness.Reports.File(alice, (await Known(poster)).ID, new[] { post.ID }, default, "spam", forward: true, Token);
var job = await DB.Default.Find<Job>()
.Match(j => j.Kind == JobKind.Deliver && j.DedupeKey.StartsWith(reporter.ActivityUri($"flag-")))
.Sort(j => j.CreatedAt, Order.Descending).ExecuteFirstAsync(Token);
var handler = new DeliveryJobHandler(_harness.Local, Peer.Http(), new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions())),
NullLogger<DeliveryJobHandler>.Instance);
var outcome = await handler.Handle(job, Token);
Assert.Equal(JobResult.Done, outcome.Result);
var received = Assert.Single(_harness.Peer.Requests, r => r.Path == "/inbox" && r.Method == "POST");
// no words: Lemmy takes no report without a reason, so the category is it
Assert.Equal("spam", JsonNode.Parse(received.Body)!["summary"]!.GetValue<string>());
var signature = HttpSignatures.Parse(received.Signature);
Assert.Equal(reporter.KeyId, signature.KeyId);
Assert.NotEqual(instance.PublicKeyPem, reporter.PublicKeyPem);
var signingString = $"(request-target): post /inbox\nhost: {received.Headers["Host"]}\ndate: {received.Headers["Date"]}\ndigest: {received.Headers["Digest"]}";
using var key = RSA.Create();
key.ImportFromPem(reporter.PublicKeyPem);
Assert.True(key.VerifyData(Encoding.UTF8.GetBytes(signingString), signature.Signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
}
[Fact]
public async Task A_comment_finds_its_community_through_its_thread_and_two_posts_are_two_flags()
{
var (_, alice) = await _harness.Persona("alice");
var (community, poster) = await OnLemmy();
var thread = await Held(poster, community.Id);
var comment = await Held(poster, parent: thread);
var answer = await Held(poster, parent: comment);
var report = await _harness.Reports.File(alice, (await Known(poster)).ID, new[] { comment.ID, answer.ID }, "harassment", "other", forward: true, Token);
Assert.True(report.Forwarded);
var flags = await _harness.Outgoing(community.SharedInbox);
Assert.Equal(2, flags.Count);
Assert.Equal(new[] { comment.ObjectURI, answer.ObjectURI }.Order(), flags.Select(f => f["object"]!.GetValue<string>()).Order());
Assert.All(flags, f => Assert.Equal(community.Id, f["to"]![0]!.GetValue<string>()));
Assert.Equal(2, flags.Select(f => f["id"]!.GetValue<string>()).Distinct().Count());
}
[Fact]
public async Task An_account_alone_on_a_lemmy_is_reported_to_nobody()
{
var (_, alice) = await _harness.Persona("alice");
var (community, poster) = await OnLemmy();
var report = await _harness.Reports.File(alice, (await Known(poster)).ID, Array.Empty<string>(), "a spammer", "spam", forward: true, Token);
Assert.False(report.Forwarded);
Assert.False((await DB.Default.Find<PrivaPub.Models.Social.Report>().OneAsync(report.ID, Token)).Forwarded);
Assert.Empty(await _harness.Outgoing(community.SharedInbox));
Assert.Empty(await _harness.Outgoing(poster.Id + "/inbox"));
}
[Fact]
public async Task Any_other_server_still_gets_the_instance_flag_and_a_lemmy_community_its_own()
{
var (_, alice) = await _harness.Persona("alice");
var (community, _) = await OnLemmy();
// an account on a Mastodon (the peer as 127.0.0.1) that wrote in the community on the Lemmy, and elsewhere
var mastodonian = new RemoteActor(_harness.Peer, "masto");
await Runs("127.0.0.1", "mastodon");
var inCommunity = await Held(mastodonian, community.Id);
var elsewhere = await Held(mastodonian);
var instance = await _harness.Local.GetInstanceActor(Token);
var report = await _harness.Reports.File(alice, (await Known(mastodonian)).ID, new[] { inCommunity.ID, elsewhere.ID }, "spam", "spam", forward: true, Token);
Assert.True(report.Forwarded);
var toCommunity = Assert.Single(await _harness.Outgoing(community.SharedInbox));
Assert.Equal(inCommunity.ObjectURI, toCommunity["object"]!.GetValue<string>());
var toAuthor = Assert.Single(await _harness.Outgoing(mastodonian.Id + "/inbox"));
Assert.Equal(instance.Uri, toAuthor["actor"]!.GetValue<string>());
Assert.Equal(new[] { mastodonian.Id, inCommunity.ObjectURI, elsewhere.ObjectURI }.Order(),
toAuthor["object"]!.AsArray().Select(o => o!.GetValue<string>()).Order());
Assert.Null(toAuthor["to"]);
}
[Fact]
public async Task A_community_on_a_server_that_takes_no_service_reports_gets_nothing_new()
{
var (_, alice) = await _harness.Persona("alice");
var community = new RemoteActor(_harness.Peer, "forum", _harness.Peer.B, type: "Group", sharedInbox: true);
var poster = new RemoteActor(_harness.Peer, "poster", _harness.Peer.B);
await Known(community);
var foreign = await Known(poster);
await Runs("localhost", "friendica");
var post = await Held(poster, community.Id);
var report = await _harness.Reports.File(alice, foreign.ID, new[] { post.ID }, "spam", "spam", forward: true, Token);
Assert.True(report.Forwarded);
Assert.Empty(await _harness.Outgoing(community.SharedInbox));
var flag = Assert.Single(await _harness.Outgoing(poster.Id + "/inbox"));
Assert.EndsWith("/peasants/privapub", flag["actor"]!.GetValue<string>());
}
[Fact]
public async Task Nobody_follows_the_reporter()
{
var follower = new RemoteActor(_harness.Peer, "follower");
var reporter = await _harness.Local.GetReporterActor(Token);
var result = await _harness.Deliver(follower, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(follower)}/follow/{Guid.NewGuid():N}", ["type"] = "Follow", ["actor"] = follower.Id, ["object"] = reporter.Uri
});
Assert.Equal(404, result.StatusCode);
Assert.False(await DB.Default.Find<Follower>().Match(f => f.ActorURI == follower.Id).ExecuteAnyAsync(Token));
}
}
}
@@ -1,50 +0,0 @@
using PrivaPub.Domain.Statuses;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
// one delivery a server: a reply to a follower's post goes to its server's shared inbox once, not also to the
// follower's own inbox (BookWyrm kept two copies of a post that reached both at once)
[Trait("Category", "Integration")]
public sealed class SharedInboxTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
[Fact]
public async Task A_reply_to_a_followers_post_reaches_its_server_once_through_the_shared_inbox()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var bob = new RemoteActor(_harness.Peer, "bob", sharedInbox: true);
await _harness.FollowedBy(alice, bob);
var path = $"/notes/{Guid.NewGuid():N}";
var noteId = new Uri(bob.Id).GetLeftPart(UriPartial.Authority) + path;
_harness.Peer.Serve(path, new JsonObject
{
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = bob.Id, ["content"] = "<p>a question</p>",
["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"), ["published"] = DateTime.UtcNow.ToString("O")
}.ToJsonString());
var parent = await _harness.RemotePosts.StoreContext(noteId, 0, token);
var reply = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "an answer", InReplyTo = parent.ID }, token);
Assert.True(reply.Ok, reply.Error);
Assert.Single(await _harness.Outgoing(bob.SharedInbox), a => a["type"]!.GetValue<string>() == "Create");
Assert.Empty(await _harness.Outgoing(bob.Id + "/inbox"));
}
}
}
-213
View File
@@ -1,213 +0,0 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Text.Json.Nodes;
using static PrivaPub.Tests.Support.Host.FederationHelpers;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Tests.Federation
{
// walls (FEP-400e, Smithereen's; owner decision 2026-10-06): a persona's followers write on its wall, PrivaPub hosts what
// they write and tells the persona's followers with Add{Note}; the persona takes it off with Remove; an account elsewhere
// that tells of a post on its own wall has it shown to its followers here
[Trait("Category", "Integration")]
public sealed class WallTests : IAsyncLifetime
{
const string Public = "https://www.w3.org/ns/activitystreams#Public";
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static CancellationToken Token => TestContext.Current.CancellationToken;
// a public note by author, written on the wall of owner (whose actor and wall are given), served at its id
string WallNote(RemoteActor author, string ownerUri, string wall, string text, string inReplyTo = default, string to = Public)
{
var path = $"/notes/{Guid.NewGuid():N}";
var origin = new Uri(author.Id).GetLeftPart(UriPartial.Authority);
var note = new JsonObject
{
["id"] = origin + path, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"<p>{text}</p>",
["to"] = new JsonArray(to, ownerUri), ["published"] = DateTime.UtcNow.ToString("O"),
["target"] = new JsonObject { ["type"] = "Collection", ["id"] = wall, ["attributedTo"] = ownerUri }
};
if (inReplyTo != default)
note["inReplyTo"] = inReplyTo;
_harness.Peer.Serve(path, note.ToJsonString());
_notes[origin + path] = note;
return origin + path;
}
readonly Dictionary<string, JsonObject> _notes = new();
// as Smithereen sends it: the whole note, to the wall's owner
JsonObject Create(RemoteActor author, string noteId) => new()
{
["id"] = noteId + "/activityCreate", ["type"] = "Create", ["actor"] = author.Id, ["to"] = _notes[noteId]["to"]!.DeepClone(),
["object"] = _notes[noteId].DeepClone()
};
Task<PostEntity> Post(string objectUri) => DB.Default.Find<PostEntity>().Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(Token);
[Fact]
public void A_persona_names_its_wall_and_lets_its_followers_write_on_it()
{
var actor = ActivityPubRenderer.Actor(new LocalActor { Id = "a", UserName = "alice", BaseAddress = Harness.Base, Kind = PrivaPub.Models.Federation.LocalActorKind.Person });
var circle = ActivityPubRenderer.Actor(new LocalActor { Id = "c", UserName = "circle", BaseAddress = Harness.Base, Kind = PrivaPub.Models.Federation.LocalActorKind.Group, IsCircle = true });
Assert.Equal($"{Harness.Base}/peasants/alice/graffiti", actor["wall"]!.GetValue<string>());
Assert.Equal($"{Harness.Base}/peasants/alice/groupies", actor["privacySettings"]!["wallPosting"]!["allowedTo"]![0]!.GetValue<string>());
Assert.Equal(Public, actor["privacySettings"]!["wallPostVisibility"]!["allowedTo"]![0]!.GetValue<string>());
Assert.Contains(actor["@context"]!.AsArray(), c => c is JsonObject terms && terms["wall"]?["@id"]?.GetValue<string>() == "sm:wall");
Assert.Null(circle["wall"]);
}
[Fact]
public async Task A_followers_post_on_the_wall_is_hosted_shown_and_told_to_the_personas_followers()
{
var (_, alice) = await _harness.Persona("alice");
var smuser = new RemoteActor(_harness.Peer, "smuser", wall: true);
await _harness.FollowedBy(alice, smuser);
var noteId = WallNote(smuser, alice.Uri, alice.Wall, "on alice's wall");
await _harness.Deliver(smuser, "/human-centipede", Create(smuser, noteId));
var post = await Post(noteId);
Assert.NotNull(post);
Assert.Equal((alice.Wall, alice.Uri, alice.Id), (post.WallURI, post.WallOwnerURI, post.WallOwnerAccountId));
Assert.True(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(Token));
Assert.True(await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id && n.PostId == post.ID && n.Type == NotificationType.Mention).ExecuteAnyAsync(Token));
var add = Assert.Single(await _harness.Outgoing(_harness.Peer.A + "/inbox"), a => a["type"]!.GetValue<string>() == "Add");
Assert.Equal((alice.Uri, noteId, alice.Wall), (add["actor"]!.GetValue<string>(), add["object"]!.GetValue<string>(), add["target"]!["id"]!.GetValue<string>()));
Assert.Contains(await _harness.Outgoing(smuser.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Add");
Assert.True(await _harness.Walls.Remove(alice, post, Token));
Assert.Null(await Post(noteId));
var remove = Assert.Single(await _harness.Outgoing(_harness.Peer.A + "/inbox"), a => a["type"]!.GetValue<string>() == "Remove");
Assert.Equal((noteId, alice.Wall), (remove["object"]!.GetValue<string>(), remove["target"]!.GetValue<string>()));
}
[Fact]
public async Task A_wall_post_from_a_stranger_a_reply_or_a_quiet_one_is_refused()
{
var (_, alice) = await _harness.Persona("alice");
var stranger = new RemoteActor(_harness.Peer, "stranger", wall: true);
var follower = new RemoteActor(_harness.Peer, "follower", wall: true);
await _harness.FollowedBy(alice, follower);
var fromStranger = WallNote(stranger, alice.Uri, alice.Wall, "from a stranger");
var reply = WallNote(follower, alice.Uri, alice.Wall, "a reply", inReplyTo: "https://elsewhere.example/notes/1");
var quiet = WallNote(follower, alice.Uri, alice.Wall, "quietly", to: follower.Id + "/followers");
await _harness.Deliver(stranger, "/human-centipede", Create(stranger, fromStranger));
await _harness.Deliver(follower, "/human-centipede", Create(follower, reply));
await _harness.Deliver(follower, "/human-centipede", Create(follower, quiet));
Assert.Null(await Post(fromStranger));
Assert.Null(await Post(reply));
Assert.Null(await Post(quiet));
Assert.DoesNotContain(await _harness.Outgoing(_harness.Peer.A + "/inbox"), a => a["type"]!.GetValue<string>() == "Add");
}
[Fact]
public async Task A_post_on_a_followed_accounts_wall_elsewhere_reaches_its_followers_here_until_it_is_taken_off()
{
var (_, alice) = await _harness.Persona("alice");
var owner = new RemoteActor(_harness.Peer, "owner", wall: true);
var writer = new RemoteActor(_harness.Peer, "writer");
await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = owner.Id, TargetInboxURL = owner.Id + "/inbox", State = FollowState.Accepted }, Token);
var noteId = WallNote(writer, owner.Id, owner.Wall, "on the owner's wall");
// (kept before PrivaPub read walls: its document is read again when it names one)
await DB.Default.SaveAsync(new PrivaPub.Models.User.ForeignAvatar
{
ActorURI = owner.Id, UserName = owner.Name, PublicKeyId = owner.KeyId, PublicKey = owner.PublicKeyPem, InboxURL = owner.Id + "/inbox",
FollowersURL = owner.Id + "/followers", UpdatedAt = DateTime.UtcNow.AddDays(-2)
}, Token);
await _harness.Deliver(owner, "/human-centipede", new JsonObject
{
["id"] = $"{owner.Id}/posts/{Guid.NewGuid():N}/activityAdd", ["type"] = "Add", ["actor"] = owner.Id, ["object"] = noteId,
["target"] = new JsonObject { ["type"] = "Collection", ["id"] = owner.Wall, ["attributedTo"] = owner.Id },
["to"] = new JsonArray(Public, owner.Id + "/followers")
});
var post = await Post(noteId);
Assert.NotNull(post);
Assert.Equal((writer.Id, owner.Id), (post.ActorURI, post.WallOwnerURI));
Assert.True(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(Token));
Assert.False(await DB.Default.Find<RemoteFeatured>().Match(f => f.ObjectURI == noteId).ExecuteAnyAsync(Token));
await _harness.Deliver(owner, "/human-centipede", new JsonObject
{
["id"] = $"{owner.Id}/posts/{Guid.NewGuid():N}/activityRemove", ["type"] = "Remove", ["actor"] = owner.Id, ["object"] = noteId, ["target"] = owner.Wall
});
Assert.Null(await Post(noteId));
}
}
[Trait("Category", "Integration")]
public sealed class WallCollectionTests : IAsyncLifetime
{
PrivaPubHost _host;
HttpClient _client;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
_client = _host.Client();
}
public ValueTask DisposeAsync()
{
_client?.Dispose();
return ValueTask.CompletedTask;
}
[Fact]
public async Task The_wall_lists_the_personas_public_posts_and_what_others_wrote_on_it()
{
var token = TestContext.Current.CancellationToken;
var persona = await _host.Persona(await _host.SignUp(), "wall");
var open = await _host.Publish(persona, "for everyone", PostVisibility.Public);
var quiet = await _host.Publish(persona, "for followers", PostVisibility.FollowersOnly);
var wall = persona.ActorUri() + "/graffiti";
var written = new PostEntity
{
ObjectURI = $"https://elsewhere.example/notes/{Guid.NewGuid():N}", ActorURI = "https://elsewhere.example/users/writer", IsFederatedCopy = true,
Visibility = PostVisibility.Public, WallURI = wall, WallOwnerURI = persona.ActorUri(), WallOwnerAccountId = persona.Id
};
await DB.Default.SaveAsync(written, token);
var collection = await _client.Fetch($"/peasants/{persona.UserName}/graffiti");
var page = await _client.Fetch($"/peasants/{persona.UserName}/graffiti?page=true");
Assert.Equal(HttpStatusCode.OK, collection.Status);
Assert.Equal((wall, 2), (collection.Json["id"]!.GetValue<string>(), collection.Json["totalItems"]!.GetValue<int>()));
var items = page.Json["orderedItems"]!.AsArray().Select(i => i!.GetValue<string>()).ToList();
Assert.Equal(new[] { written.ObjectURI, persona.ActorUri() + "/scribbles/" + open.ID }, items);
Assert.DoesNotContain(items, i => i.EndsWith(quiet.ID));
}
}
}
@@ -1 +0,0 @@
{"attributedTo":"https://gts.test/users/ines_gt59","cc":"https://privapub.test/peasants/flor_pp98","content":"\u003Cp\u003E\u003Cspan class=\u0022h-card\u0022\u003E\u003Ca href=\u0022https://privapub.test/@flor_pp98\u0022 class=\u0022u-url mention\u0022 rel=\u0022nofollow noreferrer noopener\u0022 target=\u0022_blank\u0022\u003E@\u003Cspan\u003Eflor_pp98\u003C/span\u003E\u003C/a\u003E\u003C/span\u003E bicycle the window bicycle tomorrow festival (p111)\u003C/p\u003E","contentMap":{"en":"\u003Cp\u003E\u003Cspan class=\u0022h-card\u0022\u003E\u003Ca href=\u0022https://privapub.test/@flor_pp98\u0022 class=\u0022u-url mention\u0022 rel=\u0022nofollow noreferrer noopener\u0022 target=\u0022_blank\u0022\u003E@\u003Cspan\u003Eflor_pp98\u003C/span\u003E\u003C/a\u003E\u003C/span\u003E bicycle the window bicycle tomorrow festival (p111)\u003C/p\u003E"},"id":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY","inReplyTo":"https://privapub.test/peasants/flor_pp98/scribbles/6ac2beaf092dc0af8ce6035e","interactionPolicy":{"canAnnounce":{"automaticApproval":["https://gts.test/users/ines_gt59"]},"canLike":{"automaticApproval":["https://gts.test/users/ines_gt59","https://gts.test/users/ines_gt59/followers","https://privapub.test/peasants/flor_pp98"]},"canQuote":{"automaticApproval":["https://gts.test/users/ines_gt59"]},"canReply":{"automaticApproval":["https://gts.test/users/ines_gt59","https://gts.test/users/ines_gt59/followers","https://privapub.test/peasants/flor_pp98"]}},"published":"2026-10-04T21:02:31Z","replies":{"first":{"id":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY/replies?page=true","next":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY/replies?page=true\u0026only_other_accounts=false","partOf":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY/replies","type":"CollectionPage"},"id":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY/replies","type":"Collection"},"tag":{"href":"https://privapub.test/peasants/flor_pp98","name":"@flor_pp98@privapub.test","type":"Mention"},"to":"https://gts.test/users/ines_gt59/followers","type":"Note","url":"https://gts.test/@ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY"}
@@ -1,71 +0,0 @@
{
"actor": "https://pleroma.test/users/flor_pl97",
"attachment": [],
"attributedTo": "https://pleroma.test/users/flor_pl97",
"cc": [
"https://pleroma.test/users/flor_pl97/followers"
],
"content": "mountain late window map bright postcard (p10) (edited)",
"context": "https://pleroma.test/contexts/891653c5-91ba-43f3-b3ef-b066caa3fa4d",
"conversation": "https://pleroma.test/contexts/891653c5-91ba-43f3-b3ef-b066caa3fa4d",
"formerRepresentations": {
"orderedItems": [
{
"actor": "https://pleroma.test/users/flor_pl97",
"attachment": [],
"attributedTo": "https://pleroma.test/users/flor_pl97",
"cc": [
"https://pleroma.test/users/flor_pl97/followers"
],
"content": "tomorrow library garden bridge the festival bicycle together garden early quiet morning library the postcard tomorrow mountain the small rain postcard warm garden market map mountain map (p10)",
"contentMap": {
"en": "tomorrow library garden bridge the festival bicycle together garden early quiet morning library the postcard tomorrow mountain the small rain postcard warm garden market map mountain map (p10)"
},
"context": "https://pleroma.test/contexts/891653c5-91ba-43f3-b3ef-b066caa3fa4d",
"conversation": "https://pleroma.test/contexts/891653c5-91ba-43f3-b3ef-b066caa3fa4d",
"published": "2026-10-05T00:49:58.860071Z",
"repliesCount": 2,
"source": {
"content": "tomorrow library garden bridge the festival bicycle together garden early quiet morning library the postcard tomorrow mountain the small rain postcard warm garden market map mountain map (p10)",
"mediaType": "text/plain"
},
"summary": "",
"tag": [],
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"type": "Note"
}
],
"totalItems": 1,
"type": "OrderedCollection"
},
"id": "https://pleroma.test/objects/6101c4fd-ed46-461c-a3fe-9742852f47da",
"published": "2026-10-05T00:49:58.860071Z",
"replies": {
"first": {
"id": "https://pleroma.test/objects/6101c4fd-ed46-461c-a3fe-9742852f47da/replies?page=true",
"orderedItems": [
"https://pleroma.test/objects/b7b7c066-51a8-46d1-8919-07b87f158887",
"https://privapub.test/peasants/vera_pp40/scribbles/6ac2f4520c61eaf3ab241d35"
],
"partOf": "https://pleroma.test/objects/6101c4fd-ed46-461c-a3fe-9742852f47da/replies",
"type": "OrderedCollectionPage"
},
"id": "https://pleroma.test/objects/6101c4fd-ed46-461c-a3fe-9742852f47da/replies",
"totalItems": 2,
"type": "OrderedCollection"
},
"repliesCount": 2,
"source": {
"content": "mountain late window map bright postcard (p10) (edited)",
"mediaType": "text/plain"
},
"summary": "",
"tag": [],
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"type": "Note",
"updated": "2026-10-05T00:52:40.105051Z"
}
-200
View File
@@ -1,200 +0,0 @@
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.Infrastructure.Backup;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Formats.Tar;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Http
{
// The administrator's page backs up, downloads, uploads and restores (owner decision 2026-10-07): only an
// administrator the database still says is one; a download and a restore ask for the password again, a restore for
// the host typed out; an upload resumes where it stopped, and one that would write outside its backup is refused.
[Trait("Category", "Integration")]
[Xunit.Collection(nameof(Exclusive))]
public sealed class BackupEndpointTests : IAsyncLifetime
{
const string Base = "/clientapi/admin/backups";
PrivaPubHost _host;
static CancellationToken Token => TestContext.Current.CancellationToken;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
}
public ValueTask DisposeAsync()
{
RestoreMarker.Clear(_host?.Get<Backups>().Root ?? Path.GetTempPath());
return ValueTask.CompletedTask;
}
async Task<string> BackedUp(HttpClient admin)
{
var before = _host.Get<Backups>().List().Select(b => b.Id).ToHashSet();
Assert.Equal(HttpStatusCode.Accepted, (await admin.PostAsync(Base, null, Token)).StatusCode);
var made = default(string);
Assert.True(await Wait(async () =>
{
var list = (await admin.GetFromJsonAsync<JsonObject>(Base, Token))!;
made = list["backups"]!.AsArray().Select(b => b!["id"]!.GetValue<string>()).FirstOrDefault(id => !before.Contains(id));
return made != default && list["running"] is null;
}), "the backup was never made");
return made;
}
static async Task<bool> Wait(Func<Task<bool>> done)
{
for (var i = 0; i < 120; i++)
{
if (await done())
return true;
await Task.Delay(250, Token);
}
return false;
}
[Fact]
public async Task Only_an_administrator_the_database_still_knows()
{
var root = await _host.SignUp("plain");
using (var plain = _host.As(root.Jwt))
Assert.Equal(HttpStatusCode.Forbidden, (await plain.GetAsync(Base, Token)).StatusCode);
var admin = await _host.Admin();
using var client = _host.As(admin.Jwt);
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync(Base, Token)).StatusCode);
await DB.Default.Update<RootUser>().MatchID(admin.Id).Modify(u => u.Policies, [Policies.IsUser]).ExecuteAsync(Token);
Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync(Base, Token)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await client.PostAsync(Base, null, Token)).StatusCode);
}
[Fact]
public async Task A_backup_is_made_downloaded_whole_or_in_part_and_uploaded_again()
{
var admin = await _host.Admin();
using var client = _host.As(admin.Jwt);
var id = await BackedUp(client);
var listed = (await client.GetFromJsonAsync<JsonObject>(Base, Token))!;
Assert.Equal("privapub.test", listed["host"]!.GetValue<string>());
Assert.Equal("manual", listed["backups"]!.AsArray().Single(b => b!["id"]!.GetValue<string>() == id)!["kind"]!.GetValue<string>());
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await client.PostAsJsonAsync($"{Base}/{id}/ticket", new { password = "wrong" }, Token)).StatusCode);
var ticket = (await (await client.PostAsJsonAsync($"{Base}/{id}/ticket", new { password = admin.Password }, Token)).Content.ReadFromJsonAsync<JsonObject>(Token))!;
var url = ticket["url"]!.GetValue<string>();
// a plain link: no token needed, the ticket is the credential
using var anonymous = _host.Client();
var whole = await anonymous.GetAsync(url, Token);
Assert.Equal(HttpStatusCode.OK, whole.StatusCode);
var bytes = await whole.Content.ReadAsByteArrayAsync(Token);
Assert.Equal(ticket["bytes"]!.GetValue<long>(), bytes.Length);
Assert.Equal(bytes.Length, whole.Content.Headers.ContentLength);
var names = new List<string>();
await using (var reader = new TarReader(new MemoryStream(bytes)))
while (await reader.GetNextEntryAsync(cancellationToken: Token) is { } entry)
names.Add(entry.Name);
Assert.Contains($"{id}/manifest.json", names);
Assert.Contains($"{id}/db/Avatar.jsonl.gz", names);
var part = new HttpRequestMessage(HttpMethod.Get, url);
part.Headers.Range = new RangeHeaderValue(700, 2747);
var partial = await anonymous.SendAsync(part, Token);
Assert.Equal(HttpStatusCode.PartialContent, partial.StatusCode);
Assert.Equal($"bytes 700-2747/{bytes.Length}", partial.Content.Headers.ContentRange!.ToString());
Assert.Equal(bytes.AsSpan(700, 2048).ToArray(), await partial.Content.ReadAsByteArrayAsync(Token));
Assert.Equal(HttpStatusCode.NotFound, (await anonymous.GetAsync($"{Base}/download/{new string('0', 64)}", Token)).StatusCode);
// gone, then uploaded again in three pieces, one sent at the wrong place and refused with where to go on
Assert.Equal(HttpStatusCode.OK, (await client.DeleteAsync($"{Base}/{id}", Token)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.DeleteAsync($"{Base}/{id}", Token)).StatusCode);
var upload = (await (await client.PostAsync($"{Base}/uploads", null, Token)).Content.ReadFromJsonAsync<JsonObject>(Token))!["id"]!.GetValue<string>();
var third = bytes.Length / 3;
Assert.Equal(HttpStatusCode.OK, (await Piece(client, upload, 0, bytes[..third])).StatusCode);
var gap = await Piece(client, upload, third + 10, bytes[(third + 10)..]);
Assert.Equal(HttpStatusCode.Conflict, gap.StatusCode);
Assert.Equal(third, (await gap.Content.ReadFromJsonAsync<JsonObject>(Token))!["received"]!.GetValue<long>());
Assert.Equal(HttpStatusCode.OK, (await Piece(client, upload, third, bytes[third..(2 * third)])).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await Piece(client, upload, 2 * third, bytes[(2 * third)..])).StatusCode);
var finished = await client.PostAsync($"{Base}/uploads/{upload}/finish", null, Token);
Assert.Equal(HttpStatusCode.OK, finished.StatusCode);
Assert.Equal("uploaded", (await finished.Content.ReadFromJsonAsync<JsonObject>(Token))!["kind"]!.GetValue<string>());
Assert.Empty(await _host.Get<Backups>().Verify(id, Token));
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await client.PostAsync($"{Base}/uploads/{upload}/finish", null, Token)).StatusCode);
_host.Get<Backups>().Delete(id);
}
Task<HttpResponseMessage> Piece(HttpClient client, string upload, long offset, byte[] bytes) =>
client.PutAsync($"{Base}/uploads/{upload}?offset={offset}", new ByteArrayContent(bytes), Token);
[Theory]
[InlineData("escape")]
[InlineData("link")]
[InlineData("elsewhere")]
[InlineData("junk")]
public async Task An_upload_that_is_not_a_plain_backup_is_refused_and_leaves_nothing(string how)
{
var admin = await _host.Admin();
using var client = _host.As(admin.Jwt);
const string id = "20260101-000000-manual";
var tar = new MemoryStream();
if (how == "junk")
tar.Write("not a tar at all, not even close"u8);
else
await using (var writer = new TarWriter(tar, TarEntryFormat.Pax, leaveOpen: true))
{
await writer.WriteEntryAsync(new PaxTarEntry(TarEntryType.RegularFile, $"{id}/manifest.json") { DataStream = new MemoryStream("{}"u8.ToArray()) }, Token);
await writer.WriteEntryAsync(how switch
{
"escape" => new PaxTarEntry(TarEntryType.RegularFile, $"{id}/../../evil") { DataStream = new MemoryStream("x"u8.ToArray()) },
"link" => new PaxTarEntry(TarEntryType.SymbolicLink, $"{id}/media/x.jpg") { LinkName = "/etc/passwd" },
_ => new PaxTarEntry(TarEntryType.RegularFile, "20260101-000000-nightly/db/Post.jsonl.gz") { DataStream = new MemoryStream("x"u8.ToArray()) }
}, Token);
}
var upload = (await (await client.PostAsync($"{Base}/uploads", null, Token)).Content.ReadFromJsonAsync<JsonObject>(Token))!["id"]!.GetValue<string>();
Assert.Equal(HttpStatusCode.OK, (await Piece(client, upload, 0, tar.ToArray())).StatusCode);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await client.PostAsync($"{Base}/uploads/{upload}/finish", null, Token)).StatusCode);
var root = _host.Get<Backups>().Root;
Assert.False(Directory.Exists(Path.Combine(root, id)));
Assert.False(Directory.Exists(Path.Combine(root, id + ServerBackup.PartialSuffix)));
Assert.False(File.Exists(Path.Combine(root, "..", "evil")));
}
// asked for: written for the next start (the test host has no watcher to stop it, and the marker is taken away)
[Fact]
public async Task A_restore_asks_for_the_password_and_the_host_typed_out()
{
var admin = await _host.Admin();
using var client = _host.As(admin.Jwt);
var id = await BackedUp(client);
var root = _host.Get<Backups>().Root;
try
{
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await client.PostAsJsonAsync($"{Base}/{id}/restore", new { password = "wrong", host = "privapub.test" }, Token)).StatusCode);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await client.PostAsJsonAsync($"{Base}/{id}/restore", new { password = admin.Password, host = "elsewhere.test" }, Token)).StatusCode);
Assert.Null(RestoreMarker.Read(root));
Assert.Equal(HttpStatusCode.Accepted, (await client.PostAsJsonAsync($"{Base}/{id}/restore", new { password = admin.Password, host = "PrivaPub.test" }, Token)).StatusCode);
var marker = RestoreMarker.Read(root);
Assert.Equal((id, "pending", admin.UserName), (marker.Backup, marker.State, marker.RequestedBy));
Assert.Equal(id, (await client.GetFromJsonAsync<JsonObject>(Base, Token))!["waiting"]!["backup"]!.GetValue<string>());
Assert.Equal(HttpStatusCode.Conflict, (await client.DeleteAsync($"{Base}/{id}", Token)).StatusCode);
}
finally
{
RestoreMarker.Clear(root);
_host.Get<Backups>().Delete(id);
}
}
}
}
Loaded 100 of 278 files, more files were not shown because too many files have changed in this diff. Show more