Compare commits

..
34 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 d8f163b5ce Persona separation is tested, the API is smoke-checked on deploy, OAuth rows are pruned
Build / Build (push) Successful in 59s
Deploy / privapub.thepra.dev (push) Successful in 1m13s
- PersonaSeparationTests: two personas of one login follow the same
  account and post; nothing the API maps for one contains the other's id,
  username or the root id.
- tools/smoke/mastodon-api.sh checks what a client meets first (instance
  v1/v2, discovery, app registration, client credentials, an app token
  refused by a user endpoint, the public timeline, revocation) and, given
  a persona token, verify_credentials, home and notifications. deploy.yml
  runs it after every deploy.
- OAuthPruner removes invalid tokens and authorizations older than two
  weeks, every six hours.
- The consent page no longer sets form-action, which browsers apply to the
  redirect back to the client after the form is posted.

CLAUDE.md gains the Mastodon API layout and invariants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:09:19 +02:00
thepraandClaude Opus 5.5 70b4c45eb0 The dead-host test tolerates a loaded machine
It asserted fewer than ten requests to the dead host by the time the live
one arrived, which a busy workstation can exceed without the per-host
limit being broken; the live delivery arriving at all is the point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:07:44 +02:00
thepraandClaude Opus 5.5 0028e96e76 The Mastodon client API: accounts, statuses, timelines, notifications, search
With a token for a persona, a Mastodon client can now:
- accounts: verify/update credentials (display name, note, fields, locked,
  bot, discoverable, indexable, hide collections, posting defaults; the
  change federates as Update{Person}), get, lookup, statuses (paged, by
  visibility to the viewer), relationships, search, follow and unfollow,
  follow requests (authorize and reject answer remote followers with
  Accept or Reject), remove from followers. Followers and following lists
  are shown to their owner only.
- statuses: post (plain text, mentions, hashtags, replies, content
  warnings, the four visibilities, Idempotency-Key), get, edit (PUT),
  delete returning the source for redrafting, context, history, source,
  favourite, reblog and their undos, favourited_by and reblogged_by.
- timelines: home, public (local or remote), tag; favourites;
  conversations; markers.
- notifications: list with types and exclude_types, get, dismiss, clear,
  unread count.
- /api/v2/search, resolving a handle or a URL to an account or a post.

Media, polls, pins, bookmarks, mutes, blocks, lists, filters, trends and
push answer empty lists or a 422 saying they are not supported yet, so
clients degrade instead of failing. Public reads work without a token.
Persona settings (locked, bot, indexable, discoverable) now also shape
the ActivityPub actor.

Fixed on the way: three conditional expressions whose `default` was the
value type's, so a missing limit became 1, a missing flag became false and
an attachment without dimensions became 0x0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:05:58 +02:00
thepraandClaude Opus 5.5 b54cdf78b2 One StatusService behind both client APIs, with outbound likes and boosts
Domain/Statuses/StatusService takes a persona, not a root, and is what
/clientapi and the Mastodon API share:
- Publish renders Markdown (clientapi) or plain text (Mastodon clients),
  checks the persona may see the post it replies to, opens or reuses the
  conversation for a direct post from its recipients and mentions, fans
  out and hands the Create to the outbox;
- Edit keeps a revision and sends Update{Note}; Remove soft-deletes and
  returns the post, so a client can delete and redraft;
- Favourite and Reblog work on anything the persona can see and send Like,
  Announce and their Undo to the author (and, for boosts, to followers);
  boosts are refused for anything but public and unlisted posts.

PostsService is now the clientapi wrapper that checks the root owns the
persona. A persona's own boost is a local row: the outbox renders it as
Announce, /grunts/announce-{id} resolves, and object endpoints, profile
pages and NodeInfo skip it. ContentFormat gains Plain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:55:22 +02:00
thepraandClaude Opus 5.5 4d9be37c1c Mastodon clients can sign in: OAuth with a persona per token
OpenIddict 7.7 (MongoDB stores, keys kept in Mongo so tokens survive
restarts) serves /oauth/authorize, /oauth/token, /oauth/revoke and the
discovery documents, including /.well-known/oauth-authorization-server:
- authorization code (PKCE optional) and client credentials, Mastodon's
  scopes including the granular ones, non-expiring reference tokens,
  `created_at` in the token response, and the urn:ietf:wg:oauth:2.0:oob
  page that shows the code;
- /oauth/login signs the private login into a fifteen-minute cookie that
  only /oauth sees (rate limited, antiforgery-protected); /oauth/authorize
  then asks which persona the application acts as. The token's subject
  is that persona's id and nothing else; no root id reaches a token, an
  authorization or a log line;
- the token exchange refuses a persona whose login is banned or deleted,
  and every API request checks the same.

/api/v1/apps registers applications dynamically, /api/v1/apps/
verify_credentials, /api/v1/instance (v1 and v2, "4.2.0 (compatible;
PrivaPub)") and verify_credentials answer in Mastodon's shapes: snake_case
with explicit nulls, Rails-style parameters from query, form or JSON,
{"error": ...} on failure, Link paging. CORS exposes Link.

/api goes to OpenIddict validation and everything else to the existing
JWT; the JWT failure handler no longer sends the exception and stack trace
to the client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:52:31 +02:00
thepraandClaude Opus 5.5 e6a6fe46d7 P1.2 is deployed and verified
Build / Build (push) Successful in 27s
v1.3.0 is live with follows, timelines, notifications, likes, boosts,
edits, tombstones and thread backfill; its indexes exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:42:16 +02:00
thepraandClaude Opus 5.5 7ff3a61e75 FEDERATION.md and CLAUDE.md describe follows, likes, boosts and timelines
Build / Build (push) Successful in 30s
Deploy / privapub.thepra.dev (push) Successful in 45s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:40:02 +02:00
thepraandClaude Opus 5.5 afd2e727d0 A remote delete clears what it touched
Deleting a remote post now removes its timeline entries and the reblogs
of it and lowers its parent's reply count; deleting a remote actor also
drops the follows pointing at it and its entries in home timelines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:39:45 +02:00
thepraandClaude Opus 5.5 b34c71775c Likes and boosts arrive, count, notify, and can be taken back
- Like: a Favourite per account and post (unique), the post's counter, a
  Favourite notification for a local author; only on posts the liker
  could see (public and unlisted, or followers-only and direct when they
  were addressed).
- Announce: the original is always refetched from its own origin (or is
  ours), and must be public or unlisted. A boost of a local post counts
  and notifies; a boost by an account a persona follows is stored as a
  reblog row and fanned out to its followers' home timelines (as a reblog,
  where reblogs are wanted). A boost by a stranger of a stranger's post is
  ignored. FEP-1b12 group announces of activities wait for P4.
- Undo of a Like or an Announce reverses each of them; an Undo naming only
  an id is matched against follows, likes and boosts in turn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:39:10 +02:00
thepraandClaude Opus 5.5 e99b76dbd7 Home timelines, mention notifications, and threads that fetch their parents
Fanout writes a TimelineEntry for every persona a post should reach: the
author, local followers of a local author, local followers of a remote one,
the members of a direct conversation or a circle. Mastodon's home rules
apply when it is written: a reply shows only to followers of both sides
(or to the one replied to), a reblog only where reblogs are wanted. A
mention of a local persona becomes a Mention notification.

Inbound posts are now also kept when a persona follows their author.

RemotePosts holds what CreateHandler and backfill share: building a Post
from a note, and fetching a public parent the first time a reply to it
arrives, so its author is known (a reply to an unknown or non-public
parent stays out of home timelines). Each fetched ancestor queues a
FetchAncestors job for the next one, up to ten deep.

/clientapi/timeline/home and /clientapi/notifications (with
/notifications/read) page by max_id for the persona's own root only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:37:41 +02:00
thepraandClaude Opus 5.5 a76cc34557 Posts reach their audience, edits follow them, deletes leave a tombstone
OutboxPublisher works out who a post goes to, Mastodon's way: followers'
shared inboxes for public, unlisted and followers-only; every mentioned
remote account's own inbox; the recipients only for direct; the parent's
author for a reply; a community's followers for a post in it; nobody for
a circle. Creates, updates and deletes all use it.

- Local posts take a visibility (public, unlisted, followers-only) and a
  spoiler text next to the content-warning flag.
- /clientapi/post/update keeps the previous version as a revision,
  re-renders, and sends Update{Note} with `updated` to the same to/cc.
- Deleting is now soft: the content, title, spoiler, media and revisions
  are cleared, timeline entries removed, the parent's reply count goes
  down, Delete goes to the stored audience, and the object answers 410
  with a Tombstone instead of 404.
- Editing a persona's profile sends Update{Person} to its followers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:34:35 +02:00
thepraandClaude Opus 5.5 61f6ca0676 Personas can follow: locally at once, remotely by Follow and Accept
Following records what a local persona follows, local or remote, with
its state and the Follow activity's id. FollowService (behind
/clientapi/follow, /clientapi/unfollow and /clientapi/following):
- resolves @user, user@host or an actor URI;
- a local account is followed in-process: accepted unless it approves
  followers by hand, a Follower row on the other side, a community gains
  the persona as a member, and a Follow or FollowRequest notification;
- a remote account gets a Follow signed by the persona, at
  /grunts/follow-{id}, and stays Requested until an Accept;
- unfollowing deletes the rows, or sends Undo{Follow} to a remote account.

Inbound Accept and Reject are matched to the Follow by its id (or, for an
embedded Follow without one, by its actor), and only from the account that
was followed. A remote Follow now notifies the persona too.

Notification is the per-persona record P1.2 builds on, deduplicated by
type, persona, sender and post. TimelineEntry and Favourite are created
with their indexes for the next commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:32:33 +02:00
thepraandClaude Opus 5.5 8af50dad9c P1.1 is deployed and verified
Build / Build (push) Successful in 33s
v1.2.0 serves the themed routes, NodeInfo 2.0 and 2.1 and the day-cut
actor; migrations 1-5 ran and the job queue's indexes exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:31:26 +02:00
thepraandClaude Opus 5.5 60bf21b019 CLAUDE.md describes the code after P1.1
Build / Build (push) Successful in 1m7s
Deploy / privapub.thepra.dev (push) Successful in 1m1s
The route table lists the themed names now in use, the repo map gains
Jobs, Ids, Moderation, Domain and Web, and the invariants add the job
queue, VisibilityPolicy and the rule for which remote posts are stored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:28:37 +02:00
thepraandClaude Opus 5.5 1c78da34a9 One visibility rule for every public read
VisibilityPolicy.IsPublic is the single expression for "anyone may see
this" (Public or Unlisted, not deleted); the outbox, the object and
activity endpoints, the HTML pages and NodeInfo all use it instead of
spelling it out. CanSee answers for a persona: the author, a mentioned
local persona, a conversation member for Direct, a circle member for
Circle; followers-only waits for P1.2's follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:26:51 +02:00
thepraandClaude Opus 5.5 5a13597c00 Persona and group ids say which day, nothing more
An ObjectId carries its creation second and a per-process counter, so two
avatars made one after the other by the same login got ids a few counts
apart: a link between personas that every Mastodon client would have
seen. Avatar and Group now generate ids from the UTC day plus eight random
bytes (still valid ObjectIds), and a remote post's id is made from its
published time with a random tail, so posts page in the order they were
written rather than the order they arrived.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:25:48 +02:00
thepraandClaude Opus 5.5 e6a362c0b8 Domain blocks: suspend, silence, reject media
DomainBlock (domain, severity, reject-media, public and private comment)
covers the domain and its subdomains. Admins manage them under
/clientapi/admin/domainblocks/{list,insert,delete}; the set is kept in
memory, reloaded on every change and at most five minutes stale.

A suspended domain is refused by FederationHttp.IsAllowed, so nothing is
fetched from it and no job delivers to it, and the inbox drops its
activities with a 202 before fetching any key. Reject-media strips the
attachments of posts from that domain. Silence is recorded for the
timelines and notifications that arrive in P1.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:25:05 +02:00
thepraandClaude Opus 5.5 9ec1f9930b Profile and post pages, NodeInfo 2.1, and FEDERATION.md
/@user and /@user/{id} are Razor pages showing an avatar's or community's
public and unlisted posts: no scripts, a strict CSP, no-referrer, noindex,
and an alternate link to the ActivityPub document. A client asking them
for activity+json is redirected to the actor or note, and the actor and
note redirect browsers here.

NodeInfo answers 2.1 as well as 2.0 (repository, homepage, a link to
FEDERATION.md) and counts only public local posts.

FEDERATION.md (FEP-67ff) lists the protocols, FEPs, route names,
activities and the security rules a peer will notice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:23:03 +02:00
thepraandClaude Opus 5.5 e54e17708f Outbound documents speak Mastodon's dialect under PrivaPub's route names
Local text (Domain/Content/ContentRenderer): Markdig with raw HTML off and
autolinks, or plain text for API clients; then @user and @user@host are
resolved (locally or through WebFinger) into Mastodon's h-card markup and
#tags into hashtag links, skipping code, links and e-mail addresses. A
post stores the result, its mentions and its tags, and is delivered to
the mentioned actors and the parent's author as well as to followers.

Renderer:
- Mastodon's @context (toot, schema PropertyValue, discoverable,
  indexable, blurhash, focalPoint, featured, alsoKnownAs, movedTo) and
  FEP-2c59's webfinger;
- an actor's url is its HTML page, its fields are PropertyValue
  attachments, published is cut to the day and indexable is false;
- a note's content is the stored rendering; a title becomes name and a
  bold first line, a content warning without its own text is summarised
  by the title or "Content warning"; Mention and Hashtag tags, contentMap,
  updated, and to/cc by visibility.

Routes take the agreed names: /groupies, /stalking, /scribbles/{id},
/grunts/{id} (a Create resolves), /whispers/{id} for a DM's context. The
outbox is a collection with a first page, paged by max_id. A browser
asking for an actor or a note is redirected to /@user, and WebFinger's
profile-page points there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:21:31 +02:00
thepraandClaude Opus 5.5 e9529310fe One Post model for every visibility, read from notes the way peers write them
Post gains what federation and the Mastodon API need: Visibility (Public,
Unlisted, FollowersOnly, Direct, Circle, LocalGeo), the author's account
id, to/cc, the Create's id, url, context, quote, InReplyToURI and the
parent's author, a separate SpoilerText next to the title, language,
mentions, hashtags, remote attachments (alt text, blurhash, focus, size),
reply/favourite/reblog counters, revisions, EditedAt and DeletedAt.

Direct messages are Posts with Visibility Direct and a ConversationId;
migration _005 copies DmPost rows across with their ids and fills the new
fields of existing posts. DmPost is left in place so a rollback still sees
the old messages.

Inbound:
- NoteParser reads Note, Article, Page, Question and media types: content,
  then contentMap, then _misskey_content; summary as the spoiler and name
  as the title; Mention and Hashtag tags; attachments; a PeerTube-style
  list attribution prefers the person over the channel; quote URIs.
- Addressing classifies like Mastodon, finding followers-only by the
  author's own followers URL (now stored on ForeignAvatar), not by a
  "/followers" suffix.
- Create keeps a post when a local persona is addressed or mentioned, when
  it replies to a local post (the parent's reply count goes up) or when a
  community it follows is addressed; an unsolicited public post is not
  stored. Update keeps the previous version as a revision.

The outbox and object endpoints serve only Public and Unlisted posts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:17:14 +02:00
thepraandClaude Opus 5.5 796f06acf4 The inbox answers once it has verified, and processes from the job queue
InboxService is split the way the roadmap lays out Federation/Inbox:
- InboxReceiver reads and verifies the request exactly as before, runs
  the checks that need no fetch (the activity id's origin, a Follow of a
  missing or local-only actor, an Undo of someone else's activity, an
  embedded object attributed to someone else), queues a ProcessInbox job
  and answers 202. The job's dedupe key is the activity id, so a peer that
  delivers the same activity twice is processed once.
- InboxProcessor (two at a time, eight attempts) loads the verified actor
  and hands the activity to the handler for its type.
- Handlers/{Follow,Undo,Create,Delete,Update}Handler are the old methods,
  unchanged except that they no longer produce status codes; the JSON
  helpers live in Objects/ActivityJson and the group membership helpers in
  Inbox/ForeignMembers.

A slow fetch of an object or a remote actor now delays the job, not the
sender's HTTP request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:12:34 +02:00
thepraandClaude Opus 5.5 d1a91c40c4 Deliveries run on a Mongo job queue with leases, backoff and per-host limits
The single serial DeliveryWorker is replaced by Infrastructure/Jobs:
- Job rows are leased with one FindOneAndUpdate (oldest RunAt first, a
  two-minute lease) and a reaper returns expired leases every 30 s;
- enqueueing wakes the workers, which otherwise poll every five seconds;
- delivery runs eight at a time with at most two per host, so a slow or
  dead server holds two slots, not the queue;
- a failure waits n^4 + 15 + jitter seconds (Mastodon's curve) for up to
  16 attempts; a 4xx other than 408/429 is final, a 429 honours
  Retry-After;
- RemoteInstance is a per-host circuit breaker: ten consecutive failures
  quarantine a host for an hour, doubling to a week, and its jobs wait
  without spending attempts;
- a delivery is queued once per activity and inbox (unique DedupeKey), and
  finished jobs expire after seven days (TTL on FinishedAt).

Migration _004 moves pending Delivery rows into jobs and marks them
abandoned, so a rollback to the old worker cannot send them twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:09:35 +02:00
thepraandClaude Opus 5.5 934b6fe687 P0 is deployed and verified
Build / Build (push) Successful in 25s
v1.1.0 serves the actor, NodeInfo and WebFinger, answers Swagger with 404
and inbox junk with 400 and 401; migrations 1-3 ran and the unique
indexes exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:07:23 +02:00
thepraandClaude Opus 5.5 1a62e8a567 CLAUDE.md describes the code after P0
Build / Build (push) Successful in 47s
Deploy / privapub.thepra.dev (push) Successful in 51s
The repo map gains Infrastructure/ and Objects/, the federation invariants
are the ones the code now enforces instead of a list of known holes, and
Commands, Data, Testing and Deploy cover the test project, the startup
order (warm maps, migrate, index), the admin CLI and the deploy checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:04:20 +02:00
thepraandClaude Opus 5.5 d80cd42a0c Rate limits on accounts and inboxes, a database dump before every deploy
- Sign-up, login, the invitation flows and password recovery allow ten
  requests a minute per client address; the inboxes give each sending
  origin (the keyId's) a bucket of 300 that refills at 300 a minute, and
  answer 429 beyond it, which peers retry.
- deploy.yml dumps the PrivaPub database to /var/backups before it stops
  the service (the last seven are kept), and after the swap checks that
  Swagger answers 404 and that the shared inbox answers junk with 400 and
  an unsigned activity with 401.
- ActivityPubClient and PostBoost, never used, are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:02:21 +02:00
thepraandClaude Opus 5.5 d2f0c7a14e Unique indexes, one username space, and entity maps warmed before use
Infrastructure/Data/Indexes runs at every start, after the migrations:
unique on Post/DmPost ObjectURI, ForeignAvatar ActorURI, the Follower
triple, RootToAvatar, RootUser UserName and ReservedName; plain indexes on
the lookups the services actually make (PublicKeyId, author and group
post listings, ParticipantsKey, the delivery queue).

Migration _001 runs first and removes the duplicates the races could
already have left (keeping the newest actor row, the oldest post, the
accepted follower), then fills ReservedName from every avatar and group.

ReservedName is one username space for personas, groups and the instance:
a name is reserved by an insert the unique index arbitrates, before the
avatar or group is saved, so two simultaneous sign-ups cannot both get it.
A short list of names (admin, support, abuse, postmaster, ...) is never
available.

EntityMaps.Warm touches every entity's collection one at a time before
anything else runs. MongoDB.Entities maps the Entity base class on first
touch, and two types mapped at once throw "An item with the same key has
already been added" and stay broken for the life of the process; the
parallel test run hit it, and the delivery worker racing a request could
have too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:01:08 +02:00
thepraandClaude Opus 5.5 0f85030744 No admin by username, no Swagger in production, no exception text to clients
S14 and the privacy items of P0:
- signing up as "admin" no longer grants admin; `PrivaPub admin promote
  <root>` (and `demote`) does, run on the box against the configured
  database;
- Swagger is served in Development only;
- every service and controller answers "Something went wrong." where it
  used to send ex.Message, and the SMTP warnings no longer log the
  recipient's address;
- sign-up and login no longer log the IP, User-Agent and root id together;
- invitation sign-up takes the persona's own AvatarUserName (and optional
  AvatarName) instead of naming the avatar after the private login, and
  refuses a persona username equal to the login's. Invitation login uses
  the named persona, creating it if it is new;
- recovery mail comes from "PrivaPub", not collAnon's support address name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 10:58:39 +02:00
thepraandClaude Opus 5.5 2eb2a63f1e Conversations are keyed by who is in them, and circles stay home
S8: a direct message joins a conversation only when its participants are
exactly that conversation's members, found through a new
DmGroup.ParticipantsKey (a hash of the sorted members). A remote context
no longer decides anything: it let anyone who knew a conversation's
context post into it, and joining by context while dropping a participant
would have shown a reply to someone it was not addressed to. A context is
kept only when it is on the author's origin. Sending a DM to the same
people again reuses their conversation instead of opening a new one.

S9: Group.Kind is Circle or Community. A circle is not a federated actor:
its actor, collections, WebFinger and inbox answer 404, a remote Follow is
refused, and posts in it are IsLocalOnly - never delivered, never in an
outbox, never served. Communities keep today's behaviour until P4.
Migration _003 makes every existing group a circle, marks their posts
local-only and backfills the conversation keys.

End-to-end inbox tests sign real deliveries from a fake peer: a context
injection, a forged activity id, a note attributed to someone else, a
cross-origin object, a bad signature, junk bodies and a Follow of a circle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 10:57:01 +02:00
thepraandClaude Opus 5.5 bf7c88ce71 Remote HTML is sanitized before it is stored
S5 of the roadmap. ContentSanitizer wraps HtmlSanitizer with Mastodon's
allowlist: the inline and list tags Mastodon keeps, href/rel/class and
the list attributes, microformat and mention/hashtag/ellipsis/invisible
classes, Mastodon's link schemes, every link rel=nofollow noopener
noreferrer, relative links unlinked, headings folded to a bold paragraph,
and the contents of script, style, svg, iframe and friends dropped rather
than kept as text.

Post and DmPost gain ContentHtml (what is shown) and ContentFormat
(Markdown for local, Html for remote). Inbound Create and Update, and a
remote actor's biography, are sanitized on the way in; local posts store
their Markdig rendering. Migration _002 does the same to what is already
stored, and migrations now run at startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 10:54:17 +02:00
thepraandClaude Opus 5.5 611abb5857 Inbound signatures must be fresh and cover the request line and host
S6 of the roadmap. An inbox POST is refused unless its signature covers
(request-target) and host, as well as the digest and a date or (created).
The Date or (created) may be at most an hour old and fifteen minutes ahead
(it was twelve hours either way), and an (expires) in the past is refused.
The request target is read raw from the server, so a percent-encoded path
verifies as the sender signed it.

Tests cover a Mastodon-shaped delivery and each way of tampering with it,
plus a round trip of our own outbound signature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 10:51:10 +02:00
thepraandClaude Opus 5.5 a060204dd6 A remote actor is believed only from its own origin
S1 and S2 of the roadmap. RemoteActorService:
- FetchObject accepts a document only when its id is the address it was
  served from; a same-origin document naming another address is asked for
  at that address once (how GoToSocial serves its key URIs), anything else
  is dropped;
- GetActorByKeyId accepts a key only when the actor lists it, its owner is
  the actor and it lives on the actor's origin, whether the keyId points at
  the actor or at a key document;
- a refetch for a key or an actor happens at most once per five minutes,
  so a bad signature cannot make us hammer a host;
- the cache row is written by one atomic upsert on ActorURI;
- every fetch is signed by the instance actor, never by the persona that
  happened to receive the activity.

The inbox refuses an activity whose id is not on its actor's origin, and
an Undo of someone else's activity; a Create's object, an Update and a
Delete must be on the actor's origin too, and a cross-origin object is
refetched from its own origin before it is trusted.

Tests: a fake peer on two origins serves forged actors, foreign-owned keys,
cross-origin key documents, aliases and a GoToSocial-style key address
(integration, PRIVAPUB_TEST_MONGOD=1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 10:49:58 +02:00
thepraandClaude Opus 5.5 ccc3597699 Every outbound federation request goes through one guarded client
Infrastructure/Http adds the client the roadmap's S3 and S4 ask for:
- the connect callback resolves the name itself and refuses loopback,
  private, link-local, CGNAT, documentation, multicast, ULA, NAT64, 6to4,
  Teredo and IPv4-mapped/compatible forms, then connects to the vetted
  address, so DNS rebinding cannot swap it afterwards;
- redirects are followed by hand, at most three, each one re-checked;
- bodies are capped at 1 MB after decompression, only JSON media types are
  read, every request has a 15 s budget, and a refused URL is not asked
  again for five minutes.

Actor and WebFinger fetches and inbox deliveries all use it. Test networks
can switch on Federation:AllowPrivateNetworks/AllowPlainHttp; startup
refuses both in Production.

PrivaPub.Tests (xUnit v3) starts with the address table and the fetcher's
limits against an in-process peer; build.yml and deploy.yml run it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 10:46:09 +02:00
thepraandClaude Opus 5.5 034b792801 Move the federation code under Federation/, namespaces only
Build / Build (push) Successful in 16s
Services/Federation and Controllers/ServerToServer become
Federation/{Actors,Signing,Inbox,Outbox,Rendering,Controllers}, the first step
of the roadmap's layout. No type, route or behaviour changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 10:39:41 +02:00
thepraandClaude Opus 5.5 31f6015591 CLAUDE.md, a README, and the roadmap to full ActivityPub interop
Build / Build (push) Successful in 25s
CLAUDE.md records what the project is (one private login owning unlinkable
personas, each its own actor), the deliberately odd route names (peasants,
mouth, anus, human-centipede, sniff/again, and the agreed ones still to come),
the federation and privacy invariants, the data and style conventions, and
how it deploys. docs/ROADMAP.md holds the intent reconstructed from the 2023
code, the gap audit, the owner's decisions of 2026-10-01 (Mastodon client
API, personas unlinkable to others, local-only range posts, communities and
circles), the libraries chosen, and phases P0-P5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 10:38:35 +02:00
164 changed files with 12296 additions and 1547 deletions

No files matched your search

+3
View File
@@ -14,3 +14,6 @@ jobs:
- name: Build - name: Build
run: dotnet build PrivaPub.sln -c Release run: dotnet build PrivaPub.sln -c Release
- name: Test
run: dotnet test PrivaPub.sln -c Release --no-build
+20
View File
@@ -12,6 +12,8 @@ env:
BACKUPS: /var/backups/privapub.thepra.dev BACKUPS: /var/backups/privapub.thepra.dev
LOCAL_URL: http://127.0.0.1:6970 LOCAL_URL: http://127.0.0.1:6970
PUBLIC_URL: https://privapub.thepra.dev PUBLIC_URL: https://privapub.thepra.dev
MONGO_URI: mongodb://127.0.0.1:27022
MONGO_DB: PrivaPub
jobs: jobs:
site: site:
@@ -26,6 +28,9 @@ 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
run: dotnet test PrivaPub.sln -c Release
- name: Publish - name: Publish
run: | run: |
rm -rf "$GITHUB_WORKSPACE/publish" rm -rf "$GITHUB_WORKSPACE/publish"
@@ -52,6 +57,13 @@ jobs:
echo "SNAPSHOT=$BACKUPS/site-$STAMP" >> "$GITHUB_ENV" echo "SNAPSHOT=$BACKUPS/site-$STAMP" >> "$GITHUB_ENV"
ls -1dt "$BACKUPS"/site-* 2>/dev/null | tail -n +4 | xargs -r rm -rf || true ls -1dt "$BACKUPS"/site-* 2>/dev/null | tail -n +4 | xargs -r rm -rf || true
- name: Dump the database
run: |
DUMP="$BACKUPS/mongo-$(date +%Y%m%d-%H%M%S).archive.gz"
mongodump --quiet --uri "$MONGO_URI" --db "$MONGO_DB" --gzip --archive="$DUMP"
echo "::notice::database dumped to $DUMP ($(du -h "$DUMP" | cut -f1))"
ls -1t "$BACKUPS"/mongo-*.archive.gz 2>/dev/null | tail -n +8 | xargs -r rm -f || true
- name: Stop, sync, start - name: Stop, sync, start
run: | run: |
sudo systemctl stop "$UNIT" sudo systemctl stop "$UNIT"
@@ -85,4 +97,12 @@ jobs:
[ "$code" = "200" ] || { echo "::error::the instance actor answered $code"; exit 1; } [ "$code" = "200" ] || { echo "::error::the instance actor answered $code"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/.well-known/nodeinfo") code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/.well-known/nodeinfo")
[ "$code" = "200" ] || { echo "::error::nodeinfo answered $code"; exit 1; } [ "$code" = "200" ] || { echo "::error::nodeinfo answered $code"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/swagger/index.html")
[ "$code" = "404" ] || { echo "::error::swagger answered $code in production"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' --data '{"junk":' "$PUBLIC_URL/human-centipede")
[ "$code" = "400" ] || { echo "::error::a junk inbox POST answered $code"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' \
--data '{"type":"Follow","actor":"https://example.org/users/x","object":"'"$PUBLIC_URL"'/peasants/privapub"}' "$PUBLIC_URL/human-centipede")
[ "$code" = "401" ] || { echo "::error::an unsigned inbox POST answered $code"; exit 1; }
tools/smoke/mastodon-api.sh "$PUBLIC_URL"
echo "::notice::serving $served" echo "::notice::serving $served"
+264
View File
@@ -0,0 +1,264 @@
# CLAUDE.md
Guidance for working in this repository. `docs/ROADMAP.md` holds the owner's decisions and the phased plan to full
ActivityPub interop; read it before changing anything federation-, privacy- or API-shaped.
## What this is
**PrivaPub** (repo name SocialPub) is a self-hosted ActivityPub server in C#, live at https://privapub.thepra.dev.
It is meant as a Pleroma-like microblogging server for privacy-minded people and small communities, federating with
Mastodon, GoToSocial, Pleroma/Akkoma, Misskey and Lemmy.
Its defining idea: **one private login owns several public personas.**
- **`RootUser` is the private login.** Username, password, optional email (used only for recovery), policies.
- **`Avatar` is a public persona.** Each one is an ActivityPub actor with its own RSA keys at `/peasants/{username}`.
Root and avatar are linked only through `RootToAvatar`.
- **Personas must stay unlinkable** to other users and servers; only the instance admin can see the link.
- **`Group` is an ActivityPub Group actor.** It has members, an invitation code and an optional password. Groups are
becoming two kinds: a public **community** (FEP-1b12, Lemmy-compatible) and a private, invitation-only **circle**.
- **`DmGroup` is a direct-message conversation.**
- **`privapub` is the instance actor** (type Application). It signs fetches no persona should be tied to.
Privacy features in the model:
- location-ranged posts (`Post.Location`, `RangeKm`), to become local-only and never federated;
- contacts a persona can share (`Avatar.SharedPersonalContacts`);
- private notes (`RootUserNote`, `PersonalNote`).
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`
(https://decepub.thepra.dev) is the owner's own Pleroma-FE-like PWA. It still shows mock data and is not wired to the
server.
## Route names are deliberate
The odd names are the owner's and part of the project's character. **Never "fix" them to conventional ones.** A new
actor-scoped route gets a name in the same spirit, agreed with the owner, and a name is frozen once it has federated.
| Thing | Route |
|---|---|
| Actor | `/peasants/{name}` (`/users/{name}` 301-redirects here; browsers are sent to `/@{name}`) |
| Inbox | `/peasants/{name}/mouth` |
| Outbox | `/peasants/{name}/anus` (`?page=true[&max_id=]` for pages) |
| Shared inbox | `/human-centipede` (also `/peasants/{name}/human-centipede`) |
| Followers | `/peasants/{name}/groupies` |
| Following | `/peasants/{name}/stalking` |
| Notes | `/peasants/{name}/scribbles/{id}` |
| Activities | `/peasants/{name}/grunts/{id}` (`create-{postId}` resolves) |
| DM context | `/peasants/{name}/whispers/{id}` |
| Token refresh | `/clientapi/user/sniff/again` |
Agreed for later phases: `/gossip`, `/drool`, `/echoes` (replies, likes, shares), `/trophies` (featured), `/tattoos`
(featured tags), `/flock` and `/wardens` (group members and moderators).
Routes other software looks up by name stay conventional:
- `/.well-known/*` and `/nodeinfo/*`;
- `/api/v1/*` and `/oauth/*`;
- `/@{name}` pages;
- `/media/*`.
## Repo map
```
PrivaPub.sln
PrivaPub/ ASP.NET Core Web API, net10.0
Program.cs host, Mongo init (GUIDs Standard), pipeline, /build.json
Middleware/SocialPubConfigurations.cs every DI registration (auth, federation, services, swagger, CORS)
Controllers/ClientToServer/ /clientapi/*: RootUser (signup/login/invitations/recovery), PrivateAvatar,
Group, Post (posts + DMs), Admin, Data
Infrastructure/
Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out
Jobs/ JobQueue (leases), JobWorker, Backoff, HostCircuitBreaker
Ids/ PrivacyIds (day-only ids for personas and groups, published-time ids for remote posts)
Data/ Indexes (created at start), EntityMaps.Warm, Migrations/_NNN_*.cs
Cli/ AdminCommands (`PrivaPub admin promote|demote <root>`)
RateLimiting.cs accounts (per client address) and inbox (per sending origin) policies
Federation/
Controllers/ PeasantsController (actor, outbox, followers, following, posts, inboxes),
WellKnownController (webfinger, nodeinfo), UsersController (redirect)
Actors/ LocalActorService (LocalActor, Keys, ReservedName), RemoteActorService
(authoritative fetch, key verification, WebFinger), ActorDocument (parser)
Objects/ Origin, ActivityJson, NoteParser, Addressing, ContentSanitizer
Moderation/ DomainBlocks (suspend / silence / reject media)
Signing/ HttpSignatures (draft-cavage sign/verify)
Inbox/ InboxReceiver (verify, queue, 202) → InboxProcessor (job) → Handlers/{Follow,Accept,Reject,
Undo,Create,Update,Delete,Like,Announce}; RemotePosts (build, fetch parents, FetchAncestors)
Outbox/ OutboxPublisher (who a post goes to), DeliveryService (queues jobs) + DeliveryJobHandler
Rendering/ ActivityPubRenderer (Mastodon @context, actors, notes, collections)
Domain/
Content/ ContentRenderer (Markdown or plain text → HTML with h-card mentions and hashtags)
Social/ FollowService (local in-process, remote Follow/Accept), Notifications
Timelines/ Fanout (TimelineEntry rows, Mastodon's home rules), TimelineService
Privacy/ VisibilityPolicy (IsPublic expression, CanSee)
Domain/Statuses/ StatusService: publish, edit, remove, favourite, reblog, for a persona (both client APIs use it)
Api/Mastodon/
Auth/ OpenIddict setup (keys in Mongo), MastodonScopes, TokenController, OAuthPruner
Infrastructure/ MastodonController (avatar context, scopes, errors, Link), MastodonParams, MastodonJson, Page
Entities/ Mappers/ Mastodon entities; MastodonMapper (Account, Status), AccountSearch
Controllers/ apps, instance, accounts, statuses, timelines, notifications, search, stubs
Web/Pages/ Razor: /@{user}, /@{user}/{id} (public posts only, strict CSP, noindex);
OAuth/: /oauth/login (root password), /oauth/authorize (choose persona, consent)
Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, …
Models/ Mongo entities: User/, Group/, Post/, Federation/, Jobs/, AppConfiguration
StaticServices/ DbEntities (Find<T> accessors), AuthTokenManager (JWT), PasswordHasher
Data/InitDb.cs first-run seeding (languages)
PrivaPub.ClientModels/ DTOs + validation resources shared with clients
PrivaPub.Tests/ xUnit v3; Support/ has a fake two-origin peer and a throwaway-database fixture
deploy/ nginx vhost, systemd units (privapub, privapub-mongod), max/setup.sh
.gitea/workflows/ build.yml (push) · deploy.yml (tag v*)
docs/ROADMAP.md decisions + phased plan
```
The roadmap moves code towards `Infrastructure/`, `Federation/`, `Domain/`, `Api/{ClientApi,Mastodon}` and `Web/`,
one pure-move commit at a time.
## Commands
```bash
dotnet build PrivaPub.sln -c Release
dotnet test PrivaPub.sln # unit tests; integration tests skip
PRIVAPUB_TEST_MONGOD=1 dotnet test PrivaPub.sln # all of them, against mongod on 127.0.0.1:27017
# (PRIVAPUB_TEST_MONGO overrides; a fresh database per run, dropped after)
cd PrivaPub && ASPNETCORE_ENVIRONMENT=Development \
Kestrel__Endpoints__Http__Url=http://127.0.0.1:6970 Kestrel__Endpoints__Http__Protocols=Http1AndHttp2 \
AppConfiguration__BackendBaseAddress=http://127.0.0.1:6970 MongoSettings__Database=PrivaPubTest \
dotnet run # needs a local mongod on 27017; swagger at /swagger
```
Development config (`appsettings.Development.json`) binds HTTPS 7195 with HTTP/2 only; the overrides above make it
curl-able. Outbound fetches only go to https DNS names resolving to public addresses; a test network (Pasture) sets
`Federation__AllowPrivateNetworks=true` and `Federation__AllowPlainHttp=true`, which startup refuses in Production.
Promoting an admin on the box (signing up as "admin" grants nothing):
```bash
cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin promote <root>
```
## Federation invariants
1. **Every outbound request goes through `IFederationHttp`.** Its handler resolves the name itself and connects only
to public addresses; redirects are followed by hand (three at most, each re-checked); bodies are capped at 1 MB;
only JSON media types are read; a refused URL is not asked again for five minutes. Never create another
`HttpClient` for federation.
2. **Every fetch is signed by the instance actor** (`privapub`), never by a persona; deliveries are signed by the acting
avatar or group. Both are draft-cavage rsa-sha256 over `(request-target) host date` (+ `digest` on bodies).
3. **A remote document is believed only from its own address.** `RemoteActorService.FetchObject` requires the
document's `id` to be the URL it was served from (a same-origin alias is followed once). A key is accepted only if
the actor lists it, its `owner` is the actor and it shares the actor's origin.
4. **Inbound inboxes verify everything before acting:**
- the signature covers `(request-target)`, `host`, `digest` and `date` or `(created)`;
- the Digest matches the body and the date is at most an hour old and fifteen minutes ahead;
- the signature verifies against the key owner's key, and the activity's `actor` is the key owner;
- the activity's `id`, and any object it creates, updates or deletes, is on the actor's origin; a cross-origin
object is refetched from its own origin.
5. **Status codes:**
- bad or missing signature: **401**;
- malformed or forged body: **400**;
- accepted: **202**;
- over the rate limit: **429**;
- **never 500** from `/peasants` or an inbox. Peers retry or "double-knock" based on these codes.
6. **Actor documents:**
- served as `application/activity+json`;
- `publicKey` is an SPKI PEM at `{actor}#main-key`, with `owner` equal to the actor's id;
- the shared inbox is advertised in `endpoints.sharedInbox`.
7. **Remote HTML is sanitized before it is stored** (`ContentSanitizer`); `Post.ContentHtml` is what is shown,
`ContentFormat` says what `Text` holds. Remote names are plain text.
8. **Circles never federate.** A circle's actor, collections, WebFinger and inbox answer 404, and its posts are
`IsLocalOnly`. Only communities are Group actors.
9. **A DM joins a conversation only by `DmGroup.ParticipantsKey`**, the exact set of its participants; a remote
`context` decides nothing. DMs are `Post`s with `Visibility = Direct` and a `ConversationId` (`DmPost` is legacy).
10. **Nothing slow happens inside a request.** Deliveries and inbox processing are `Job`s (`Infrastructure/Jobs`):
leased, retried on Mastodon's curve, at most two per host, paused per host by `RemoteInstance`. The inbox answers
202 once it has verified and queued; a handler must be idempotent (unique `ObjectURI`, job `DedupeKey`).
11. **Every "may anyone see this" goes through `VisibilityPolicy.IsPublic`;** a persona-specific read uses `CanSee`.
12. **Remote content is stored only when someone here asked for it:** a persona follows the author, is addressed or
mentioned, it replies to a local post, or it is addressed to a community the author follows; a public parent is
fetched as context. Followers-only is detected by the author's stored `followers` URL.
13. **Home timelines are written, not computed:** every stored or created post goes through `Fanout.Distribute`, and
every delete removes its `TimelineEntry` rows. Local deletes are soft (content cleared, 410 Tombstone).
## Mastodon client API invariants
1. **A token is one persona.** Its subject is the avatar id; the root id lives only in the fifteen-minute `/oauth`
cookie used while choosing the persona, and never in a token, an authorization or a response.
2. `/api/*` authenticates with OpenIddict validation, everything else with the old JWT (`PrivaPub` policy scheme).
Every `/api` request re-checks that the persona's root is neither banned nor deleted (`MastodonController`).
3. Read parameters through `Params` (query, form and JSON merged Rails-style), never MVC binding. A value type read
from a conditional must say `(int?)null`, not `default`: that bug once made every list one item long.
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.
6. Advertise `4.2.0 (compatible; PrivaPub)` until grouped notifications exist.
## Privacy invariants
- **No root id in federation output, NodeInfo or logs, no IP next to an identity in logs, and no `ex.Message` to a
client** ("Something went wrong." instead).
- **Never derive a public name from the root username.** Invitation sign-up takes `AvatarUserName` and refuses one
equal to the login.
- **One username space:** personas, groups and the instance reserve their name in `ReservedName` (unique index) before
they are saved; `LocalActorService.TryReserveUserName` is the only way to claim one.
- **Per-avatar state stays per avatar:** blocks, mutes, notifications, follows. Nothing may relate sibling avatars.
- **Location-ranged posts never federate.**
## Data
- MongoDB through **MongoDB.Entities 25.1**, instance API: `DB.Default.Find<T>()`, `.SaveAsync(e)`, `.Update<T>()`,
`.DeleteAsync<T>()`, `.CountAsync<T>()`. `DbEntities` wraps the finds.
- **Pass no cancellation token to `DeleteAsync` outside a transaction;** v25 throws if you do.
- **Collection name = class name, so never rename an entity class.**
- `Entity.ID` is a 24-character lowercase hex string; `GenerateNewID()` returns `object`, so cast it.
- New fields must be additive: a deploy rollback restores the binary, not the database.
- **Startup order:** `EntityMaps.Warm()` (every entity's class map, one at a time; two mapped at once throw "An item
with the same key has already been added" and stay broken), then `MigrateAsync` (`Infrastructure/Data/Migrations`,
`_NNN_` order, each runs once), then `Indexes.Create()`. A new entity needs nothing; a new unique index needs a
dedupe migration before it.
- Production runs its **own mongod** on 127.0.0.1:27022 (unit `privapub-mongod`, no auth, data in
`/var/lib/privapub/mongo`). The box's shared mongod needs credentials nobody here has.
## Code style
- Tabs, block-scoped namespaces, Allman braces.
- New services use `readonly _camel` fields and constructor injection. Older services use PascalCase fields; leave
them.
- Services return `WebResult` (`PrivaPub.ClientModels/WebResult.cs`): `result.Invalidate(localizer[...], status)`.
Controllers turn it into a status code.
- Localised strings go through `IStringLocalizer<GenericRes>`.
- Every `Display`/`ErrorMessage` resource key must exist in `FieldsNameResource`/`ErrorsResource`, including the
`Designer.cs`, which the CLI build doesn't regenerate. A missing key throws at validation time.
- ActivityPub output is built with `System.Text.Json.Nodes` in `ActivityPubRenderer`, not typed models. Inbound
documents are read through `InboxService.Id`/`Value`, which handle string, object and array.
- Libraries chosen for the roadmap: HtmlSanitizer, Markdig (`DisableHtml`), NSign (RFC 9421 inbound), OpenIddict +
OpenIddict.MongoDb, NetVips, Blurhash.Core, FFMpegCore. No ImageSharp (licence key enforced), no MassTransit.
## Testing
`PrivaPub.Tests` (xUnit v3). Unit tests need nothing; tests marked `Category=Integration` need a mongod and skip
without `PRIVAPUB_TEST_MONGOD=1`. CI runs the unit tests only (the box's mongods are production).
- `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 `InboxService.Receive` with a signed request, not through the private handlers.
Beyond the tests, verify by building, running locally, and exercising:
- the client API (sign up, create an avatar, a group, a post);
- the ActivityPub endpoints with curl and `Accept: application/activity+json`.
Interop is checked against real servers: Fediverse Pasture with podman on the workstation, verify.funfedi.dev, and
the owner's GoToSocial at social.arasaka.software. **Ask before acting from the owner's GoToSocial account.**
## Deploy
- **CI/CD:** push to `master` runs `build.yml` (build + tests) on the instance-wide `build` runner. A `v*` tag runs
`deploy.yml`: tests, self-contained linux-x64 publish, snapshot and `mongodump` to `/var/backups/privapub.thepra.dev`,
stop → rsync → start, a `127.0.0.1:6970/build.json` health loop with rollback, then public checks (actor, NodeInfo,
Swagger 404, inbox junk 400, unsigned 401) and `tools/smoke/mastodon-api.sh` (app registration, client credentials,
discovery, instance, public timeline; pass a persona token as a second argument to check the signed-in side).
- **The box:** Max (`nuvola.xyz`). Unit `privapub` runs as www-data from `/var/www/privapub.thepra.dev` with
`ASPNETCORE_ENVIRONMENT=Production`.
- **One-time root setup:** `deploy/max/setup.sh`, run through `../arasaka.software/tools/max/run.sh`.
- **Config:** `appsettings.Production.json` is committed and deployed, **secrets included, by the owner's convention**
(the same as arasaka.software). A hand edit on the box is lost at the next deploy.
- **Secrets drift:** `AppConfigurationService` copies `AppConfiguration` into Mongo on first boot and reads the stored
copy afterwards, so changing those keys needs a Mongo update as well.
- **Logs:** `journalctl -u privapub`, plus the `logs` database on the private mongod.
+100
View File
@@ -0,0 +1,100 @@
# Federation
PrivaPub is an ActivityPub server written in C#. This document follows
[FEP-67ff](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md) and describes how it federates.
## Supported federation protocols and standards
- [ActivityPub](https://www.w3.org/TR/activitypub/) (server-to-server)
- [WebFinger](https://webfinger.net/)
- [HTTP Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures), `rsa-sha256` / `hs2019` with RSA keys
- [NodeInfo](https://nodeinfo.diaspora.software/) 2.0 and 2.1
## Supported FEPs
- [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md)
- [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md)
- [FEP-2c59: Discovery of a WebFinger address from an ActivityPub actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2c59/fep-2c59.md)
Planned: FEP-1b12 (communities), FEP-8fcf (followers synchronisation), FEP-5feb (`indexable`), FEP-7628 (Move),
FEP-044f (quotes).
## Actors
Every account is an *avatar*: one private login can own several, and they are deliberately unlinkable. Nothing in an
actor document, a collection, NodeInfo or a delivery relates two avatars of the same login.
| Thing | Address |
|---|---|
| Actor (Person, Group, Application) | `/peasants/{name}` (`/users/{name}` redirects) |
| Inbox | `/peasants/{name}/mouth` |
| Outbox | `/peasants/{name}/anus` |
| Shared inbox | `/human-centipede` |
| Followers / following | `/peasants/{name}/groupies`, `/peasants/{name}/stalking` |
| Objects | `/peasants/{name}/scribbles/{id}` |
| Activities | `/peasants/{name}/grunts/{id}` |
| Direct-message context | `/peasants/{name}/whispers/{id}` |
| Profile and post pages | `/@{name}`, `/@{name}/{id}` |
The names are the project's own and are stable; resolve actors through WebFinger, not by guessing a path.
- The key is `{actor}#main-key`, RSA 2048, served as SPKI PEM with `owner` set to the actor.
- `published` on an actor is truncated to the day. `indexable` is `false`.
- 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.
## Groups
A group is either a **community** or a **circle**.
- A community is a `Group` actor. It accepts `Follow` and re-shares (`Announce`) posts from its followers that address
it. Full FEP-1b12 behaviour (announcing activities, `audience`, moderation) is planned.
- A circle is private and does not federate yet: its actor, collections and WebFinger answer 404, and its posts are never
delivered.
## Activities
Received:
| Activity | Effect |
|---|---|
| `Follow` | follows an avatar or community; `Accept` is sent unless the community approves members by hand |
| `Accept{Follow}`, `Reject{Follow}` | completes or ends a follow an avatar requested |
| `Undo{Follow, Like, Announce}` | reverses it |
| `Create{Note, Article, Page, Question, …}` | stored when a local avatar follows the author, is addressed or mentioned, when it replies to a local post, or when it is addressed to a community the author follows; a public parent is fetched to complete the thread |
| `Update{Note}` | replaces the content; the previous version is kept |
| `Update{Person}` | refetches the actor |
| `Like` | counted and notified, on posts the liker could see |
| `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin |
| `Delete` | deletes the object, or the actor and its follows |
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
`Announce` (communities). A deleted post answers 410 with a `Tombstone`.
A `Create`'s `Note` carries Mastodon's `content`, `contentMap`, `summary` and `sensitive`, plus `Mention` and `Hashtag`
tags. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show
`name`. A content warning without its own text uses the title, or "Content warning".
Visibility is expressed in `to`/`cc` the way Mastodon does it: public, unlisted, followers-only and direct. Inbound
followers-only posts are recognised by the author's own `followers` collection.
## Security rules a peer will notice
- **Signatures.** Inbox POSTs must be signed over `(request-target)`, `host`, `digest` and `date` (or `(created)`). The
date may be at most one hour old and fifteen minutes ahead. A bad signature gets 401, malformed input 400, an accepted
activity 202, too many requests 429. Activities are processed after the 202.
- **Origins.** An actor document is accepted only from the address it names as its `id`. A key only if its actor lists
it with `owner` set to the actor and on the actor's origin. An activity's `id`, and any object it creates, updates or
deletes, must be on its actor's origin. An embedded object from another origin is fetched from that origin.
- **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three
redirects and read at most 1 MB.
- **HTML.** Received HTML is sanitised to Mastodon's allowlist.
- **Delivery.** Failed deliveries are retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused,
starting at an hour and growing to a week.
## Known limitations
- Avatars cannot send likes or boosts yet (that arrives with the Mastodon client API), and media uploads and polls are not
implemented.
- Collections expose counts, not members.
- Only `rsa-sha256`-style keys are verified. RFC 9421 signatures are planned.
@@ -0,0 +1,33 @@
using PrivaPub.ClientModels.Resources;
using System.ComponentModel.DataAnnotations;
namespace PrivaPub.ClientModels.Admin
{
public class DomainBlockForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(253, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Domain { get; set; }
public bool Suspend { get; set; } = true;
public bool RejectMedia { get; set; }
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string PublicComment { get; set; }
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string PrivateComment { get; set; }
}
public class ViewDomainBlock
{
public string Id { get; set; }
public string Domain { get; set; }
public string Severity { get; set; }
public bool RejectMedia { get; set; }
public string PublicComment { get; set; }
public string PrivateComment { get; set; }
public DateTime CreatedAt { get; set; }
}
}
@@ -24,6 +24,7 @@ namespace PrivaPub.ClientModels.Group
public string Description { get; set; } public string Description { get; set; }
public string InvitationPassword { get; set; } public string InvitationPassword { get; set; }
public bool IsCommunity { get; set; }
public bool IsDiscoverable { get; set; } = true; public bool IsDiscoverable { get; set; } = true;
public bool ManuallyApprovesMembers { get; set; } public bool ManuallyApprovesMembers { get; set; }
} }
+1
View File
@@ -8,6 +8,7 @@ namespace PrivaPub.ClientModels.Group
public string Description { get; set; } public string Description { get; set; }
public string Url { get; set; } public string Url { get; set; }
public string Handle { get; set; } public string Handle { get; set; }
public bool IsCommunity { get; set; }
public bool IsDiscoverable { get; set; } public bool IsDiscoverable { get; set; }
public bool ManuallyApprovesMembers { get; set; } public bool ManuallyApprovesMembers { get; set; }
public bool IsOwner { get; set; } public bool IsOwner { get; set; }
@@ -10,5 +10,12 @@ namespace PrivaPub.ClientModels
StringLength(Constants.GroupInvitationLength, MinimumLength = Constants.GroupInvitationLength, StringLength(Constants.GroupInvitationLength, MinimumLength = Constants.GroupInvitationLength,
ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource))] ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource))]
public string InvitationCode { get; set; } public string InvitationCode { get; set; }
[StringLength(32, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
RegularExpression("^[a-z0-9_]+$", ErrorMessageResourceName = "EmptySpacesNotAllowed", ErrorMessageResourceType = typeof(ErrorsResource))]
public string AvatarUserName { get; set; }
[StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string AvatarName { get; set; }
} }
} }
@@ -19,6 +19,32 @@ namespace PrivaPub.ClientModels.Post
public string GroupId { get; set; } public string GroupId { get; set; }
public string AnsweringToPostId { get; set; } public string AnsweringToPostId { get; set; }
public bool HasContentWarning { get; set; } public bool HasContentWarning { get; set; }
[StringLength(500, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string SpoilerText { get; set; }
public string Visibility { get; set; }//public (default), unlisted, followersonly
}
public class UpdatePostForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
public string AvatarId { get; set; }
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
public string PostId { get; set; }
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(5000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Text { get; set; }
[StringLength(200, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Title { get; set; }
public bool HasContentWarning { get; set; }
[StringLength(500, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string SpoilerText { get; set; }
} }
public class InsertDmForm public class InsertDmForm
+3
View File
@@ -8,12 +8,15 @@ namespace PrivaPub.ClientModels.Post
public string AuthorActorURI { get; set; } public string AuthorActorURI { get; set; }
public string GroupId { get; set; } public string GroupId { get; set; }
public string DmGroupId { get; set; } public string DmGroupId { get; set; }
public string Visibility { get; set; }
public string AnsweringToPostId { get; set; } public string AnsweringToPostId { get; set; }
public string Title { get; set; } public string Title { get; set; }
public string Text { get; set; } public string Text { get; set; }
public string ContentHtml { get; set; }
public bool HasContentWarning { get; set; } public bool HasContentWarning { get; set; }
public bool IsFederatedCopy { get; set; } public bool IsFederatedCopy { get; set; }
public DateTime CreationDate { get; set; } public DateTime CreationDate { get; set; }
public ViewPost ReblogOf { get; set; }
} }
public class ViewDmGroup public class ViewDmGroup
@@ -0,0 +1,37 @@
using PrivaPub.ClientModels.Resources;
using System.ComponentModel.DataAnnotations;
namespace PrivaPub.ClientModels.Social
{
public class FollowForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
public string AvatarId { get; set; }
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(2048, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Target { get; set; }//user@host, @user, or an actor URI
}
public class ViewFollowing
{
public string Id { get; set; }
public string TargetActorURI { get; set; }
public string TargetAccountId { get; set; }
public bool TargetIsLocal { get; set; }
public string State { get; set; }
public DateTime CreatedAt { get; set; }
}
public class ViewNotification
{
public string Id { get; set; }
public string Type { get; set; }
public string FromAccountId { get; set; }
public string FromActorURI { get; set; }
public string PostId { get; set; }
public bool IsRead { get; set; }
public DateTime CreatedAt { get; set; }
}
}
+82
View File
@@ -0,0 +1,82 @@
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));
}
}
@@ -0,0 +1,61 @@
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);
}
}
}
@@ -0,0 +1,91 @@
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<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> GetInstanceActor(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
@@ -0,0 +1,124 @@
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>());
}
}
}
+92
View File
@@ -0,0 +1,92 @@
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"));
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);
}
}
}
+162
View File
@@ -0,0 +1,162 @@
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);
}
}
}
@@ -0,0 +1,42 @@
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));
}
}
}
@@ -0,0 +1,51 @@
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));
}
}
@@ -0,0 +1,135 @@
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()}\"")));
[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));
}
}
}
@@ -0,0 +1,346 @@
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.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);
_processor = new InboxProcessor(remote, new IActivityHandler[]
{
new FollowHandler(db, _local, remote, delivery),
new UndoHandler(db, _local),
new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue)),
new DeleteHandler(db, _local, remote, delivery),
new UpdateHandler(db, _local, remote)
}, 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");
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));
}
[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);
}
[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);
await DB.Default.SaveAsync(new DomainBlock { Domain = "localhost", Severity = DomainBlockSeverity.Suspend }, token);
try
{
await _blocks.Reload(token);
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));
}
finally
{
await DB.Default.DeleteAsync<DomainBlock>(b => b.Domain == "localhost");
await _blocks.Reload(token);
}
}
[Fact]
public void A_block_covers_subdomains_but_not_lookalikes()
{
var blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance);
typeof(DomainBlocks).GetField("_blocks", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.SetValue(blocks, new Dictionary<string, DomainBlock> { ["evil.example"] = new() { Domain = "evil.example", Severity = DomainBlockSeverity.Silence } });
typeof(DomainBlocks).GetField("_loadedAt", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.SetValue(blocks, DateTime.UtcNow);
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 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_is_not_a_federated_actor()
{
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.False(actor.IsFederated);
Assert.Equal(404, (await Deliver(bob, "/human-centipede", follow)).StatusCode);
}
}
}
@@ -0,0 +1,137 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels.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
{
[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);
}
[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);
}
}
}
@@ -0,0 +1,114 @@
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" } }
]
}
""";
[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);
}
[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("""{ "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));
}
}
@@ -0,0 +1,244 @@
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();
}
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));
}
}
+103
View File
@@ -0,0 +1,103 @@
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, 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:12Z", 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>());
}
}
}
@@ -0,0 +1,127 @@
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 Microsoft.Extensions.Options;
using PrivaPub.Federation.Moderation;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Models.Federation;
namespace PrivaPub.Tests.Infrastructure
{
public sealed class FederationHttpTests : IAsyncLifetime
{
WebApplication _peer;
string _base;
public async ValueTask InitializeAsync()
{
var builder = WebApplication.CreateSlimBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
_peer = builder.Build();
_peer.MapGet("/actor", () => Results.Text("{\"id\":\"x\"}", "application/activity+json"));
_peer.MapGet("/html", () => Results.Text("<html></html>", "text/html"));
_peer.MapGet("/big", () => Results.Text("{\"a\":\"" + new string('a', FederationHttp.MaxResponseBytes) + "\"}", "application/activity+json"));
_peer.MapGet("/hop/{n:int}", (int n) => Results.Redirect(n == 0 ? "/actor" : $"/hop/{n - 1}"));
_peer.MapGet("/gone", () => Results.StatusCode(410));
await _peer.StartAsync();
_base = _peer.Urls.First();
}
public async ValueTask DisposeAsync() => await _peer.DisposeAsync();
static FederationHttp Client(bool allowTestNetwork = true)
{
var options = new FederationOptions { AllowPrivateNetworks = allowTestNetwork, AllowPlainHttp = allowTestNetwork };
var services = new ServiceCollection();
services.AddHttpClient(FederationHttp.ClientName)
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options));
var provider = services.BuildServiceProvider();
return new FederationHttp(provider.GetRequiredService<IHttpClientFactory>(), new MemoryCache(new MemoryCacheOptions()),
new StaticOptionsMonitor(options), new StaticBlocks(), NullLogger<FederationHttp>.Instance);
}
[Fact]
public async Task Reads_a_json_document()
{
using var fetched = await Client().GetJson($"{_base}/actor", "application/activity+json", default, TestContext.Current.CancellationToken);
Assert.NotNull(fetched);
Assert.Equal("x", fetched.Root.GetProperty("id").GetString());
}
[Fact]
public async Task Follows_up_to_three_redirects_and_reports_the_final_url()
{
using var fetched = await Client().GetJson($"{_base}/hop/2", "application/activity+json", default, TestContext.Current.CancellationToken);
Assert.NotNull(fetched);
Assert.Equal($"{_base}/actor", fetched.FinalUri.ToString());
}
[Fact]
public async Task Refuses_a_fourth_redirect() =>
Assert.Null(await Client().GetJson($"{_base}/hop/3", "application/activity+json", default, TestContext.Current.CancellationToken));
[Fact]
public async Task Refuses_html() =>
Assert.Null(await Client().GetJson($"{_base}/html", "application/activity+json", default, TestContext.Current.CancellationToken));
[Fact]
public async Task Refuses_a_body_over_the_limit() =>
Assert.Null(await Client().GetJson($"{_base}/big", "application/activity+json", default, TestContext.Current.CancellationToken));
[Fact]
public async Task Refuses_an_error_status() =>
Assert.Null(await Client().GetJson($"{_base}/gone", "application/activity+json", default, TestContext.Current.CancellationToken));
[Fact]
public async Task Refuses_a_private_network_in_production_mode() =>
Assert.Null(await Client(allowTestNetwork: false).GetJson($"{_base}/actor", "application/activity+json", default, TestContext.Current.CancellationToken));
[Theory]
[InlineData("https://mastodon.social/users/Gargron", true)]
[InlineData("http://mastodon.social/users/Gargron", false)]
[InlineData("https://user:pass@mastodon.social/", false)]
[InlineData("https://localhost/", false)]
[InlineData("https://printer.local/", false)]
[InlineData("https://metadata.google.internal/", false)]
[InlineData("https://127.0.0.1/", false)]
[InlineData("https://[::1]/", false)]
[InlineData("ftp://example.org/", false)]
public void IsAllowed_takes_https_dns_names_only(string url, bool allowed) =>
Assert.Equal(allowed, Client(allowTestNetwork: false).IsAllowed(new Uri(url)));
[Fact]
public void IsAllowed_refuses_a_suspended_domain_and_its_subdomains()
{
var http = new FederationHttp(new ServiceCollection().AddHttpClient().BuildServiceProvider().GetRequiredService<IHttpClientFactory>(),
new MemoryCache(new MemoryCacheOptions()), new StaticOptionsMonitor(new FederationOptions()), new StaticBlocks("evil.example"),
NullLogger<FederationHttp>.Instance);
Assert.False(http.IsAllowed(new Uri("https://evil.example/users/x")));
Assert.False(http.IsAllowed(new Uri("https://cdn.evil.example/a.png")));
Assert.True(http.IsAllowed(new Uri("https://notevil.example/users/x")));
}
sealed class StaticBlocks : IDomainBlocks
{
readonly string[] _suspended;
public StaticBlocks(params string[] suspended) => _suspended = suspended;
public DomainBlock Find(string host) => _suspended.Any(s => host == s || host.EndsWith("." + s))
? new DomainBlock { Domain = host, Severity = DomainBlockSeverity.Suspend }
: default;
public bool IsSuspended(string host) => Find(host) != default;
public Task Reload(CancellationToken token) => Task.CompletedTask;
}
sealed class StaticOptionsMonitor : IOptionsMonitor<FederationOptions>
{
public StaticOptionsMonitor(FederationOptions value) => CurrentValue = value;
public FederationOptions CurrentValue { get; }
public FederationOptions Get(string name) => CurrentValue;
public IDisposable OnChange(Action<FederationOptions, string> listener) => default;
}
}
}
@@ -0,0 +1,63 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Infrastructure.Data;
using PrivaPub.Infrastructure.Data.Migrations;
using PrivaPub.Models;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using PrivaPub.Tests.Support;
namespace PrivaPub.Tests.Infrastructure
{
[Trait("Category", "Integration")]
public class IndexTests
{
[Fact]
public async Task Duplicates_are_removed_and_the_unique_indexes_then_hold()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
await DB.Default.Index<ForeignAvatar>().DropAllAsync(token);
await DB.Default.Index<Follower>().DropAllAsync(token);
var actorUri = $"https://r.example/users/{Guid.NewGuid():N}";
var localId = Guid.NewGuid().ToString("N")[..24];
await DB.Default.SaveAsync(new[]
{
new ForeignAvatar { ActorURI = actorUri, Name = "old", UpdatedAt = DateTime.UtcNow.AddDays(-2) },
new ForeignAvatar { ActorURI = actorUri, Name = "new", UpdatedAt = DateTime.UtcNow }
}, token);
await DB.Default.SaveAsync(new[]
{
new Follower { LocalActorId = localId, ActorURI = actorUri, IsAccepted = false },
new Follower { LocalActorId = localId, ActorURI = actorUri, IsAccepted = true }
}, token);
await new _001_deduplicate_before_unique_indexes().UpgradeAsync();
await Indexes.Create(token);
Assert.Equal("new", (await DB.Default.Find<ForeignAvatar>().Match(a => a.ActorURI == actorUri).ExecuteSingleAsync(token)).Name);
Assert.True((await DB.Default.Find<Follower>().Match(f => f.LocalActorId == localId).ExecuteSingleAsync(token)).IsAccepted);
var duplicate = await Assert.ThrowsAsync<MongoWriteException>(async () =>
await DB.Default.SaveAsync(new ForeignAvatar { ActorURI = actorUri }, token));
Assert.Equal(ServerErrorCategory.DuplicateKey, duplicate.WriteError.Category);
}
[Fact]
public async Task A_username_is_reserved_once_across_personas_and_groups()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var local = new LocalActorService(new DbEntities(), new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = "https://privapub.test" }));
var name = $"n{Guid.NewGuid():N}"[..20];
Assert.True(await local.TryReserveUserName(name, LocalActorKind.Person, "a", token));
Assert.False(await local.TryReserveUserName(name.ToUpperInvariant(), LocalActorKind.Group, "b", token));
Assert.True(await local.IsUserNameTaken(name, token));
Assert.False(await local.TryReserveUserName("admin", LocalActorKind.Person, "c", token));
Assert.False(await local.TryReserveUserName(LocalActorService.InstanceUserName, LocalActorKind.Person, "c", token));
}
}
}
@@ -0,0 +1,86 @@
using System.Net;
using PrivaPub.Infrastructure.Http;
namespace PrivaPub.Tests.Infrastructure
{
public class IpRangeGuardTests
{
[Theory]
[InlineData("1.1.1.1")]
[InlineData("8.8.8.8")]
[InlineData("93.184.215.14")]
[InlineData("172.15.255.255")]
[InlineData("172.32.0.1")]
[InlineData("100.63.255.255")]
[InlineData("100.128.0.0")]
[InlineData("2a01:4f8::1")]
[InlineData("2606:4700:4700::1111")]
[InlineData("::ffff:1.1.1.1")]
public void Public_addresses_are_allowed(string address) =>
Assert.True(IpRangeGuard.IsPublic(IPAddress.Parse(address)));
[Theory]
[InlineData("0.0.0.0")]
[InlineData("10.1.2.3")]
[InlineData("100.64.0.1")]
[InlineData("100.127.255.255")]
[InlineData("127.0.0.1")]
[InlineData("127.255.255.254")]
[InlineData("169.254.169.254")]
[InlineData("172.16.0.1")]
[InlineData("172.31.255.255")]
[InlineData("192.0.0.170")]
[InlineData("192.0.2.1")]
[InlineData("192.88.99.1")]
[InlineData("192.168.1.1")]
[InlineData("198.18.0.1")]
[InlineData("198.51.100.7")]
[InlineData("203.0.113.9")]
[InlineData("224.0.0.251")]
[InlineData("239.255.255.250")]
[InlineData("240.0.0.1")]
[InlineData("255.255.255.255")]
[InlineData("::")]
[InlineData("::1")]
[InlineData("::127.0.0.1")]
[InlineData("::ffff:127.0.0.1")]
[InlineData("::ffff:10.0.0.1")]
[InlineData("::ffff:169.254.169.254")]
[InlineData("64:ff9b::7f00:1")]
[InlineData("64:ff9b::808:808")]
[InlineData("64:ff9b:1::1")]
[InlineData("100::1")]
[InlineData("2001::1")]
[InlineData("2001:0:4136:e378:8000:63bf:3fff:fdd2")]
[InlineData("2001:db8::1")]
[InlineData("2001:10::1")]
[InlineData("2002:7f00:1::1")]
[InlineData("2002:c0a8:101::1")]
[InlineData("3fff::1")]
[InlineData("fc00::1")]
[InlineData("fd12:3456:789a::1")]
[InlineData("fe80::1")]
[InlineData("fec0::1")]
[InlineData("ff02::1")]
public void Private_and_special_addresses_are_refused(string address) =>
Assert.False(IpRangeGuard.IsPublic(IPAddress.Parse(address)));
[Fact]
public async Task Connect_refuses_a_name_that_resolves_to_loopback()
{
await Assert.ThrowsAsync<BlockedDestinationException>(async () =>
await SafeHttpHandlerFactory.Connect(new DnsEndPoint("localhost", 443), allowPrivateNetworks: false, TestContext.Current.CancellationToken));
}
[Theory]
[InlineData("127.0.0.1")]
[InlineData("[::1]")]
[InlineData("169.254.169.254")]
public async Task Connect_refuses_a_private_literal(string host)
{
await Assert.ThrowsAsync<BlockedDestinationException>(async () =>
await SafeHttpHandlerFactory.Connect(new DnsEndPoint(host, 443), allowPrivateNetworks: false, TestContext.Current.CancellationToken));
}
}
}
@@ -0,0 +1,145 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Infrastructure
{
public class BackoffTests
{
[Fact]
public void Grows_like_mastodons()
{
Assert.InRange(Backoff.After(1).TotalSeconds, 16, 16 + 9 * 2);
Assert.InRange(Backoff.After(10).TotalSeconds, 10015, 10015 + 9 * 11);
}
[Fact]
public void Quarantines_a_host_only_past_the_threshold()
{
Assert.Equal(TimeSpan.Zero, Backoff.HostQuarantine(9, 10));
Assert.Equal(TimeSpan.FromHours(1), Backoff.HostQuarantine(10, 10));
Assert.Equal(TimeSpan.FromHours(4), Backoff.HostQuarantine(12, 10));
Assert.Equal(TimeSpan.FromDays(7), Backoff.HostQuarantine(40, 10));
}
}
[Trait("Category", "Integration")]
public sealed class JobQueueTests : IAsyncLifetime
{
Peer _peer;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_peer = await Peer.Start();
}
public async ValueTask DisposeAsync()
{
if (_peer != default)
await _peer.DisposeAsync();
}
static Job NewJob(string host = default, string dedupe = default) => new()
{
Kind = (JobKind)99,
Host = host,
DedupeKey = dedupe,
Payload = "{}"
};
[Fact]
public async Task A_dedupe_key_is_queued_once()
{
var queue = new JobQueue();
var key = Guid.NewGuid().ToString("N");
var inserted = await queue.EnqueueMany(new[] { NewJob(dedupe: key), NewJob(dedupe: key) }, TestContext.Current.CancellationToken);
Assert.Equal(1, inserted);
}
[Fact]
public async Task A_busy_host_is_skipped_and_a_failed_job_backs_off()
{
var token = TestContext.Current.CancellationToken;
var queue = new JobQueue();
var kind = (JobKind)(100 + Random.Shared.Next(1000));
var busy = $"busy{Guid.NewGuid():N}.example";
var free = $"free{Guid.NewGuid():N}.example";
await queue.EnqueueMany(new[] { new Job { Kind = kind, Host = busy }, new Job { Kind = kind, Host = free } }, token);
var leased = await queue.Lease(kind, new[] { busy }, token);
Assert.Equal(free, leased.Host);
Assert.Equal(1, leased.Attempts);
await queue.Finish(leased, JobOutcome.Retry("boom"), maxAttempts: 3, token);
var after = await DB.Default.Find<Job>().OneAsync(leased.ID, token);
Assert.Equal(JobState.Pending, after.State);
Assert.True(after.RunAt > DateTime.UtcNow.AddSeconds(10));
leased.Attempts = 3;
await queue.Finish(leased, JobOutcome.Retry("boom"), maxAttempts: 3, token);
Assert.Equal(JobState.Dead, (await DB.Default.Find<Job>().OneAsync(leased.ID, token)).State);
}
[Fact]
public async Task An_expired_lease_returns_to_the_queue()
{
var token = TestContext.Current.CancellationToken;
var job = new Job { Kind = (JobKind)98, State = JobState.Running, LeasedUntil = DateTime.UtcNow.AddMinutes(-1) };
await DB.Default.SaveAsync(job, token);
await new JobQueue().Reap(token);
Assert.Equal(JobState.Pending, (await DB.Default.Find<Job>().OneAsync(job.ID, token)).State);
}
[Fact]
public async Task A_dead_host_does_not_hold_up_deliveries_to_live_ones()
{
var token = TestContext.Current.CancellationToken;
var (privateKey, publicKey) = Keys.NewKeyPair();
var avatar = new Avatar { UserName = $"sender{Guid.NewGuid():N}"[..20], PrivateKey = privateKey, PublicKey = publicKey };
await DB.Default.SaveAsync(avatar, token);
var local = new LocalActorService(new DbEntities(), new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = "https://privapub.test" }));
var sender = local.FromAvatar(avatar);
var queue = new JobQueue();
var delivery = new DeliveryService(new DbEntities(), queue);
_peer.Answer("/dead/inbox", 503, TimeSpan.FromMilliseconds(300));
_peer.Answer("/live/inbox", 202);
var deadInboxes = Enumerable.Range(0, 60).Select(i => $"{_peer.B}/dead/inbox?{i}");
await delivery.Enqueue(sender, deadInboxes, new JsonObject { ["id"] = $"https://privapub.test/a/{Guid.NewGuid():N}", ["type"] = "Create" }, token);
await delivery.Enqueue(sender, new[] { $"{_peer.A}/live/inbox" }, new JsonObject { ["id"] = $"https://privapub.test/a/{Guid.NewGuid():N}", ["type"] = "Create" }, token);
var handler = new DeliveryJobHandler(local, Peer.Http(), new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions())), NullLogger<DeliveryJobHandler>.Instance);
using var worker = new JobWorker(queue, new IJobHandler[] { handler }, NullLogger<JobWorker>.Instance);
await worker.StartAsync(token);
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && !_peer.Requests.Any(r => r.Path == "/live/inbox"))
await Task.Delay(50, token);
var live = Assert.Single(_peer.Requests, r => r.Path == "/live/inbox");
Assert.True(_peer.Requests.Count(r => r.Path == "/dead/inbox") < 30);
while (DateTime.UtcNow < deadline && !await DB.Default.Find<RemoteInstance>().Match(i => i.Host == "localhost").ExecuteAnyAsync(token))
await Task.Delay(50, token);
await worker.StopAsync(token);
Assert.Contains($"keyId=\"{sender.KeyId}\"", live.Signature);
var instance = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == "localhost").ExecuteFirstAsync(token);
Assert.True(instance.ConsecutiveFailures > 0);
}
}
}
@@ -0,0 +1,49 @@
using MongoDB.Entities;
using PrivaPub.Infrastructure.Data.Migrations;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
namespace PrivaPub.Tests.Infrastructure
{
[Trait("Category", "Integration")]
public class MigrationTests
{
[Fact]
public async Task Stored_remote_content_is_sanitized_and_local_content_rendered()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var remote = new Post { IsFederatedCopy = true, Text = "<p>hi<script>alert(1)</script></p>", ObjectURI = $"https://r.example/{Guid.NewGuid():N}" };
var local = new Post { Text = "**bold** <b>raw</b>", ObjectURI = $"https://privapub.test/{Guid.NewGuid():N}" };
await DB.Default.SaveAsync(new[] { remote, local }, token);
await new _002_sanitize_stored_content().UpgradeAsync();
var migratedRemote = await DB.Default.Find<Post>().OneAsync(remote.ID, token);
var migratedLocal = await DB.Default.Find<Post>().OneAsync(local.ID, token);
Assert.Equal("<p>hi</p>", migratedRemote.Text);
Assert.Equal("<p>hi</p>", migratedRemote.ContentHtml);
Assert.Equal(ContentFormat.Html, migratedRemote.ContentFormat);
Assert.Equal("**bold** <b>raw</b>", migratedLocal.Text);
Assert.Equal("<p><strong>bold</strong> &lt;b&gt;raw&lt;/b&gt;</p>", migratedLocal.ContentHtml);
Assert.Equal(ContentFormat.Markdown, migratedLocal.ContentFormat);
}
[Fact]
public async Task Direct_posts_move_into_posts_with_their_conversation()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var dm = new DmPost { GroupId = "conversation1", Text = "psst", ObjectURI = $"https://privapub.test/{Guid.NewGuid():N}", GroupUserId = "a1" };
await DB.Default.SaveAsync(dm, token);
await new _005_direct_posts_join_posts().UpgradeAsync();
var post = await DB.Default.Find<Post>().OneAsync(dm.ID, token);
Assert.Equal(PostVisibility.Direct, post.Visibility);
Assert.Equal("conversation1", post.ConversationId);
Assert.Equal("a1", post.AuthorAccountId);
}
}
}
@@ -0,0 +1,40 @@
using MongoDB.Bson;
using PrivaPub.Infrastructure.Ids;
using PrivaPub.Models.User;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Tests.Infrastructure
{
public class PrivacyIdsTests
{
[Fact]
public void Persona_ids_carry_only_the_day_and_share_nothing_else()
{
var first = (string)new Avatar().GenerateNewID();
var second = (string)new Avatar().GenerateNewID();
Assert.True(ObjectId.TryParse(first, out var a));
Assert.True(ObjectId.TryParse(second, out var b));
Assert.Equal(DateTime.UtcNow.Date, a.CreationTime);
Assert.Equal(a.CreationTime, b.CreationTime);
Assert.NotEqual(first[8..14], second[8..14]);
Assert.Equal(DateTime.UtcNow.Date, ObjectId.Parse((string)new GroupEntity().GenerateNewID()).CreationTime);
}
[Fact]
public void A_remote_post_id_sorts_by_its_published_time()
{
var older = PrivacyIds.At(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc));
var newer = PrivacyIds.At(new DateTime(2025, 1, 2, 0, 0, 0, DateTimeKind.Utc));
Assert.True(string.CompareOrdinal(older, newer) < 0);
Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), ObjectId.Parse(older).CreationTime);
}
[Fact]
public void A_published_time_in_the_future_is_clamped() =>
Assert.True(ObjectId.Parse(PrivacyIds.At(DateTime.UtcNow.AddYears(50))).CreationTime < DateTime.UtcNow.AddDays(2));
}
}
+29
View File
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PrivaPub\PrivaPub.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+129
View File
@@ -0,0 +1,129 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Entities;
using PrivaPub.Domain.Content;
using PrivaPub.Domain.Social;
using PrivaPub.Domain.Statuses;
using PrivaPub.Domain.Timelines;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Federation.Outbox;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.User;
using PrivaPub.Resources;
using PrivaPub.Services;
using PrivaPub.StaticServices;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Support
{
public sealed class Harness : IAsyncDisposable
{
public const string Host = "privapub.test";
public const string Base = "https://" + Host;
Harness(Peer peer)
{
Peer = peer;
var cache = new MemoryCache(new MemoryCacheOptions());
Local = new LocalActorService(Db, new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = Base }));
Remote = new RemoteActorService(Peer.Http(cache), Local, cache, Db);
Delivery = new DeliveryService(Db, Queue);
Fanout = new Fanout(Db);
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue);
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance);
Processor = new InboxProcessor(Remote, new IActivityHandler[]
{
new FollowHandler(Db, Local, Remote, Delivery),
new AcceptHandler(Db, Local),
new RejectHandler(Db, Local),
new UndoHandler(Db, Local),
new LikeHandler(Db),
new AnnounceHandler(Db, Local, RemotePosts, Fanout),
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts),
new DeleteHandler(Db, Local, Remote, Delivery),
new UpdateHandler(Db, Local, Remote)
}, NullLogger<InboxProcessor>.Instance);
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
Content = new ContentRenderer(Local, Remote);
Outbox = new OutboxPublisher(Db, Local, Delivery);
Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout);
Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer<GenericRes>(), NullLogger<PostsService>.Instance);
Timelines = new TimelineService(Db, new KeyLocalizer<GenericRes>());
}
public Peer Peer { get; }
public DbEntities Db { get; } = new();
public JobQueue Queue { get; } = new();
public LocalActorService Local { get; }
public RemoteActorService Remote { get; }
public DeliveryService Delivery { get; }
public InboxReceiver Receiver { get; }
public InboxProcessor Processor { get; }
public FollowService Follows { get; }
public ContentRenderer Content { get; }
public OutboxPublisher Outbox { get; }
public PostsService Posts { get; }
public StatusService Statuses { get; }
public Fanout Fanout { get; }
public RemotePosts RemotePosts { get; }
public TimelineService Timelines { get; }
public async Task FollowedBy(LocalActor local, RemoteActor follower) =>
await DB.Default.SaveAsync(new Follower
{
LocalActorId = local.Id,
LocalActorKind = local.Kind,
ActorURI = follower.Id,
InboxURL = follower.Id + "/inbox",
SharedInboxURL = follower.SharedInbox
});
public static async Task<Harness> Start() => new(await Peer.Start());
public async Task<(string RootId, LocalActor Avatar)> Persona(string name, string rootId = default)
{
rootId ??= Guid.NewGuid().ToString("N")[..24];
var (privateKey, publicKey) = Keys.NewKeyPair();
var avatar = new Avatar { UserName = $"{name}{Guid.NewGuid():N}"[..20], Name = name, PrivateKey = privateKey, PublicKey = publicKey };
await DB.Default.SaveAsync(avatar);
await DB.Default.SaveAsync(new RootToAvatar { RootId = rootId, AvatarId = avatar.ID });
return (rootId, Local.FromAvatar(avatar));
}
public async Task<InboxResult> Deliver(RemoteActor sender, string path, JsonNode activity)
{
var result = await Receiver.Receive(sender.Post(Host, path, activity), default, CancellationToken.None);
var dedupe = "inbox|" + (activity is JsonObject ? activity["id"]?.GetValue<string>() : default);
var job = await DB.Default.Find<Job>().Match(j => j.DedupeKey == dedupe).ExecuteFirstAsync();
if (job != default)
Assert.Equal(JobResult.Done, (await Processor.Handle(job, CancellationToken.None)).Result);
return result;
}
public async Task<List<JsonObject>> Outgoing(string inbox) =>
(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver).ExecuteAsync())
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
.Where(p => p.Inbox == inbox)
.Select(p => JsonNode.Parse(p.Body)!.AsObject())
.ToList();
public async ValueTask DisposeAsync() => await Peer.DisposeAsync();
}
public sealed class KeyLocalizer<T> : IStringLocalizer<T>
{
public LocalizedString this[string name] => new(name, name);
public LocalizedString this[string name, params object[] arguments] => new(name, string.Format(name, arguments));
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) => Enumerable.Empty<LocalizedString>();
}
}
+44
View File
@@ -0,0 +1,44 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Infrastructure.Data;
[assembly: AssemblyFixture(typeof(PrivaPub.Tests.Support.MongoFixture))]
namespace PrivaPub.Tests.Support
{
public sealed class MongoFixture : IAsyncLifetime
{
public const string Skip = "set PRIVAPUB_TEST_MONGOD=1 (and optionally PRIVAPUB_TEST_MONGO) to run the tests that need a mongod";
public static bool Enabled => Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGOD") == "1";
public string Database { get; } = $"PrivaPubTests_{Guid.NewGuid():N}";
public async ValueTask InitializeAsync()
{
if (!Enabled)
return;
try
{
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
}
catch (BsonSerializationException)
{
}
var connection = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGO") ?? "mongodb://127.0.0.1:27017";
await DB.InitAsync(Database, MongoClientSettings.FromConnectionString(connection));
EntityMaps.Warm();
await Indexes.Create();
}
public async ValueTask DisposeAsync()
{
if (Enabled)
await DB.Default.Database().Client.DropDatabaseAsync(Database);
}
}
}
+98
View File
@@ -0,0 +1,98 @@
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 Microsoft.Extensions.Options;
using PrivaPub.Federation.Moderation;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Models.Federation;
using System.Collections.Concurrent;
namespace PrivaPub.Tests.Support
{
public sealed class Peer : IAsyncDisposable
{
readonly WebApplication _app;
readonly ConcurrentDictionary<string, string> _documents = new();
readonly ConcurrentDictionary<string, (int Status, TimeSpan Delay)> _answers = new();
public int Port { get; }
public string A => $"http://127.0.0.1:{Port}";
public string B => $"http://localhost:{Port}";
public ConcurrentQueue<HttpRequestRecord> Requests { get; } = new();
Peer(WebApplication app, int port)
{
_app = app;
Port = port;
}
public static async Task<Peer> Start()
{
var builder = WebApplication.CreateSlimBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
var app = builder.Build();
Peer peer = default;
app.Run(async context =>
{
peer.Requests.Enqueue(new(context.Request.Method, context.Request.Path, context.Request.Headers["Signature"].ToString()));
var key = context.Request.Path.Value;
if (peer._answers.TryGetValue(key, out var answer))
{
if (answer.Delay > TimeSpan.Zero)
await Task.Delay(answer.Delay);
context.Response.StatusCode = answer.Status;
return;
}
if (!peer._documents.TryGetValue(key, out var document))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
context.Response.ContentType = "application/activity+json";
await context.Response.WriteAsync(document.Replace("{A}", peer.A).Replace("{B}", peer.B));
});
await app.StartAsync();
peer = new Peer(app, new Uri(app.Urls.First()).Port);
return peer;
}
public void Serve(string path, string json) => _documents[path] = json;
public void Answer(string path, int status, TimeSpan delay = default) => _answers[path] = (status, delay);
public static FederationHttp Http(IMemoryCache cache = default, IDomainBlocks blocks = default)
{
var options = new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true };
var services = new ServiceCollection();
services.AddHttpClient(FederationHttp.ClientName)
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options));
return new FederationHttp(services.BuildServiceProvider().GetRequiredService<IHttpClientFactory>(),
cache ?? new MemoryCache(new MemoryCacheOptions()), new StaticOptions<FederationOptions>(options),
blocks ?? new NoBlocks(), NullLogger<FederationHttp>.Instance);
}
public async ValueTask DisposeAsync() => await _app.DisposeAsync();
}
public sealed class NoBlocks : IDomainBlocks
{
public DomainBlock Find(string host) => default;
public bool IsSuspended(string host) => false;
public Task Reload(CancellationToken token) => Task.CompletedTask;
}
public sealed record HttpRequestRecord(string Method, string Path, string Signature);
public sealed class StaticOptions<T> : IOptionsMonitor<T>
{
public StaticOptions(T value) => CurrentValue = value;
public T CurrentValue { get; }
public T Get(string name) => CurrentValue;
public IDisposable OnChange(Action<T, string> listener) => default;
}
}
+66
View File
@@ -0,0 +1,66 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using PrivaPub.Federation.Signing;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Support
{
public sealed class RemoteActor
{
readonly RSA _key = RSA.Create(2048);
public RemoteActor(Peer peer, string name, string origin = default)
{
Name = $"{name}{Guid.NewGuid():N}"[..20];
Id = $"{origin ?? peer.A}/users/{Name}";
peer.Serve($"/users/{Name}", Document().ToJsonString());
}
public string Name { get; }
public string Id { get; }
public string KeyId => Id + "#main-key";
public string SharedInbox => Id.Split("/users/")[0] + "/inbox";
public JsonObject Document() => new()
{
["id"] = Id,
["type"] = "Person",
["preferredUsername"] = Name,
["inbox"] = Id + "/inbox",
["followers"] = Id + "/followers",
["publicKey"] = new JsonObject
{
["id"] = KeyId,
["owner"] = Id,
["publicKeyPem"] = _key.ExportSubjectPublicKeyInfoPem()
}
};
public HttpRequest Post(string host, string path, JsonNode activity)
{
var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
var digest = HttpSignatures.Digest(body);
var signingString = $"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}";
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 = path;
context.Features.Get<IHttpRequestFeature>().RawTarget = path;
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=\"(request-target) host date digest\",signature=\"{signature}\"";
context.Request.Body = new MemoryStream(body);
context.Request.ContentLength = body.Length;
return context.Request;
}
}
}
+34
View File
@@ -7,20 +7,54 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PrivaPub", "PrivaPub\PrivaP
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PrivaPub.ClientModels", "PrivaPub.ClientModels\PrivaPub.ClientModels.csproj", "{5E63C68C-0E60-4418-A53A-FF4EF0007080}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PrivaPub.ClientModels", "PrivaPub.ClientModels\PrivaPub.ClientModels.csproj", "{5E63C68C-0E60-4418-A53A-FF4EF0007080}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PrivaPub.Tests", "PrivaPub.Tests\PrivaPub.Tests.csproj", "{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|Any CPU.Build.0 = Debug|Any CPU {EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|x64.ActiveCfg = Debug|Any CPU
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|x64.Build.0 = Debug|Any CPU
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|x86.ActiveCfg = Debug|Any CPU
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|x86.Build.0 = Debug|Any CPU
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|Any CPU.ActiveCfg = Release|Any CPU {EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|Any CPU.Build.0 = Release|Any CPU {EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|Any CPU.Build.0 = Release|Any CPU
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|x64.ActiveCfg = Release|Any CPU
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|x64.Build.0 = Release|Any CPU
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|x86.ActiveCfg = Release|Any CPU
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|x86.Build.0 = Release|Any CPU
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|Any CPU.Build.0 = Debug|Any CPU {5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|x64.ActiveCfg = Debug|Any CPU
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|x64.Build.0 = Debug|Any CPU
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|x86.ActiveCfg = Debug|Any CPU
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|x86.Build.0 = Debug|Any CPU
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|Any CPU.ActiveCfg = Release|Any CPU {5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|Any CPU.Build.0 = Release|Any CPU {5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|Any CPU.Build.0 = Release|Any CPU
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|x64.ActiveCfg = Release|Any CPU
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|x64.Build.0 = Release|Any CPU
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|x86.ActiveCfg = Release|Any CPU
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|x86.Build.0 = Release|Any CPU
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Debug|x64.ActiveCfg = Debug|Any CPU
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Debug|x64.Build.0 = Debug|Any CPU
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Debug|x86.ActiveCfg = Debug|Any CPU
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Debug|x86.Build.0 = Debug|Any CPU
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Release|Any CPU.Build.0 = Release|Any CPU
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Release|x64.ActiveCfg = Release|Any CPU
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Release|x64.Build.0 = Release|Any CPU
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Release|x86.ActiveCfg = Release|Any CPU
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -0,0 +1,37 @@
namespace PrivaPub.Api.Mastodon.Auth
{
public static class MastodonScopes
{
public static readonly string[] All =
{
"read", "write", "follow", "push", "profile",
"read:accounts", "read:blocks", "read:bookmarks", "read:favourites", "read:filters", "read:follows", "read:lists",
"read:mutes", "read:notifications", "read:search", "read:statuses",
"write:accounts", "write:blocks", "write:bookmarks", "write:conversations", "write:favourites", "write:filters",
"write:follows", "write:lists", "write:media", "write:mutes", "write:notifications", "write:reports", "write:statuses"
};
public static IReadOnlyList<string> Parse(string scopes)
{
var parsed = (scopes ?? string.Empty).Split(new[] { ' ', '+', ',' }, StringSplitOptions.RemoveEmptyEntries)
.Where(s => All.Contains(s))
.Distinct()
.ToList();
return parsed.Count == 0 ? new[] { "read" } : parsed;
}
public static bool Grants(IEnumerable<string> granted, string required)
{
var scopes = granted as ICollection<string> ?? granted.ToList();
if (scopes.Contains(required))
return true;
var colon = required.IndexOf(':');
if (colon > 0 && scopes.Contains(required[..colon]))
return true;
return required.StartsWith("read:follows") || required.StartsWith("write:follows") || required.StartsWith("write:blocks")
|| required.StartsWith("read:blocks") || required.StartsWith("write:mutes") || required.StartsWith("read:mutes")
? scopes.Contains("follow")
: false;
}
}
}
+44
View File
@@ -0,0 +1,44 @@
using OpenIddict.Abstractions;
namespace PrivaPub.Api.Mastodon.Auth
{
public class OAuthPruner : BackgroundService
{
static readonly TimeSpan Interval = TimeSpan.FromHours(6);
static readonly TimeSpan Threshold = TimeSpan.FromDays(14);
readonly IServiceProvider _services;
readonly ILogger<OAuthPruner> _logger;
public OAuthPruner(IServiceProvider services, ILogger<OAuthPruner> logger)
{
_services = services;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(Interval, stoppingToken);
using var scope = _services.CreateScope();
var threshold = DateTimeOffset.UtcNow - Threshold;
var tokens = await scope.ServiceProvider.GetRequiredService<IOpenIddictTokenManager>().PruneAsync(threshold, stoppingToken);
var authorizations = await scope.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>().PruneAsync(threshold, stoppingToken);
if (tokens + authorizations > 0)
_logger.LogInformation("{Service} removed {Tokens} tokens and {Authorizations} authorizations", nameof(OAuthPruner), tokens, authorizations);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "{Service} pass failed", nameof(OAuthPruner));
}
}
}
}
}
+112
View File
@@ -0,0 +1,112 @@
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using MongoDB.Driver;
using MongoDB.Entities;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using PrivaPub.Models;
using System.Net;
using static OpenIddict.Server.OpenIddictServerEvents;
namespace PrivaPub.Api.Mastodon.Auth
{
public static class OAuthSetup
{
public const string LoginScheme = "PrivaPub.OAuth";
public const string OutOfBand = "urn:ietf:wg:oauth:2.0:oob";
public static IServiceCollection PrivaPubOAuth(this IServiceCollection services, IWebHostEnvironment environment)
{
services.AddSingleton<IMongoDatabase>(_ => DB.Default.Database());
services.AddHostedService<OAuthPruner>();
services.AddAuthentication()
.AddCookie(LoginScheme, options =>
{
options.Cookie.Name = "privapub.oauth";
options.Cookie.Path = "/oauth";
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.ExpireTimeSpan = TimeSpan.FromMinutes(15);
options.SlidingExpiration = false;
options.LoginPath = "/oauth/login";
});
services.AddOpenIddict()
.AddCore(options => options.UseMongoDb())
.AddServer(options =>
{
options.SetAuthorizationEndpointUris("/oauth/authorize")
.SetTokenEndpointUris("/oauth/token")
.SetRevocationEndpointUris("/oauth/revoke")
.SetConfigurationEndpointUris("/.well-known/openid-configuration", "/.well-known/oauth-authorization-server");
options.AllowAuthorizationCodeFlow().AllowClientCredentialsFlow();
options.RegisterScopes(MastodonScopes.All);
options.UseReferenceAccessTokens();
options.SetAccessTokenLifetime(null);
options.SetAuthorizationCodeLifetime(TimeSpan.FromMinutes(10));
options.DisableAccessTokenEncryption();
var aspNetCore = options.UseAspNetCore()
.EnableAuthorizationEndpointPassthrough()
.EnableTokenEndpointPassthrough();
if (!environment.IsProduction())
aspNetCore.DisableTransportSecurityRequirement();
options.AddEventHandler<ApplyAuthorizationResponseContext>(builder => builder.UseInlineHandler(OutOfBandCode)
.SetOrder(OpenIddictServerAspNetCoreHandlers.Authentication.ProcessFormPostResponse.Descriptor.Order - 1_000)
.SetType(OpenIddictServerHandlerType.Custom));
options.AddEventHandler<ApplyTokenResponseContext>(builder => builder.UseInlineHandler(context =>
{
if (context.Response.AccessToken != default)
context.Response["created_at"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
return default;
}));
})
.AddValidation(options =>
{
options.UseLocalServer();
options.UseAspNetCore();
});
services.AddOptions<OpenIddictServerOptions>().Configure<IOptionsMonitor<AppConfiguration>>((options, app) =>
{
options.Issuer = new Uri(app.CurrentValue.BackendBaseAddress.TrimEnd('/') + "/");
options.SigningCredentials.Add(new SigningCredentials(OidcKey.Load(OidcKey.Signing), SecurityAlgorithms.RsaSha256));
options.EncryptionCredentials.Add(new EncryptingCredentials(OidcKey.Load(OidcKey.Encryption),
SecurityAlgorithms.RsaOAEP, SecurityAlgorithms.Aes256CbcHmacSha512));
});
return services;
}
static async ValueTask OutOfBandCode(ApplyAuthorizationResponseContext context)
{
if (context.RedirectUri != OutOfBand || context.Response.Code == default)
return;
var http = context.Transaction.GetHttpRequest()?.HttpContext;
if (http == default)
return;
var code = WebUtility.HtmlEncode(context.Response.Code);
http.Response.StatusCode = StatusCodes.Status200OK;
http.Response.ContentType = "text/html; charset=utf-8";
http.Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'";
http.Response.Headers["Cache-Control"] = "no-store";
await http.Response.WriteAsync($$"""
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Authorization code</title><style>body{font:16px/1.5 system-ui,sans-serif;max-width:480px;margin:48px auto;padding:0 16px}
code{display:block;font-size:1.1rem;padding:12px;border:1px solid #ccc;word-break:break-all}</style></head>
<body><h1>Authorization code</h1><p>Copy this code into the application:</p><code>{{code}}</code></body></html>
""");
context.HandleRequest();
}
}
}
+33
View File
@@ -0,0 +1,33 @@
using Microsoft.IdentityModel.Tokens;
using MongoDB.Entities;
using System.Security.Cryptography;
namespace PrivaPub.Api.Mastodon.Auth
{
public class OidcKey : Entity
{
public string Use { get; set; }
public string PrivateKeyPem { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public const string Signing = "signing";
public const string Encryption = "encryption";
public static RsaSecurityKey Load(string use)
{
var key = DB.Default.Find<OidcKey>().Match(k => k.Use == use).Sort(k => k.CreatedAt, Order.Ascending).ExecuteFirstAsync().GetAwaiter().GetResult();
if (key == default)
{
using var created = RSA.Create(2048);
key = new OidcKey { Use = use, PrivateKeyPem = created.ExportRSAPrivateKeyPem() };
DB.Default.SaveAsync(key).GetAwaiter().GetResult();
key = DB.Default.Find<OidcKey>().Match(k => k.Use == use).Sort(k => k.CreatedAt, Order.Ascending).ExecuteFirstAsync().GetAwaiter().GetResult();
}
var rsa = RSA.Create();
rsa.ImportFromPem(key.PrivateKeyPem);
return new RsaSecurityKey(rsa) { KeyId = key.ID };
}
}
}
@@ -0,0 +1,70 @@
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using PrivaPub.StaticServices;
using System.Security.Claims;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace PrivaPub.Api.Mastodon.Auth
{
[ApiController]
public class TokenController : ControllerBase
{
readonly DbEntities _dbEntities;
public TokenController(DbEntities dbEntities)
{
_dbEntities = dbEntities;
}
[HttpPost("/oauth/token"), IgnoreAntiforgeryToken, Produces("application/json")]
public async Task<IActionResult> Exchange(CancellationToken token)
{
var request = HttpContext.GetOpenIddictServerRequest();
if (request == default)
return BadRequest();
if (request.IsAuthorizationCodeGrantType())
{
var principal = (await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)).Principal;
if (principal == default || !await Usable(principal.GetClaim(Claims.Subject), token))
return Refuse(Errors.InvalidGrant, "The persona can no longer be used.");
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
if (request.IsClientCredentialsGrantType())
{
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);
identity.SetClaim(Claims.Subject, "app:" + request.ClientId);
identity.SetScopes(MastodonScopes.Parse(request.Scope));
identity.SetDestinations(_ => new[] { Destinations.AccessToken });
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
return Refuse(Errors.UnsupportedGrantType, "The grant type is not supported.");
}
async Task<bool> Usable(string avatarId, CancellationToken token)
{
if (string.IsNullOrEmpty(avatarId))
return false;
var link = await _dbEntities.RootToAvatars.Match(r => r.AvatarId == avatarId).ExecuteFirstAsync(token);
var root = link == default ? default : await _dbEntities.RootUsers.MatchID(link.RootId).ExecuteFirstAsync(token);
var avatar = await _dbEntities.Avatars.MatchID(avatarId).ExecuteFirstAsync(token);
return root is { IsBanned: false, DeletedAt: null } && avatar is { DeletionAt: null };
}
ForbidResult Refuse(string error, string description) => Forbid(new AuthenticationProperties(new Dictionary<string, string>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = error,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = description
}), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
}
@@ -0,0 +1,294 @@
using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Privacy;
using PrivaPub.Domain.Social;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Api.Mastodon.Controllers
{
public class AccountsController : MastodonController
{
readonly MastodonMapper _mapper;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IFollowService _follows;
readonly IOutboxPublisher _outbox;
public AccountsController(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors,
IFollowService follows, IOutboxPublisher outbox)
{
_mapper = mapper;
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
_follows = follows;
_outbox = outbox;
}
[HttpGet("/api/v1/accounts/verify_credentials"), Scope("read:accounts")]
public async Task<IActionResult> VerifyCredentials(CancellationToken token) => Json(await _mapper.Local(Me, withSource: true, token));
[HttpPatch("/api/v1/accounts/update_credentials"), Scope("write:accounts")]
public async Task<IActionResult> UpdateCredentials(CancellationToken token)
{
var avatar = await _dbEntities.Avatars.MatchID(Me.Id).ExecuteFirstAsync(token);
if (Params.Has("display_name"))
avatar.Name = Params.Get("display_name")?.Trim();
if (Params.Has("note"))
avatar.Biography = Params.Get("note");
if (Params.Bool("locked") is { } locked)
avatar.Settings.IsLocked = locked;
if (Params.Bool("bot") is { } bot)
avatar.Settings.IsBot = bot;
if (Params.Bool("discoverable") is { } discoverable)
avatar.Settings.IsDiscoverable = discoverable;
if (Params.Bool("indexable") is { } indexable)
avatar.Settings.IsIndexable = indexable;
if (Params.Bool("hide_collections") is { } hide)
avatar.Settings.HideCollections = hide;
if (Params.Get("source[privacy]") is { } privacy && privacy is "public" or "unlisted" or "private")
avatar.Settings.DefaultVisibility = privacy;
if (Params.Bool("source[sensitive]") is { } sensitive)
avatar.Settings.DefaultSensitive = sensitive;
if (Params.Has("source[language]"))
avatar.Settings.DefaultLanguage = Params.Get("source[language]");
var fields = new Dictionary<string, string>();
for (var i = 0; i < 4; i++)
{
var name = Params.Get($"fields_attributes[{i}][name]") ?? Params.Get($"fields_attributes[][name]");
if (!string.IsNullOrWhiteSpace(name))
fields[name.Trim()] = Params.Get($"fields_attributes[{i}][value]")?.Trim() ?? string.Empty;
}
if (Params.List("fields_attributes[][name]").Count > 0)
{
var names = Params.List("fields_attributes[][name]");
var values = Params.List("fields_attributes[][value]");
fields = names.Select((n, i) => (n, v: i < values.Count ? values[i] : string.Empty)).Where(f => !string.IsNullOrWhiteSpace(f.n))
.Take(4).ToDictionary(f => f.n.Trim(), f => f.v.Trim());
}
if (fields.Count > 0 || Params.Has("fields_attributes[0][name]"))
avatar.Fields = fields;
avatar.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(avatar, token);
var actor = _localActors.FromAvatar(avatar);
await _outbox.PublishProfile(actor, token);
return Json(await _mapper.Local(actor, withSource: true, token));
}
[HttpGet("/api/v1/accounts/lookup"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Lookup(CancellationToken token)
{
var acct = Params.Get("acct")?.Trim().TrimStart('@');
var parts = acct?.Split('@');
if (parts is not { Length: 1 or 2 } || string.IsNullOrEmpty(parts[0]))
return NotFoundError();
var localDomain = new Uri(_localActors.BaseAddress).Authority;
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
{
var local = await _localActors.FindByUserName(parts[0], token);
return local is { IsFederated: true, Kind: not LocalActorKind.Application } ? Json(await _mapper.Local(local, false, token)) : NotFoundError();
}
var userName = parts[0];
var domain = parts[1].ToLowerInvariant();
var foreign = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain).ExecuteFirstAsync(token);
return foreign == default ? NotFoundError() : Json(_mapper.Foreign(foreign));
}
[HttpGet("/api/v1/accounts/relationships"), Scope("read:follows")]
public async Task<IActionResult> Relationships(CancellationToken token)
{
var relationships = new List<Relationship>();
foreach (var id in Params.List("id").Distinct().Take(40))
relationships.Add(await Relationship(id, token));
return Json(relationships);
}
[HttpGet("/api/v1/accounts/search"), Scope("read:accounts")]
public async Task<IActionResult> Search([FromServices] AccountSearch search, CancellationToken token) =>
Json(await search.Find(Params.Get("q"), Params.Bool("resolve") == true && MyId != default, Limit(), token));
[HttpGet("/api/v1/accounts/{id}"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Get(string id, CancellationToken token)
{
var account = await _mapper.Account(id, token);
return account == default ? NotFoundError() : Json(account);
}
[HttpGet("/api/v1/accounts/{id}/statuses"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Statuses(string id, CancellationToken token)
{
var (local, remote) = await Find(id, token);
if (local == default && remote == default)
return NotFoundError();
if (Params.Bool("pinned") == true)
return Json(Array.Empty<Status>());
var query = local != default
? _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue)
: _dbEntities.Posts.Match(p => p.ActorURI == remote.ActorURI && p.IsFederatedCopy && !p.DeletedAt.HasValue);
var viewerFollows = MyId != default && local != default
&& await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetAccountId == local.Id && f.State == FollowState.Accepted).ExecuteAnyAsync(token);
if (MyId != id)
query.Match(viewerFollows
? p => p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted || p.Visibility == PostVisibility.FollowersOnly
: p => p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted);
else
query.Match(p => p.Visibility != PostVisibility.LocalGeo);
if (Params.Bool("exclude_replies") == true)
query.Match(p => p.InReplyToURI == null);
if (Params.Bool("exclude_reblogs") == true)
query.Match(p => p.ReblogOfPostId == null);
if (Params.Bool("only_media") == true)
query.Match(p => p.Media.Count > 0);
if (Params.Get("tagged") is { } tag)
query.Match(p => p.Tags.Contains(tag.ToLowerInvariant()));
var posts = await Page.From(Params, Limit()).Fetch(query, p => p.ID, token);
var statuses = await _mapper.Statuses(posts, MyId, token);
Link($"/api/v1/accounts/{id}/statuses", posts.LastOrDefault()?.ID, posts.FirstOrDefault()?.ID);
return Json(statuses);
}
[HttpGet("/api/v1/accounts/{id}/followers"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Followers(string id, CancellationToken token)
{
var (local, _) = await Find(id, token);
if (local == default || local.Id != MyId)
return Json(Array.Empty<Account>());
var followers = await _dbEntities.Followers.Match(f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted)
.Sort(f => f.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
return Json(await AccountsFor(followers.Select(f => f.ActorURI), token));
}
[HttpGet("/api/v1/accounts/{id}/following"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Following(string id, CancellationToken token)
{
var (local, _) = await Find(id, token);
if (local == default || local.Id != MyId)
return Json(Array.Empty<Account>());
var following = await _dbEntities.Followings.Match(f => f.AvatarId == local.Id && f.State == FollowState.Accepted)
.Sort(f => f.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
var accounts = await _mapper.Accounts(following.Select(f => f.TargetAccountId), token);
return Json(following.Select(f => accounts.GetValueOrDefault(f.TargetAccountId)).Where(a => a != default).ToList());
}
[HttpPost("/api/v1/accounts/{id}/follow"), Scope("write:follows")]
public async Task<IActionResult> Follow(string id, CancellationToken token)
{
var (local, remote) = await Find(id, token);
if (local == default && remote == default)
return NotFoundError();
var following = await _follows.FollowAs(Me, local?.Uri ?? remote.ActorURI, Params.Bool("reblogs") != false, token);
return following == default ? Error(StatusCodes.Status403Forbidden, "This action is not allowed") : Json(await Relationship(id, token));
}
[HttpPost("/api/v1/accounts/{id}/unfollow"), Scope("write:follows")]
public async Task<IActionResult> Unfollow(string id, CancellationToken token)
{
var (local, remote) = await Find(id, token);
if (local == default && remote == default)
return NotFoundError();
await _follows.UnfollowAs(Me, local?.Uri ?? remote.ActorURI, token);
return Json(await Relationship(id, token));
}
[HttpPost("/api/v1/accounts/{id}/remove_from_followers"), Scope("write:follows")]
public async Task<IActionResult> RemoveFromFollowers(string id, CancellationToken token)
{
var (local, remote) = await Find(id, token);
var uri = local?.Uri ?? remote?.ActorURI;
if (uri == default)
return NotFoundError();
await DB.Default.DeleteAsync<Follower>(f => f.LocalActorId == Me.Id && f.ActorURI == uri);
if (local != default)
await DB.Default.DeleteAsync<Following>(f => f.AvatarId == local.Id && f.TargetActorURI == Me.Uri);
return Json(await Relationship(id, token));
}
[HttpGet("/api/v1/follow_requests"), Scope("read:follows")]
public async Task<IActionResult> FollowRequests(CancellationToken token)
{
var requests = await _dbEntities.Followers.Match(f => f.LocalActorId == Me.Id && f.LocalActorKind == LocalActorKind.Person && !f.IsAccepted)
.Sort(f => f.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
return Json(await AccountsFor(requests.Select(r => r.ActorURI), token));
}
[HttpPost("/api/v1/follow_requests/{id}/authorize"), Scope("write:follows")]
public async Task<IActionResult> Authorize(string id, CancellationToken token) =>
await _follows.Decide(Me, id, accept: true, token) ? Json(await Relationship(id, token)) : NotFoundError();
[HttpPost("/api/v1/follow_requests/{id}/reject"), Scope("write:follows")]
public async Task<IActionResult> Reject(string id, CancellationToken token) =>
await _follows.Decide(Me, id, accept: false, token) ? Json(await Relationship(id, token)) : NotFoundError();
[HttpGet("/api/v1/accounts/{id}/featured_tags"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult FeaturedTags(string id) => Json(Array.Empty<object>());
[HttpGet("/api/v1/accounts/{id}/lists"), Scope("read:lists")]
public IActionResult Lists(string id) => Json(Array.Empty<object>());
[HttpGet("/api/v1/accounts/familiar_followers"), Scope("read:follows")]
public IActionResult FamiliarFollowers() => Json(Params.List("id").Select(id => new { id, accounts = Array.Empty<Account>() }).ToList());
async Task<List<Account>> AccountsFor(IEnumerable<string> actorUris, CancellationToken token)
{
var accounts = new List<Account>();
foreach (var uri in actorUris)
{
var local = await _localActors.FindByUri(uri, token);
if (local != default)
{
accounts.Add(await _mapper.Local(local, false, token));
continue;
}
var foreign = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == uri).ExecuteFirstAsync(token);
if (foreign != default)
accounts.Add(_mapper.Foreign(foreign));
}
return accounts;
}
async Task<(LocalActor Local, ForeignAvatar Remote)> Find(string id, CancellationToken token)
{
var avatar = await _dbEntities.Avatars.MatchID(id).ExecuteFirstAsync(token);
if (avatar is { DeletionAt: null })
return (_localActors.FromAvatar(avatar), default);
var group = await _dbEntities.Groups.MatchID(id).ExecuteFirstAsync(token);
if (group is { DeletionAt: null } && _localActors.FromGroup(group) is { IsFederated: true } community)
return (community, default);
return (default, await _dbEntities.ForeignAvatars.MatchID(id).ExecuteFirstAsync(token));
}
async Task<Relationship> Relationship(string id, CancellationToken token)
{
var (local, remote) = await Find(id, token);
var uri = local?.Uri ?? remote?.ActorURI;
var following = uri == default ? default : await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetActorURI == uri).ExecuteFirstAsync(token);
var followedBy = uri == default ? default : await _dbEntities.Followers.Match(f => f.LocalActorId == MyId && f.ActorURI == uri).ExecuteFirstAsync(token);
return new Relationship
{
Id = id,
Following = following?.State == FollowState.Accepted,
Requested = following?.State == FollowState.Requested,
ShowingReblogs = following?.ShowReblogs ?? false,
FollowedBy = followedBy?.IsAccepted == true,
RequestedBy = followedBy is { IsAccepted: false }
};
}
}
}
@@ -0,0 +1,97 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
using PrivaPub.Api.Mastodon.Auth;
using PrivaPub.Api.Mastodon.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using System.Security.Cryptography;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace PrivaPub.Api.Mastodon.Controllers
{
public class AppsController : MastodonController
{
readonly IOpenIddictApplicationManager _applications;
public AppsController(IOpenIddictApplicationManager applications)
{
_applications = applications;
}
[HttpPost("/api/v1/apps"), AllowAnonymous]
public async Task<IActionResult> Create(CancellationToken token)
{
var name = Params.Get("client_name")?.Trim();
if (string.IsNullOrEmpty(name) || name.Length > 200)
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Application name can't be blank");
var redirects = Params.List("redirect_uris").SelectMany(r => r.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).Distinct().ToList();
if (redirects.Count == 0 || redirects.Any(r => !Uri.TryCreate(r, UriKind.Absolute, out var uri) || uri.Scheme is "javascript" or "data"))
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Redirect URI must be an absolute URI.");
var scopes = MastodonScopes.Parse(string.Join(' ', Params.List("scopes")));
var descriptor = new OpenIddictApplicationDescriptor
{
ClientId = Secret(32),
ClientSecret = Secret(32),
ClientType = ClientTypes.Confidential,
ConsentType = ConsentTypes.Explicit,
DisplayName = name,
Permissions =
{
Permissions.Endpoints.Authorization,
Permissions.Endpoints.Token,
Permissions.Endpoints.Revocation,
Permissions.GrantTypes.AuthorizationCode,
Permissions.GrantTypes.ClientCredentials,
Permissions.ResponseTypes.Code
}
};
foreach (var scope in scopes)
descriptor.Permissions.Add(Permissions.Prefixes.Scope + scope);
foreach (var redirect in redirects)
descriptor.RedirectUris.Add(new Uri(redirect));
var website = Params.Get("website");
if (Uri.TryCreate(website, UriKind.Absolute, out var site) && site.Scheme is "https" or "http")
descriptor.Properties["website"] = System.Text.Json.JsonSerializer.SerializeToElement(website);
var application = await _applications.CreateAsync(descriptor, token);
return Json(new Application
{
Id = await _applications.GetIdAsync(application, token),
Name = name,
Website = website,
Scopes = scopes.ToList(),
RedirectUris = redirects,
RedirectUri = string.Join("\n", redirects),
ClientId = descriptor.ClientId,
ClientSecret = descriptor.ClientSecret
});
}
[HttpGet("/api/v1/apps/verify_credentials")]
public async Task<IActionResult> Verify(CancellationToken token)
{
var clientId = User.GetPresenters().FirstOrDefault() ?? User.GetClaim(Claims.ClientId);
var application = clientId == default ? default : await _applications.FindByClientIdAsync(clientId, token);
if (application == default)
return Error(StatusCodes.Status401Unauthorized, "The access token is invalid");
var properties = await _applications.GetPropertiesAsync(application, token);
return Json(new
{
id = await _applications.GetIdAsync(application, token),
name = await _applications.GetDisplayNameAsync(application, token),
website = properties.TryGetValue("website", out var website) ? website.GetString() : default,
scopes = User.GetScopes().ToList(),
redirect_uris = (await _applications.GetRedirectUrisAsync(application, token)).ToList(),
vapid_key = string.Empty
});
}
static string Secret(int bytes) => Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
}
@@ -0,0 +1,107 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.User;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Api.Mastodon.Controllers
{
public class InstanceController : MastodonController
{
public const string Version = "4.2.0 (compatible; PrivaPub)";
const string Description = "A small ActivityPub server where one private login keeps several unlinkable public personas.";
readonly ILocalActorService _localActors;
public InstanceController(ILocalActorService localActors)
{
_localActors = localActors;
}
string Domain => new Uri(_localActors.BaseAddress).Authority;
static object Statuses => new { max_characters = 5000, max_media_attachments = 4, characters_reserved_per_url = 23 };
static object MediaAttachments => new
{
supported_mime_types = new[] { "image/jpeg", "image/png", "image/gif", "image/webp", "video/mp4", "video/webm", "audio/mpeg", "audio/ogg" },
image_size_limit = 16 * 1024 * 1024,
image_matrix_limit = 16_777_216,
video_size_limit = 99 * 1024 * 1024,
video_frame_rate_limit = 60,
video_matrix_limit = 2_304_000
};
static object Polls => new { max_options = 4, max_characters_per_option = 50, min_expiration = 300, max_expiration = 2_629_746 };
[HttpGet("/api/v1/instance"), AllowAnonymous]
public async Task<IActionResult> V1(CancellationToken token)
{
var users = await DB.Default.CountAsync<Avatar>(a => !a.DeletionAt.HasValue, token);
var statuses = await DB.Default.CountAsync<PostEntity>(f => f.Where(p => !p.IsFederatedCopy) & f.Where(VisibilityPolicy.IsPublic), token);
var domains = (await DB.Default.Find<ForeignAvatar, string>().Project(a => a.Domain).ExecuteAsync(token)).Distinct().Count();
return Json(new
{
uri = Domain,
title = "PrivaPub",
short_description = Description,
description = Description,
email = string.Empty,
version = Version,
urls = new { streaming_api = $"wss://{Domain}" },
stats = new { user_count = users, status_count = statuses, domain_count = domains },
thumbnail = $"{_localActors.BaseAddress}/media/missing-header.png",
languages = new[] { "en" },
registrations = false,
approval_required = false,
invites_enabled = false,
configuration = new { accounts = new { max_featured_tags = 0 }, statuses = Statuses, media_attachments = MediaAttachments, polls = Polls },
contact_account = default(object),
rules = Array.Empty<object>()
});
}
[HttpGet("/api/v2/instance"), AllowAnonymous]
public IActionResult V2() => Json(new
{
domain = Domain,
title = "PrivaPub",
version = Version,
source_url = "https://git.thepra.dev/thepra/SocialPub",
description = Description,
usage = new { users = new { active_month = 0 } },
thumbnail = new { url = $"{_localActors.BaseAddress}/media/missing-header.png" },
languages = new[] { "en" },
configuration = new
{
urls = new { streaming = $"wss://{Domain}" },
accounts = new { max_featured_tags = 0, max_pinned_statuses = 0 },
statuses = Statuses,
media_attachments = MediaAttachments,
polls = Polls,
translation = new { enabled = false }
},
registrations = new { enabled = false, approval_required = false, message = default(string) },
contact = new { email = string.Empty, account = default(object) },
rules = Array.Empty<object>()
});
[HttpGet("/api/v1/instance/peers"), AllowAnonymous]
public IActionResult Peers() => Json(Array.Empty<string>());
[HttpGet("/api/v1/instance/activity"), AllowAnonymous]
public IActionResult Activity() => Json(Array.Empty<object>());
[HttpGet("/api/v1/instance/rules"), AllowAnonymous]
public IActionResult Rules() => Json(Array.Empty<object>());
[HttpGet("/api/v1/instance/extended_description"), AllowAnonymous]
public IActionResult ExtendedDescription() => Json(new { updated_at = MastodonJson.Day(DateTime.UtcNow), content = $"<p>{Description}</p>" });
}
}
@@ -0,0 +1,149 @@
using Microsoft.AspNetCore.Mvc;
using PrivaPub.Api.Mastodon.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Outbox;
using PrivaPub.Domain.Social;
using PrivaPub.Models.Post;
using PrivaPub.StaticServices;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Api.Mastodon.Controllers
{
public class SearchController : MastodonController
{
readonly MastodonMapper _mapper;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IRemotePosts _remotePosts;
readonly AccountSearch _search;
public SearchController(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors,
IRemotePosts remotePosts, AccountSearch search)
{
_mapper = mapper;
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
_remotePosts = remotePosts;
_search = search;
}
[HttpGet("/api/v2/search"), Scope("read:search", requiresUser: false)]
public async Task<IActionResult> Search(CancellationToken token)
{
var q = Params.Get("q")?.Trim();
var type = Params.Get("type");
var resolve = Params.Bool("resolve") == true && MyId != default;
var results = new SearchResults();
if (string.IsNullOrEmpty(q))
return Json(results);
if (q.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
var post = await _dbEntities.Posts.Match(p => (p.ObjectURI == q || p.Url == q) && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
if (post == default && resolve && type is null or "statuses")
post = await _remotePosts.StoreContext(q, 0, token);
if (post != default && type is null or "statuses" && await VisibilityPolicy.CanSee(post, MyId, token))
results.Statuses.Add(await _mapper.Status(post, MyId, token));
if (results.Statuses.Count == 0 && type is null or "accounts")
{
var local = await _localActors.FindByUri(q, token);
var foreign = local == default ? (resolve ? await _remoteActors.GetActor(q, refresh: false, token) : default) : default;
if (local is { IsFederated: true })
results.Accounts.Add(await _mapper.Local(local, false, token));
else if (foreign != default)
results.Accounts.Add(_mapper.Foreign(foreign));
}
return Json(results);
}
if (type is null or "accounts")
{
results.Accounts = await _search.Find(q, resolve, Limit(), token);
}
if (type is null or "hashtags" && q.TrimStart('#') is { Length: > 0 } tag)
results.Hashtags.Add(new { name = tag.ToLowerInvariant(), url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag.ToLowerInvariant())}", history = Array.Empty<object>() });
return Json(results);
}
}
public class StubsController : MastodonController
{
[HttpGet("/api/v1/custom_emojis"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult CustomEmojis() => Json(Array.Empty<object>());
[HttpGet("/api/v1/filters"), Scope("read:filters")]
public IActionResult FiltersV1() => Json(Array.Empty<object>());
[HttpGet("/api/v2/filters"), Scope("read:filters")]
public IActionResult FiltersV2() => Json(Array.Empty<object>());
[HttpGet("/api/v1/lists"), Scope("read:lists")]
public IActionResult Lists() => Json(Array.Empty<object>());
[HttpGet("/api/v1/announcements"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult Announcements() => Json(Array.Empty<object>());
[HttpGet("/api/v1/trends"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult Trends() => Json(Array.Empty<object>());
[HttpGet("/api/v1/trends/tags"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult TrendTags() => Json(Array.Empty<object>());
[HttpGet("/api/v1/trends/statuses"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult TrendStatuses() => Json(Array.Empty<object>());
[HttpGet("/api/v1/trends/links"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult TrendLinks() => Json(Array.Empty<object>());
[HttpGet("/api/v1/suggestions"), Scope("read")]
public IActionResult SuggestionsV1() => Json(Array.Empty<object>());
[HttpGet("/api/v2/suggestions"), Scope("read")]
public IActionResult SuggestionsV2() => Json(Array.Empty<object>());
[HttpGet("/api/v1/followed_tags"), Scope("read:follows")]
public IActionResult FollowedTags() => Json(Array.Empty<object>());
[HttpGet("/api/v1/blocks"), Scope("read:blocks")]
public IActionResult Blocks() => Json(Array.Empty<object>());
[HttpGet("/api/v1/mutes"), Scope("read:mutes")]
public IActionResult Mutes() => Json(Array.Empty<object>());
[HttpGet("/api/v1/domain_blocks"), Scope("read:blocks")]
public IActionResult DomainBlocks() => Json(Array.Empty<string>());
[HttpGet("/api/v1/bookmarks"), Scope("read:bookmarks")]
public IActionResult Bookmarks() => Json(Array.Empty<object>());
[HttpGet("/api/v1/endorsements"), Scope("read:accounts")]
public IActionResult Endorsements() => Json(Array.Empty<object>());
[HttpGet("/api/v1/featured_tags"), Scope("read:accounts")]
public IActionResult FeaturedTags() => Json(Array.Empty<object>());
[HttpGet("/api/v1/scheduled_statuses"), Scope("read:statuses")]
public IActionResult ScheduledStatuses() => Json(Array.Empty<object>());
[HttpGet("/api/v1/preferences"), Scope("read:accounts")]
public IActionResult Preferences() => Json(new Dictionary<string, object>
{
["posting:default:visibility"] = Me.Settings.DefaultVisibility ?? "public",
["posting:default:sensitive"] = Me.Settings.DefaultSensitive,
["posting:default:language"] = Me.Settings.DefaultLanguage,
["reading:expand:media"] = "default",
["reading:expand:spoilers"] = false
});
[HttpGet("/api/v1/push/subscription"), Scope("push")]
public IActionResult PushSubscription() => Error(StatusCodes.Status404NotFound, "Record not found");
}
}
@@ -0,0 +1,276 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Privacy;
using PrivaPub.Domain.Statuses;
using PrivaPub.Models.Post;
using PrivaPub.StaticServices;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Api.Mastodon.Controllers
{
public class StatusesController : MastodonController
{
const int MaxContext = 200;
readonly IStatusService _statuses;
readonly MastodonMapper _mapper;
readonly DbEntities _dbEntities;
readonly IMemoryCache _cache;
public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache)
{
_statuses = statuses;
_mapper = mapper;
_dbEntities = dbEntities;
_cache = cache;
}
[HttpPost("/api/v1/statuses"), Scope("write:statuses")]
public async Task<IActionResult> Create(CancellationToken token)
{
var idempotency = Request.Headers["Idempotency-Key"].ToString();
var cacheKey = string.IsNullOrEmpty(idempotency) ? default : $"idempotency:{MyId}:{idempotency}";
if (cacheKey != default && _cache.TryGetValue(cacheKey, out string earlier))
{
var existing = await _dbEntities.Posts.MatchID(earlier).ExecuteFirstAsync(token);
if (existing != default)
return Json(await _mapper.Status(existing, MyId, token));
}
if (Params.List("media_ids").Count > 0)
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not supported yet");
if (Params.Has("poll[options]"))
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Polls are not supported yet");
var outcome = await _statuses.Publish(Me, new StatusDraft
{
Text = Params.Get("status"),
PlainText = true,
SpoilerText = Params.Get("spoiler_text"),
Sensitive = Params.Bool("sensitive") ?? Me.Settings.DefaultSensitive,
Visibility = Visibility(Params.Get("visibility") ?? Me.Settings.DefaultVisibility),
InReplyTo = Params.Get("in_reply_to_id"),
Language = Params.Get("language") ?? Me.Settings.DefaultLanguage
}, token);
if (!outcome.Ok)
return Error(outcome.Status, outcome.Error);
if (cacheKey != default)
_cache.Set(cacheKey, outcome.Post.ID, TimeSpan.FromHours(1));
return Json(await _mapper.Status(outcome.Post, MyId, token));
}
[HttpGet("/api/v1/statuses"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Many(CancellationToken token)
{
var ids = Params.List("id").Take(20).ToList();
var posts = await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token);
var visible = new List<PostEntity>();
foreach (var post in posts)
if (await VisibilityPolicy.CanSee(post, MyId, token))
visible.Add(post);
return Json(await _mapper.Statuses(visible, MyId, token));
}
[HttpGet("/api/v1/statuses/{id}"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Get(string id, CancellationToken token)
{
var post = await Visible(id, token);
return post == default ? NotFoundError() : Json(await _mapper.Status(post, MyId, token));
}
[HttpPut("/api/v1/statuses/{id}"), Scope("write:statuses")]
public async Task<IActionResult> Edit(string id, CancellationToken token)
{
if (Params.List("media_ids").Count > 0)
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not supported yet");
var outcome = await _statuses.Edit(Me, id, new StatusDraft
{
Text = Params.Get("status"),
PlainText = true,
SpoilerText = Params.Get("spoiler_text"),
Sensitive = Params.Bool("sensitive") ?? false,
Language = Params.Get("language")
}, token);
return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error);
}
[HttpDelete("/api/v1/statuses/{id}"), Scope("write:statuses")]
public async Task<IActionResult> Delete(string id, CancellationToken token)
{
var before = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
if (before == default)
return NotFoundError();
var status = await _mapper.Status(before, MyId, token);
var outcome = await _statuses.Remove(Me, id, token);
if (!outcome.Ok)
return Error(outcome.Status, outcome.Error);
if (status != default)
{
status.Text = before.Text;
status.SpoilerText = before.SpoilerText ?? string.Empty;
}
return Json(status);
}
[HttpGet("/api/v1/statuses/{id}/context"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Context(string id, CancellationToken token)
{
var post = await Visible(id, token);
if (post == default)
return NotFoundError();
var ancestors = new List<PostEntity>();
var cursor = post;
while (!string.IsNullOrEmpty(cursor.AnsweringToPostId) && ancestors.Count < MaxContext)
{
cursor = await _dbEntities.Posts.MatchID(cursor.AnsweringToPostId).ExecuteFirstAsync(token);
if (cursor == default || cursor.DeletedAt.HasValue || !await VisibilityPolicy.CanSee(cursor, MyId, token))
break;
ancestors.Insert(0, cursor);
}
var descendants = new List<PostEntity>();
var frontier = new List<string> { post.ID };
while (frontier.Count > 0 && descendants.Count < MaxContext)
{
var parents = frontier;
var children = await _dbEntities.Posts.Match(p => parents.Contains(p.AnsweringToPostId) && !p.DeletedAt.HasValue && p.ReblogOfPostId == null)
.Sort(p => p.ID, Order.Ascending).Limit(MaxContext).ExecuteAsync(token);
frontier = new List<string>();
foreach (var child in children)
if (await VisibilityPolicy.CanSee(child, MyId, token))
{
descendants.Add(child);
frontier.Add(child.ID);
}
}
return Json(new Context
{
Ancestors = await _mapper.Statuses(ancestors, MyId, token),
Descendants = await _mapper.Statuses(descendants.OrderBy(d => d.ID, StringComparer.Ordinal).ToList(), MyId, token)
});
}
[HttpGet("/api/v1/statuses/{id}/history"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> History(string id, CancellationToken token)
{
var post = await Visible(id, token);
if (post == default)
return NotFoundError();
var account = await _mapper.Account(MastodonMapper.AuthorOf(post), token);
var edits = post.Revisions.Select(r => new StatusEdit
{
Content = r.ContentHtml ?? string.Empty,
SpoilerText = r.SpoilerText ?? string.Empty,
Sensitive = r.HasContentWarning,
CreatedAt = MastodonJson.Time(r.EditedAt),
Account = account
}).ToList();
edits.Add(new StatusEdit
{
Content = MastodonMapper.Content(post),
SpoilerText = post.SpoilerText ?? string.Empty,
Sensitive = post.HasContentWarning,
CreatedAt = MastodonJson.Time(post.EditedAt ?? post.CreationDate),
Account = account
});
return Json(edits);
}
[HttpGet("/api/v1/statuses/{id}/source"), Scope("read:statuses")]
public async Task<IActionResult> Source(string id, CancellationToken token)
{
var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
return post == default ? NotFoundError() : Json(new StatusSource { Id = post.ID, Text = post.Text ?? string.Empty, SpoilerText = post.SpoilerText ?? string.Empty });
}
[HttpPost("/api/v1/statuses/{id}/favourite"), Scope("write:favourites")]
public Task<IActionResult> Favourite(string id, CancellationToken token) => Toggle(_statuses.Favourite(Me, id, true, token), id, token);
[HttpPost("/api/v1/statuses/{id}/unfavourite"), Scope("write:favourites")]
public Task<IActionResult> Unfavourite(string id, CancellationToken token) => Toggle(_statuses.Favourite(Me, id, false, token), id, token);
[HttpPost("/api/v1/statuses/{id}/reblog"), Scope("write:statuses")]
public async Task<IActionResult> Reblog(string id, CancellationToken token)
{
var outcome = await _statuses.Reblog(Me, id, true, Visibility(Params.Get("visibility")), token);
return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error);
}
[HttpPost("/api/v1/statuses/{id}/unreblog"), Scope("write:statuses")]
public Task<IActionResult> Unreblog(string id, CancellationToken token) => Toggle(_statuses.Reblog(Me, id, false, PostVisibility.Public, token), id, token);
[HttpGet("/api/v1/statuses/{id}/favourited_by"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> FavouritedBy(string id, CancellationToken token)
{
if (await Visible(id, token) == default)
return NotFoundError();
var favourites = await _dbEntities.Favourites.Match(f => f.PostId == id).Sort(f => f.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
var accounts = await _mapper.Accounts(favourites.Select(f => f.AccountId), token);
return Json(favourites.Select(f => accounts.GetValueOrDefault(f.AccountId)).Where(a => a != default).ToList());
}
[HttpGet("/api/v1/statuses/{id}/reblogged_by"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> RebloggedBy(string id, CancellationToken token)
{
if (await Visible(id, token) == default)
return NotFoundError();
var reblogs = await _dbEntities.Posts.Match(p => p.ReblogOfPostId == id && !p.DeletedAt.HasValue).Sort(p => p.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
var accounts = await _mapper.Accounts(reblogs.Select(MastodonMapper.AuthorOf), token);
return Json(reblogs.Select(r => accounts.GetValueOrDefault(MastodonMapper.AuthorOf(r))).Where(a => a != default).ToList());
}
[HttpPost("/api/v1/statuses/{id}/bookmark"), Scope("write:bookmarks")]
public Task<IActionResult> Bookmark(string id, CancellationToken token) => Unchanged(id, token);
[HttpPost("/api/v1/statuses/{id}/unbookmark"), Scope("write:bookmarks")]
public Task<IActionResult> Unbookmark(string id, CancellationToken token) => Unchanged(id, token);
[HttpPost("/api/v1/statuses/{id}/mute"), Scope("write:mutes")]
public Task<IActionResult> Mute(string id, CancellationToken token) => Unchanged(id, token);
[HttpPost("/api/v1/statuses/{id}/unmute"), Scope("write:mutes")]
public Task<IActionResult> Unmute(string id, CancellationToken token) => Unchanged(id, token);
[HttpPost("/api/v1/statuses/{id}/pin"), Scope("write:accounts")]
public IActionResult Pin(string id) => Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Pinning is not supported yet");
[HttpPost("/api/v1/statuses/{id}/unpin"), Scope("write:accounts")]
public Task<IActionResult> Unpin(string id, CancellationToken token) => Unchanged(id, token);
async Task<IActionResult> Toggle(Task<StatusOutcome> action, string id, CancellationToken token)
{
var outcome = await action;
if (!outcome.Ok)
return Error(outcome.Status, outcome.Error);
var post = await _dbEntities.Posts.MatchID(id).ExecuteFirstAsync(token);
return Json(await _mapper.Status(post, MyId, token));
}
async Task<IActionResult> Unchanged(string id, CancellationToken token)
{
var post = await Visible(id, token);
return post == default ? NotFoundError() : Json(await _mapper.Status(post, MyId, token));
}
async Task<PostEntity> Visible(string id, CancellationToken token)
{
var post = await _dbEntities.Posts.Match(p => p.ID == id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
return post != default && post.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(post, MyId, token) ? post : default;
}
static PostVisibility Visibility(string value) => value?.ToLowerInvariant() switch
{
"unlisted" => PostVisibility.Unlisted,
"private" => PostVisibility.FollowersOnly,
"direct" => PostVisibility.Direct,
_ => PostVisibility.Public
};
}
}
@@ -0,0 +1,243 @@
using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Privacy;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.StaticServices;
using PostEntity = PrivaPub.Models.Post.Post;
using NotificationEntity = PrivaPub.Models.Social.Notification;
using MarkerEntity = PrivaPub.Models.Social.Marker;
namespace PrivaPub.Api.Mastodon.Controllers
{
public class TimelinesController : MastodonController
{
readonly MastodonMapper _mapper;
readonly DbEntities _dbEntities;
public TimelinesController(MastodonMapper mapper, DbEntities dbEntities)
{
_mapper = mapper;
_dbEntities = dbEntities;
}
[HttpGet("/api/v1/timelines/home"), Scope("read:statuses")]
public async Task<IActionResult> Home(CancellationToken token)
{
var entries = await Page.From(Params, Limit()).Fetch(_dbEntities.TimelineEntries.Match(e => e.AvatarId == MyId), e => e.PostId, token);
var ids = entries.Select(e => e.PostId).ToList();
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID);
var statuses = await _mapper.Statuses(ids.Where(posts.ContainsKey).Select(id => posts[id]).ToList(), MyId, token);
Link("/api/v1/timelines/home", entries.LastOrDefault()?.PostId, entries.FirstOrDefault()?.PostId);
return Json(statuses);
}
[HttpGet("/api/v1/timelines/public"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Public(CancellationToken token)
{
var query = _dbEntities.Posts.Match(p => p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && p.ReblogOfPostId == null);
if (Params.Bool("local") == true)
query.Match(p => !p.IsFederatedCopy);
if (Params.Bool("remote") == true)
query.Match(p => p.IsFederatedCopy);
if (Params.Bool("only_media") == true)
query.Match(p => p.Media.Count > 0);
return await Respond(query, "/api/v1/timelines/public", token);
}
[HttpGet("/api/v1/timelines/tag/{hashtag}"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Tag(string hashtag, CancellationToken token)
{
var tag = hashtag.TrimStart('#').ToLowerInvariant();
var query = _dbEntities.Posts.Match(p => p.Tags.Contains(tag) && p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && p.ReblogOfPostId == null);
if (Params.Bool("local") == true)
query.Match(p => !p.IsFederatedCopy);
return await Respond(query, $"/api/v1/timelines/tag/{hashtag}", token);
}
[HttpGet("/api/v1/timelines/list/{id}"), Scope("read:lists")]
public IActionResult List(string id) => Json(Array.Empty<Status>());
[HttpGet("/api/v1/favourites"), Scope("read:favourites")]
public async Task<IActionResult> Favourites(CancellationToken token)
{
var favourites = await Page.From(Params, Limit()).Fetch(_dbEntities.Favourites.Match(f => f.AccountId == MyId), f => f.ID, token);
var ids = favourites.Select(f => f.PostId).ToList();
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID);
var visible = new List<PostEntity>();
foreach (var id in ids.Where(posts.ContainsKey))
if (await VisibilityPolicy.CanSee(posts[id], MyId, token))
visible.Add(posts[id]);
Link("/api/v1/favourites", favourites.LastOrDefault()?.ID, favourites.FirstOrDefault()?.ID);
return Json(await _mapper.Statuses(visible, MyId, token));
}
[HttpGet("/api/v1/conversations"), Scope("read:statuses")]
public async Task<IActionResult> Conversations(CancellationToken token)
{
var groups = await _dbEntities.DmGroups.Match(g => !g.DeletionAt.HasValue && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId))
.Sort(g => g.UpdatedAt, Order.Descending).Limit(Limit()).ExecuteAsync(token);
var conversations = new List<Conversation>();
foreach (var group in groups)
{
var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID && !p.DeletedAt.HasValue).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token);
if (last == default)
continue;
var others = new List<string>();
foreach (var member in group.Members.Where(m => !(m.AvatarId == MyId && !m.IsForeign)))
others.Add(member.IsForeign
? (await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == member.AvatarId).ExecuteFirstAsync(token))?.ID
: member.AvatarId);
var accounts = await _mapper.Accounts(others, token);
conversations.Add(new Conversation
{
Id = group.ID,
Accounts = accounts.Values.ToList(),
LastStatus = await _mapper.Status(last, MyId, token)
});
}
return Json(conversations);
}
[HttpPost("/api/v1/conversations/{id}/read"), Scope("write:conversations")]
public async Task<IActionResult> ReadConversation(string id, CancellationToken token)
{
var group = await _dbEntities.DmGroups.Match(g => g.ID == id && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId)).ExecuteFirstAsync(token);
if (group == default)
return NotFoundError();
var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID && !p.DeletedAt.HasValue).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token);
return Json(new Conversation { Id = group.ID, LastStatus = last == default ? default : await _mapper.Status(last, MyId, token) });
}
[HttpGet("/api/v1/markers"), Scope("read:statuses")]
public async Task<IActionResult> Markers(CancellationToken token)
{
var wanted = Params.List("timeline");
var markers = await DB.Default.Find<MarkerEntity>().Match(m => m.AvatarId == MyId).ExecuteAsync(token);
return Json(markers.Where(m => wanted.Count == 0 || wanted.Contains(m.Timeline))
.ToDictionary(m => m.Timeline, m => new Entities.Marker { LastReadId = m.LastReadId, Version = m.Version, UpdatedAt = MastodonJson.Time(m.UpdatedAt) }));
}
[HttpPost("/api/v1/markers"), Scope("write:statuses")]
public async Task<IActionResult> SaveMarkers(CancellationToken token)
{
var result = new Dictionary<string, Entities.Marker>();
foreach (var timeline in new[] { "home", "notifications" })
{
var lastRead = Params.Get($"{timeline}[last_read_id]");
if (string.IsNullOrEmpty(lastRead))
continue;
var marker = await DB.Default.UpdateAndGet<MarkerEntity>()
.Match(m => m.AvatarId == MyId && m.Timeline == timeline)
.Modify(m => m.LastReadId, lastRead)
.Modify(m => m.UpdatedAt, DateTime.UtcNow)
.Modify(b => b.Inc(m => m.Version, 1))
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
result[timeline] = new Entities.Marker { LastReadId = marker.LastReadId, Version = marker.Version, UpdatedAt = MastodonJson.Time(marker.UpdatedAt) };
}
return Json(result);
}
async Task<IActionResult> Respond(Find<PostEntity, PostEntity> query, string path, CancellationToken token)
{
var posts = await Page.From(Params, Limit()).Fetch(query, p => p.ID, token);
Link(path, posts.LastOrDefault()?.ID, posts.FirstOrDefault()?.ID);
return Json(await _mapper.Statuses(posts, MyId, token));
}
}
public class NotificationsController : MastodonController
{
static readonly Dictionary<NotificationType, string> Names = new()
{
[NotificationType.Mention] = "mention",
[NotificationType.Follow] = "follow",
[NotificationType.FollowRequest] = "follow_request",
[NotificationType.Favourite] = "favourite",
[NotificationType.Reblog] = "reblog",
[NotificationType.Update] = "update"
};
readonly MastodonMapper _mapper;
readonly DbEntities _dbEntities;
public NotificationsController(MastodonMapper mapper, DbEntities dbEntities)
{
_mapper = mapper;
_dbEntities = dbEntities;
}
[HttpGet("/api/v1/notifications"), Scope("read:notifications")]
public async Task<IActionResult> List(CancellationToken token)
{
var types = Params.List("types").Select(Parse).Where(t => t.HasValue).Select(t => t.Value).ToList();
var excluded = Params.List("exclude_types").Select(Parse).Where(t => t.HasValue).Select(t => t.Value).ToList();
var query = _dbEntities.Notifications.Match(n => n.AvatarId == MyId);
if (types.Count > 0)
query.Match(n => types.Contains(n.Type));
if (excluded.Count > 0)
query.Match(n => !excluded.Contains(n.Type));
if (Params.Get("account_id") is { } accountId)
query.Match(n => n.FromAccountId == accountId);
var notifications = await Page.From(Params, Limit(15, 30)).Fetch(query, n => n.ID, token);
Link("/api/v1/notifications", notifications.LastOrDefault()?.ID, notifications.FirstOrDefault()?.ID);
return Json(await Map(notifications, token));
}
[HttpGet("/api/v1/notifications/{id}"), Scope("read:notifications")]
public async Task<IActionResult> Get(string id, CancellationToken token)
{
var notification = await _dbEntities.Notifications.Match(n => n.ID == id && n.AvatarId == MyId).ExecuteFirstAsync(token);
return notification == default ? NotFoundError() : Json((await Map(new List<NotificationEntity> { notification }, token)).FirstOrDefault());
}
[HttpPost("/api/v1/notifications/clear"), Scope("write:notifications")]
public async Task<IActionResult> Clear(CancellationToken token)
{
await DB.Default.DeleteAsync<NotificationEntity>(n => n.AvatarId == MyId);
return Json(new { });
}
[HttpPost("/api/v1/notifications/{id}/dismiss"), Scope("write:notifications")]
public async Task<IActionResult> Dismiss(string id, CancellationToken token)
{
await DB.Default.DeleteAsync<NotificationEntity>(n => n.ID == id && n.AvatarId == MyId);
return Json(new { });
}
[HttpGet("/api/v1/notifications/unread_count"), Scope("read:notifications")]
public async Task<IActionResult> UnreadCount(CancellationToken token) =>
Json(new { count = await DB.Default.CountAsync<NotificationEntity>(n => n.AvatarId == MyId && !n.IsRead, token) });
async Task<List<Entities.Notification>> Map(List<NotificationEntity> notifications, CancellationToken token)
{
var accounts = await _mapper.Accounts(notifications.Select(n => n.FromAccountId), token);
var postIds = notifications.Where(n => n.PostId != default).Select(n => n.PostId).Distinct().ToList();
var posts = postIds.Count == 0
? new List<PostEntity>()
: await _dbEntities.Posts.Match(p => postIds.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token);
var statuses = (await _mapper.Statuses(posts, MyId, token)).ToDictionary(s => s.Id);
return notifications
.Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty) && (n.PostId == default || statuses.ContainsKey(n.PostId)))
.Select(n => new Entities.Notification
{
Id = n.ID,
Type = Names[n.Type],
CreatedAt = MastodonJson.Time(n.CreatedAt),
GroupKey = $"ungrouped-{n.ID}",
Account = accounts[n.FromAccountId],
Status = n.PostId == default ? default : statuses[n.PostId]
})
.ToList();
}
static NotificationType? Parse(string name) => Names.FirstOrDefault(n => n.Value == name) is { Value: not null } pair ? pair.Key : (NotificationType?)null;
}
}
+200
View File
@@ -0,0 +1,200 @@
namespace PrivaPub.Api.Mastodon.Entities
{
public class Account
{
public string Id { get; set; }
public string Username { get; set; }
public string Acct { get; set; }
public string DisplayName { get; set; }
public bool Locked { get; set; }
public bool Bot { get; set; }
public bool? Discoverable { get; set; }
public bool Indexable { get; set; }
public bool Group { get; set; }
public string CreatedAt { get; set; }
public string Note { get; set; }
public string Url { get; set; }
public string Uri { get; set; }
public string Avatar { get; set; }
public string AvatarStatic { get; set; }
public string Header { get; set; }
public string HeaderStatic { get; set; }
public int FollowersCount { get; set; }
public int FollowingCount { get; set; }
public int StatusesCount { get; set; }
public string LastStatusAt { get; set; }
public bool? HideCollections { get; set; }
public bool Noindex { get; set; } = true;
public List<object> Emojis { get; set; } = new();
public List<object> Roles { get; set; } = new();
public List<Field> Fields { get; set; } = new();
public Source Source { get; set; }
}
public class Field
{
public string Name { get; set; }
public string Value { get; set; }
public string VerifiedAt { get; set; }
}
public class Source
{
public string Privacy { get; set; } = "public";
public bool Sensitive { get; set; }
public string Language { get; set; }
public string Note { get; set; } = string.Empty;
public List<Field> Fields { get; set; } = new();
public int FollowRequestsCount { get; set; }
public string AttributionDomains { get; set; }
}
public class Status
{
public string Id { get; set; }
public string Uri { get; set; }
public string Url { get; set; }
public Account Account { get; set; }
public string InReplyToId { get; set; }
public string InReplyToAccountId { get; set; }
public Status Reblog { get; set; }
public string Content { get; set; } = string.Empty;
public string CreatedAt { get; set; }
public string EditedAt { get; set; }
public List<object> Emojis { get; set; } = new();
public int RepliesCount { get; set; }
public int ReblogsCount { get; set; }
public int FavouritesCount { get; set; }
public bool Reblogged { get; set; }
public bool Favourited { get; set; }
public bool Muted { get; set; }
public bool Bookmarked { get; set; }
public bool Pinned { get; set; }
public bool Sensitive { get; set; }
public string SpoilerText { get; set; } = string.Empty;
public string Visibility { get; set; }
public List<MediaAttachment> MediaAttachments { get; set; } = new();
public List<StatusMention> Mentions { get; set; } = new();
public List<StatusTag> Tags { get; set; } = new();
public object Card { get; set; }
public object Poll { get; set; }
public Application Application { get; set; }
public string Language { get; set; }
public string Text { get; set; }
public List<object> Filtered { get; set; } = new();
}
public class MediaAttachment
{
public string Id { get; set; }
public string Type { get; set; }
public string Url { get; set; }
public string PreviewUrl { get; set; }
public string RemoteUrl { get; set; }
public object Meta { get; set; }
public string Description { get; set; }
public string Blurhash { get; set; }
}
public class StatusMention
{
public string Id { get; set; }
public string Username { get; set; }
public string Url { get; set; }
public string Acct { get; set; }
}
public class StatusTag
{
public string Name { get; set; }
public string Url { get; set; }
}
public class Application
{
public string Id { get; set; }
public string Name { get; set; }
public string Website { get; set; }
public List<string> Scopes { get; set; } = new();
public List<string> RedirectUris { get; set; } = new();
public string RedirectUri { get; set; }
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public string VapidKey { get; set; } = string.Empty;
}
public class Relationship
{
public string Id { get; set; }
public bool Following { get; set; }
public bool ShowingReblogs { get; set; }
public bool Notifying { get; set; }
public List<string> Languages { get; set; }
public bool FollowedBy { get; set; }
public bool Blocking { get; set; }
public bool BlockedBy { get; set; }
public bool Muting { get; set; }
public bool MutingNotifications { get; set; }
public bool Requested { get; set; }
public bool RequestedBy { get; set; }
public bool DomainBlocking { get; set; }
public bool Endorsed { get; set; }
public string Note { get; set; } = string.Empty;
}
public class Notification
{
public string Id { get; set; }
public string Type { get; set; }
public string CreatedAt { get; set; }
public string GroupKey { get; set; }
public Account Account { get; set; }
public Status Status { get; set; }
}
public class Context
{
public List<Status> Ancestors { get; set; } = new();
public List<Status> Descendants { get; set; } = new();
}
public class StatusEdit
{
public string Content { get; set; }
public string SpoilerText { get; set; }
public bool Sensitive { get; set; }
public string CreatedAt { get; set; }
public Account Account { get; set; }
public List<MediaAttachment> MediaAttachments { get; set; } = new();
public List<object> Emojis { get; set; } = new();
}
public class StatusSource
{
public string Id { get; set; }
public string Text { get; set; }
public string SpoilerText { get; set; }
}
public class SearchResults
{
public List<Account> Accounts { get; set; } = new();
public List<Status> Statuses { get; set; } = new();
public List<object> Hashtags { get; set; } = new();
}
public class Conversation
{
public string Id { get; set; }
public bool Unread { get; set; }
public List<Account> Accounts { get; set; } = new();
public Status LastStatus { get; set; }
}
public class Marker
{
public string LastReadId { get; set; }
public int Version { get; set; }
public string UpdatedAt { get; set; }
}
}
@@ -0,0 +1,105 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using MongoDB.Entities;
using OpenIddict.Abstractions;
using OpenIddict.Validation.AspNetCore;
using PrivaPub.Api.Mastodon.Auth;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace PrivaPub.Api.Mastodon.Infrastructure
{
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public sealed class ScopeAttribute : Attribute
{
public ScopeAttribute(string scope, bool requiresUser = true)
{
Scope = scope;
RequiresUser = requiresUser;
}
public string Scope { get; }
public bool RequiresUser { get; }
}
[ApiController, Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)]
public abstract class MastodonController : ControllerBase, IAsyncActionFilter
{
MastodonParams _params;
protected LocalActor Me { get; private set; }
protected string MyId => Me?.Id;
protected MastodonParams Params => _params;
[NonAction]
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
_params = await MastodonParams.Read(Request, HttpContext.RequestAborted);
var scope = context.ActionDescriptor.EndpointMetadata.OfType<ScopeAttribute>().LastOrDefault();
if (User.Identity?.IsAuthenticated == true)
{
if (scope != default && !MastodonScopes.Grants(User.GetScopes(), scope.Scope))
{
context.Result = Error(StatusCodes.Status403Forbidden, "This action is outside the authorized scopes");
return;
}
Me = await Resolve(User.GetClaim(Claims.Subject), HttpContext.RequestAborted);
}
if (scope?.RequiresUser == true && Me == default)
{
context.Result = Error(StatusCodes.Status401Unauthorized, "This method requires an authenticated user");
return;
}
await next();
}
async Task<LocalActor> Resolve(string avatarId, CancellationToken token)
{
if (string.IsNullOrEmpty(avatarId))
return default;
var services = HttpContext.RequestServices;
var db = services.GetRequiredService<DbEntities>();
var link = await db.RootToAvatars.Match(r => r.AvatarId == avatarId).ExecuteFirstAsync(token);
if (link == default)
return default;
var root = await db.RootUsers.MatchID(link.RootId).ExecuteFirstAsync(token);
if (root is not { IsBanned: false, DeletedAt: null })
return default;
return await services.GetRequiredService<ILocalActorService>().FindById(LocalActorKind.Person, avatarId, token);
}
protected JsonResult Json(object value, int status = StatusCodes.Status200OK) =>
new(value, MastodonJson.Options) { StatusCode = status };
protected static JsonResult Error(int status, string message) =>
new(new { error = message }, MastodonJson.Options) { StatusCode = status };
protected JsonResult NotFoundError() => Error(StatusCodes.Status404NotFound, "Record not found");
protected void Link(string path, string maxId, string minId)
{
var links = new List<string>();
var baseUrl = $"{Request.Scheme}://{Request.Host}{path}";
var query = Request.Query.Where(q => q.Key is not ("max_id" or "min_id" or "since_id")).Select(q => $"{q.Key}={Uri.EscapeDataString(q.Value.ToString())}").ToList();
string With(string key, string value) => baseUrl + "?" + string.Join("&", query.Append($"{key}={value}"));
if (maxId != default)
links.Add($"<{With("max_id", maxId)}>; rel=\"next\"");
if (minId != default)
links.Add($"<{With("min_id", minId)}>; rel=\"prev\"");
if (links.Count > 0)
Response.Headers["Link"] = string.Join(", ", links);
}
protected int Limit(int fallback = 20, int max = 40) => Math.Clamp(Params.Int("limit") ?? fallback, 1, max);
}
}
@@ -0,0 +1,24 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace PrivaPub.Api.Mastodon.Infrastructure
{
public static class MastodonJson
{
public static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DictionaryKeyPolicy = default,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }
};
public static string Time(DateTime value) =>
DateTime.SpecifyKind(value, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ss.fffZ", System.Globalization.CultureInfo.InvariantCulture);
public static string Day(DateTime value) =>
DateTime.SpecifyKind(value, DateTimeKind.Utc).Date.ToString("yyyy-MM-ddT00:00:00.000Z", System.Globalization.CultureInfo.InvariantCulture);
}
}
@@ -0,0 +1,109 @@
using Microsoft.AspNetCore.Http.Features;
using System.Text.Json;
namespace PrivaPub.Api.Mastodon.Infrastructure
{
public sealed class MastodonParams
{
readonly Dictionary<string, List<string>> _values;
MastodonParams(Dictionary<string, List<string>> values)
{
_values = values;
}
public static async Task<MastodonParams> Read(HttpRequest request, CancellationToken token)
{
var values = new Dictionary<string, List<string>>(StringComparer.Ordinal);
foreach (var pair in request.Query)
foreach (var value in pair.Value)
Add(values, pair.Key, value);
if (request.HasFormContentType)
{
var form = await request.ReadFormAsync(token);
foreach (var pair in form)
foreach (var value in pair.Value)
Add(values, pair.Key, value);
}
else if (request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true && request.ContentLength != 0)
{
try
{
using var document = await JsonDocument.ParseAsync(request.Body, cancellationToken: token);
Flatten(values, default, document.RootElement);
}
catch (JsonException)
{
}
}
return new MastodonParams(values);
}
public static MastodonParams From(IEnumerable<KeyValuePair<string, string>> pairs)
{
var values = new Dictionary<string, List<string>>(StringComparer.Ordinal);
foreach (var pair in pairs)
Add(values, pair.Key, pair.Value);
return new MastodonParams(values);
}
public bool Has(string name) => _values.ContainsKey(name);
public string Get(string name) => _values.TryGetValue(name, out var list) && list.Count > 0 ? list[^1] : default;
public IReadOnlyList<string> List(string name) => _values.TryGetValue(name, out var list) ? list : (IReadOnlyList<string>)Array.Empty<string>();
public bool? Bool(string name) => Get(name)?.ToLowerInvariant() switch
{
"true" or "1" or "on" or "yes" => true,
"false" or "0" or "off" or "no" or "" => false,
_ => (bool?)null
};
public int? Int(string name) => int.TryParse(Get(name), out var value) ? value : (int?)null;
static void Add(Dictionary<string, List<string>> values, string key, string value)
{
key = key.EndsWith("[]", StringComparison.Ordinal) ? key[..^2] : key;
if (!values.TryGetValue(key, out var list))
values[key] = list = new List<string>();
list.Add(value);
}
static void Flatten(Dictionary<string, List<string>> values, string prefix, JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
foreach (var property in element.EnumerateObject())
Flatten(values, prefix == default ? property.Name : $"{prefix}[{property.Name}]", property.Value);
break;
case JsonValueKind.Array:
foreach (var item in element.EnumerateArray())
{
if (item.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
Flatten(values, prefix + "[]", item);
else
Add(values, prefix, Scalar(item));
}
break;
case JsonValueKind.Null or JsonValueKind.Undefined:
break;
default:
if (prefix != default)
Add(values, prefix, Scalar(element));
break;
}
}
static string Scalar(JsonElement element) => element.ValueKind switch
{
JsonValueKind.String => element.GetString(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => element.GetRawText()
};
}
}
@@ -0,0 +1,30 @@
using MongoDB.Driver;
using MongoDB.Entities;
using System.Linq.Expressions;
namespace PrivaPub.Api.Mastodon.Infrastructure
{
public sealed record Page(string MaxId, string SinceId, string MinId, int Limit)
{
public static Page From(MastodonParams parameters, int limit) =>
new(parameters.Get("max_id"), parameters.Get("since_id"), parameters.Get("min_id"), limit);
public async Task<List<T>> Fetch<T>(Find<T, T> query, Expression<Func<T, string>> id, CancellationToken token) where T : IEntity
{
var field = new ExpressionFieldDefinition<T, string>(id);
if (!string.IsNullOrEmpty(MaxId))
query.Match(Builders<T>.Filter.Lt(field, MaxId));
if (!string.IsNullOrEmpty(SinceId))
query.Match(Builders<T>.Filter.Gt(field, SinceId));
if (!string.IsNullOrEmpty(MinId))
query.Match(Builders<T>.Filter.Gt(field, MinId));
var ascending = !string.IsNullOrEmpty(MinId);
var sortField = new ExpressionFieldDefinition<T>(Expression.Lambda<Func<T, object>>(Expression.Convert(id.Body, typeof(object)), id.Parameters));
var items = await query.Sort(s => ascending ? s.Ascending(sortField) : s.Descending(sortField)).Limit(Limit).ExecuteAsync(token);
if (ascending)
items.Reverse();
return items;
}
}
}
@@ -0,0 +1,55 @@
using PrivaPub.Api.Mastodon.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.StaticServices;
namespace PrivaPub.Api.Mastodon.Mappers
{
public class AccountSearch
{
readonly MastodonMapper _mapper;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
public AccountSearch(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors)
{
_mapper = mapper;
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
}
public async Task<List<Account>> Find(string q, bool resolve, int limit, CancellationToken token)
{
q = q?.Trim().TrimStart('@');
if (string.IsNullOrEmpty(q))
return new List<Account>();
var results = new List<Account>();
var parts = q.Split('@');
var localDomain = new Uri(_localActors.BaseAddress).Authority;
if (parts.Length == 2 && !parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(parts[0]))
{
var userName = parts[0];
var domain = parts[1].ToLowerInvariant();
var known = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain).ExecuteFirstAsync(token);
if (known == default && resolve)
{
var actorUri = await _remoteActors.ResolveHandle(q, token);
known = actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token);
}
if (known != default)
results.Add(_mapper.Foreign(known));
return results;
}
var prefix = parts[0].ToLowerInvariant();
foreach (var avatar in await _dbEntities.Avatars.Match(a => a.UserName.StartsWith(prefix) && !a.DeletionAt.HasValue && a.Settings.IsDiscoverable != false)
.Limit(limit).ExecuteAsync(token))
results.Add(await _mapper.Local(_localActors.FromAvatar(avatar), false, token));
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => f.UserName.StartsWith(parts[0]) && f.IsDiscoverable)
.Limit(Math.Max(0, limit - results.Count)).ExecuteAsync(token))
results.Add(_mapper.Foreign(foreign));
return results;
}
}
}
@@ -0,0 +1,256 @@
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Net;
using GroupEntity = PrivaPub.Models.Group.Group;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Api.Mastodon.Mappers
{
public class MastodonMapper
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
public MastodonMapper(DbEntities dbEntities, ILocalActorService localActors)
{
_dbEntities = dbEntities;
_localActors = localActors;
}
string MissingAvatar => $"{_localActors.BaseAddress}/media/missing-avatar.png";
string MissingHeader => $"{_localActors.BaseAddress}/media/missing-header.png";
public async Task<Account> Local(LocalActor actor, bool withSource, CancellationToken token)
{
var followers = await DB.Default.CountAsync<Follower>(f => f.LocalActorId == actor.Id && f.LocalActorKind == actor.Kind && f.IsAccepted, token);
var following = actor.Kind == LocalActorKind.Person
? await DB.Default.CountAsync<Following>(f => f.AvatarId == actor.Id && f.State == FollowState.Accepted, token)
: 0;
var latest = await _dbEntities.Posts
.Match(p => (actor.Kind == LocalActorKind.Group ? p.GroupId == actor.Id : p.GroupUserId == actor.Id) && !p.IsFederatedCopy
&& !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct && p.ReblogOfPostId == null)
.Sort(p => p.ID, Order.Descending)
.Limit(1)
.ExecuteFirstAsync(token);
var statuses = latest == default
? 0
: await DB.Default.CountAsync<PostEntity>(p => (actor.Kind == LocalActorKind.Group ? p.GroupId == actor.Id : p.GroupUserId == actor.Id)
&& !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct && p.ReblogOfPostId == null, token);
var account = new Account
{
Id = actor.Id,
Username = actor.UserName,
Acct = actor.UserName,
DisplayName = actor.Name ?? actor.UserName,
Locked = actor.ManuallyApprovesFollowers,
Bot = actor.IsBot,
Discoverable = actor.Discoverable,
Indexable = actor.Indexable,
Noindex = !actor.Indexable,
HideCollections = actor.Settings.HideCollections,
Group = actor.Kind == LocalActorKind.Group,
CreatedAt = MastodonJson.Day(actor.Published),
Note = ActivityPubRenderer.Html(actor.Summary),
Url = actor.HtmlUrl,
Uri = actor.Uri,
Avatar = actor.PictureURL ?? MissingAvatar,
AvatarStatic = actor.PictureURL ?? MissingAvatar,
Header = actor.ThumbnailURL ?? MissingHeader,
HeaderStatic = actor.ThumbnailURL ?? MissingHeader,
FollowersCount = (int)followers,
FollowingCount = (int)following,
StatusesCount = (int)statuses,
LastStatusAt = latest == default ? default : latest.CreationDate.ToString("yyyy-MM-dd"),
Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = ActivityPubRenderer.FieldValue(f.Value) }).ToList()
};
if (withSource)
account.Source = new Source
{
Privacy = actor.Settings.DefaultVisibility ?? "public",
Sensitive = actor.Settings.DefaultSensitive,
Language = actor.Settings.DefaultLanguage,
Note = actor.Summary ?? string.Empty,
Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(),
FollowRequestsCount = (int)await DB.Default.CountAsync<Follower>(f => f.LocalActorId == actor.Id && !f.IsAccepted, token)
};
return account;
}
public Account Foreign(ForeignAvatar foreign) => new()
{
Id = foreign.ID,
Username = foreign.UserName,
Acct = $"{foreign.UserName}@{foreign.Domain}",
DisplayName = foreign.Name ?? foreign.UserName ?? string.Empty,
Bot = foreign.AvatarType is AvatarType.Service or AvatarType.Application,
Group = foreign.AvatarType == AvatarType.Group,
Discoverable = foreign.IsDiscoverable,
CreatedAt = MastodonJson.Day(foreign.CreatedAt),
Note = foreign.Biography ?? string.Empty,
Url = foreign.Url ?? foreign.ActorURI,
Uri = foreign.ActorURI,
Avatar = foreign.PictureURL ?? MissingAvatar,
AvatarStatic = foreign.PictureURL ?? MissingAvatar,
Header = foreign.ThumbnailURL ?? MissingHeader,
HeaderStatic = foreign.ThumbnailURL ?? MissingHeader
};
public async Task<Account> Account(string id, CancellationToken token) =>
(await Accounts(new[] { id }, token)).GetValueOrDefault(id);
public async Task<Dictionary<string, Account>> Accounts(IEnumerable<string> ids, CancellationToken token)
{
var wanted = ids.Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList();
var accounts = new Dictionary<string, Account>();
if (wanted.Count == 0)
return accounts;
foreach (var avatar in await _dbEntities.Avatars.Match(a => wanted.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token))
accounts[avatar.ID] = await Local(_localActors.FromAvatar(avatar), false, token);
foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue).ExecuteAsync(token))
accounts[group.ID] = await Local(_localActors.FromGroup(group), false, token);
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID)).ExecuteAsync(token))
accounts[foreign.ID] = Foreign(foreign);
return accounts;
}
public async Task<Status> Status(PostEntity post, string viewerId, CancellationToken token) =>
(await Statuses(new[] { post }, viewerId, token)).FirstOrDefault();
public async Task<List<Status>> Statuses(IReadOnlyCollection<PostEntity> posts, string viewerId, CancellationToken token)
{
var originalIds = posts.Where(p => p.ReblogOfPostId != default).Select(p => p.ReblogOfPostId).Distinct().ToList();
var originals = originalIds.Count == 0
? new Dictionary<string, PostEntity>()
: (await _dbEntities.Posts.Match(p => originalIds.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID);
var all = posts.Concat(originals.Values).ToList();
var mentionUris = all.SelectMany(p => p.Mentions).Where(m => !m.IsLocal).Select(m => m.ActorURI).Distinct().ToList();
var mentionAccounts = mentionUris.Count == 0
? new Dictionary<string, ForeignAvatar>()
: (await _dbEntities.ForeignAvatars.Match(f => mentionUris.Contains(f.ActorURI)).ExecuteAsync(token)).ToDictionary(f => f.ActorURI);
var accounts = await Accounts(all.Select(AuthorOf), token);
var ids = all.Select(p => p.ID).ToList();
var favourited = viewerId == default
? new HashSet<string>()
: (await _dbEntities.Favourites.Match(f => f.AccountId == viewerId && ids.Contains(f.PostId)).ExecuteAsync(token)).Select(f => f.PostId).ToHashSet();
var reblogged = viewerId == default
? new HashSet<string>()
: (await _dbEntities.Posts.Match(p => p.AuthorAccountId == viewerId && ids.Contains(p.ReblogOfPostId) && !p.DeletedAt.HasValue).ExecuteAsync(token))
.Select(p => p.ReblogOfPostId).ToHashSet();
Status Map(PostEntity post)
{
if (!accounts.TryGetValue(AuthorOf(post), out var account))
return default;
var status = new Status
{
Id = post.ID,
Uri = post.ObjectURI,
Url = post.Url ?? post.ObjectURI,
Account = account,
InReplyToId = post.AnsweringToPostId,
InReplyToAccountId = post.InReplyToAccountId,
Content = Content(post),
CreatedAt = MastodonJson.Time(post.CreationDate),
EditedAt = post.EditedAt.HasValue ? MastodonJson.Time(post.EditedAt.Value) : default,
RepliesCount = post.RepliesCount,
ReblogsCount = post.ReblogsCount,
FavouritesCount = post.FavouritesCount,
Favourited = favourited.Contains(post.ID),
Reblogged = reblogged.Contains(post.ID),
Sensitive = post.HasContentWarning,
SpoilerText = post.SpoilerText ?? (post.HasContentWarning ? post.Title ?? ActivityPubRenderer.ContentWarning : string.Empty),
Visibility = Visibility(post.Visibility),
Language = post.Language,
MediaAttachments = post.Media.Select(Media).ToList(),
Mentions = post.Mentions.Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList()
};
return status;
}
var mapped = new List<Status>();
foreach (var post in posts)
{
var status = Map(post);
if (status == default)
continue;
if (post.ReblogOfPostId != default)
{
if (!originals.TryGetValue(post.ReblogOfPostId, out var original) || Map(original) is not { } inner)
continue;
status.Reblog = inner;
status.Content = string.Empty;
status.Reblogged = reblogged.Contains(original.ID);
}
mapped.Add(status);
}
return mapped;
}
public static string AuthorOf(PostEntity post) => post.AuthorAccountId ?? post.GroupUserId;
public static string Content(PostEntity post)
{
var content = post.ContentHtml ?? ActivityPubRenderer.Html(post.Text);
return string.IsNullOrEmpty(post.Title) || post.IsFederatedCopy
? content ?? string.Empty
: $"<p><strong>{WebUtility.HtmlEncode(post.Title)}</strong></p>{content}";
}
public static string Visibility(PostVisibility visibility) => visibility switch
{
PostVisibility.Unlisted => "unlisted",
PostVisibility.FollowersOnly or PostVisibility.Circle => "private",
PostVisibility.Direct => "direct",
_ => "public"
};
StatusMention Mention(PostMention mention, IReadOnlyDictionary<string, ForeignAvatar> foreign)
{
if (mention.IsLocal)
{
var handle = mention.Handle?.TrimStart('@').Split('@')[0];
return new StatusMention { Id = mention.AccountId, Username = handle, Acct = handle, Url = mention.ActorURI };
}
if (!foreign.TryGetValue(mention.ActorURI, out var account))
return default;
return new StatusMention { Id = account.ID, Username = account.UserName, Acct = $"{account.UserName}@{account.Domain}", Url = account.Url ?? account.ActorURI };
}
static MediaAttachment Media(PostMedia media) => new()
{
Id = media.Id.ToString("N"),
Type = media.ContentType switch
{
{ } type when type.StartsWith("image/gif") => "gifv",
{ } type when type.StartsWith("image/") => "image",
{ } type when type.StartsWith("video/") => "video",
{ } type when type.StartsWith("audio/") => "audio",
_ => "unknown"
},
Url = media.URL ?? media.RemoteURL,
PreviewUrl = media.URL ?? media.RemoteURL,
RemoteUrl = media.RemoteURL,
Description = media.Description,
Blurhash = media.Blurhash,
Meta = media.Width.HasValue && media.Height.HasValue
? new { original = new { width = media.Width, height = media.Height, aspect = (double)media.Width / media.Height.Value } }
: default
};
}
}
@@ -45,7 +45,7 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(RemoveUsers)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(RemoveUsers)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
@@ -73,7 +73,7 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(BanUsers)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(BanUsers)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
@@ -99,7 +99,7 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(UnbanUsers)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(UnbanUsers)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
@@ -122,7 +122,7 @@ namespace PrivaPub.Controllers.ClientToServer
// catch (Exception ex) // catch (Exception ex)
// { // {
// Logger.LogError(ex, $"{nameof(User)}.{nameof(GetUsers)}()"); // Logger.LogError(ex, $"{nameof(User)}.{nameof(GetUsers)}()");
// return BadRequest(result.Invalidate(ex.Message)); // return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
// } // }
//} //}
} }
@@ -48,7 +48,7 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(DataController)}.{nameof(Languages)}()"); Logger.LogError(ex, $"{nameof(DataController)}.{nameof(Languages)}()");
return BadRequest(result.Invalidate(ex.Message, exception: ex)); return BadRequest(result.Invalidate("Something went wrong.", exception: ex));
} }
} }
} }
@@ -0,0 +1,74 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Admin;
using PrivaPub.Federation.Moderation;
using PrivaPub.Models.Federation;
using PrivaPub.Resources;
namespace PrivaPub.Controllers.ClientToServer
{
[ApiController,
Route("clientapi/admin/domainblocks"),
Authorize(Policy = Policies.IsAdmin)]
public class DomainBlockController : ControllerBase
{
readonly IDomainBlocks _domainBlocks;
readonly IStringLocalizer _localizer;
public DomainBlockController(IDomainBlocks domainBlocks, IStringLocalizer<GenericRes> localizer)
{
_domainBlocks = domainBlocks;
_localizer = localizer;
}
[HttpGet, Route("/clientapi/admin/domainblocks/list")]
public async Task<IActionResult> List(CancellationToken token) =>
Ok((await DB.Default.Find<DomainBlock>().Sort(b => b.Domain, Order.Ascending).ExecuteAsync(token)).Select(ToView).ToList());
[HttpPost, Route("/clientapi/admin/domainblocks/insert")]
public async Task<IActionResult> Insert(DomainBlockForm form, CancellationToken token)
{
var domain = DomainBlocks.Normalise(form.Domain);
if (!ModelState.IsValid || Uri.CheckHostName(domain) != UriHostNameType.Dns)
return BadRequest(new WebResult().Invalidate(_localizer["Invalid model."]));
var block = await DB.Default.UpdateAndGet<DomainBlock>()
.Match(b => b.Domain == domain)
.Modify(b => b.Domain, domain)
.Modify(b => b.Severity, form.Suspend ? DomainBlockSeverity.Suspend : DomainBlockSeverity.Silence)
.Modify(b => b.RejectMedia, form.RejectMedia)
.Modify(b => b.PublicComment, form.PublicComment)
.Modify(b => b.PrivateComment, form.PrivateComment)
.Modify(b => b.SetOnInsert(x => x.CreatedAt, DateTime.UtcNow))
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
await _domainBlocks.Reload(token);
return Ok(ToView(block));
}
[HttpPost, Route("/clientapi/admin/domainblocks/delete")]
public async Task<IActionResult> Delete([FromQuery] string domain, CancellationToken token)
{
domain = DomainBlocks.Normalise(domain);
await DB.Default.DeleteAsync<DomainBlock>(b => b.Domain == domain);
await _domainBlocks.Reload(token);
return Ok();
}
static ViewDomainBlock ToView(DomainBlock block) => new()
{
Id = block.ID,
Domain = block.Domain,
Severity = block.Severity.ToString(),
RejectMedia = block.RejectMedia,
PublicComment = block.PublicComment,
PrivateComment = block.PrivateComment,
CreatedAt = block.CreatedAt
};
}
}
@@ -32,6 +32,10 @@ namespace PrivaPub.Controllers.ClientToServer
public async Task<IActionResult> Insert(InsertPostForm form, CancellationToken token) => public async Task<IActionResult> Insert(InsertPostForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _postsService.InsertPost(User.GetUserId(), form, token)); !ModelState.IsValid ? Invalid() : Answer(await _postsService.InsertPost(User.GetUserId(), form, token));
[HttpPost, Route("/clientapi/post/update")]
public async Task<IActionResult> Update(UpdatePostForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _postsService.UpdatePost(User.GetUserId(), form, token));
[HttpPost, Route("/clientapi/post/delete")] [HttpPost, Route("/clientapi/post/delete")]
public async Task<IActionResult> Delete(DeletePostForm form, CancellationToken token) => public async Task<IActionResult> Delete(DeletePostForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _postsService.DeletePost(User.GetUserId(), form, token)); !ModelState.IsValid ? Invalid() : Answer(await _postsService.DeletePost(User.GetUserId(), form, token));
@@ -52,7 +52,7 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"{nameof(PrivateAvatarController)}.{nameof(InsertAvatar)}()"); _logger.LogError(ex, $"{nameof(PrivateAvatarController)}.{nameof(InsertAvatar)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(_localizer["Something went wrong."]));
} }
} }
@@ -74,7 +74,7 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"{nameof(PrivateAvatarController)}.{nameof(UpdateAvatar)}()"); _logger.LogError(ex, $"{nameof(PrivateAvatarController)}.{nameof(UpdateAvatar)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(_localizer["Something went wrong."]));
} }
} }
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -10,6 +11,8 @@ using PrivaPub.ClientModels.Group;
using PrivaPub.ClientModels.User; using PrivaPub.ClientModels.User;
using PrivaPub.ClientModels.User.Avatar; using PrivaPub.ClientModels.User.Avatar;
using PrivaPub.Extensions; using PrivaPub.Extensions;
using PrivaPub.Federation.Actors;
using PrivaPub.Infrastructure;
using PrivaPub.Models; using PrivaPub.Models;
using PrivaPub.Models.User; using PrivaPub.Models.User;
using PrivaPub.Resources; using PrivaPub.Resources;
@@ -28,6 +31,7 @@ namespace PrivaPub.Controllers.ClientToServer
readonly IRootUsersService UsersService; readonly IRootUsersService UsersService;
readonly IGroupUsersService GroupUsersService; readonly IGroupUsersService GroupUsersService;
readonly IPrivateAvatarUsersService AvatarUsersService; readonly IPrivateAvatarUsersService AvatarUsersService;
readonly ILocalActorService LocalActors;
readonly AuthTokenManager AuthTokenManager; readonly AuthTokenManager AuthTokenManager;
readonly IOptionsMonitor<AppConfiguration> AppConfiguration; readonly IOptionsMonitor<AppConfiguration> AppConfiguration;
readonly ILogger<RootUserController> Logger; readonly ILogger<RootUserController> Logger;
@@ -36,6 +40,7 @@ namespace PrivaPub.Controllers.ClientToServer
public RootUserController(IRootUsersService usersService, public RootUserController(IRootUsersService usersService,
IGroupUsersService groupUsersService, IGroupUsersService groupUsersService,
IPrivateAvatarUsersService avatarUsersService, IPrivateAvatarUsersService avatarUsersService,
ILocalActorService localActors,
AuthTokenManager authTokenManager, AuthTokenManager authTokenManager,
IOptionsMonitor<AppConfiguration> appConfiguration, IOptionsMonitor<AppConfiguration> appConfiguration,
IStringLocalizer<GenericRes> localizer, IStringLocalizer<GenericRes> localizer,
@@ -44,6 +49,7 @@ namespace PrivaPub.Controllers.ClientToServer
UsersService = usersService; UsersService = usersService;
GroupUsersService = groupUsersService; GroupUsersService = groupUsersService;
AvatarUsersService = avatarUsersService; AvatarUsersService = avatarUsersService;
LocalActors = localActors;
AuthTokenManager = authTokenManager; AuthTokenManager = authTokenManager;
AppConfiguration = appConfiguration; AppConfiguration = appConfiguration;
Localizer = localizer; Localizer = localizer;
@@ -52,7 +58,7 @@ namespace PrivaPub.Controllers.ClientToServer
#region User endpoints #region User endpoints
[HttpPost, Route("/clientapi/user/signup"), Authorize(Policy = Policies.IsUser), AllowAnonymous] [HttpPost, Route("/clientapi/user/signup"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser), AllowAnonymous]
public async Task<IActionResult> SignUp(LoginForm signUpForm) public async Task<IActionResult> SignUp(LoginForm signUpForm)
{ {
if (User.Identity?.IsAuthenticated ?? false) return Redirect("/"); if (User.Identity?.IsAuthenticated ?? false) return Redirect("/");
@@ -67,18 +73,16 @@ namespace PrivaPub.Controllers.ClientToServer
(var user, var userSettings) = ((RootUser, ViewUserSettings))result.Data; (var user, var userSettings) = ((RootUser, ViewUserSettings))result.Data;
var jwtUser = AuthTokenManager.GenerateToken(user, userSettings); var jwtUser = AuthTokenManager.GenerateToken(user, userSettings);
Logger.LogInformation(
$"{nameof(SignUp)}();IP:[{HttpContext.Connection?.RemoteIpAddress}];\nUser-Agent:[{Request.Headers["User-Agent"]}];\nUserId:[{user.ID}]");
return Ok(jwtUser); return Ok(jwtUser);
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(SignUp)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(SignUp)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
[HttpPost, Route("/clientapi/user/login"), Authorize(Policy = Policies.IsUser), AllowAnonymous] [HttpPost, Route("/clientapi/user/login"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser), AllowAnonymous]
public async Task<IActionResult> Login(LoginForm loginForm) public async Task<IActionResult> Login(LoginForm loginForm)
{ {
if (User.Identity?.IsAuthenticated ?? false) return Redirect("/discussions"); if (User.Identity?.IsAuthenticated ?? false) return Redirect("/discussions");
@@ -94,19 +98,17 @@ namespace PrivaPub.Controllers.ClientToServer
var (user, userSettings) = var (user, userSettings) =
((RootUser, ViewUserSettings))result.Data; ((RootUser, ViewUserSettings))result.Data;
var jwtUser = AuthTokenManager.GenerateToken(user, userSettings); var jwtUser = AuthTokenManager.GenerateToken(user, userSettings);
Logger.LogInformation(
$"{nameof(Login)}();IP:[{HttpContext.Connection?.RemoteIpAddress}];\nUser-Agent:[{Request.Headers["User-Agent"]}];\nUserId:[{user.ID}]");
return Ok(jwtUser); return Ok(jwtUser);
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(Login)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(Login)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
[HttpPost, Route("/clientapi/user/invitation/signup"), AllowAnonymous] [HttpPost, Route("/clientapi/user/invitation/signup"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
public async Task<IActionResult> InvitationSignUp(InvitationLoginForm signUpForm, CancellationToken token) public async Task<IActionResult> InvitationSignUp(InvitationLoginForm signUpForm, CancellationToken token)
{ {
var result = new WebResult(); var result = new WebResult();
@@ -118,6 +120,11 @@ namespace PrivaPub.Controllers.ClientToServer
if (!invitation.IsValid) if (!invitation.IsValid)
return StatusCode(invitation.StatusCode, invitation); return StatusCode(invitation.StatusCode, invitation);
var avatarUserName = signUpForm.AvatarUserName?.Trim().ToLowerInvariant();
var personaProblem = await PersonaProblem(avatarUserName, signUpForm.UserName, token);
if (personaProblem != default)
return BadRequest(result.Invalidate(personaProblem));
result = await UsersService.SignUpAsync(signUpForm, signUpForm.InvitationCode); result = await UsersService.SignUpAsync(signUpForm, signUpForm.InvitationCode);
if (!result.IsValid) if (!result.IsValid)
return StatusCode(result.StatusCode, result); return StatusCode(result.StatusCode, result);
@@ -126,8 +133,8 @@ namespace PrivaPub.Controllers.ClientToServer
var avatar = await AvatarUsersService.InsertAvatar(new InsertAvatarForm var avatar = await AvatarUsersService.InsertAvatar(new InsertAvatarForm
{ {
RootId = user.ID, RootId = user.ID,
Name = user.UserName, Name = string.IsNullOrWhiteSpace(signUpForm.AvatarName) ? avatarUserName : signUpForm.AvatarName.Trim(),
UserName = user.UserName, UserName = avatarUserName,
Biography = string.Empty Biography = string.Empty
}); });
if (!avatar.IsValid) if (!avatar.IsValid)
@@ -142,18 +149,16 @@ namespace PrivaPub.Controllers.ClientToServer
if (!joined.IsValid) if (!joined.IsValid)
return StatusCode(joined.StatusCode, joined); return StatusCode(joined.StatusCode, joined);
Logger.LogInformation(
$"{nameof(InvitationSignUp)}();IP:[{HttpContext.Connection?.RemoteIpAddress}];\nUser-Agent:[{Request.Headers["User-Agent"]}];\nUserId:[{user.ID}]");
return Ok(AuthTokenManager.GenerateToken(user, userSettings)); return Ok(AuthTokenManager.GenerateToken(user, userSettings));
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(InvitationSignUp)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(InvitationSignUp)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
[HttpPost, Route("/clientapi/user/invitation/login"), AllowAnonymous] [HttpPost, Route("/clientapi/user/invitation/login"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
public async Task<IActionResult> InvitationLogin(InvitationLoginForm loginForm, CancellationToken token) public async Task<IActionResult> InvitationLogin(InvitationLoginForm loginForm, CancellationToken token)
{ {
var result = new WebResult(); var result = new WebResult();
@@ -171,14 +176,20 @@ namespace PrivaPub.Controllers.ClientToServer
var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data; var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data;
var avatars = await AvatarUsersService.GetRootAvatars(user.ID, token); var avatars = await AvatarUsersService.GetRootAvatars(user.ID, token);
var avatar = (avatars.Data as List<ViewAvatar>)?.FirstOrDefault(); var avatarUserName = loginForm.AvatarUserName?.Trim().ToLowerInvariant();
var avatar = string.IsNullOrEmpty(avatarUserName)
? (avatars.Data as List<ViewAvatar>)?.FirstOrDefault()
: (avatars.Data as List<ViewAvatar>)?.FirstOrDefault(a => a.UserName == avatarUserName);
if (avatar == default) if (avatar == default)
{ {
var personaProblem = await PersonaProblem(avatarUserName, user.UserName, token);
if (personaProblem != default)
return BadRequest(new WebResult().Invalidate(personaProblem));
var inserted = await AvatarUsersService.InsertAvatar(new InsertAvatarForm var inserted = await AvatarUsersService.InsertAvatar(new InsertAvatarForm
{ {
RootId = user.ID, RootId = user.ID,
Name = user.UserName, Name = string.IsNullOrWhiteSpace(loginForm.AvatarName) ? avatarUserName : loginForm.AvatarName.Trim(),
UserName = user.UserName, UserName = avatarUserName,
Biography = string.Empty Biography = string.Empty
}); });
if (!inserted.IsValid) if (!inserted.IsValid)
@@ -195,17 +206,26 @@ namespace PrivaPub.Controllers.ClientToServer
if (!joined.IsValid) if (!joined.IsValid)
return StatusCode(joined.StatusCode, joined); return StatusCode(joined.StatusCode, joined);
Logger.LogInformation(
$"{nameof(InvitationLogin)}();IP:[{HttpContext.Connection?.RemoteIpAddress}];\nUser-Agent:[{Request.Headers["User-Agent"]}];\nUserId:[{user.ID}]");
return Ok(AuthTokenManager.GenerateToken(user, userSettings)); return Ok(AuthTokenManager.GenerateToken(user, userSettings));
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(InvitationLogin)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(InvitationLogin)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
async Task<string> PersonaProblem(string avatarUserName, string rootUserName, CancellationToken token)
{
if (string.IsNullOrEmpty(avatarUserName))
return Localizer["Choose a username for your persona."];
if (string.Equals(avatarUserName, rootUserName?.Trim(), StringComparison.OrdinalIgnoreCase))
return Localizer["Your persona's username must differ from your login."];
if (await LocalActors.IsUserNameTaken(avatarUserName, token))
return Localizer["The username '{0}' is already take.", avatarUserName];
return default;
}
[HttpGet, Route("/clientapi/user/logout"), Authorize(Policy = Policies.IsUser)] [HttpGet, Route("/clientapi/user/logout"), Authorize(Policy = Policies.IsUser)]
public IActionResult Logout() public IActionResult Logout()
{ {
@@ -217,7 +237,7 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(Logout)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(Logout)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
@@ -238,7 +258,7 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(UpdateUser)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(UpdateUser)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
@@ -259,7 +279,7 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(UpdateUserSettings)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(UpdateUserSettings)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
@@ -280,7 +300,7 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(UpdatePassword)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(UpdatePassword)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
@@ -299,7 +319,7 @@ namespace PrivaPub.Controllers.ClientToServer
// catch (Exception ex) // catch (Exception ex)
// { // {
// Logger.LogError(ex, $"{nameof(User)}.{nameof(GetUser)}()"); // Logger.LogError(ex, $"{nameof(User)}.{nameof(GetUser)}()");
// return BadRequest(result.Invalidate(ex.Message)); // return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
// } // }
//} //}
@@ -317,11 +337,11 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(GetUserSettings)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(GetUserSettings)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
[HttpPost, Route("/clientapi/user/recover/password"), AllowAnonymous] [HttpPost, Route("/clientapi/user/recover/password"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
public async Task<IActionResult> RecoverPassword(PasswordRecoveryForm passwordRecoveryForm) public async Task<IActionResult> RecoverPassword(PasswordRecoveryForm passwordRecoveryForm)
{ {
var result = new WebResult(); var result = new WebResult();
@@ -341,11 +361,11 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(RecoverPassword)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(RecoverPassword)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
[HttpPost, Route("/clientapi/user/recover/valid"), AllowAnonymous] [HttpPost, Route("/clientapi/user/recover/valid"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
public async Task<IActionResult> IsValidRecoveryCode( public async Task<IActionResult> IsValidRecoveryCode(
[FromBody, [FromBody,
Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)), Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
@@ -364,11 +384,11 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(IsValidRecoveryCode)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(IsValidRecoveryCode)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
[HttpPost, Route("/clientapi/user/recover/update/password"), AllowAnonymous] [HttpPost, Route("/clientapi/user/recover/update/password"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
public async Task<IActionResult> ChangePassword(NewPasswordForm newPasswordForm) public async Task<IActionResult> ChangePassword(NewPasswordForm newPasswordForm)
{ {
var result = new WebResult(); var result = new WebResult();
@@ -385,7 +405,7 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(ChangePassword)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(ChangePassword)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
@@ -406,7 +426,7 @@ namespace PrivaPub.Controllers.ClientToServer
// catch (Exception ex) // catch (Exception ex)
// { // {
// Logger.LogError(ex, $"{nameof(User)}.{nameof(RemoveSelf)}()"); // Logger.LogError(ex, $"{nameof(User)}.{nameof(RemoveSelf)}()");
// return BadRequest(result.Invalidate(ex.Message)); // return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
// } // }
//} //}
@@ -0,0 +1,43 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Social;
using PrivaPub.Domain.Social;
using PrivaPub.Extensions;
using PrivaPub.Resources;
namespace PrivaPub.Controllers.ClientToServer
{
[ApiController,
Route("clientapi/social"),
Authorize(Policy = Policies.IsUser)]
public class SocialController : ControllerBase
{
readonly IFollowService _follows;
readonly IStringLocalizer _localizer;
public SocialController(IFollowService follows, IStringLocalizer<GenericRes> localizer)
{
_follows = follows;
_localizer = localizer;
}
[HttpPost, Route("/clientapi/follow")]
public async Task<IActionResult> Follow(FollowForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _follows.Follow(User.GetUserId(), form, token));
[HttpPost, Route("/clientapi/unfollow")]
public async Task<IActionResult> Unfollow(FollowForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _follows.Unfollow(User.GetUserId(), form, token));
[HttpGet, Route("/clientapi/following")]
public async Task<IActionResult> Following([FromQuery] string avatarId, CancellationToken token) =>
Answer(await _follows.Followings(User.GetUserId(), avatarId, token));
IActionResult Invalid() => BadRequest(new WebResult().Invalidate(_localizer["Invalid model."]));
IActionResult Answer(WebResult result) => result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
}
}
@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PrivaPub.ClientModels;
using PrivaPub.Domain.Timelines;
using PrivaPub.Extensions;
namespace PrivaPub.Controllers.ClientToServer
{
[ApiController,
Route("clientapi/timeline"),
Authorize(Policy = Policies.IsUser)]
public class TimelineController : ControllerBase
{
readonly ITimelineService _timelines;
public TimelineController(ITimelineService timelines)
{
_timelines = timelines;
}
[HttpGet, Route("/clientapi/timeline/home")]
public async Task<IActionResult> Home([FromQuery] string avatarId, [FromQuery(Name = "max_id")] string maxId, [FromQuery] int limit = 20,
CancellationToken token = default) =>
Answer(await _timelines.Home(User.GetUserId(), avatarId, maxId, limit, token));
[HttpGet, Route("/clientapi/notifications")]
public async Task<IActionResult> Notifications([FromQuery] string avatarId, [FromQuery(Name = "max_id")] string maxId, [FromQuery] int limit = 20,
CancellationToken token = default) =>
Answer(await _timelines.Notifications(User.GetUserId(), avatarId, maxId, limit, token));
[HttpPost, Route("/clientapi/notifications/read")]
public async Task<IActionResult> Read([FromQuery] string avatarId, CancellationToken token) =>
Answer(await _timelines.MarkNotificationsRead(User.GetUserId(), avatarId, token));
IActionResult Answer(WebResult result) => result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
}
}
@@ -1,154 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Services.Federation;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Controllers.ServerToServer
{
[ApiController, Route("peasants")]
public class PeasantsController : ControllerBase
{
const string ActivityContentType = "application/activity+json; charset=utf-8";
const int OutboxSize = 20;
readonly ILocalActorService _localActors;
readonly IInboxService _inbox;
readonly DbEntities _dbEntities;
readonly ILogger<PeasantsController> _logger;
public PeasantsController(ILocalActorService localActors, IInboxService inbox, DbEntities dbEntities,
ILogger<PeasantsController> logger)
{
_localActors = localActors;
_inbox = inbox;
_dbEntities = dbEntities;
_logger = logger;
}
[HttpGet, Route("{actor}")]
public async Task<IActionResult> GetActor(string actor, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
return local == default ? NotFound() : Activity(ActivityPubRenderer.Actor(local));
}
[HttpGet, Route("{actor}/anus")]
public async Task<IActionResult> Outbox(string actor, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local == default)
return NotFound();
var total = local.Kind == LocalActorKind.Group
? await DB.Default.CountAsync<PostEntity>(p => p.GroupId == local.Id, token)
: await DB.Default.CountAsync<PostEntity>(p => p.GroupUserId == local.Id && !p.IsFederatedCopy, token);
var latest = await (local.Kind == LocalActorKind.Group
? _dbEntities.Posts.Match(p => p.GroupId == local.Id)
: _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy))
.Sort(p => p.CreationDate, Order.Descending)
.Limit(OutboxSize)
.ExecuteAsync(token);
var items = new List<JsonNode>();
foreach (var post in latest)
{
if (local.Kind == LocalActorKind.Group)
{
items.Add(ActivityPubRenderer.Announce(local, post.ObjectURI, $"announce-{post.ID}"));
continue;
}
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
var note = ActivityPubRenderer.Note(post, local, group, default);
items.Add(ActivityPubRenderer.Create(local, note, $"create-{post.ID}"));
}
return Activity(ActivityPubRenderer.OrderedCollection(local.Outbox, (int)total, items));
}
[HttpGet, Route("{actor}/followers")]
public async Task<IActionResult> Followers(string actor, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local == default)
return NotFound();
var count = await DB.Default.CountAsync<Follower>(
f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted, token);
return Activity(ActivityPubRenderer.OrderedCollection(local.Followers, (int)count, default));
}
[HttpGet, Route("{actor}/following")]
public async Task<IActionResult> Following(string actor, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
return local == default ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, Enumerable.Empty<JsonNode>()));
}
[HttpGet, Route("{actor}/posts/{postId}")]
public async Task<IActionResult> Post(string actor, string postId, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local == default || local.Kind != LocalActorKind.Person)
return NotFound();
var post = await _dbEntities.Posts
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy)
.ExecuteFirstAsync(token);
if (post == default)
return NotFound();
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
var replyTo = await ReplyTarget(post.AnsweringToPostId, token);
var note = ActivityPubRenderer.Note(post, local, group, replyTo);
note["@context"] = ActivityPubRenderer.ActivityStreams;
return Activity(note);
}
[HttpPost, Route("{actor}/mouth")]
public async Task<IActionResult> Inbox(string actor, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local == default)
return NotFound();
return Answer(await _inbox.Receive(Request, local, token));
}
[HttpPost, Route("{actor}/human-centipede")]
public async Task<IActionResult> ActorSharedInbox(string actor, CancellationToken token) =>
Answer(await _inbox.Receive(Request, default, token));
[HttpPost, Route("/human-centipede")]
public async Task<IActionResult> SharedInbox(CancellationToken token) =>
Answer(await _inbox.Receive(Request, default, token));
async Task<string> ReplyTarget(string answeringToPostId, CancellationToken token)
{
if (string.IsNullOrEmpty(answeringToPostId))
return default;
if (answeringToPostId.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
return answeringToPostId;
return (await _dbEntities.Posts.MatchID(answeringToPostId).ExecuteFirstAsync(token))?.ObjectURI;
}
IActionResult Answer(InboxResult result)
{
if (result.Error != default)
_logger.LogInformation("Inbox refused with {Status}: {Error}", result.StatusCode, result.Error);
return result.Error == default ? StatusCode(result.StatusCode) : StatusCode(result.StatusCode, result.Error);
}
ContentResult Activity(JsonObject document) => new()
{
Content = document.ToJsonString(),
ContentType = ActivityContentType,
StatusCode = StatusCodes.Status200OK
};
}
}
@@ -1,96 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using PrivaPub.Services.Federation;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Controllers.ServerToServer
{
[ApiController]
public class WellKnownController : ControllerBase
{
readonly ILocalActorService _localActors;
readonly DbEntities _dbEntities;
public WellKnownController(ILocalActorService localActors, DbEntities dbEntities)
{
_localActors = localActors;
_dbEntities = dbEntities;
}
[HttpGet, Route("/.well-known/webfinger")]
public async Task<IActionResult> WebFinger([FromQuery] string resource, CancellationToken token)
{
if (string.IsNullOrEmpty(resource))
return BadRequest();
LocalActor actor;
if (resource.StartsWith("acct:", StringComparison.OrdinalIgnoreCase))
{
var parts = resource[5..].TrimStart('@').Split('@');
var domain = new Uri(_localActors.BaseAddress).Authority;
if (parts.Length != 2 || !parts[1].Equals(domain, StringComparison.OrdinalIgnoreCase))
return NotFound();
actor = await _localActors.FindByUserName(parts[0], token);
}
else
actor = await _localActors.FindByUri(resource, token);
if (actor == default)
return NotFound();
var document = new JsonObject
{
["subject"] = $"acct:{actor.Handle}",
["aliases"] = new JsonArray(actor.Uri),
["links"] = new JsonArray(
new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = actor.Uri },
new JsonObject { ["rel"] = "http://webfinger.net/rel/profile-page", ["type"] = "text/html", ["href"] = actor.Uri })
};
return Content(document.ToJsonString(), "application/jrd+json; charset=utf-8");
}
[HttpGet, Route("/.well-known/nodeinfo")]
public IActionResult NodeInfoLinks()
{
var document = new JsonObject
{
["links"] = new JsonArray(new JsonObject
{
["rel"] = "http://nodeinfo.diaspora.software/ns/schema/2.0",
["href"] = $"{_localActors.BaseAddress}/nodeinfo/2.0"
})
};
return Content(document.ToJsonString(), "application/json; charset=utf-8");
}
[HttpGet, Route("/nodeinfo/2.0")]
public async Task<IActionResult> NodeInfo(CancellationToken token)
{
var users = await DB.Default.CountAsync<Avatar>(a => !a.DeletionAt.HasValue, token);
var posts = await DB.Default.CountAsync<PostEntity>(p => !p.IsFederatedCopy, token);
var document = new JsonObject
{
["version"] = "2.0",
["software"] = new JsonObject { ["name"] = "privapub", ["version"] = BuildInfo.Ref },
["protocols"] = new JsonArray("activitypub"),
["services"] = new JsonObject { ["inbound"] = new JsonArray(), ["outbound"] = new JsonArray() },
["openRegistrations"] = true,
["usage"] = new JsonObject
{
["users"] = new JsonObject { ["total"] = users },
["localPosts"] = posts
},
["metadata"] = new JsonObject()
};
return Content(document.ToJsonString(), "application/json; profile=\"http://nodeinfo.diaspora.software/ns/schema/2.0#\"; charset=utf-8");
}
}
}
+150
View File
@@ -0,0 +1,150 @@
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
using Markdig;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using System.Net;
using System.Text.RegularExpressions;
namespace PrivaPub.Domain.Content
{
public sealed record ResolvedMention(string ActorUri, string UserName, string Domain, string ProfileUrl, bool IsLocal, string AccountId, string Inbox)
{
public string Handle => $"{UserName}@{Domain}";
}
public sealed record RenderedContent(string Html, IReadOnlyList<ResolvedMention> Mentions, IReadOnlyList<string> Tags);
public interface IContentRenderer
{
Task<RenderedContent> Markdown(string text, CancellationToken token);
Task<RenderedContent> PlainText(string text, CancellationToken token);
string TagUrl(string tag);
}
public partial class ContentRenderer : IContentRenderer
{
const int MaxMentions = 20;
static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().DisableHtml().UseAutoLinks().UseEmphasisExtras().Build();
static readonly HtmlParser Parser = new();
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
public ContentRenderer(ILocalActorService localActors, IRemoteActorService remoteActors)
{
_localActors = localActors;
_remoteActors = remoteActors;
}
public string TagUrl(string tag) => $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag)}";
public Task<RenderedContent> Markdown(string text, CancellationToken token) =>
Decorate(string.IsNullOrWhiteSpace(text) ? string.Empty : Markdig.Markdown.ToHtml(text, Pipeline), token);
public Task<RenderedContent> PlainText(string text, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(text))
return Decorate(string.Empty, token);
var paragraphs = Regex.Split(text.Replace("\r\n", "\n").Trim(), "\n{2,}")
.Select(p => "<p>" + Linkify(WebUtility.HtmlEncode(p)).Replace("\n", "<br>") + "</p>");
return Decorate(string.Concat(paragraphs), token);
}
async Task<RenderedContent> Decorate(string html, CancellationToken token)
{
var document = Parser.ParseDocument($"<body>{html}</body>");
var textNodes = document.Body.Descendants<IText>()
.Where(node => !node.Ancestors<IElement>().Any(e => e.LocalName is "a" or "code" or "pre"))
.ToList();
var handles = textNodes.SelectMany(node => MentionPattern().Matches(node.Data).Select(m => m.Value))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(MaxMentions)
.ToList();
var mentions = new Dictionary<string, ResolvedMention>(StringComparer.OrdinalIgnoreCase);
foreach (var handle in handles)
{
var resolved = await Resolve(handle, token);
if (resolved != default)
mentions[handle] = resolved;
}
var tags = new List<string>();
foreach (var node in textNodes)
{
var replaced = Replace(node.Data, mentions, tags);
if (replaced == default)
continue;
var fragment = Parser.ParseFragment(replaced, node.ParentElement);
foreach (var child in fragment.ToList())
node.Parent.InsertBefore(child, node);
node.Parent.RemoveChild(node);
}
return new RenderedContent(
ContentSanitizer.Html(document.Body.InnerHtml),
mentions.Values.DistinctBy(m => m.ActorUri).ToList(),
tags.Distinct().ToList());
}
string Replace(string text, IReadOnlyDictionary<string, ResolvedMention> mentions, List<string> tags)
{
var changed = false;
var encoded = WebUtility.HtmlEncode(text);
encoded = MentionPattern().Replace(encoded, match =>
{
if (!mentions.TryGetValue(WebUtility.HtmlDecode(match.Value), out var mention))
return match.Value;
changed = true;
return $"<span class=\"h-card\" translate=\"no\"><a href=\"{WebUtility.HtmlEncode(mention.ProfileUrl)}\" class=\"u-url mention\">@<span>{WebUtility.HtmlEncode(mention.UserName)}</span></a></span>";
});
encoded = HashtagPattern().Replace(encoded, match =>
{
var tag = match.Groups["tag"].Value;
if (tag.All(char.IsDigit))
return match.Value;
changed = true;
tags.Add(tag.ToLowerInvariant());
return $"{match.Groups["lead"].Value}<a href=\"{WebUtility.HtmlEncode(TagUrl(tag.ToLowerInvariant()))}\" class=\"mention hashtag\" rel=\"tag\">#<span>{tag}</span></a>";
});
return changed ? encoded : default;
}
async Task<ResolvedMention> Resolve(string handle, CancellationToken token)
{
var parts = handle.TrimStart('@').Split('@');
var localDomain = new Uri(_localActors.BaseAddress).Authority;
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
{
var local = await _localActors.FindByUserName(parts[0], token);
return local is { IsFederated: true, Kind: not LocalActorKind.Application }
? new ResolvedMention(local.Uri, local.UserName, local.Domain, local.Uri, true, local.Id, local.Inbox)
: default;
}
var actorUri = await _remoteActors.ResolveHandle($"{parts[0]}@{parts[1]}", token);
var actor = actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token);
return actor == default
? default
: new ResolvedMention(actor.ActorURI, actor.UserName ?? parts[0], parts[1].ToLowerInvariant(), actor.Url ?? actor.ActorURI, false, actor.ID,
actor.InboxURL);
}
static string Linkify(string encoded) =>
UrlPattern().Replace(encoded, match => $"<a href=\"{match.Value}\">{match.Value}</a>");
[GeneratedRegex(@"(?<![\w@/.])@[a-zA-Z0-9_]+(?:@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+(?::\d+)?)?(?![\w@])")]
private static partial Regex MentionPattern();
[GeneratedRegex(@"(?<lead>^|[^\w&#/])#(?<tag>[\p{L}\p{N}_]{1,100})(?![\p{L}\p{N}_])")]
private static partial Regex HashtagPattern();
[GeneratedRegex(@"https?://[^\s<]+[^\s<.,;:!?)\]'""]")]
private static partial Regex UrlPattern();
}
}
@@ -0,0 +1,42 @@
using MongoDB.Entities;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using System.Linq.Expressions;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Domain.Privacy
{
public static class VisibilityPolicy
{
public static readonly Expression<Func<Post, bool>> IsPublic = p =>
!p.DeletedAt.HasValue && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted);
static readonly Func<Post, bool> IsPublicCompiled = IsPublic.Compile();
public static async Task<bool> CanSee(Post post, string viewerAvatarId, CancellationToken token)
{
if (post == default || post.DeletedAt.HasValue)
return false;
if (IsPublicCompiled(post))
return true;
if (string.IsNullOrEmpty(viewerAvatarId))
return false;
if (post.GroupUserId == viewerAvatarId || post.Mentions.Any(m => m.IsLocal && m.AccountId == viewerAvatarId))
return true;
return post.Visibility switch
{
PostVisibility.Direct => !string.IsNullOrEmpty(post.ConversationId) && await DB.Default.Find<DmGroup>()
.Match(g => g.ID == post.ConversationId && g.Members.Any(m => !m.IsForeign && m.AvatarId == viewerAvatarId))
.ExecuteAnyAsync(token),
PostVisibility.Circle => !string.IsNullOrEmpty(post.GroupId) && await DB.Default.Find<GroupEntity>()
.Match(g => g.ID == post.GroupId && g.Members.Any(m => !m.IsForeign && m.AvatarId == viewerAvatarId))
.ExecuteAnyAsync(token),
_ => false
};
}
}
}
+299
View File
@@ -0,0 +1,299 @@
using Microsoft.Extensions.Localization;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Social;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.Resources;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Domain.Social
{
public interface IFollowService
{
Task<WebResult> Follow(string rootUserId, FollowForm form, CancellationToken token);
Task<WebResult> Unfollow(string rootUserId, FollowForm form, CancellationToken token);
Task<WebResult> Followings(string rootUserId, string avatarId, CancellationToken token);
Task<Following> FollowAs(LocalActor follower, string target, bool showReblogs, CancellationToken token);
Task UnfollowAs(LocalActor follower, string target, CancellationToken token);
Task<bool> Decide(LocalActor me, string followerAccountId, bool accept, CancellationToken token);
}
public class FollowService : IFollowService
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IDeliveryService _delivery;
readonly IStringLocalizer<GenericRes> _localizer;
readonly ILogger<FollowService> _logger;
public FollowService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IStringLocalizer<GenericRes> localizer, ILogger<FollowService> logger)
{
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
_delivery = delivery;
_localizer = localizer;
_logger = logger;
}
public async Task<WebResult> Follow(string rootUserId, FollowForm form, CancellationToken token)
{
var result = new WebResult();
try
{
var follower = await OwnedAvatar(rootUserId, form.AvatarId, token);
if (follower == default)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
var following = await FollowAs(follower, form.Target, true, token);
if (following == default)
return result.Invalidate(_localizer["Account not found."], StatusCodes.Status404NotFound);
result.Data = ToView(following);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(FollowService)}.{nameof(Follow)}");
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> Unfollow(string rootUserId, FollowForm form, CancellationToken token)
{
var result = new WebResult();
try
{
var follower = await OwnedAvatar(rootUserId, form.AvatarId, token);
if (follower == default)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
await UnfollowAs(follower, form.Target, token);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(FollowService)}.{nameof(Unfollow)}");
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
}
public async Task<Following> FollowAs(LocalActor follower, string target, bool showReblogs, CancellationToken token)
{
var (local, remote) = await ResolveTarget(target, token);
if (local == default && remote == default)
return default;
if (local != default && (local.Id == follower.Id || !local.IsFederated || local.Kind == LocalActorKind.Application))
return default;
var targetUri = local?.Uri ?? remote.ActorURI;
var existing = await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token);
if (existing != default)
{
if (existing.ShowReblogs != showReblogs)
await DB.Default.Update<Following>().MatchID(existing.ID).Modify(f => f.ShowReblogs, showReblogs).ExecuteAsync(token);
existing.ShowReblogs = showReblogs;
return existing;
}
var following = new Following
{
AvatarId = follower.Id,
TargetActorURI = targetUri,
TargetAccountId = local?.Id ?? remote.ID,
TargetIsLocal = local != default,
TargetInboxURL = local?.Inbox ?? remote.InboxURL,
ShowReblogs = showReblogs,
State = local is { ManuallyApprovesFollowers: false } ? FollowState.Accepted : FollowState.Requested
};
following.ID = (string)following.GenerateNewID();
following.FollowActivityURI = follower.ActivityUri($"follow-{following.ID}");
try
{
await DB.Default.SaveAsync(following, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token);
}
if (local != default)
await FollowLocally(follower, local, following, token);
else
await _delivery.Enqueue(follower, new[] { remote.InboxURL }, FollowActivity(follower, following), token);
return following;
}
public async Task UnfollowAs(LocalActor follower, string target, CancellationToken token)
{
var (local, remote) = await ResolveTarget(target, token);
var targetUri = local?.Uri ?? remote?.ActorURI ?? target;
var following = await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token);
if (following == default)
return;
await DB.Default.DeleteAsync<Following>(following.ID);
if (following.TargetIsLocal)
{
await DB.Default.DeleteAsync<Follower>(f => f.LocalActorId == following.TargetAccountId && f.ActorURI == follower.Uri);
await DB.Default.Update<GroupEntity>().MatchID(following.TargetAccountId)
.Modify(b => b.PullFilter(g => g.Members, m => !m.IsForeign && m.AvatarId == follower.Id && m.Role == GroupRole.Member))
.ExecuteAsync(token);
return;
}
var undo = new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = follower.ActivityUri($"undo-follow-{following.ID}"),
["type"] = "Undo",
["actor"] = follower.Uri,
["object"] = FollowActivity(follower, following)
};
await _delivery.Enqueue(follower, new[] { following.TargetInboxURL }, undo, token);
}
public async Task<bool> Decide(LocalActor me, string followerAccountId, bool accept, CancellationToken token)
{
var localFollower = await _localActors.FindById(LocalActorKind.Person, followerAccountId, token);
var remoteFollower = localFollower == default ? await _dbEntities.ForeignAvatars.MatchID(followerAccountId).ExecuteFirstAsync(token) : default;
var followerUri = localFollower?.Uri ?? remoteFollower?.ActorURI;
if (followerUri == default)
return false;
var request = await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id && f.LocalActorKind == me.Kind && f.ActorURI == followerUri && !f.IsAccepted)
.ExecuteFirstAsync(token);
if (request == default)
return false;
if (accept)
await DB.Default.Update<Follower>().MatchID(request.ID).Modify(f => f.IsAccepted, true).ExecuteAsync(token);
else
await DB.Default.DeleteAsync<Follower>(request.ID);
if (localFollower != default)
{
if (accept)
await DB.Default.Update<Following>().Match(f => f.AvatarId == localFollower.Id && f.TargetActorURI == me.Uri)
.Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
else
await DB.Default.DeleteAsync<Following>(f => f.AvatarId == localFollower.Id && f.TargetActorURI == me.Uri);
}
else
{
var follow = new JsonObject
{
["id"] = request.FollowActivityURI,
["type"] = "Follow",
["actor"] = followerUri,
["object"] = me.Uri
};
var answer = accept
? ActivityPubRenderer.Accept(me, follow, $"accept-{request.ID}-{DateTime.UtcNow.Ticks}")
: new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = me.ActivityUri($"reject-{request.ID}-{DateTime.UtcNow.Ticks}"),
["type"] = "Reject",
["actor"] = me.Uri,
["object"] = follow
};
await _delivery.Enqueue(me, new[] { request.InboxURL }, answer, token);
}
if (accept)
await Notifications.Add(me.Id, NotificationType.Follow, followerAccountId, followerUri, default, token);
return true;
}
public async Task<WebResult> Followings(string rootUserId, string avatarId, CancellationToken token)
{
var result = new WebResult();
var follower = await OwnedAvatar(rootUserId, avatarId, token);
if (follower == default)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
result.Data = (await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id).Sort(f => f.ID, Order.Descending).ExecuteAsync(token))
.Select(ToView).ToList();
return result;
}
async Task FollowLocally(LocalActor follower, LocalActor target, Following following, CancellationToken token)
{
await DB.Default.Update<Follower>()
.Match(f => f.LocalActorId == target.Id && f.LocalActorKind == target.Kind && f.ActorURI == follower.Uri)
.Modify(f => f.InboxURL, follower.Inbox)
.Modify(f => f.SharedInboxURL, follower.SharedInbox)
.Modify(f => f.FollowActivityURI, following.FollowActivityURI)
.Modify(f => f.IsAccepted, following.State == FollowState.Accepted)
.Modify(b => b.SetOnInsert(f => f.CreationDate, DateTime.UtcNow))
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
if (target.Kind == LocalActorKind.Group && following.State == FollowState.Accepted)
await DB.Default.Update<GroupEntity>()
.Match(g => g.ID == target.Id && !g.Members.Any(m => !m.IsForeign && m.AvatarId == follower.Id))
.Modify(b => b.Push(g => g.Members, new GroupMember { AvatarId = follower.Id }))
.ExecuteAsync(token);
if (target.Kind == LocalActorKind.Person)
await Notifications.Add(target.Id, following.State == FollowState.Accepted ? NotificationType.Follow : NotificationType.FollowRequest,
follower.Id, follower.Uri, default, token);
}
async Task<(LocalActor Local, ForeignAvatar Remote)> ResolveTarget(string target, CancellationToken token)
{
target = target?.Trim();
if (string.IsNullOrEmpty(target))
return default;
if (target.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || target.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
{
var byUri = await _localActors.FindByUri(target, token);
return byUri != default ? (byUri, default) : (default, await _remoteActors.GetActor(target, refresh: false, token));
}
var parts = target.TrimStart('@').Split('@');
var localDomain = new Uri(_localActors.BaseAddress).Authority;
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
return (await _localActors.FindByUserName(parts[0], token), default);
var actorUri = await _remoteActors.ResolveHandle($"{parts[0]}@{parts[1]}", token);
return (default, actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token));
}
static JsonObject FollowActivity(LocalActor follower, Following following) => new()
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = following.FollowActivityURI,
["type"] = "Follow",
["actor"] = follower.Uri,
["object"] = following.TargetActorURI
};
async Task<LocalActor> OwnedAvatar(string rootUserId, string avatarId, CancellationToken token)
{
if (string.IsNullOrEmpty(rootUserId) || string.IsNullOrEmpty(avatarId))
return default;
if (!await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token))
return default;
return await _localActors.FindById(LocalActorKind.Person, avatarId, token);
}
static ViewFollowing ToView(Following following) => new()
{
Id = following.ID,
TargetActorURI = following.TargetActorURI,
TargetAccountId = following.TargetAccountId,
TargetIsLocal = following.TargetIsLocal,
State = following.State.ToString(),
CreatedAt = following.CreatedAt
};
}
}
+32
View File
@@ -0,0 +1,32 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Models.Social;
namespace PrivaPub.Domain.Social
{
public static class Notifications
{
public static async Task Add(string avatarId, NotificationType type, string fromAccountId, string fromActorUri, string postId,
CancellationToken token)
{
if (string.IsNullOrEmpty(avatarId) || avatarId == fromAccountId)
return;
try
{
await DB.Default.SaveAsync(new Notification
{
AvatarId = avatarId,
Type = type,
FromAccountId = fromAccountId,
FromActorURI = fromActorUri,
PostId = postId,
DedupeKey = $"{type}|{avatarId}|{fromActorUri}|{postId}"
}, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
}
}
}
}
+489
View File
@@ -0,0 +1,489 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Domain.Content;
using PrivaPub.Domain.Privacy;
using PrivaPub.Domain.Social;
using PrivaPub.Domain.Timelines;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Domain.Statuses
{
public sealed class StatusDraft
{
public string Text { get; init; }
public bool PlainText { get; init; }
public string Title { get; init; }
public string SpoilerText { get; init; }
public bool Sensitive { get; init; }
public PostVisibility Visibility { get; init; } = PostVisibility.Public;
public string InReplyTo { get; init; }
public string GroupId { get; init; }
public string Language { get; init; }
public string ConversationId { get; init; }
public IReadOnlyList<string> Recipients { get; init; } = Array.Empty<string>();
}
public sealed record StatusOutcome(PostEntity Post, int Status = StatusCodes.Status200OK, string Error = default)
{
public bool Ok => Error == default;
public static StatusOutcome Fail(int status, string error) => new(default, status, error);
}
public interface IStatusService
{
Task<StatusOutcome> Publish(LocalActor author, StatusDraft draft, CancellationToken token);
Task<StatusOutcome> Edit(LocalActor author, string postId, StatusDraft draft, CancellationToken token);
Task<StatusOutcome> Remove(LocalActor author, string postId, CancellationToken token);
Task<StatusOutcome> Favourite(LocalActor me, string postId, bool on, CancellationToken token);
Task<StatusOutcome> Reblog(LocalActor me, string postId, bool on, PostVisibility visibility, CancellationToken token);
}
public class StatusService : IStatusService
{
const int MaxRevisions = 20;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IDeliveryService _delivery;
readonly IContentRenderer _content;
readonly IOutboxPublisher _outbox;
readonly IFanout _fanout;
public StatusService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IContentRenderer content, IOutboxPublisher outbox, IFanout fanout)
{
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
_delivery = delivery;
_content = content;
_outbox = outbox;
_fanout = fanout;
}
public async Task<StatusOutcome> Publish(LocalActor author, StatusDraft draft, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(draft.Text))
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Text can't be blank");
LocalActor group = default;
if (!string.IsNullOrEmpty(draft.GroupId))
{
var groupEntity = await _dbEntities.Groups.MatchID(draft.GroupId).ExecuteFirstAsync(token);
if (groupEntity == default || groupEntity.DeletionAt.HasValue || !groupEntity.Members.Any(m => !m.IsForeign && m.AvatarId == author.Id))
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Group not found");
group = _localActors.FromGroup(groupEntity);
}
var parent = await Parent(draft.InReplyTo, token);
if (!string.IsNullOrEmpty(draft.InReplyTo) && parent == default && !IsRemoteUri(draft.InReplyTo))
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
if (parent != default && !await VisibilityPolicy.CanSee(parent, author.Id, token))
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
var rendered = draft.PlainText ? await _content.PlainText(draft.Text, token) : await _content.Markdown(draft.Text, token);
var isLocalOnly = group is { IsFederated: false };
var visibility = isLocalOnly ? PostVisibility.Circle : draft.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo ? PostVisibility.Public : draft.Visibility;
var post = new PostEntity
{
GroupUserId = author.Id,
AuthorAccountId = author.Id,
GroupId = group?.Id,
Visibility = visibility,
Title = Clean(draft.Title),
SpoilerText = Clean(draft.SpoilerText),
HasContentWarning = draft.Sensitive || Clean(draft.SpoilerText) != default,
Text = draft.Text,
ContentHtml = rendered.Html,
ContentFormat = draft.PlainText ? ContentFormat.Plain : ContentFormat.Markdown,
Language = Clean(draft.Language),
Mentions = rendered.Mentions.Select(ToMention).ToList(),
Tags = rendered.Tags.ToList(),
AnsweringToPostId = parent?.ID,
InReplyToURI = parent?.ObjectURI ?? (IsRemoteUri(draft.InReplyTo) ? draft.InReplyTo : default),
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
IsLocalOnly = isLocalOnly,
ActorURI = author.Uri
};
post.ID = (string)post.GenerateNewID();
post.ObjectURI = author.PostUri(post.ID);
post.Url = author.PostHtmlUrl(post.ID);
JsonObject create = default;
if (visibility == PostVisibility.Direct)
{
var conversation = await Conversation(author, draft, rendered.Mentions, token);
if (!conversation.Ok)
return StatusOutcome.Fail(conversation.Status, conversation.Error);
var (dmGroup, recipients) = (conversation.Group, conversation.Recipients);
post.ConversationId = dmGroup.ID;
post.ContextURI = dmGroup.ConversationURI;
post.Mentions = recipients.Select(r => new PostMention { ActorURI = r.Uri, Handle = "@" + r.Handle, IsLocal = r.LocalId != default, AccountId = r.LocalId ?? r.ForeignId })
.Concat(post.Mentions)
.DistinctBy(m => m.ActorURI)
.ToList();
var note = ActivityPubRenderer.DirectNote(post, author, recipients.Select(r => (r.Uri, r.Handle)).ToList(), dmGroup.ConversationURI);
create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}");
post.To = Strings(note["to"]);
await DB.Default.Update<DmGroup>().MatchID(dmGroup.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token);
}
else if (!isLocalOnly)
{
var note = ActivityPubRenderer.Note(post, author, group, post.InReplyToURI);
create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}");
post.To = Strings(note["to"]);
post.Cc = Strings(note["cc"]);
}
post.ActivityURI = create?["id"]?.GetValue<string>();
await DB.Default.SaveAsync(post, token);
if (parent != default)
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
await _fanout.Distribute(post, token);
if (create != default)
await _outbox.Publish(author, post, create, token);
if (group is { IsFederated: true } && visibility is PostVisibility.Public or PostVisibility.Unlisted)
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, post.ObjectURI, $"announce-{post.ID}"), token);
return new StatusOutcome(post);
}
public async Task<StatusOutcome> Edit(LocalActor author, string postId, StatusDraft draft, CancellationToken token)
{
var post = await Own(author, postId, token);
if (post == default)
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
if (string.IsNullOrWhiteSpace(draft.Text))
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Text can't be blank");
post.Revisions.Add(new PostRevision
{
Title = post.Title,
SpoilerText = post.SpoilerText,
ContentHtml = post.ContentHtml,
HasContentWarning = post.HasContentWarning,
EditedAt = post.EditedAt ?? post.CreationDate
});
if (post.Revisions.Count > MaxRevisions)
post.Revisions.RemoveRange(0, post.Revisions.Count - MaxRevisions);
var plain = draft.PlainText || post.ContentFormat == ContentFormat.Plain;
var rendered = plain ? await _content.PlainText(draft.Text, token) : await _content.Markdown(draft.Text, token);
post.Title = draft.Title == default ? post.Title : Clean(draft.Title);
post.SpoilerText = Clean(draft.SpoilerText);
post.HasContentWarning = draft.Sensitive || post.SpoilerText != default;
post.Text = draft.Text;
post.ContentHtml = rendered.Html;
post.Language = Clean(draft.Language) ?? post.Language;
post.Mentions = post.Visibility == PostVisibility.Direct
? post.Mentions.Concat(rendered.Mentions.Select(ToMention)).DistinctBy(m => m.ActorURI).ToList()
: rendered.Mentions.Select(ToMention).ToList();
post.Tags = rendered.Tags.ToList();
post.EditedAt = DateTime.UtcNow;
post.UpdateDate = post.EditedAt;
await DB.Default.SaveAsync(post, token);
if (!post.IsLocalOnly)
{
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
var note = post.Visibility == PostVisibility.Direct
? ActivityPubRenderer.DirectNote(post, author, Array.Empty<(string, string)>(), post.ContextURI)
: ActivityPubRenderer.Note(post, author, group, post.InReplyToURI);
note["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray());
note["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray());
var update = new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = author.ActivityUri($"update-{post.ID}-{new DateTimeOffset(post.EditedAt.Value).ToUnixTimeSeconds()}"),
["type"] = "Update",
["actor"] = author.Uri,
["to"] = note["to"]!.DeepClone(),
["cc"] = note["cc"]!.DeepClone(),
["object"] = note
};
await _outbox.Publish(author, post, update, token);
}
return new StatusOutcome(post);
}
public async Task<StatusOutcome> Remove(LocalActor author, string postId, CancellationToken token)
{
var post = await _dbEntities.Posts.Match(p => p.ID == postId && p.GroupUserId == author.Id && !p.IsFederatedCopy).ExecuteFirstAsync(token);
if (post == default || post.DeletedAt.HasValue)
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
if (post.ReblogOfPostId != default)
return await Reblog(author, post.ReblogOfPostId, false, post.Visibility, token);
var audience = await _outbox.Audience(author, post, token);
await DB.Default.Update<PostEntity>().MatchID(post.ID)
.Modify(p => p.DeletedAt, DateTime.UtcNow)
.Modify(p => p.Text, null)
.Modify(p => p.ContentHtml, null)
.Modify(p => p.Title, null)
.Modify(p => p.SpoilerText, null)
.Modify(p => p.Media, new List<PostMedia>())
.Modify(p => p.Revisions, new List<PostRevision>())
.ExecuteAsync(token);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID);
if (!string.IsNullOrEmpty(post.AnsweringToPostId))
await DB.Default.Update<PostEntity>().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, -1)).ExecuteAsync(token);
if (audience.Count > 0)
{
var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()), new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray()));
await _delivery.Enqueue(author, audience, delete, token);
}
return new StatusOutcome(post);
}
public async Task<StatusOutcome> Favourite(LocalActor me, string postId, bool on, CancellationToken token)
{
var post = await Visible(me, postId, token);
if (post == default)
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
var activityId = me.ActivityUri($"like-{post.ID}");
if (on)
{
try
{
await DB.Default.SaveAsync(new Favourite { AccountId = me.Id, ActorURI = me.Uri, PostId = post.ID, ActivityURI = activityId }, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return new StatusOutcome(post);
}
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.FavouritesCount, 1)).ExecuteAsync(token);
post.FavouritesCount++;
if (!post.IsFederatedCopy)
await Notifications.Add(post.GroupUserId, NotificationType.Favourite, me.Id, me.Uri, post.ID, token);
}
else
{
var removed = await DB.Default.DeleteAsync<Favourite>(f => f.AccountId == me.Id && f.PostId == post.ID);
if (removed.DeletedCount == 0)
return new StatusOutcome(post);
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.FavouritesCount, -1)).ExecuteAsync(token);
post.FavouritesCount--;
}
if (post.IsFederatedCopy && await AuthorInbox(post, token) is { } inbox)
{
var like = new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = activityId,
["type"] = "Like",
["actor"] = me.Uri,
["object"] = post.ObjectURI
};
await _delivery.Enqueue(me, new[] { inbox }, on ? like : Undo(me, like, $"undo-like-{post.ID}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"), token);
}
return new StatusOutcome(post);
}
public async Task<StatusOutcome> Reblog(LocalActor me, string postId, bool on, PostVisibility visibility, CancellationToken token)
{
var original = await Visible(me, postId, token);
if (original?.ReblogOfPostId != default)
original = await Visible(me, original.ReblogOfPostId, token);
if (original == default)
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
if (original.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This post can't be boosted");
var existing = await _dbEntities.Posts.Match(p => p.ReblogOfPostId == original.ID && p.AuthorAccountId == me.Id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
var announceId = me.ActivityUri($"announce-{original.ID}");
var announce = new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = announceId,
["type"] = "Announce",
["actor"] = me.Uri,
["published"] = ActivityPubRenderer.Timestamp(DateTime.UtcNow),
["to"] = new JsonArray(visibility == PostVisibility.Unlisted ? me.Followers : ActivityPubRenderer.Public),
["cc"] = new JsonArray(visibility == PostVisibility.Unlisted ? ActivityPubRenderer.Public : me.Followers, original.ActorURI),
["object"] = original.ObjectURI
};
if (on)
{
if (existing != default)
return new StatusOutcome(existing);
var reblog = new PostEntity
{
GroupUserId = me.Id,
AuthorAccountId = me.Id,
ReblogOfPostId = original.ID,
Visibility = visibility == PostVisibility.Unlisted ? PostVisibility.Unlisted : PostVisibility.Public,
ObjectURI = announceId,
ActivityURI = announceId,
ActorURI = me.Uri,
To = Strings(announce["to"]),
Cc = Strings(announce["cc"])
};
try
{
await DB.Default.SaveAsync(reblog, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return new StatusOutcome(await _dbEntities.Posts.Match(p => p.ObjectURI == announceId).ExecuteFirstAsync(token));
}
await DB.Default.Update<PostEntity>().MatchID(original.ID).Modify(b => b.Inc(p => p.ReblogsCount, 1)).ExecuteAsync(token);
if (!original.IsFederatedCopy)
await Notifications.Add(original.GroupUserId, NotificationType.Reblog, me.Id, me.Uri, original.ID, token);
await _fanout.Distribute(reblog, token);
await _delivery.EnqueueToFollowers(me, announce, token, await AuthorInbox(original, token) is { } inbox ? new[] { inbox } : default);
return new StatusOutcome(reblog);
}
if (existing == default)
return new StatusOutcome(original);
await DB.Default.DeleteAsync<PostEntity>(existing.ID);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == existing.ID);
await DB.Default.Update<PostEntity>().MatchID(original.ID).Modify(b => b.Inc(p => p.ReblogsCount, -1)).ExecuteAsync(token);
await _delivery.EnqueueToFollowers(me, Undo(me, announce, $"undo-announce-{original.ID}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"), token,
await AuthorInbox(original, token) is { } authorInbox ? new[] { authorInbox } : default);
return new StatusOutcome(original);
}
sealed record Recipient(string Uri, string Handle, string LocalId, string ForeignId, string Inbox);
sealed record ConversationResult(DmGroup Group, IReadOnlyList<Recipient> Recipients, int Status = StatusCodes.Status200OK, string Error = default)
{
public bool Ok => Error == default;
}
async Task<ConversationResult> Conversation(LocalActor author, StatusDraft draft, IReadOnlyList<ResolvedMention> mentioned, CancellationToken token)
{
DmGroup dmGroup = default;
if (!string.IsNullOrEmpty(draft.ConversationId))
{
dmGroup = await _dbEntities.DmGroups.MatchID(draft.ConversationId).ExecuteFirstAsync(token);
if (dmGroup == default || !dmGroup.Members.Any(m => !m.IsForeign && m.AvatarId == author.Id))
return new(default, default, StatusCodes.Status404NotFound, "Conversation not found");
}
else
{
var members = new List<GroupMember> { new() { AvatarId = author.Id } };
foreach (var handle in draft.Recipients.Distinct(StringComparer.OrdinalIgnoreCase))
{
var member = await ResolveRecipient(handle, token);
if (member == default)
return new(default, default, StatusCodes.Status404NotFound, $"Recipient '{handle}' not found");
if (members.All(m => m.AvatarId != member.AvatarId))
members.Add(member);
}
foreach (var mention in mentioned.Where(m => m.AccountId != author.Id))
{
var member = mention.IsLocal ? new GroupMember { AvatarId = mention.AccountId } : new GroupMember { AvatarId = mention.ActorUri, IsForeign = true };
if (members.All(m => m.AvatarId != member.AvatarId))
members.Add(member);
}
if (members.Count < 2)
return new(default, default, StatusCodes.Status422UnprocessableEntity, "Validation failed: A direct message needs at least one recipient");
var key = DmGroup.KeyOf(members);
dmGroup = await _dbEntities.DmGroups.Match(g => g.ParticipantsKey == key && !g.DeletionAt.HasValue).ExecuteFirstAsync(token);
if (dmGroup == default)
{
dmGroup = new DmGroup { Members = members, ParticipantsKey = key };
dmGroup.ID = (string)dmGroup.GenerateNewID();
dmGroup.ConversationURI = author.ConversationUri(dmGroup.ID);
await DB.Default.SaveAsync(dmGroup, token);
}
}
var recipients = new List<Recipient>();
foreach (var member in dmGroup.Members.Where(m => !(m.AvatarId == author.Id && !m.IsForeign)))
{
if (member.IsForeign)
{
var foreign = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == member.AvatarId).ExecuteFirstAsync(token);
if (foreign != default)
recipients.Add(new Recipient(foreign.ActorURI, $"{foreign.UserName}@{foreign.Domain}", default, foreign.ID, foreign.InboxURL));
}
else if (await _localActors.FindById(LocalActorKind.Person, member.AvatarId, token) is { } local)
recipients.Add(new Recipient(local.Uri, local.Handle, local.Id, default, default));
}
return new(dmGroup, recipients);
}
async Task<GroupMember> ResolveRecipient(string recipient, CancellationToken token)
{
var handle = recipient.Trim().TrimStart('@');
if (!handle.Contains('@') || handle.EndsWith("@" + new Uri(_localActors.BaseAddress).Authority, StringComparison.OrdinalIgnoreCase))
{
var local = await _localActors.FindByUserName(handle.Split('@')[0], token);
return local is { Kind: LocalActorKind.Person } ? new GroupMember { AvatarId = local.Id } : default;
}
var actorUri = await _remoteActors.ResolveHandle(handle, token);
var foreign = actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token);
return foreign == default ? default : new GroupMember { AvatarId = foreign.ActorURI, IsForeign = true };
}
async Task<PostEntity> Parent(string reference, CancellationToken token)
{
if (string.IsNullOrEmpty(reference))
return default;
return IsRemoteUri(reference)
? await _dbEntities.Posts.Match(p => p.ObjectURI == reference && !p.DeletedAt.HasValue).ExecuteFirstAsync(token)
: await _dbEntities.Posts.Match(p => p.ID == reference && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
}
async Task<PostEntity> Own(LocalActor author, string postId, CancellationToken token) =>
await _dbEntities.Posts.Match(p => p.ID == postId && p.GroupUserId == author.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.ReblogOfPostId == null)
.ExecuteFirstAsync(token);
async Task<PostEntity> Visible(LocalActor me, string postId, CancellationToken token)
{
var post = string.IsNullOrEmpty(postId) ? default : await _dbEntities.Posts.Match(p => p.ID == postId && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
return post != default && await VisibilityPolicy.CanSee(post, me.Id, token) ? post : default;
}
async Task<string> AuthorInbox(PostEntity post, CancellationToken token)
{
if (!post.IsFederatedCopy || string.IsNullOrEmpty(post.AuthorAccountId))
return default;
return (await _dbEntities.ForeignAvatars.MatchID(post.AuthorAccountId).ExecuteFirstAsync(token))?.InboxURL;
}
static JsonObject Undo(LocalActor actor, JsonObject inner, string activityId) => new()
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = actor.ActivityUri(activityId),
["type"] = "Undo",
["actor"] = actor.Uri,
["object"] = inner.DeepClone()
};
static PostMention ToMention(ResolvedMention mention) => new()
{
ActorURI = mention.ActorUri,
Handle = "@" + mention.Handle,
IsLocal = mention.IsLocal,
AccountId = mention.AccountId
};
static bool IsRemoteUri(string reference) =>
reference != default && (reference.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || reference.StartsWith("http://", StringComparison.OrdinalIgnoreCase));
static string Clean(string value) => string.IsNullOrWhiteSpace(value) ? default : value.Trim();
static List<string> Strings(JsonNode node) =>
node is JsonArray array ? array.Select(n => n?.GetValue<string>()).Where(s => s != default).ToList() : new List<string>();
}
}
+93
View File
@@ -0,0 +1,93 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Domain.Social;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.StaticServices;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Domain.Timelines
{
public interface IFanout
{
Task Distribute(Post post, CancellationToken token);
}
public class Fanout : IFanout
{
readonly DbEntities _dbEntities;
public Fanout(DbEntities dbEntities)
{
_dbEntities = dbEntities;
}
public async Task Distribute(Post post, CancellationToken token)
{
var recipients = new HashSet<string>(StringComparer.Ordinal);
if (!post.IsFederatedCopy && !string.IsNullOrEmpty(post.GroupUserId))
recipients.Add(post.GroupUserId);
switch (post.Visibility)
{
case PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly:
var followers = post.IsFederatedCopy
? await _dbEntities.Followings.Match(f => f.TargetActorURI == post.ActorURI && f.State == FollowState.Accepted).ExecuteAsync(token)
: await _dbEntities.Followings.Match(f => f.TargetAccountId == post.GroupUserId && f.TargetIsLocal && f.State == FollowState.Accepted).ExecuteAsync(token);
foreach (var following in followers)
if (await Shows(following, post, token))
recipients.Add(following.AvatarId);
break;
case PostVisibility.Direct when !string.IsNullOrEmpty(post.ConversationId):
var conversation = await _dbEntities.DmGroups.MatchID(post.ConversationId).ExecuteFirstAsync(token);
foreach (var member in conversation?.Members.Where(m => !m.IsForeign) ?? Enumerable.Empty<GroupMember>())
recipients.Add(member.AvatarId);
break;
case PostVisibility.Circle when !string.IsNullOrEmpty(post.GroupId):
var circle = await DB.Default.Find<GroupEntity>().MatchID(post.GroupId).ExecuteFirstAsync(token);
foreach (var member in circle?.Members.Where(m => !m.IsForeign) ?? Enumerable.Empty<GroupMember>())
recipients.Add(member.AvatarId);
break;
}
foreach (var avatarId in recipients)
{
try
{
await DB.Default.SaveAsync(new TimelineEntry
{
AvatarId = avatarId,
PostId = post.ID,
AuthorAccountId = post.AuthorAccountId ?? post.GroupUserId,
ReblogOfPostId = post.ReblogOfPostId
}, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
}
}
if (string.IsNullOrEmpty(post.ReblogOfPostId))
foreach (var mention in post.Mentions.Where(m => m.IsLocal && !string.IsNullOrEmpty(m.AccountId)))
await Notifications.Add(mention.AccountId, NotificationType.Mention, post.AuthorAccountId ?? post.GroupUserId, post.ActorURI, post.ID, token);
}
async Task<bool> Shows(Following following, Post post, CancellationToken token)
{
if (!string.IsNullOrEmpty(post.ReblogOfPostId))
return following.ShowReblogs;
var repliedTo = post.InReplyToAccountId;
var author = post.AuthorAccountId ?? post.GroupUserId;
if (string.IsNullOrEmpty(post.InReplyToURI) || repliedTo == author || repliedTo == following.AvatarId)
return true;
if (string.IsNullOrEmpty(repliedTo))
return false;
return await _dbEntities.Followings
.Match(f => f.AvatarId == following.AvatarId && f.TargetAccountId == repliedTo && f.State == FollowState.Accepted)
.ExecuteAnyAsync(token);
}
}
}
@@ -0,0 +1,119 @@
using Microsoft.Extensions.Localization;
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels.Social;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Resources;
using PrivaPub.StaticServices;
namespace PrivaPub.Domain.Timelines
{
public interface ITimelineService
{
Task<WebResult> Home(string rootUserId, string avatarId, string maxId, int limit, CancellationToken token);
Task<WebResult> Notifications(string rootUserId, string avatarId, string maxId, int limit, CancellationToken token);
Task<WebResult> MarkNotificationsRead(string rootUserId, string avatarId, CancellationToken token);
}
public class TimelineService : ITimelineService
{
const int MaxLimit = 40;
readonly DbEntities _dbEntities;
readonly IStringLocalizer<GenericRes> _localizer;
public TimelineService(DbEntities dbEntities, IStringLocalizer<GenericRes> localizer)
{
_dbEntities = dbEntities;
_localizer = localizer;
}
public async Task<WebResult> Home(string rootUserId, string avatarId, string maxId, int limit, CancellationToken token)
{
var result = new WebResult();
if (!await Owns(rootUserId, avatarId, token))
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
var query = _dbEntities.TimelineEntries.Match(e => e.AvatarId == avatarId);
if (!string.IsNullOrEmpty(maxId))
query.Match(f => f.Lt(e => e.PostId, maxId));
var entries = await query.Sort(e => e.PostId, Order.Descending).Limit(Math.Clamp(limit, 1, MaxLimit)).ExecuteAsync(token);
var ids = entries.Select(e => e.PostId).Concat(entries.Where(e => e.ReblogOfPostId != default).Select(e => e.ReblogOfPostId)).Distinct().ToList();
var posts = ids.Count == 0
? new Dictionary<string, Post>()
: (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID);
result.Data = entries
.Where(e => posts.ContainsKey(e.PostId) && (e.ReblogOfPostId == default || posts.ContainsKey(e.ReblogOfPostId)))
.Select(e =>
{
var view = ToView(posts[e.PostId]);
if (e.ReblogOfPostId != default)
view.ReblogOf = ToView(posts[e.ReblogOfPostId]);
return view;
})
.ToList();
return result;
}
public async Task<WebResult> Notifications(string rootUserId, string avatarId, string maxId, int limit, CancellationToken token)
{
var result = new WebResult();
if (!await Owns(rootUserId, avatarId, token))
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
var query = _dbEntities.Notifications.Match(n => n.AvatarId == avatarId);
if (!string.IsNullOrEmpty(maxId))
query.Match(f => f.Lt(n => n.ID, maxId));
result.Data = (await query.Sort(n => n.ID, Order.Descending).Limit(Math.Clamp(limit, 1, MaxLimit)).ExecuteAsync(token))
.Select(n => new ViewNotification
{
Id = n.ID,
Type = n.Type.ToString(),
FromAccountId = n.FromAccountId,
FromActorURI = n.FromActorURI,
PostId = n.PostId,
IsRead = n.IsRead,
CreatedAt = n.CreatedAt
})
.ToList();
return result;
}
public async Task<WebResult> MarkNotificationsRead(string rootUserId, string avatarId, CancellationToken token)
{
var result = new WebResult();
if (!await Owns(rootUserId, avatarId, token))
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
await DB.Default.Update<Notification>().Match(n => n.AvatarId == avatarId && !n.IsRead).Modify(n => n.IsRead, true).ExecuteAsync(token);
return result;
}
async Task<bool> Owns(string rootUserId, string avatarId, CancellationToken token) =>
!string.IsNullOrEmpty(rootUserId) && !string.IsNullOrEmpty(avatarId)
&& await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token);
static ViewPost ToView(Post post) => new()
{
Id = post.ID,
ObjectURI = post.ObjectURI,
AuthorAvatarId = post.IsFederatedCopy ? default : post.GroupUserId,
AuthorActorURI = post.ActorURI,
GroupId = post.GroupId,
DmGroupId = post.ConversationId,
Visibility = post.Visibility.ToString(),
AnsweringToPostId = post.AnsweringToPostId,
Title = post.Title,
Text = post.Text,
ContentHtml = post.ContentHtml,
HasContentWarning = post.HasContentWarning,
IsFederatedCopy = post.IsFederatedCopy,
CreationDate = post.CreationDate
};
}
}
@@ -0,0 +1,82 @@
using System.Text.Json;
using PrivaPub.Federation.Objects;
namespace PrivaPub.Federation.Actors
{
public sealed record ActorKey(string Id, string Pem);
public sealed class ActorDocument
{
static readonly string[] ActorTypes = { "Person", "Service", "Application", "Group", "Organization" };
public string Id { get; init; }
public string Type { get; init; }
public string PreferredUsername { get; init; }
public string Name { get; init; }
public string Summary { get; init; }
public string Url { get; init; }
public string Inbox { get; init; }
public string Outbox { get; init; }
public string Followers { get; init; }
public string Following { get; init; }
public string SharedInbox { get; init; }
public string Icon { get; init; }
public bool Discoverable { get; init; } = true;
public IReadOnlyList<ActorKey> Keys { get; init; } = Array.Empty<ActorKey>();
public ActorKey Key(string keyId) => Keys.FirstOrDefault(k => k.Id == keyId);
public static ActorDocument Parse(JsonElement root)
{
if (root.ValueKind != JsonValueKind.Object)
return default;
var id = RemoteActorService.Text(root, "id");
var type = RemoteActorService.Text(root, "type");
if (Origin.Of(id) == default || !ActorTypes.Contains(type))
return default;
var inbox = RemoteActorService.Text(root, "inbox");
return new ActorDocument
{
Id = id,
Type = type,
PreferredUsername = RemoteActorService.Text(root, "preferredUsername"),
Name = RemoteActorService.Text(root, "name"),
Summary = RemoteActorService.Text(root, "summary"),
Url = RemoteActorService.Text(root, "url") ?? id,
Inbox = Origin.Same(inbox, id) ? inbox : default,
Outbox = RemoteActorService.Text(root, "outbox"),
Followers = RemoteActorService.Text(root, "followers"),
Following = RemoteActorService.Text(root, "following"),
SharedInbox = root.TryGetProperty("endpoints", out var endpoints) ? RemoteActorService.Text(endpoints, "sharedInbox") : default,
Icon = root.TryGetProperty("icon", out var icon) ? RemoteActorService.Text(icon, "url") : default,
Discoverable = !root.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False,
Keys = ParseKeys(root, id)
};
}
static List<ActorKey> ParseKeys(JsonElement root, string actorId)
{
var keys = new List<ActorKey>();
if (!root.TryGetProperty("publicKey", out var publicKey))
return keys;
var candidates = publicKey.ValueKind switch
{
JsonValueKind.Object => new[] { publicKey },
JsonValueKind.Array => publicKey.EnumerateArray().Where(k => k.ValueKind == JsonValueKind.Object).ToArray(),
_ => Array.Empty<JsonElement>()
};
foreach (var key in candidates)
{
var keyId = RemoteActorService.Text(key, "id");
var owner = RemoteActorService.Text(key, "owner");
var pem = RemoteActorService.Text(key, "publicKeyPem");
if (string.IsNullOrEmpty(pem) || owner != actorId || !Origin.Same(keyId, actorId))
continue;
keys.Add(new ActorKey(keyId, pem));
}
return keys;
}
}
}
@@ -1,9 +1,11 @@
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using MongoDB.Driver;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Models; using PrivaPub.Models;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.User; using PrivaPub.Models.User;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
@@ -11,7 +13,7 @@ using System.Security.Cryptography;
using GroupEntity = PrivaPub.Models.Group.Group; using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Services.Federation namespace PrivaPub.Federation.Actors
{ {
public class LocalActor public class LocalActor
{ {
@@ -26,20 +28,28 @@ namespace PrivaPub.Services.Federation
public string PublicKeyPem { get; init; } public string PublicKeyPem { get; init; }
public bool Discoverable { get; init; } = true; public bool Discoverable { get; init; } = true;
public bool ManuallyApprovesFollowers { get; init; } public bool ManuallyApprovesFollowers { get; init; }
public bool IsFederated { get; init; } = true;
public bool IsBot { get; init; }
public bool Indexable { get; init; }
public AvatarSettings Settings { get; init; } = new();
public DateTime Published { get; init; } public DateTime Published { get; init; }
public string BaseAddress { get; init; } public string BaseAddress { get; init; }
public IReadOnlyDictionary<string, string> Fields { get; init; } = new Dictionary<string, string>();
public string Uri => $"{BaseAddress}/peasants/{UserName}"; public string Uri => $"{BaseAddress}/peasants/{UserName}";
public string KeyId => $"{Uri}#main-key"; public string KeyId => $"{Uri}#main-key";
public string Inbox => $"{Uri}/mouth"; public string Inbox => $"{Uri}/mouth";
public string Outbox => $"{Uri}/anus"; public string Outbox => $"{Uri}/anus";
public string Followers => $"{Uri}/followers"; public string Followers => $"{Uri}/groupies";
public string Following => $"{Uri}/following"; public string Following => $"{Uri}/stalking";
public string SharedInbox => $"{BaseAddress}/human-centipede"; public string SharedInbox => $"{BaseAddress}/human-centipede";
public string Domain => new Uri(BaseAddress).Authority; public string Domain => new Uri(BaseAddress).Authority;
public string Handle => $"{UserName}@{Domain}"; public string Handle => $"{UserName}@{Domain}";
public string PostUri(string postId) => $"{Uri}/posts/{postId}"; public string HtmlUrl => $"{BaseAddress}/@{UserName}";
public string ActivityUri(string activityId) => $"{Uri}/activities/{activityId}"; public string PostUri(string postId) => $"{Uri}/scribbles/{postId}";
public string PostHtmlUrl(string postId) => $"{HtmlUrl}/{postId}";
public string ActivityUri(string activityId) => $"{Uri}/grunts/{activityId}";
public string ConversationUri(string conversationId) => $"{Uri}/whispers/{conversationId}";
} }
public interface ILocalActorService public interface ILocalActorService
@@ -50,6 +60,7 @@ namespace PrivaPub.Services.Federation
Task<LocalActor> FindByUri(string actorUri, CancellationToken token); Task<LocalActor> FindByUri(string actorUri, CancellationToken token);
Task<LocalActor> GetInstanceActor(CancellationToken token); Task<LocalActor> GetInstanceActor(CancellationToken token);
Task<bool> IsUserNameTaken(string userName, CancellationToken token); Task<bool> IsUserNameTaken(string userName, CancellationToken token);
Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token);
LocalActor FromAvatar(Avatar avatar); LocalActor FromAvatar(Avatar avatar);
LocalActor FromGroup(GroupEntity group); LocalActor FromGroup(GroupEntity group);
} }
@@ -58,8 +69,15 @@ namespace PrivaPub.Services.Federation
{ {
public const string InstanceUserName = "privapub"; public const string InstanceUserName = "privapub";
static readonly HashSet<string> ReservedByInstance = new(StringComparer.Ordinal)
{
InstanceUserName, "admin", "administrator", "root", "system", "support", "help", "moderator", "mod",
"abuse", "postmaster", "webmaster", "hostmaster", "security", "noreply", "no_reply", "null", "undefined"
};
readonly DbEntities _dbEntities; readonly DbEntities _dbEntities;
readonly IOptionsMonitor<AppConfiguration> _appConfiguration; readonly IOptionsMonitor<AppConfiguration> _appConfiguration;
InstanceActor _instanceActor;
public LocalActorService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration) public LocalActorService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration)
{ {
@@ -115,13 +133,7 @@ namespace PrivaPub.Services.Federation
public async Task<LocalActor> GetInstanceActor(CancellationToken token) public async Task<LocalActor> GetInstanceActor(CancellationToken token)
{ {
var instance = await _dbEntities.InstanceActors.ExecuteFirstAsync(token); var instance = _instanceActor ??= await LoadInstanceActor(token);
if (instance == default)
{
var (privateKey, publicKey) = Keys.NewKeyPair();
instance = new InstanceActor { PrivateKey = privateKey, PublicKey = publicKey };
await DB.Default.SaveAsync(instance, token);
}
return new LocalActor return new LocalActor
{ {
@@ -138,14 +150,39 @@ namespace PrivaPub.Services.Federation
}; };
} }
async Task<InstanceActor> LoadInstanceActor(CancellationToken token)
{
var instance = await _dbEntities.InstanceActors.Sort(i => i.CreationDate, Order.Ascending).ExecuteFirstAsync(token);
if (instance != default)
return instance;
var (privateKey, publicKey) = Keys.NewKeyPair();
instance = new InstanceActor { PrivateKey = privateKey, PublicKey = publicKey };
await DB.Default.SaveAsync(instance, token);
return instance;
}
public async Task<bool> IsUserNameTaken(string userName, CancellationToken token) public async Task<bool> IsUserNameTaken(string userName, CancellationToken token)
{ {
userName = userName?.ToLowerInvariant(); userName = userName?.ToLowerInvariant();
if (string.IsNullOrEmpty(userName) || userName == InstanceUserName) if (string.IsNullOrEmpty(userName) || ReservedByInstance.Contains(userName))
return true; return true;
if (await _dbEntities.Avatars.Match(a => a.UserName == userName).ExecuteAnyAsync(token)) return await DB.Default.Find<ReservedName>().Match(r => r.Name == userName).ExecuteAnyAsync(token);
}
public async Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token)
{
userName = userName?.ToLowerInvariant();
if (string.IsNullOrEmpty(userName) || ReservedByInstance.Contains(userName))
return false;
try
{
await DB.Default.SaveAsync(new ReservedName { Name = userName, OwnerKind = kind, OwnerId = ownerId }, token);
return true; return true;
return await _dbEntities.Groups.Match(g => g.UserName == userName).ExecuteAnyAsync(token); }
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return false;
}
} }
public LocalActor FromAvatar(Avatar avatar) => new() public LocalActor FromAvatar(Avatar avatar) => new()
@@ -160,6 +197,12 @@ namespace PrivaPub.Services.Federation
PrivateKeyPem = avatar.PrivateKey, PrivateKeyPem = avatar.PrivateKey,
PublicKeyPem = avatar.PublicKey, PublicKeyPem = avatar.PublicKey,
Published = avatar.CreatedAt, Published = avatar.CreatedAt,
Fields = avatar.Fields ?? new Dictionary<string, string>(),
ManuallyApprovesFollowers = avatar.Settings?.IsLocked == true,
Discoverable = avatar.Settings?.IsDiscoverable != false,
IsBot = avatar.Settings?.IsBot == true,
Indexable = avatar.Settings?.IsIndexable == true,
Settings = avatar.Settings ?? new AvatarSettings(),
BaseAddress = BaseAddress BaseAddress = BaseAddress
}; };
@@ -176,6 +219,7 @@ namespace PrivaPub.Services.Federation
PublicKeyPem = group.PublicKey, PublicKeyPem = group.PublicKey,
Discoverable = group.IsDiscoverable, Discoverable = group.IsDiscoverable,
ManuallyApprovesFollowers = group.ManuallyApprovesMembers, ManuallyApprovesFollowers = group.ManuallyApprovesMembers,
IsFederated = group.Kind == GroupKind.Community,
Published = group.CreationDate, Published = group.CreationDate,
BaseAddress = BaseAddress BaseAddress = BaseAddress
}; };
@@ -0,0 +1,199 @@
using MongoDB.Entities;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Signing;
using PrivaPub.Infrastructure.Http;
namespace PrivaPub.Federation.Actors
{
public interface IRemoteActorService
{
Task<FetchedJson> FetchObject(string uri, CancellationToken token);
Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token);
Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token);
Task<string> ResolveHandle(string handle, CancellationToken token);
}
public class RemoteActorService : IRemoteActorService
{
public const string ActivityJson = "application/activity+json";
const string Accept = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";
static readonly TimeSpan CacheLifetime = TimeSpan.FromDays(1);
static readonly TimeSpan RefetchInterval = TimeSpan.FromMinutes(5);
readonly IFederationHttp _http;
readonly ILocalActorService _localActors;
readonly IMemoryCache _cache;
readonly DbEntities _dbEntities;
public RemoteActorService(IFederationHttp http, ILocalActorService localActors, IMemoryCache cache, DbEntities dbEntities)
{
_http = http;
_localActors = localActors;
_cache = cache;
_dbEntities = dbEntities;
}
public async Task<FetchedJson> FetchObject(string uri, CancellationToken token)
{
var signer = await _localActors.GetInstanceActor(token);
var fetched = await Get(uri, signer, token);
if (fetched == default)
return default;
var id = Text(fetched.Root, "id");
if (Origin.IsDocumentAt(id, fetched.FinalUri))
return fetched;
var finalUri = fetched.FinalUri;
fetched.Dispose();
if (!Origin.Same(id, finalUri.AbsoluteUri))
return default;
var named = await Get(id, signer, token);
if (named != default && Origin.IsDocumentAt(Text(named.Root, "id"), named.FinalUri))
return named;
named?.Dispose();
return default;
}
public async Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token)
{
if (string.IsNullOrEmpty(actorUri))
return default;
actorUri = StripFragment(actorUri);
var cached = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token);
if (cached != default && !refresh && DateTime.UtcNow - cached.UpdatedAt < CacheLifetime)
return cached;
if (!MayFetch(actorUri))
return cached;
using var fetched = await FetchObject(actorUri, token);
var actor = fetched == default ? default : ActorDocument.Parse(fetched.Root);
if (actor == default)
return cached;
var key = cached == default ? default : actor.Key(cached.PublicKeyId);
return await Upsert(actor, key ?? actor.Keys.FirstOrDefault(), token);
}
public async Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token)
{
if (Origin.Of(keyId) == default)
return default;
var cached = await _dbEntities.ForeignAvatars.Match(a => a.PublicKeyId == keyId).ExecuteFirstAsync(token);
if (cached != default && !string.IsNullOrEmpty(cached.PublicKey) && !refresh)
return cached;
if (!MayFetch(keyId))
return cached;
using var fetched = await FetchObject(StripFragment(keyId), token);
if (fetched == default)
return cached;
var actor = ActorDocument.Parse(fetched.Root);
if (actor == default)
{
var owner = Text(fetched.Root, "owner");
if (Text(fetched.Root, "id") != keyId || !Origin.Same(owner, keyId))
return default;
using var ownerDocument = await FetchObject(owner, token);
actor = ownerDocument == default ? default : ActorDocument.Parse(ownerDocument.Root);
}
var key = actor?.Key(keyId);
if (key == default)
return default;
return await Upsert(actor, key, token);
}
public async Task<string> ResolveHandle(string handle, CancellationToken token)
{
var parts = handle?.TrimStart('@').Split('@');
if (parts is not { Length: 2 } || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1]))
return default;
var url = $"https://{parts[1]}/.well-known/webfinger?resource={Uri.EscapeDataString($"acct:{parts[0]}@{parts[1]}")}";
using var fetched = await _http.GetJson(url, "application/jrd+json, application/json", sign: default, token);
if (fetched == default)
return default;
var document = fetched.Document;
if (!document.RootElement.TryGetProperty("links", out var links) || links.ValueKind != JsonValueKind.Array)
return default;
foreach (var link in links.EnumerateArray())
{
var type = Text(link, "type") ?? string.Empty;
if (Text(link, "rel") == "self" && (type.Contains("activity+json") || type.Contains("ld+json")))
return Text(link, "href");
}
return default;
}
async Task<FetchedJson> Get(string uri, LocalActor signer, CancellationToken token) =>
await _http.GetJson(uri, Accept, request => HttpSignatures.Sign(request, signer, body: null), token);
bool MayFetch(string uri)
{
var key = "remote-actor:fetched:" + uri;
if (_cache.TryGetValue(key, out _))
return false;
_cache.Set(key, true, RefetchInterval);
return true;
}
static async Task<ForeignAvatar> Upsert(ActorDocument actor, ActorKey key, CancellationToken token)
{
var now = DateTime.UtcNow;
return await DB.Default.UpdateAndGet<ForeignAvatar>()
.Match(a => a.ActorURI == actor.Id)
.Modify(a => a.UserName, actor.PreferredUsername)
.Modify(a => a.Name, actor.Name)
.Modify(a => a.Biography, ContentSanitizer.Html(actor.Summary))
.Modify(a => a.Url, actor.Url)
.Modify(a => a.Domain, new Uri(actor.Id).Authority)
.Modify(a => a.InboxURL, actor.Inbox)
.Modify(a => a.OutboxURL, actor.Outbox)
.Modify(a => a.FollowersURL, actor.Followers)
.Modify(a => a.FollowingURL, actor.Following)
.Modify(a => a.SharedInboxURL, actor.SharedInbox)
.Modify(a => a.PictureURL, actor.Icon)
.Modify(a => a.IsDiscoverable, actor.Discoverable)
.Modify(a => a.AvatarType, Enum.TryParse<AvatarType>(actor.Type, out var type) ? type : AvatarType.Person)
.Modify(a => a.PublicKeyId, key?.Id)
.Modify(a => a.PublicKey, key?.Pem)
.Modify(a => a.UpdatedAt, now)
.Modify(b => b.SetOnInsert(a => a.CreatedAt, now))
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
}
public static string StripFragment(string uri)
{
var hash = uri.IndexOf('#');
return hash < 0 ? uri : uri[..hash];
}
public static string Text(JsonElement element, string property)
{
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(property, out var value))
return default;
return value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Object => Text(value, "id") ?? Text(value, "href"),
JsonValueKind.Array => value.EnumerateArray().Select(v => v.ValueKind == JsonValueKind.String ? v.GetString() : Text(v, "id")).FirstOrDefault(v => v != null),
_ => default
};
}
}
}
@@ -0,0 +1,245 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Inbox;
using PrivaPub.Infrastructure;
namespace PrivaPub.Federation.Controllers
{
[ApiController, Route("peasants")]
public class PeasantsController : ControllerBase
{
const string ActivityContentType = "application/activity+json; charset=utf-8";
const int OutboxSize = 20;
readonly ILocalActorService _localActors;
readonly IInboxReceiver _inbox;
readonly DbEntities _dbEntities;
readonly ILogger<PeasantsController> _logger;
public PeasantsController(ILocalActorService localActors, IInboxReceiver inbox, DbEntities dbEntities,
ILogger<PeasantsController> logger)
{
_localActors = localActors;
_inbox = inbox;
_dbEntities = dbEntities;
_logger = logger;
}
[HttpGet, Route("{actor}")]
public async Task<IActionResult> GetActor(string actor, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true })
return NotFound();
if (WantsHtml() && local.Kind != LocalActorKind.Application)
return Redirect(local.HtmlUrl);
return Activity(ActivityPubRenderer.Actor(local));
}
[HttpGet, Route("{actor}/anus")]
public async Task<IActionResult> Outbox(string actor, [FromQuery] bool page, [FromQuery(Name = "max_id")] string maxId,
CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true })
return NotFound();
if (!page)
{
var total = local.Kind == LocalActorKind.Group
? await DB.Default.CountAsync<PostEntity>(f => f.Where(p => p.GroupId == local.Id) & f.Where(VisibilityPolicy.IsPublic), token)
: await DB.Default.CountAsync<PostEntity>(f => f.Where(p => p.GroupUserId == local.Id && !p.IsFederatedCopy) & f.Where(VisibilityPolicy.IsPublic), token);
return Activity(ActivityPubRenderer.OrderedCollection(local.Outbox, (int)total, default, $"{local.Outbox}?page=true"));
}
var query = local.Kind == LocalActorKind.Group
? _dbEntities.Posts.Match(p => p.GroupId == local.Id).Match(VisibilityPolicy.IsPublic)
: _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy).Match(VisibilityPolicy.IsPublic);
if (!string.IsNullOrEmpty(maxId))
query.Match(f => f.Lt(p => p.ID, maxId));
var latest = await query.Sort(p => p.ID, Order.Descending).Limit(OutboxSize).ExecuteAsync(token);
var items = new List<JsonNode>();
foreach (var post in latest)
{
if (local.Kind == LocalActorKind.Group)
{
items.Add(ActivityPubRenderer.Announce(local, post.ObjectURI, $"announce-{post.ID}"));
continue;
}
if (post.ReblogOfPostId != default)
{
if (await AnnounceFor(post, token) is { } announce)
items.Add(announce);
continue;
}
items.Add(await CreateFor(post, local, token));
}
var pageId = string.IsNullOrEmpty(maxId) ? $"{local.Outbox}?page=true" : $"{local.Outbox}?page=true&max_id={maxId}";
var next = latest.Count == OutboxSize ? $"{local.Outbox}?page=true&max_id={latest[^1].ID}" : default;
return Activity(ActivityPubRenderer.OrderedCollectionPage(pageId, local.Outbox, items, next, default));
}
[HttpGet, Route("{actor}/groupies")]
public async Task<IActionResult> Followers(string actor, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true })
return NotFound();
var count = await DB.Default.CountAsync<Follower>(
f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted, token);
return Activity(ActivityPubRenderer.OrderedCollection(local.Followers, (int)count, default));
}
[HttpGet, Route("{actor}/stalking")]
public async Task<IActionResult> Following(string actor, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, default));
}
[HttpGet, Route("{actor}/scribbles/{postId}")]
public async Task<IActionResult> Post(string actor, string postId, CancellationToken token)
{
var (local, post) = await PublicPost(actor, postId, token);
if (post == default)
return await Tombstone(actor, postId, token) ?? NotFound();
if (WantsHtml())
return Redirect(local.PostHtmlUrl(post.ID));
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
var note = ActivityPubRenderer.Note(post, local, group, post.InReplyToURI);
note["@context"] = ActivityPubRenderer.Context();
return Activity(note);
}
[HttpGet, Route("{actor}/grunts/{activityId}")]
public async Task<IActionResult> Grunt(string actor, string activityId, CancellationToken token)
{
if (activityId.StartsWith("announce-", StringComparison.Ordinal))
{
var booster = await _localActors.FindByUserName(actor, token);
var uri = booster?.ActivityUri(activityId);
var reblog = uri == default ? default : await _dbEntities.Posts.Match(p => p.ObjectURI == uri && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
var rendered = reblog == default ? default : await AnnounceFor(reblog, token);
return rendered == default ? NotFound() : Activity(rendered);
}
if (!activityId.StartsWith("create-", StringComparison.Ordinal))
return NotFound();
var (local, post) = await PublicPost(actor, activityId["create-".Length..], token);
return post == default ? NotFound() : Activity(await CreateFor(post, local, token));
}
[HttpPost, Route("{actor}/mouth"), EnableRateLimiting(RateLimiting.Inbox)]
public async Task<IActionResult> Inbox(string actor, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true })
return NotFound();
return Answer(await _inbox.Receive(Request, local, token));
}
[HttpPost, Route("{actor}/human-centipede"), EnableRateLimiting(RateLimiting.Inbox)]
public async Task<IActionResult> ActorSharedInbox(string actor, CancellationToken token) =>
Answer(await _inbox.Receive(Request, default, token));
[HttpPost, Route("/human-centipede"), EnableRateLimiting(RateLimiting.Inbox)]
public async Task<IActionResult> SharedInbox(CancellationToken token) =>
Answer(await _inbox.Receive(Request, default, token));
async Task<(LocalActor Actor, PostEntity Post)> PublicPost(string actor, string postId, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true, Kind: LocalActorKind.Person })
return default;
var post = await _dbEntities.Posts
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && p.ReblogOfPostId == null)
.Match(VisibilityPolicy.IsPublic)
.ExecuteFirstAsync(token);
return (local, post);
}
async Task<IActionResult> Tombstone(string actor, string postId, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true, Kind: LocalActorKind.Person })
return default;
var deleted = await _dbEntities.Posts
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && p.DeletedAt.HasValue
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted))
.ExecuteFirstAsync(token);
if (deleted == default)
return default;
var tombstone = new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = local.PostUri(deleted.ID),
["type"] = "Tombstone",
["formerType"] = "Note",
["deleted"] = ActivityPubRenderer.Timestamp(deleted.DeletedAt.Value)
};
return new ContentResult { Content = tombstone.ToJsonString(), ContentType = ActivityContentType, StatusCode = StatusCodes.Status410Gone };
}
async Task<JsonObject> AnnounceFor(PostEntity reblog, CancellationToken token)
{
var original = await _dbEntities.Posts.MatchID(reblog.ReblogOfPostId).ExecuteFirstAsync(token);
if (original == default || original.DeletedAt.HasValue)
return default;
return new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = reblog.ObjectURI,
["type"] = "Announce",
["actor"] = reblog.ActorURI,
["published"] = ActivityPubRenderer.Timestamp(reblog.CreationDate),
["to"] = new JsonArray(reblog.To.Select(t => (JsonNode)t).ToArray()),
["cc"] = new JsonArray(reblog.Cc.Select(c => (JsonNode)c).ToArray()),
["object"] = original.ObjectURI
};
}
async Task<JsonObject> CreateFor(PostEntity post, LocalActor author, CancellationToken token)
{
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
var note = ActivityPubRenderer.Note(post, author, group, post.InReplyToURI);
return ActivityPubRenderer.Create(author, note, $"create-{post.ID}");
}
bool WantsHtml()
{
var accept = Request.Headers.Accept.ToString();
return accept.Contains("text/html", StringComparison.OrdinalIgnoreCase)
&& !accept.Contains("activity+json", StringComparison.OrdinalIgnoreCase)
&& !accept.Contains("ld+json", StringComparison.OrdinalIgnoreCase);
}
IActionResult Answer(InboxResult result)
{
if (result.Error != default)
_logger.LogInformation("Inbox refused with {Status}: {Error}", result.StatusCode, result.Error);
return result.Error == default ? StatusCode(result.StatusCode) : StatusCode(result.StatusCode, result.Error);
}
ContentResult Activity(JsonObject document) => new()
{
Content = document.ToJsonString(),
ContentType = ActivityContentType,
StatusCode = StatusCodes.Status200OK
};
}
}
@@ -1,6 +1,6 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace PrivaPub.Controllers.ServerToServer namespace PrivaPub.Federation.Controllers
{ {
[ApiController, Route("users")] [ApiController, Route("users")]
public class UsersController : ControllerBase public class UsersController : ControllerBase
@@ -0,0 +1,118 @@
using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Actors;
namespace PrivaPub.Federation.Controllers
{
[ApiController]
public class WellKnownController : ControllerBase
{
readonly ILocalActorService _localActors;
readonly DbEntities _dbEntities;
public WellKnownController(ILocalActorService localActors, DbEntities dbEntities)
{
_localActors = localActors;
_dbEntities = dbEntities;
}
[HttpGet, Route("/.well-known/webfinger")]
public async Task<IActionResult> WebFinger([FromQuery] string resource, CancellationToken token)
{
if (string.IsNullOrEmpty(resource))
return BadRequest();
LocalActor actor;
if (resource.StartsWith("acct:", StringComparison.OrdinalIgnoreCase))
{
var parts = resource[5..].TrimStart('@').Split('@');
var domain = new Uri(_localActors.BaseAddress).Authority;
if (parts.Length != 2 || !parts[1].Equals(domain, StringComparison.OrdinalIgnoreCase))
return NotFound();
actor = await _localActors.FindByUserName(parts[0], token);
}
else
actor = await _localActors.FindByUri(resource, token);
if (actor is not { IsFederated: true })
return NotFound();
var document = new JsonObject
{
["subject"] = $"acct:{actor.Handle}",
["aliases"] = actor.Kind == LocalActorKind.Application ? new JsonArray(actor.Uri) : new JsonArray(actor.HtmlUrl, actor.Uri),
["links"] = actor.Kind == LocalActorKind.Application
? new JsonArray(new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = actor.Uri })
: new JsonArray(
new JsonObject { ["rel"] = "http://webfinger.net/rel/profile-page", ["type"] = "text/html", ["href"] = actor.HtmlUrl },
new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = actor.Uri })
};
return Content(document.ToJsonString(), "application/jrd+json; charset=utf-8");
}
[HttpGet, Route("/.well-known/nodeinfo")]
public IActionResult NodeInfoLinks()
{
var document = new JsonObject
{
["links"] = new JsonArray(
new JsonObject
{
["rel"] = "http://nodeinfo.diaspora.software/ns/schema/2.1",
["href"] = $"{_localActors.BaseAddress}/nodeinfo/2.1"
},
new JsonObject
{
["rel"] = "http://nodeinfo.diaspora.software/ns/schema/2.0",
["href"] = $"{_localActors.BaseAddress}/nodeinfo/2.0"
})
};
return Content(document.ToJsonString(), "application/json; charset=utf-8");
}
[HttpGet, Route("/nodeinfo/{version:regex(^2\\.[[01]]$)}")]
public async Task<IActionResult> NodeInfo(string version, CancellationToken token)
{
var users = await DB.Default.CountAsync<Avatar>(a => !a.DeletionAt.HasValue, token);
var posts = await DB.Default.CountAsync<PostEntity>(f => f.Where(p => !p.IsFederatedCopy && p.ReblogOfPostId == null) & f.Where(VisibilityPolicy.IsPublic), token);
var software = new JsonObject { ["name"] = "privapub", ["version"] = BuildInfo.Ref };
if (version == "2.1")
{
software["repository"] = "https://git.thepra.dev/thepra/SocialPub";
software["homepage"] = "https://git.thepra.dev/thepra/SocialPub";
}
var document = new JsonObject
{
["version"] = version,
["software"] = software,
["protocols"] = new JsonArray("activitypub"),
["services"] = new JsonObject { ["inbound"] = new JsonArray(), ["outbound"] = new JsonArray() },
["openRegistrations"] = true,
["usage"] = new JsonObject
{
["users"] = new JsonObject { ["total"] = users },
["localPosts"] = posts
},
["metadata"] = new JsonObject
{
["nodeName"] = "PrivaPub",
["nodeDescription"] = "A small ActivityPub server where one private login keeps several unlinkable public personas.",
["federation"] = new JsonObject { ["document"] = "https://git.thepra.dev/thepra/SocialPub/src/branch/master/FEDERATION.md" }
}
};
return Content(document.ToJsonString(),
$"application/json; profile=\"http://nodeinfo.diaspora.software/ns/schema/{version}#\"; charset=utf-8");
}
}
}
@@ -0,0 +1,37 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Federation.Inbox
{
public static class ForeignMembers
{
public static async Task<bool> IsAcceptedFollower(LocalActor group, string actorUri, CancellationToken token) =>
await DB.Default.Find<Follower>()
.Match(f => f.LocalActorId == group.Id && f.LocalActorKind == LocalActorKind.Group && f.ActorURI == actorUri && f.IsAccepted)
.ExecuteAnyAsync(token);
public static async Task AddForeignMember(string groupId, string actorUri, CancellationToken token)
{
var group = await DB.Default.Find<GroupEntity>().MatchID(groupId).ExecuteFirstAsync(token);
if (group == default || group.Members.Any(m => m.IsForeign && m.AvatarId == actorUri))
return;
group.Members.Add(new GroupMember { AvatarId = actorUri, IsForeign = true });
group.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(group, token);
}
public static async Task RemoveForeignMember(string groupId, string actorUri, CancellationToken token)
{
var group = await DB.Default.Find<GroupEntity>().MatchID(groupId).ExecuteFirstAsync(token);
if (group == default || group.Members.RemoveAll(m => m.IsForeign && m.AvatarId == actorUri) == 0)
return;
group.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync<GroupEntity>(group, token);
}
}
}
@@ -0,0 +1,67 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Inbox.Handlers
{
public class AcceptHandler : IActivityHandler
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
public AcceptHandler(DbEntities dbEntities, ILocalActorService localActors)
{
_dbEntities = dbEntities;
_localActors = localActors;
}
public virtual string Type => "Accept";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{
var following = await FindFollowing(activity["object"], actor, _dbEntities, _localActors, token);
if (following == default)
return;
await Apply(following, token);
}
protected virtual Task Apply(Following following, CancellationToken token) =>
DB.Default.Update<Following>().MatchID(following.ID).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
public static async Task<Following> FindFollowing(JsonNode follow, ForeignAvatar target, DbEntities dbEntities, ILocalActorService localActors,
CancellationToken token)
{
if (follow is JsonObject && Value(follow, "type") is { } type && type != "Follow")
return default;
var followId = Id(follow);
var byId = followId == default
? default
: await dbEntities.Followings.Match(f => f.FollowActivityURI == followId && f.TargetActorURI == target.ActorURI).ExecuteFirstAsync(token);
if (byId != default || follow is not JsonObject)
return byId;
var follower = await localActors.FindByUri(Id(follow["actor"]), token);
return follower == default
? default
: await dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == target.ActorURI).ExecuteFirstAsync(token);
}
}
public class RejectHandler : AcceptHandler
{
public RejectHandler(DbEntities dbEntities, ILocalActorService localActors) : base(dbEntities, localActors)
{
}
public override string Type => "Reject";
protected override Task Apply(Following following, CancellationToken token) => DB.Default.DeleteAsync<Following>(following.ID);
}
}
@@ -0,0 +1,106 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Domain.Social;
using PrivaPub.Domain.Timelines;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Infrastructure.Ids;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Globalization;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Inbox.Handlers
{
public class AnnounceHandler : IActivityHandler
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemotePosts _remotePosts;
readonly IFanout _fanout;
public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout)
{
_dbEntities = dbEntities;
_localActors = localActors;
_remotePosts = remotePosts;
_fanout = fanout;
}
public string Type => "Announce";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{
var inner = activity["object"];
if (inner is JsonObject && Value(inner, "type") is "Create" or "Update" or "Delete" or "Like" or "Dislike" or "Undo" or "Add" or "Remove" or "Block")
return;
var objectUri = Id(inner);
var announceId = Id(activity);
if (objectUri == default || announceId == default)
return;
if (await _dbEntities.Posts.Match(p => p.ObjectURI == announceId).ExecuteAnyAsync(token))
return;
var isLocal = objectUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase);
var original = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
var followed = await _dbEntities.Followings.Match(f => f.TargetActorURI == actor.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token);
if (original is not { IsFederatedCopy: false } && !followed)
return;
if (original == default && !isLocal)
original = await _remotePosts.StoreContext(objectUri, 0, token);
if (original == default || original.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
return;
var to = Strings(activity["to"]);
var cc = Strings(activity["cc"]);
var published = DateTimeOffset.TryParse(Value(activity, "published"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var at)
? at.UtcDateTime
: DateTime.UtcNow;
var reblog = new PostEntity
{
ID = PrivacyIds.At(published),
ObjectURI = announceId,
ActivityURI = announceId,
ActorURI = actor.ActorURI,
AuthorAccountId = actor.ID,
ReblogOfPostId = original.ID,
Visibility = Addressing.Classify(to, cc, actor.FollowersURL),
To = to,
Cc = cc,
IsFederatedCopy = true,
CreationDate = published,
UpdateDate = published
};
if (reblog.Visibility == PostVisibility.Direct)
return;
try
{
await DB.Default.SaveAsync(reblog, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return;
}
await DB.Default.Update<PostEntity>().MatchID(original.ID).Modify(b => b.Inc(p => p.ReblogsCount, 1)).ExecuteAsync(token);
if (!original.IsFederatedCopy)
await Notifications.Add(original.GroupUserId, NotificationType.Reblog, actor.ID, actor.ActorURI, original.ID, token);
await _fanout.Distribute(reblog, token);
}
static List<string> Strings(JsonNode node) => node switch
{
JsonArray array => array.Select(Id).Where(id => id != default).ToList(),
JsonNode single when Id(single) is { } id => new List<string> { id },
_ => new List<string>()
};
}
}
@@ -0,0 +1,171 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Domain.Timelines;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Moderation;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
using PrivaPub.Infrastructure.Ids;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Inbox.ForeignMembers;
using static PrivaPub.Federation.Objects.ActivityJson;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Inbox.Handlers
{
public class CreateHandler : IActivityHandler
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IDeliveryService _delivery;
readonly IDomainBlocks _domainBlocks;
readonly IFanout _fanout;
readonly IRemotePosts _remotePosts;
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts)
{
_fanout = fanout;
_remotePosts = remotePosts;
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
_delivery = delivery;
_domainBlocks = domainBlocks;
}
public string Type => "Create";
public async Task Handle(JsonNode activity, ForeignAvatar author, CancellationToken token)
{
var node = activity["object"];
if (node is not JsonObject || !Origin.Same(Id(node), author.ActorURI))
{
using var fetched = await _remoteActors.FetchObject(Id(node), token);
node = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText());
}
var note = NoteParser.Parse(node);
if (note == default || note.AttributedTo != author.ActorURI)
return;
if (await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id).ExecuteAnyAsync(token))
return;
var to = note.To.Concat(Strings(activity["to"])).Distinct(StringComparer.Ordinal).ToList();
var cc = note.Cc.Concat(Strings(activity["cc"])).Distinct(StringComparer.Ordinal).ToList();
var visibility = Addressing.Classify(to, cc, author.FollowersURL);
var addressed = to.Concat(cc)
.Concat(Addresses(activity))
.Concat(note.Mentions.Select(m => m.ActorURI))
.Append(note.Audience)
.Where(a => a != default && !Addressing.IsPublic(a) && a != author.FollowersURL)
.Distinct(StringComparer.Ordinal)
.ToList();
var localTargets = new List<LocalActor>();
foreach (var uri in addressed)
{
var local = await _localActors.FindByUri(uri, token);
if (local is { IsFederated: true } && localTargets.All(l => l.Id != local.Id))
localTargets.Add(local);
}
var persons = localTargets.Where(t => t.Kind == LocalActorKind.Person).ToList();
var parent = string.IsNullOrEmpty(note.InReplyTo)
? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
var group = visibility is PostVisibility.Public or PostVisibility.Unlisted
? localTargets.FirstOrDefault(t => t.Kind == LocalActorKind.Group)
: default;
if (group != default && !await IsAcceptedFollower(group, author.ActorURI, token))
group = default;
var repliesToLocal = parent is { IsFederatedCopy: false } && visibility != PostVisibility.Direct;
var followed = visibility != PostVisibility.Direct && await _dbEntities.Followings
.Match(f => f.TargetActorURI == author.ActorURI && f.State == FollowState.Accepted)
.ExecuteAnyAsync(token);
if (visibility == PostVisibility.Direct ? persons.Count == 0 : group == default && persons.Count == 0 && !repliesToLocal && !followed)
return;
if (parent == default && visibility != PostVisibility.Direct)
parent = await _remotePosts.Parent(note.InReplyTo, 1, token);
DmGroup conversation = default;
if (visibility == PostVisibility.Direct)
{
var participants = persons.Select(p => p.Uri)
.Concat(addressed.Where(a => !IsCollection(a)))
.Append(author.ActorURI)
.ToList();
conversation = await FindOrCreateConversation(participants, Origin.Same(note.Context, author.ActorURI) ? note.Context : default, token);
}
var post = await _remotePosts.Build(note, author, visibility, to, cc, parent, token);
post.ActivityURI = Id(activity);
post.GroupId = group?.Id;
post.ConversationId = conversation?.ID;
try
{
await DB.Default.SaveAsync(post, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return;
}
if (parent != default)
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
await _fanout.Distribute(post, token);
if (conversation != default)
await DB.Default.Update<DmGroup>().MatchID(conversation.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token);
if (group != default)
{
var announce = ActivityPubRenderer.Announce(group, note.Id, $"announce-{post.ID}");
await _delivery.EnqueueToFollowers(group, announce, token);
}
}
async Task<DmGroup> FindOrCreateConversation(List<string> participantUris, string context, CancellationToken token)
{
var members = new List<GroupMember>();
foreach (var uri in participantUris.Distinct(StringComparer.Ordinal))
{
var local = await _localActors.FindByUri(uri, token);
var member = local != default && local.Kind == LocalActorKind.Person
? new GroupMember { AvatarId = local.Id }
: new GroupMember { AvatarId = uri, IsForeign = true };
if (members.All(m => m.AvatarId != member.AvatarId || m.IsForeign != member.IsForeign))
members.Add(member);
}
var key = DmGroup.KeyOf(members);
var match = await _dbEntities.DmGroups.Match(g => g.ParticipantsKey == key && !g.DeletionAt.HasValue).ExecuteFirstAsync(token);
if (match != default)
return match;
var conversation = new DmGroup { Members = members, ConversationURI = context, ParticipantsKey = key };
await DB.Default.SaveAsync(conversation, token);
return conversation;
}
static bool IsCollection(string uri) =>
uri.EndsWith("/followers", StringComparison.Ordinal) || uri.EndsWith("/following", StringComparison.Ordinal);
static IEnumerable<string> Strings(JsonNode node) => node switch
{
JsonArray array => array.Select(Id).Where(id => id != default),
JsonNode single when Id(single) is { } id => new[] { id },
_ => Enumerable.Empty<string>()
};
}
}
@@ -0,0 +1,75 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Inbox.ForeignMembers;
using static PrivaPub.Federation.Objects.ActivityJson;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Inbox.Handlers
{
public class DeleteHandler : IActivityHandler
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IDeliveryService _delivery;
public DeleteHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery)
{
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
_delivery = delivery;
}
public string Type => "Delete";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{
var delete = activity;
var objectUri = Id(delete["object"]);
if (!Origin.Same(objectUri, actor.ActorURI))
return;
if (objectUri == actor.ActorURI)
{
actor.DeletionAt = DateTime.UtcNow;
actor.AccountState = AvatarAccountState.Deleted;
await DB.Default.SaveAsync(actor, token);
var followers = await _dbEntities.Followers.Match(f => f.ActorURI == actor.ActorURI).ExecuteAsync(token);
foreach (var follower in followers)
{
await DB.Default.DeleteAsync<Follower>(follower.ID);
if (follower.LocalActorKind == LocalActorKind.Group)
await RemoveForeignMember(follower.LocalActorId, actor.ActorURI, token);
}
await DB.Default.DeleteAsync<Following>(f => f.TargetActorURI == actor.ActorURI);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.AuthorAccountId == actor.ID);
return;
}
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
if (post == default)
return;
await DB.Default.DeleteAsync<PostEntity>(post.ID);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID);
await DB.Default.DeleteAsync<PostEntity>(p => p.ReblogOfPostId == post.ID);
if (!string.IsNullOrEmpty(post.AnsweringToPostId))
await DB.Default.Update<PostEntity>().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, -1)).ExecuteAsync(token);
}
}
}
@@ -0,0 +1,79 @@
using MongoDB.Entities;
using PrivaPub.Domain.Social;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Social;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Inbox.ForeignMembers;
using static PrivaPub.Federation.Objects.ActivityJson;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Inbox.Handlers
{
public class FollowHandler : IActivityHandler
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IDeliveryService _delivery;
public FollowHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery)
{
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
_delivery = delivery;
}
public string Type => "Follow";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{
var follow = activity;
var follower = actor;
var target = await _localActors.FindByUri(Id(follow["object"]), token);
if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application)
return;
var existing = await _dbEntities.Followers
.Match(f => f.LocalActorId == target.Id && f.LocalActorKind == target.Kind && f.ActorURI == follower.ActorURI)
.ExecuteFirstAsync(token);
var record = existing ?? new Follower
{
LocalActorId = target.Id,
LocalActorKind = target.Kind,
ActorURI = follower.ActorURI
};
record.InboxURL = follower.InboxURL;
record.SharedInboxURL = follower.SharedInboxURL;
record.FollowActivityURI = Id(follow);
record.IsAccepted = existing?.IsAccepted == true || !target.ManuallyApprovesFollowers;
await DB.Default.SaveAsync(record, token);
if (target.Kind == LocalActorKind.Person)
await Notifications.Add(target.Id, record.IsAccepted ? NotificationType.Follow : NotificationType.FollowRequest,
follower.ID, follower.ActorURI, default, token);
if (!record.IsAccepted)
return;
if (target.Kind == LocalActorKind.Group)
await AddForeignMember(target.Id, follower.ActorURI, token);
var accept = ActivityPubRenderer.Accept(target, follow, $"accept-{record.ID}-{DateTime.UtcNow.Ticks}");
await _delivery.Enqueue(target, new[] { follower.InboxURL }, accept, token);
return;
}
}
}
@@ -0,0 +1,56 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Domain.Social;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Inbox.Handlers
{
public class LikeHandler : IActivityHandler
{
readonly DbEntities _dbEntities;
public LikeHandler(DbEntities dbEntities)
{
_dbEntities = dbEntities;
}
public string Type => "Like";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{
var objectUri = Id(activity["object"]);
var post = objectUri == default
? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
if (post == default || !MaySee(post, actor))
return;
try
{
await DB.Default.SaveAsync(new Favourite { AccountId = actor.ID, ActorURI = actor.ActorURI, PostId = post.ID, ActivityURI = Id(activity) }, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return;
}
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.FavouritesCount, 1)).ExecuteAsync(token);
if (!post.IsFederatedCopy)
await Notifications.Add(post.GroupUserId, NotificationType.Favourite, actor.ID, actor.ActorURI, post.ID, token);
}
public static bool MaySee(PostEntity post, ForeignAvatar actor) =>
post.Visibility is PostVisibility.Public or PostVisibility.Unlisted
|| post.Visibility is PostVisibility.FollowersOnly or PostVisibility.Direct
&& (post.To.Contains(actor.ActorURI) || post.Cc.Contains(actor.ActorURI) || post.Mentions.Any(m => m.ActorURI == actor.ActorURI));
}
}
@@ -0,0 +1,95 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Inbox.ForeignMembers;
using static PrivaPub.Federation.Objects.ActivityJson;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Inbox.Handlers
{
public class UndoHandler : IActivityHandler
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
public UndoHandler(DbEntities dbEntities, ILocalActorService localActors)
{
_dbEntities = dbEntities;
_localActors = localActors;
}
public string Type => "Undo";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{
var inner = activity["object"];
var innerType = inner is JsonObject ? Value(inner, "type") : default;
var innerId = Id(inner);
if (innerType is null or "Follow")
await UndoFollow(inner, innerId, actor, token);
if (innerType is null or "Like")
await UndoLike(inner, innerId, actor, token);
if (innerType is null or "Announce")
await UndoAnnounce(innerId, actor, token);
}
async Task UndoFollow(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token)
{
var followers = await _dbEntities.Followers.Match(f => f.ActorURI == actor.ActorURI).ExecuteAsync(token);
var targetUri = inner is JsonObject ? Id(inner["object"]) : default;
var target = targetUri == default ? default : await _localActors.FindByUri(targetUri, token);
foreach (var follower in followers)
{
var matchesActivity = innerId != default && follower.FollowActivityURI == innerId;
var matchesTarget = target != default && target.Id == follower.LocalActorId && target.Kind == follower.LocalActorKind;
if (!matchesActivity && !matchesTarget)
continue;
await DB.Default.DeleteAsync<Follower>(follower.ID);
if (follower.LocalActorKind == LocalActorKind.Group)
await RemoveForeignMember(follower.LocalActorId, actor.ActorURI, token);
}
}
async Task UndoLike(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token)
{
var favourite = innerId == default
? default
: await _dbEntities.Favourites.Match(f => f.ActivityURI == innerId && f.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
if (favourite == default && inner is JsonObject && Id(inner["object"]) is { } objectUri)
{
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
favourite = post == default
? default
: await _dbEntities.Favourites.Match(f => f.PostId == post.ID && f.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
}
if (favourite == default)
return;
await DB.Default.DeleteAsync<Favourite>(favourite.ID);
await DB.Default.Update<PostEntity>().MatchID(favourite.PostId).Modify(b => b.Inc(p => p.FavouritesCount, -1)).ExecuteAsync(token);
}
async Task UndoAnnounce(string innerId, ForeignAvatar actor, CancellationToken token)
{
if (innerId == default)
return;
var reblog = await _dbEntities.Posts
.Match(p => p.ObjectURI == innerId && p.ActorURI == actor.ActorURI && p.ReblogOfPostId != null)
.ExecuteFirstAsync(token);
if (reblog == default)
return;
await DB.Default.DeleteAsync<PostEntity>(reblog.ID);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == reblog.ID);
await DB.Default.Update<PostEntity>().MatchID(reblog.ReblogOfPostId).Modify(b => b.Inc(p => p.ReblogsCount, -1)).ExecuteAsync(token);
}
}
}
@@ -0,0 +1,84 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Inbox.Handlers
{
public class UpdateHandler : IActivityHandler
{
const int MaxRevisions = 20;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors)
{
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
}
public string Type => "Update";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{
var inner = activity["object"];
if (Id(inner) == actor.ActorURI)
{
await _remoteActors.GetActor(actor.ActorURI, refresh: true, token);
return;
}
if (inner is not JsonObject || !Origin.Same(Id(inner), actor.ActorURI))
return;
var note = NoteParser.Parse(inner);
if (note == default || note.AttributedTo != actor.ActorURI)
return;
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
if (post == default || post.DeletedAt.HasValue)
return;
post.Revisions.Add(new PostRevision
{
Title = post.Title,
SpoilerText = post.SpoilerText,
ContentHtml = post.ContentHtml,
HasContentWarning = post.HasContentWarning,
EditedAt = post.EditedAt ?? post.CreationDate
});
if (post.Revisions.Count > MaxRevisions)
post.Revisions.RemoveRange(0, post.Revisions.Count - MaxRevisions);
var mentions = new List<PostMention>();
foreach (var mention in note.Mentions)
{
var local = await _localActors.FindByUri(mention.ActorURI, token);
mentions.Add(new PostMention { ActorURI = mention.ActorURI, Handle = mention.Handle, IsLocal = local != default, AccountId = local?.Id });
}
post.Title = note.Title;
post.SpoilerText = note.SpoilerText;
post.HasContentWarning = note.Sensitive;
post.Text = note.ContentHtml;
post.ContentHtml = note.ContentHtml;
post.Language = note.Language;
post.Tags = note.Tags.ToList();
post.Mentions = mentions;
post.Media = note.Attachments.ToList();
post.EditedAt = note.Updated ?? DateTime.UtcNow;
post.UpdateDate = DateTime.UtcNow;
await DB.Default.SaveAsync(post, token);
}
}
}
@@ -0,0 +1,54 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.User;
using System.Text.Json;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Inbox
{
public interface IActivityHandler
{
string Type { get; }
Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token);
}
public class InboxProcessor : IJobHandler
{
readonly IRemoteActorService _remoteActors;
readonly IReadOnlyDictionary<string, IActivityHandler> _handlers;
readonly ILogger<InboxProcessor> _logger;
public InboxProcessor(IRemoteActorService remoteActors, IEnumerable<IActivityHandler> handlers, ILogger<InboxProcessor> logger)
{
_remoteActors = remoteActors;
_handlers = handlers.ToDictionary(h => h.Type, StringComparer.Ordinal);
_logger = logger;
}
public JobKind Kind => JobKind.ProcessInbox;
public int Concurrency => 2;
public int MaxAttempts => 8;
public int PerHostLimit => 2;
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
{
var payload = JsonSerializer.Deserialize<InboxPayload>(job.Payload);
var activity = payload == default ? default : JsonNode.Parse(payload.Activity);
var type = Value(activity, "type");
if (type == default || !_handlers.TryGetValue(type, out var handler))
return JobOutcome.Done;
var actor = await _remoteActors.GetActor(payload.ActorURI, refresh: false, token);
if (actor == default)
return JobOutcome.Retry("the actor could not be loaded");
await handler.Handle(activity, actor, token);
_logger.LogInformation("Processed {Type} {Id} from {Actor}", type, Id(activity), actor.ActorURI);
return JobOutcome.Done;
}
}
}
+134
View File
@@ -0,0 +1,134 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Moderation;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Signing;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using System.Text.Json;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Inbox
{
public sealed record InboxResult(int StatusCode, string Error = default);
public sealed record InboxPayload(string ActorURI, string Activity);
public interface IInboxReceiver
{
Task<InboxResult> Receive(HttpRequest request, LocalActor recipient, CancellationToken token);
}
public class InboxReceiver : IInboxReceiver
{
const int MaxBodyBytes = 1024 * 1024;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IJobQueue _queue;
readonly IDomainBlocks _domainBlocks;
readonly ILogger<InboxReceiver> _logger;
public InboxReceiver(ILocalActorService localActors, IRemoteActorService remoteActors, IJobQueue queue, IDomainBlocks domainBlocks,
ILogger<InboxReceiver> logger)
{
_localActors = localActors;
_remoteActors = remoteActors;
_queue = queue;
_domainBlocks = domainBlocks;
_logger = logger;
}
public async Task<InboxResult> Receive(HttpRequest request, LocalActor recipient, CancellationToken token)
{
if (request.ContentLength > MaxBodyBytes)
return new(StatusCodes.Status413PayloadTooLarge);
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer, token);
if (buffer.Length > MaxBodyBytes)
return new(StatusCodes.Status413PayloadTooLarge);
var body = buffer.ToArray();
JsonNode activity;
try
{
activity = JsonNode.Parse(body);
}
catch (JsonException)
{
return new(StatusCodes.Status400BadRequest, "the body is not JSON");
}
if (activity is not JsonObject)
return new(StatusCodes.Status400BadRequest, "the body is not an activity");
var type = Value(activity, "type");
var actorUri = Id(activity["actor"]);
if (string.IsNullOrEmpty(type) || string.IsNullOrEmpty(actorUri))
return new(StatusCodes.Status400BadRequest, "type and actor are required");
var parameters = HttpSignatures.Parse(request.Headers["Signature"].ToString());
if (parameters == default)
return new(StatusCodes.Status401Unauthorized, "missing or unreadable Signature header");
if (_domainBlocks.IsSuspended(HostOf(parameters.KeyId)) || _domainBlocks.IsSuspended(HostOf(actorUri)))
return new(StatusCodes.Status202Accepted);
var requestProblem = HttpSignatures.CheckRequest(request, parameters, body);
if (requestProblem != default)
return new(StatusCodes.Status401Unauthorized, requestProblem);
var signingString = HttpSignatures.SigningString(request, parameters);
var keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: false, token);
if (keyOwner == default && type == "Delete" && Id(activity["object"]) == actorUri)
return new(StatusCodes.Status202Accepted);
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
{
keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token);
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
return new(StatusCodes.Status401Unauthorized, "the signature does not verify");
}
if (!string.Equals(keyOwner.ActorURI, actorUri, StringComparison.Ordinal))
return new(StatusCodes.Status401Unauthorized, "the activity's actor is not the key's owner");
var shapeProblem = await ShapeProblem(type, activity, actorUri, token);
if (shapeProblem != default)
return shapeProblem;
var activityId = Id(activity);
await _queue.Enqueue(JobKind.ProcessInbox, JsonSerializer.Serialize(new InboxPayload(actorUri, activity.ToJsonString())),
new Uri(actorUri).Host.ToLowerInvariant(), activityId == default ? default : "inbox|" + activityId, token);
_logger.LogInformation("Inbox {Recipient}: {Type} from {Actor} queued", recipient?.Handle ?? "shared", type, actorUri);
return new(StatusCodes.Status202Accepted);
}
static string HostOf(string uri) => Uri.TryCreate(uri, UriKind.Absolute, out var parsed) ? parsed.Host : default;
async Task<InboxResult> ShapeProblem(string type, JsonNode activity, string actorUri, CancellationToken token)
{
var activityId = Id(activity);
if (activityId != default && !Origin.Same(activityId, actorUri))
return new(StatusCodes.Status400BadRequest, "the activity's id is not on its actor's origin");
var inner = activity["object"];
switch (type)
{
case "Follow":
var target = await _localActors.FindByUri(Id(inner), token);
if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application)
return new(StatusCodes.Status404NotFound, "no such local actor");
break;
case "Undo" when inner is JsonObject && Id(inner["actor"]) != actorUri:
return new(StatusCodes.Status400BadRequest, "an actor can only undo its own activities");
case "Create" or "Update" when inner is JsonObject && Origin.Same(Id(inner), actorUri)
&& inner["attributedTo"] != default && Id(inner["attributedTo"]) != actorUri
&& Value(inner, "type") is not ("Person" or "Service" or "Application" or "Group" or "Organization"):
return new(StatusCodes.Status400BadRequest, "the object is not attributed to the actor");
}
return default;
}
}
}
+173
View File
@@ -0,0 +1,173 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Moderation;
using PrivaPub.Federation.Objects;
using PrivaPub.Infrastructure.Ids;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Inbox
{
public sealed record AncestorsPayload(string PostId, int Depth);
public interface IRemotePosts
{
Task<PostEntity> Build(NoteDocument note, ForeignAvatar author, PostVisibility visibility, IReadOnlyList<string> to, IReadOnlyList<string> cc,
PostEntity parent, CancellationToken token);
Task<PostEntity> Parent(string inReplyTo, int depth, CancellationToken token);
Task<PostEntity> StoreContext(string objectUri, int depth, CancellationToken token);
}
public class RemotePosts : IRemotePosts
{
public const int MaxDepth = 10;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IDomainBlocks _domainBlocks;
readonly IJobQueue _queue;
public RemotePosts(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDomainBlocks domainBlocks,
IJobQueue queue)
{
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
_domainBlocks = domainBlocks;
_queue = queue;
}
public async Task<PostEntity> Build(NoteDocument note, ForeignAvatar author, PostVisibility visibility, IReadOnlyList<string> to,
IReadOnlyList<string> cc, PostEntity parent, CancellationToken token)
{
var mentions = new List<PostMention>();
foreach (var mention in note.Mentions)
{
var local = await _localActors.FindByUri(mention.ActorURI, token);
mentions.Add(new PostMention { ActorURI = mention.ActorURI, Handle = mention.Handle, IsLocal = local != default, AccountId = local?.Id });
}
return new PostEntity
{
ID = PrivacyIds.At(note.Published),
ObjectURI = note.Id,
ActorURI = author.ActorURI,
AuthorAccountId = author.ID,
Visibility = visibility,
To = to.ToList(),
Cc = cc.ToList(),
Url = note.Url,
ContextURI = note.Context,
QuoteURI = note.QuoteUri,
Title = note.Title,
SpoilerText = note.SpoilerText,
HasContentWarning = note.Sensitive,
Text = note.ContentHtml,
ContentHtml = note.ContentHtml,
ContentFormat = ContentFormat.Html,
Language = note.Language,
Mentions = mentions,
Tags = note.Tags.ToList(),
Media = _domainBlocks.Find(new Uri(author.ActorURI).Host)?.RejectMedia == true ? new() : note.Attachments.ToList(),
InReplyToURI = note.InReplyTo,
AnsweringToPostId = parent?.ID,
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
IsFederatedCopy = true,
CreationDate = note.Published,
UpdateDate = note.Updated ?? note.Published,
EditedAt = note.Updated
};
}
public async Task<PostEntity> Parent(string inReplyTo, int depth, CancellationToken token)
{
if (string.IsNullOrEmpty(inReplyTo))
return default;
var known = await _dbEntities.Posts.Match(p => p.ObjectURI == inReplyTo && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
if (known != default || inReplyTo.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
return known;
return await StoreContext(inReplyTo, depth, token);
}
public async Task<PostEntity> StoreContext(string objectUri, int depth, CancellationToken token)
{
var existing = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
if (existing != default || depth > MaxDepth)
return existing;
using var fetched = await _remoteActors.FetchObject(objectUri, token);
var note = fetched == default ? default : NoteParser.Parse(JsonNode.Parse(fetched.Root.GetRawText()));
if (note == default || !Origin.Same(note.Id, note.AttributedTo))
return default;
var visibility = Addressing.Classify(note.To, note.Cc, default);
if (visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
return default;
var author = await _remoteActors.GetActor(note.AttributedTo, refresh: false, token);
if (author == default)
return default;
var grandparent = string.IsNullOrEmpty(note.InReplyTo)
? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
var post = await Build(note, author, visibility, note.To, note.Cc, grandparent, token);
try
{
await DB.Default.SaveAsync(post, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
}
if (grandparent == default && !string.IsNullOrEmpty(note.InReplyTo) && depth < MaxDepth)
await _queue.Enqueue(JobKind.FetchAncestors, JsonSerializer.Serialize(new AncestorsPayload(post.ID, depth + 1)), new Uri(note.InReplyTo).Host,
"ancestors|" + post.ID, token);
return post;
}
}
public class AncestorsJobHandler : IJobHandler
{
readonly DbEntities _dbEntities;
readonly IRemotePosts _remotePosts;
public AncestorsJobHandler(DbEntities dbEntities, IRemotePosts remotePosts)
{
_dbEntities = dbEntities;
_remotePosts = remotePosts;
}
public JobKind Kind => JobKind.FetchAncestors;
public int Concurrency => 1;
public int MaxAttempts => 3;
public int PerHostLimit => 1;
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
{
var payload = JsonSerializer.Deserialize<AncestorsPayload>(job.Payload);
var child = await _dbEntities.Posts.MatchID(payload.PostId).ExecuteFirstAsync(token);
if (child == default || string.IsNullOrEmpty(child.InReplyToURI) || !string.IsNullOrEmpty(child.AnsweringToPostId))
return JobOutcome.Done;
var parent = await _remotePosts.Parent(child.InReplyToURI, payload.Depth, token);
if (parent == default)
return JobOutcome.Done;
await DB.Default.Update<PostEntity>().MatchID(child.ID)
.Modify(p => p.AnsweringToPostId, parent.ID)
.Modify(p => p.InReplyToAccountId, parent.AuthorAccountId ?? parent.GroupUserId)
.ExecuteAsync(token);
return JobOutcome.Done;
}
}
}
@@ -0,0 +1,77 @@
using MongoDB.Entities;
using PrivaPub.Models.Federation;
namespace PrivaPub.Federation.Moderation
{
public interface IDomainBlocks
{
DomainBlock Find(string host);
bool IsSuspended(string host);
Task Reload(CancellationToken token);
}
public class DomainBlocks : IDomainBlocks
{
static readonly TimeSpan Staleness = TimeSpan.FromMinutes(5);
readonly ILogger<DomainBlocks> _logger;
IReadOnlyDictionary<string, DomainBlock> _blocks = new Dictionary<string, DomainBlock>();
DateTime _loadedAt = DateTime.MinValue;
int _reloading;
public DomainBlocks(ILogger<DomainBlocks> logger)
{
_logger = logger;
}
public static string Normalise(string domain) => domain?.Trim().Trim('.').ToLowerInvariant();
public DomainBlock Find(string host)
{
RefreshIfStale();
host = Normalise(host);
var blocks = _blocks;
while (!string.IsNullOrEmpty(host))
{
if (blocks.TryGetValue(host, out var block))
return block;
var dot = host.IndexOf('.');
host = dot < 0 ? default : host[(dot + 1)..];
}
return default;
}
public bool IsSuspended(string host) => Find(host)?.Severity == DomainBlockSeverity.Suspend;
public async Task Reload(CancellationToken token)
{
var blocks = await DB.Default.Find<DomainBlock>().ExecuteAsync(token);
_blocks = blocks.Where(b => !string.IsNullOrEmpty(b.Domain))
.GroupBy(b => Normalise(b.Domain))
.ToDictionary(g => g.Key, g => g.First());
_loadedAt = DateTime.UtcNow;
}
void RefreshIfStale()
{
if (DateTime.UtcNow - _loadedAt < Staleness || Interlocked.Exchange(ref _reloading, 1) == 1)
return;
_ = Task.Run(async () =>
{
try
{
await Reload(CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Domain blocks could not be reloaded");
}
finally
{
Interlocked.Exchange(ref _reloading, 0);
}
});
}
}
}
@@ -0,0 +1,39 @@
using System.Text.Json.Nodes;
namespace PrivaPub.Federation.Objects
{
public static class ActivityJson
{
public static string Id(JsonNode node) => node switch
{
JsonValue value when value.TryGetValue<string>(out var text) => text,
JsonObject obj => Value(obj, "id") ?? Value(obj, "href"),
JsonArray array => array.Select(Id).FirstOrDefault(i => i != default),
_ => default
};
public static string Value(JsonNode node, string property) =>
node is JsonObject obj && obj[property] is JsonValue value && value.TryGetValue<string>(out var text) ? text : default;
public static IEnumerable<string> Addresses(JsonNode node)
{
foreach (var field in new[] { "to", "cc", "bto", "bcc", "audience" })
{
switch (node[field])
{
case JsonArray array:
foreach (var item in array)
{
var id = Id(item);
if (id != default)
yield return id;
}
break;
case JsonNode single when Id(single) is { } id:
yield return id;
break;
}
}
}
}
}
+22
View File
@@ -0,0 +1,22 @@
using PrivaPub.Models.Post;
namespace PrivaPub.Federation.Objects
{
public static class Addressing
{
public const string Public = "https://www.w3.org/ns/activitystreams#Public";
public static bool IsPublic(string address) => address is Public or "as:Public" or "Public";
public static PostVisibility Classify(IReadOnlyCollection<string> to, IReadOnlyCollection<string> cc, string authorFollowers)
{
if (to.Any(IsPublic))
return PostVisibility.Public;
if (cc.Any(IsPublic))
return PostVisibility.Unlisted;
if (!string.IsNullOrEmpty(authorFollowers) && (to.Contains(authorFollowers) || cc.Contains(authorFollowers)))
return PostVisibility.FollowersOnly;
return PostVisibility.Direct;
}
}
}
@@ -0,0 +1,80 @@
using AngleSharp.Css.Dom;
using AngleSharp.Dom;
using Ganss.Xss;
using System.Text.RegularExpressions;
namespace PrivaPub.Federation.Objects
{
public static partial class ContentSanitizer
{
static readonly HtmlSanitizer Sanitizer = Build();
public static string Html(string html) =>
string.IsNullOrWhiteSpace(html) ? string.Empty : Sanitizer.Sanitize(html).Trim();
static HtmlSanitizer Build()
{
var sanitizer = new HtmlSanitizer(new HtmlSanitizerOptions
{
AllowedTags = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"p", "br", "span", "a", "abbr", "del", "s", "pre", "blockquote", "code", "b", "strong", "u", "i", "em",
"sub", "sup", "ul", "ol", "li", "ruby", "rt", "rp", "h1", "h2", "h3", "h4", "h5", "h6"
},
AllowedAttributes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"href", "rel", "class", "translate", "start", "reversed", "value", "title"
},
AllowedCssProperties = new HashSet<string>(),
AllowedAtRules = new HashSet<CssRuleType>(),
AllowedSchemes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"http", "https", "dat", "dweb", "ipfs", "ipns", "ssb", "gopher", "xmpp", "magnet", "gemini"
},
UriAttributes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "href" }
})
{
KeepChildNodes = true
};
foreach (var allowed in new[] { "mention", "hashtag", "ellipsis", "invisible" })
sanitizer.AllowedClasses.Add(allowed);
sanitizer.RemovingCssClass += (_, e) => e.Cancel = MicroformatClass().IsMatch(e.CssClass);
sanitizer.RemovingTag += (_, e) =>
{
if (e.Tag.LocalName is "script" or "style" or "template" or "iframe" or "object" or "embed" or "noscript" or "svg" or "math")
e.Tag.InnerHtml = string.Empty;
};
sanitizer.PostProcessNode += (_, e) =>
{
if (e.Node is not IElement element)
return;
switch (element.LocalName)
{
case "a":
if (!SchemePrefix().IsMatch(element.GetAttribute("href") ?? string.Empty))
element.RemoveAttribute("href");
element.SetAttribute("rel", "nofollow noopener noreferrer");
element.SetAttribute("target", "_blank");
break;
case "h1" or "h2" or "h3" or "h4" or "h5" or "h6":
var paragraph = e.Document.CreateElement("p");
var strong = e.Document.CreateElement("strong");
while (element.FirstChild != default)
strong.AppendChild(element.FirstChild);
paragraph.AppendChild(strong);
e.ReplacementNodes.Add(paragraph);
break;
}
};
return sanitizer;
}
[GeneratedRegex("^[a-z][a-z0-9+.-]*:", RegexOptions.IgnoreCase)]
private static partial Regex SchemePrefix();
[GeneratedRegex("^(h|p|u|dt|e)-[a-z0-9-]+$")]
private static partial Regex MicroformatClass();
}
}
+179
View File
@@ -0,0 +1,179 @@
using PrivaPub.Models.Post;
using System.Globalization;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Objects
{
public sealed class NoteDocument
{
public string Id { get; init; }
public string Type { get; init; }
public string AttributedTo { get; init; }
public string ContentHtml { get; init; }
public string Title { get; init; }
public string SpoilerText { get; init; }
public bool Sensitive { get; init; }
public string Language { get; init; }
public string Url { get; init; }
public string InReplyTo { get; init; }
public string Context { get; init; }
public string Audience { get; init; }
public string QuoteUri { get; init; }
public DateTime Published { get; init; }
public DateTime? Updated { get; init; }
public IReadOnlyList<string> To { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> Cc { get; init; } = Array.Empty<string>();
public IReadOnlyList<PostMention> Mentions { get; init; } = Array.Empty<PostMention>();
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
public IReadOnlyList<PostMedia> Attachments { get; init; } = Array.Empty<PostMedia>();
}
public static class NoteParser
{
static readonly string[] NoteTypes = { "Note", "Article", "Page", "Question", "Event", "Video", "Audio", "Image" };
const int MaxAttachments = 16;
const int MaxTags = 64;
public static NoteDocument Parse(JsonNode node)
{
if (node is not JsonObject note)
return default;
var type = Value(note, "type");
var id = Id(note);
if (!NoteTypes.Contains(type) || Origin.Of(id) == default)
return default;
var (contentHtml, language) = Content(note);
var summary = Value(note, "summary");
return new NoteDocument
{
Id = id,
Type = type,
AttributedTo = Attribution(note["attributedTo"]),
ContentHtml = ContentSanitizer.Html(contentHtml),
Title = Plain(Value(note, "name"), 500),
SpoilerText = Plain(summary, 500),
Sensitive = note["sensitive"] is JsonValue sensitive && sensitive.TryGetValue<bool>(out var s) && s
|| !string.IsNullOrWhiteSpace(summary),
Language = language,
Url = UrlOf(note["url"]) ?? id,
InReplyTo = Id(note["inReplyTo"]),
Context = Id(note["context"]) ?? Value(note, "conversation"),
Audience = Id(note["audience"]),
QuoteUri = Value(note, "quote") ?? Value(note, "quoteUrl") ?? Value(note, "quoteUri") ?? Value(note, "_misskey_quote"),
Published = Time(Value(note, "published")) ?? DateTime.UtcNow,
Updated = Time(Value(note, "updated")),
To = List(note["to"]),
Cc = List(note["cc"]),
Mentions = Tags(note, "Mention").Select(t => new PostMention { ActorURI = Value(t, "href"), Handle = Value(t, "name") })
.Where(m => Origin.Of(m.ActorURI) != default)
.DistinctBy(m => m.ActorURI)
.Take(MaxTags)
.ToList(),
Tags = Tags(note, "Hashtag").Select(t => Value(t, "name")?.TrimStart('#').ToLowerInvariant())
.Where(t => !string.IsNullOrEmpty(t))
.Distinct()
.Take(MaxTags)
.ToList(),
Attachments = Attachments(note)
};
}
static (string Html, string Language) Content(JsonObject note)
{
var content = Value(note, "content");
string language = default;
if (note["contentMap"] is JsonObject map && map.Count > 0)
{
var first = map.First();
language = first.Key;
if (string.IsNullOrEmpty(content) && first.Value is JsonValue value && value.TryGetValue<string>(out var mapped))
content = mapped;
}
if (string.IsNullOrEmpty(content) && Value(note, "_misskey_content") is { } misskey)
content = System.Net.WebUtility.HtmlEncode(misskey).Replace("\n", "<br>");
return (content, language);
}
static string Attribution(JsonNode node) => node switch
{
JsonArray array => array.OfType<JsonObject>().Where(o => Value(o, "type") is "Person" or "Service" or "Application" or "Organization").Select(Id)
.Concat(array.OfType<JsonValue>().Select(Id))
.Concat(array.OfType<JsonObject>().Where(o => Value(o, "type") == "Group").Select(Id))
.FirstOrDefault(id => id != default),
_ => Id(node)
};
static IEnumerable<JsonObject> Tags(JsonObject note, string type) =>
(note["tag"] switch
{
JsonArray array => array.OfType<JsonObject>(),
JsonObject single => new[] { single },
_ => Enumerable.Empty<JsonObject>()
}).Where(t => Value(t, "type") == type);
static List<PostMedia> Attachments(JsonObject note)
{
var items = note["attachment"] switch
{
JsonArray array => array.OfType<JsonObject>(),
JsonObject single => new[] { single },
_ => Enumerable.Empty<JsonObject>()
};
return items
.Where(a => Value(a, "type") is "Document" or "Image" or "Video" or "Audio")
.Select(a => new PostMedia
{
RemoteURL = UrlOf(a["url"]),
ContentType = Value(a, "mediaType"),
Description = Plain(Value(a, "name") ?? Value(a, "summary"), 1500),
Blurhash = Value(a, "blurhash"),
Width = Int(a["width"]),
Height = Int(a["height"]),
Focus = a["focalPoint"] is JsonArray focus && focus.Count == 2
? focus.Select(f => f is JsonValue v && v.TryGetValue<double>(out var d) ? (float)Math.Clamp(d, -1, 1) : 0f).ToArray()
: default
})
.Where(m => Origin.Of(m.RemoteURL) != default)
.Take(MaxAttachments)
.ToList();
}
static string UrlOf(JsonNode node) => node switch
{
JsonValue value when value.TryGetValue<string>(out var text) => text,
JsonObject obj => Value(obj, "href") ?? Value(obj, "id"),
JsonArray array => array.Select(item => item is JsonObject link && Value(link, "mediaType") is { } mediaType && !mediaType.StartsWith("text/html")
? default
: UrlOf(item))
.FirstOrDefault(url => url != default) ?? array.Select(UrlOf).FirstOrDefault(url => url != default),
_ => default
};
static IReadOnlyList<string> List(JsonNode node) => node switch
{
JsonArray array => array.Select(Id).Where(id => id != default).Distinct(StringComparer.Ordinal).ToList(),
JsonNode single when Id(single) is { } id => new[] { id },
_ => Array.Empty<string>()
};
static DateTime? Time(string value) =>
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)
? parsed.UtcDateTime
: default;
static int? Int(JsonNode node) =>
node is JsonValue value && value.TryGetValue<int>(out var number) && number is > 0 and < 100_000 ? number : (int?)null;
static string Plain(string value, int max)
{
if (string.IsNullOrWhiteSpace(value))
return default;
value = value.Trim();
return value.Length <= max ? value : value[..max];
}
}
}
+24
View File
@@ -0,0 +1,24 @@
namespace PrivaPub.Federation.Objects
{
public static class Origin
{
public static string Of(string uri) =>
Uri.TryCreate(uri, UriKind.Absolute, out var parsed) ? Of(parsed) : default;
public static string Of(Uri uri) =>
uri is { IsAbsoluteUri: true } && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp)
? $"{uri.Scheme}://{uri.IdnHost.ToLowerInvariant()}:{uri.Port}"
: default;
public static bool Same(string first, string second)
{
var origin = Of(first);
return origin != default && origin == Of(second);
}
public static bool IsDocumentAt(string id, Uri location) =>
Uri.TryCreate(id, UriKind.Absolute, out var parsed)
&& location is { IsAbsoluteUri: true }
&& Uri.Compare(parsed, location, UriComponents.HttpRequestUrl, UriFormat.UriEscaped, StringComparison.Ordinal) == 0;
}
}
@@ -0,0 +1,155 @@
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.StaticServices;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json.Nodes;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Signing;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Jobs;
using System.Text.Json;
namespace PrivaPub.Federation.Outbox
{
public interface IDeliveryService
{
Task Enqueue(LocalActor signer, IEnumerable<string> inboxes, JsonObject activity, CancellationToken token);
Task EnqueueToFollowers(LocalActor signer, JsonObject activity, CancellationToken token, IEnumerable<string> extraInboxes = default);
Task<IReadOnlyList<string>> FollowerInboxes(LocalActor actor, CancellationToken token);
}
public sealed record DeliveryPayload(string SignerId, LocalActorKind SignerKind, string Inbox, string Body);
public class DeliveryService : IDeliveryService
{
readonly DbEntities _dbEntities;
readonly IJobQueue _queue;
public DeliveryService(DbEntities dbEntities, IJobQueue queue)
{
_dbEntities = dbEntities;
_queue = queue;
}
public async Task Enqueue(LocalActor signer, IEnumerable<string> inboxes, JsonObject activity, CancellationToken token)
{
var body = activity.ToJsonString();
var activityId = activity["id"] is JsonValue id && id.TryGetValue<string>(out var text) ? text : default;
var jobs = inboxes
.Where(i => !string.IsNullOrEmpty(i) && !i.StartsWith(signer.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.Ordinal)
.Select(inbox => Uri.TryCreate(inbox, UriKind.Absolute, out var uri) ? (inbox, uri) : default)
.Where(target => target.uri != default)
.Select(target => new Job
{
Kind = JobKind.Deliver,
Host = target.uri.Host.ToLowerInvariant(),
DedupeKey = activityId == default ? default : $"{activityId}|{target.inbox}",
Payload = JsonSerializer.Serialize(new DeliveryPayload(signer.Id, signer.Kind, target.inbox, body))
})
.ToList();
if (jobs.Count > 0)
await _queue.EnqueueMany(jobs, token);
}
public async Task EnqueueToFollowers(LocalActor signer, JsonObject activity, CancellationToken token, IEnumerable<string> extraInboxes = default)
{
var inboxes = (await FollowerInboxes(signer, token)).Concat(extraInboxes ?? Enumerable.Empty<string>());
await Enqueue(signer, inboxes, activity, token);
}
public async Task<IReadOnlyList<string>> FollowerInboxes(LocalActor actor, CancellationToken token)
{
var followers = await _dbEntities.Followers
.Match(f => f.LocalActorId == actor.Id && f.LocalActorKind == actor.Kind && f.IsAccepted)
.ExecuteAsync(token);
return followers.Select(f => string.IsNullOrEmpty(f.SharedInboxURL) ? f.InboxURL : f.SharedInboxURL)
.Distinct(StringComparer.Ordinal)
.ToList();
}
}
public class DeliveryJobHandler : IJobHandler
{
readonly ILocalActorService _actors;
readonly IFederationHttp _http;
readonly IHostCircuitBreaker _breaker;
readonly ILogger<DeliveryJobHandler> _logger;
public DeliveryJobHandler(ILocalActorService actors, IFederationHttp http, IHostCircuitBreaker breaker, ILogger<DeliveryJobHandler> logger)
{
_actors = actors;
_http = http;
_breaker = breaker;
_logger = logger;
}
public JobKind Kind => JobKind.Deliver;
public int Concurrency => 8;
public int MaxAttempts => 16;
public int PerHostLimit => 2;
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
{
var payload = JsonSerializer.Deserialize<DeliveryPayload>(job.Payload);
if (payload == default || !Uri.TryCreate(payload.Inbox, UriKind.Absolute, out var inbox) || !_http.IsAllowed(inbox))
return JobOutcome.Dead("not a deliverable inbox");
var unavailableUntil = await _breaker.UnavailableUntil(job.Host, token);
if (unavailableUntil.HasValue)
return JobOutcome.Defer(unavailableUntil.Value, "the host is unavailable");
var signer = await _actors.FindById(payload.SignerKind, payload.SignerId, token);
if (signer == default)
return JobOutcome.Dead("the signing actor no longer exists");
var body = Encoding.UTF8.GetBytes(payload.Body);
using var request = new HttpRequestMessage(HttpMethod.Post, inbox) { Content = new ByteArrayContent(body) };
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(RemoteActorService.ActivityJson);
HttpSignatures.Sign(request, signer, body);
try
{
using var response = await _http.Send(request, token);
var status = (int)response.StatusCode;
if (response.IsSuccessStatusCode)
{
await _breaker.Succeeded(job.Host, token);
return JobOutcome.Done;
}
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
var retryAfter = response.Headers.RetryAfter?.Delta ?? (response.Headers.RetryAfter?.Date - DateTimeOffset.UtcNow);
return retryAfter > TimeSpan.Zero
? JobOutcome.Defer(DateTime.UtcNow + Min(retryAfter.Value, TimeSpan.FromHours(6)), "429")
: JobOutcome.Retry("429");
}
if (status is >= 400 and < 500 && status != 408)
{
await _breaker.Succeeded(job.Host, token);
_logger.LogInformation("Delivery to {Inbox} refused with {Status}", payload.Inbox, status);
return JobOutcome.Dead($"{status} {response.ReasonPhrase}");
}
await _breaker.Failed(job.Host, $"{status}", token);
return JobOutcome.Retry($"{status} {response.ReasonPhrase}");
}
catch (BlockedDestinationException ex)
{
return JobOutcome.Dead(ex.Message);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !token.IsCancellationRequested)
{
await _breaker.Failed(job.Host, ex.GetType().Name, token);
return JobOutcome.Retry(ex.Message);
}
}
static TimeSpan Min(TimeSpan a, TimeSpan b) => a < b ? a : b;
}
}
@@ -0,0 +1,95 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Outbox
{
public interface IOutboxPublisher
{
Task<IReadOnlyList<string>> Audience(LocalActor author, PostEntity post, CancellationToken token);
Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token);
Task PublishProfile(LocalActor actor, CancellationToken token);
}
public class OutboxPublisher : IOutboxPublisher
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
public OutboxPublisher(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery)
{
_dbEntities = dbEntities;
_localActors = localActors;
_delivery = delivery;
}
public async Task<IReadOnlyList<string>> Audience(LocalActor author, PostEntity post, CancellationToken token)
{
if (post.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo || post.IsLocalOnly)
return Array.Empty<string>();
var inboxes = new List<string>();
if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly)
inboxes.AddRange(await _delivery.FollowerInboxes(author, token));
var addressed = post.Mentions.Where(m => !m.IsLocal).Select(m => m.ActorURI)
.Concat(post.Visibility == PostVisibility.Direct ? post.To.Concat(post.Cc) : Enumerable.Empty<string>())
.Where(uri => !uri.StartsWith(author.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.Ordinal)
.ToList();
foreach (var uri in addressed)
{
var actor = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == uri).ExecuteFirstAsync(token);
if (actor != default && !string.IsNullOrEmpty(actor.InboxURL))
inboxes.Add(actor.InboxURL);
}
if (post.Visibility != PostVisibility.Direct && !string.IsNullOrEmpty(post.InReplyToAccountId))
{
var parentAuthor = await _dbEntities.ForeignAvatars.MatchID(post.InReplyToAccountId).ExecuteFirstAsync(token);
if (parentAuthor != default && !string.IsNullOrEmpty(parentAuthor.InboxURL))
inboxes.Add(parentAuthor.InboxURL);
}
if (!string.IsNullOrEmpty(post.GroupId) && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted)
{
var group = await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
if (group is { IsFederated: true })
inboxes.AddRange(await _delivery.FollowerInboxes(group, token));
}
return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
}
public async Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token)
{
var inboxes = await Audience(author, post, token);
if (inboxes.Count > 0)
await _delivery.Enqueue(author, inboxes, activity, token);
}
public async Task PublishProfile(LocalActor actor, CancellationToken token)
{
var document = ActivityPubRenderer.Actor(actor);
document.Remove("@context");
var update = new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = actor.ActivityUri($"update-profile-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"),
["type"] = "Update",
["actor"] = actor.Uri,
["to"] = new JsonArray(ActivityPubRenderer.Public),
["cc"] = new JsonArray(actor.Followers),
["object"] = document
};
await _delivery.EnqueueToFollowers(actor, update, token);
}
}
}
@@ -0,0 +1,279 @@
using Markdig;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using System.Globalization;
using System.Net;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Rendering
{
public static class ActivityPubRenderer
{
public const string ActivityStreams = "https://www.w3.org/ns/activitystreams";
public const string Public = Addressing.Public;
public const string ContentWarning = "Content warning";
static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().DisableHtml().Build();
public static JsonArray Context() => new(
ActivityStreams,
"https://w3id.org/security/v1",
new JsonObject
{
["manuallyApprovesFollowers"] = "as:manuallyApprovesFollowers",
["sensitive"] = "as:sensitive",
["Hashtag"] = "as:Hashtag",
["alsoKnownAs"] = new JsonObject { ["@id"] = "as:alsoKnownAs", ["@type"] = "@id" },
["movedTo"] = new JsonObject { ["@id"] = "as:movedTo", ["@type"] = "@id" },
["toot"] = "http://joinmastodon.org/ns#",
["featured"] = new JsonObject { ["@id"] = "toot:featured", ["@type"] = "@id" },
["featuredTags"] = new JsonObject { ["@id"] = "toot:featuredTags", ["@type"] = "@id" },
["discoverable"] = "toot:discoverable",
["indexable"] = "toot:indexable",
["blurhash"] = "toot:blurhash",
["focalPoint"] = new JsonObject { ["@container"] = "@list", ["@id"] = "toot:focalPoint" },
["schema"] = "http://schema.org#",
["PropertyValue"] = "schema:PropertyValue",
["value"] = "schema:value",
["webfinger"] = "https://purl.archive.org/socialweb/webfinger#webfinger"
});
public static string Html(string markdown) =>
string.IsNullOrEmpty(markdown) ? string.Empty : Markdown.ToHtml(markdown, Pipeline).Trim();
public static string Timestamp(DateTime value) =>
DateTime.SpecifyKind(value, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture);
public static string Day(DateTime value) =>
DateTime.SpecifyKind(value, DateTimeKind.Utc).Date.ToString("yyyy-MM-ddT00:00:00Z", CultureInfo.InvariantCulture);
public static string TagUrl(string baseAddress, string tag) => $"{baseAddress}/tags/{Uri.EscapeDataString(tag)}";
public static JsonObject Actor(LocalActor actor)
{
var document = new JsonObject
{
["@context"] = Context(),
["id"] = actor.Uri,
["type"] = actor.Kind switch
{
LocalActorKind.Group => "Group",
LocalActorKind.Application => "Application",
_ when actor.IsBot => "Service",
_ => "Person"
},
["preferredUsername"] = actor.UserName,
["name"] = actor.Name,
["summary"] = Html(actor.Summary),
["url"] = actor.Kind == LocalActorKind.Application ? actor.Uri : actor.HtmlUrl,
["inbox"] = actor.Inbox,
["outbox"] = actor.Outbox,
["followers"] = actor.Followers,
["following"] = actor.Following,
["published"] = Day(actor.Published),
["manuallyApprovesFollowers"] = actor.ManuallyApprovesFollowers,
["discoverable"] = actor.Discoverable,
["indexable"] = actor.Indexable,
["webfinger"] = actor.Handle,
["endpoints"] = new JsonObject { ["sharedInbox"] = actor.SharedInbox },
["publicKey"] = new JsonObject
{
["id"] = actor.KeyId,
["owner"] = actor.Uri,
["publicKeyPem"] = Keys.ToSubjectPublicKeyInfoPem(actor.PublicKeyPem)
},
["attachment"] = new JsonArray(actor.Fields.Select(field => (JsonNode)new JsonObject
{
["type"] = "PropertyValue",
["name"] = field.Key,
["value"] = FieldValue(field.Value)
}).ToArray())
};
if (!string.IsNullOrEmpty(actor.PictureURL))
document["icon"] = new JsonObject { ["type"] = "Image", ["url"] = actor.PictureURL };
if (!string.IsNullOrEmpty(actor.ThumbnailURL))
document["image"] = new JsonObject { ["type"] = "Image", ["url"] = actor.ThumbnailURL };
return document;
}
public static JsonObject Note(PostEntity post, LocalActor author, LocalActor group, string inReplyTo)
{
var mentions = post.Mentions.Select(m => (JsonNode)m.ActorURI).ToArray();
var (to, cc) = post.Visibility switch
{
PostVisibility.Unlisted => (new JsonArray(author.Followers), new JsonArray(mentions.Prepend(Public).ToArray())),
PostVisibility.FollowersOnly => (new JsonArray(author.Followers), new JsonArray(mentions)),
PostVisibility.Direct => (new JsonArray(mentions), new JsonArray()),
_ => (new JsonArray(Public), new JsonArray(mentions.Prepend(author.Followers).ToArray()))
};
if (group != default)
cc.Add(group.Uri);
var note = NoteBody(post, author, to, cc, inReplyTo);
if (group != default)
note["audience"] = group.Uri;
return note;
}
public static JsonObject DirectNote(PostEntity post, LocalActor author, IReadOnlyList<(string Uri, string Handle)> recipients,
string context)
{
var note = NoteBody(post, author, new JsonArray(recipients.Select(r => (JsonNode)r.Uri).ToArray()), new JsonArray(), post.InReplyToURI);
var named = post.Mentions.Select(m => m.ActorURI).ToHashSet();
var tags = note["tag"]!.AsArray();
foreach (var recipient in recipients.Where(r => !named.Contains(r.Uri)))
tags.Add(new JsonObject { ["type"] = "Mention", ["href"] = recipient.Uri, ["name"] = "@" + recipient.Handle });
var unmentioned = recipients.Where(r => !named.Contains(r.Uri)).ToList();
if (unmentioned.Count > 0)
note["content"] = "<p>" + string.Join(" ", unmentioned.Select(r =>
$"<span class=\"h-card\" translate=\"no\"><a href=\"{WebUtility.HtmlEncode(r.Uri)}\" class=\"u-url mention\">@<span>{WebUtility.HtmlEncode(r.Handle.Split('@')[0])}</span></a></span>"))
+ "</p>" + note["content"]!.GetValue<string>();
if (!string.IsNullOrEmpty(context))
note["context"] = context;
return note;
}
static JsonObject NoteBody(PostEntity post, LocalActor author, JsonArray to, JsonArray cc, string inReplyTo)
{
var content = post.ContentHtml ?? Html(post.Text);
if (!string.IsNullOrEmpty(post.Title))
content = $"<p><strong>{WebUtility.HtmlEncode(post.Title)}</strong></p>{content}";
var note = new JsonObject
{
["id"] = author.PostUri(post.ID),
["type"] = "Note",
["attributedTo"] = author.Uri,
["content"] = content,
["published"] = Timestamp(post.CreationDate),
["url"] = author.PostHtmlUrl(post.ID),
["to"] = to,
["cc"] = cc,
["sensitive"] = post.HasContentWarning,
["tag"] = new JsonArray(post.Mentions
.Select(m => (JsonNode)new JsonObject { ["type"] = "Mention", ["href"] = m.ActorURI, ["name"] = MentionName(m) })
.Concat(post.Tags.Select(t => (JsonNode)new JsonObject
{
["type"] = "Hashtag",
["href"] = TagUrl(author.BaseAddress, t),
["name"] = "#" + t
}))
.ToArray())
};
if (!string.IsNullOrEmpty(post.Language))
note["contentMap"] = new JsonObject { [post.Language] = content };
if (!string.IsNullOrEmpty(post.Title))
note["name"] = post.Title;
var summary = post.SpoilerText ?? (post.HasContentWarning ? post.Title ?? ContentWarning : default);
if (!string.IsNullOrEmpty(summary))
{
note["summary"] = summary;
note["sensitive"] = true;
}
if (!string.IsNullOrEmpty(inReplyTo))
note["inReplyTo"] = inReplyTo;
if (post.EditedAt.HasValue)
note["updated"] = Timestamp(post.EditedAt.Value);
return note;
}
static string MentionName(PostMention mention)
{
var handle = mention.Handle?.TrimStart('@');
if (string.IsNullOrEmpty(handle))
return "@" + mention.ActorURI;
return "@" + (handle.Contains('@') ? handle : $"{handle}@{new Uri(mention.ActorURI).Authority}");
}
public static string FieldValue(string value)
{
var encoded = WebUtility.HtmlEncode(value ?? string.Empty);
return Uri.TryCreate(value, UriKind.Absolute, out var link) && link.Scheme is "https" or "http"
? $"<a href=\"{encoded}\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\" translate=\"no\">{encoded}</a>"
: encoded;
}
public static JsonObject Create(LocalActor actor, JsonObject note, string activityId) => new()
{
["@context"] = Context(),
["id"] = actor.ActivityUri(activityId),
["type"] = "Create",
["actor"] = actor.Uri,
["published"] = note["published"]?.DeepClone(),
["to"] = note["to"]?.DeepClone(),
["cc"] = note["cc"]?.DeepClone(),
["object"] = note
};
public static JsonObject Announce(LocalActor group, string objectUri, string activityId) => new()
{
["@context"] = ActivityStreams,
["id"] = group.ActivityUri(activityId),
["type"] = "Announce",
["actor"] = group.Uri,
["published"] = Timestamp(DateTime.UtcNow),
["to"] = new JsonArray(Public),
["cc"] = new JsonArray(group.Followers),
["object"] = objectUri
};
public static JsonObject Delete(LocalActor actor, string objectUri, string activityId, JsonArray to, JsonArray cc) => new()
{
["@context"] = ActivityStreams,
["id"] = actor.ActivityUri(activityId),
["type"] = "Delete",
["actor"] = actor.Uri,
["to"] = to,
["cc"] = cc,
["object"] = new JsonObject { ["id"] = objectUri, ["type"] = "Tombstone" }
};
public static JsonObject Accept(LocalActor actor, JsonNode follow, string activityId) => new()
{
["@context"] = ActivityStreams,
["id"] = actor.ActivityUri(activityId),
["type"] = "Accept",
["actor"] = actor.Uri,
["object"] = follow.DeepClone()
};
public static JsonObject OrderedCollection(string id, int totalItems, IEnumerable<JsonNode> items, string first = default)
{
var collection = new JsonObject
{
["@context"] = ActivityStreams,
["id"] = id,
["type"] = "OrderedCollection",
["totalItems"] = totalItems
};
if (first != default)
collection["first"] = first;
if (items != default)
collection["orderedItems"] = new JsonArray(items.ToArray());
return collection;
}
public static JsonObject OrderedCollectionPage(string id, string partOf, IEnumerable<JsonNode> items, string next, string prev)
{
var page = new JsonObject
{
["@context"] = Context(),
["id"] = id,
["type"] = "OrderedCollectionPage",
["partOf"] = partOf,
["orderedItems"] = new JsonArray(items.ToArray())
};
if (next != default)
page["next"] = next;
if (prev != default)
page["prev"] = prev;
return page;
}
}
}
@@ -1,15 +1,19 @@
using Microsoft.AspNetCore.Http.Features;
using System.Globalization; using System.Globalization;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using PrivaPub.Federation.Actors;
namespace PrivaPub.Services.Federation namespace PrivaPub.Federation.Signing
{ {
public sealed record SignatureParameters(string KeyId, string Algorithm, string[] Headers, byte[] Signature, public sealed record SignatureParameters(string KeyId, string Algorithm, string[] Headers, byte[] Signature,
string Created, string Expires); string Created, string Expires);
public static class HttpSignatures public static class HttpSignatures
{ {
static readonly TimeSpan AllowedClockSkew = TimeSpan.FromHours(12); public static readonly TimeSpan MaxAge = TimeSpan.FromHours(1);
public static readonly TimeSpan MaxClockSkew = TimeSpan.FromMinutes(15);
public static string Digest(byte[] body) => "SHA-256=" + Convert.ToBase64String(SHA256.HashData(body)); public static string Digest(byte[] body) => "SHA-256=" + Convert.ToBase64String(SHA256.HashData(body));
@@ -104,7 +108,7 @@ namespace PrivaPub.Services.Federation
switch (header) switch (header)
{ {
case "(request-target)": case "(request-target)":
lines.Add($"(request-target): {request.Method.ToLowerInvariant()} {request.PathBase}{request.Path}{request.QueryString}"); lines.Add($"(request-target): {request.Method.ToLowerInvariant()} {RequestTarget(request)}");
break; break;
case "(created)": case "(created)":
lines.Add($"(created): {parameters.Created}"); lines.Add($"(created): {parameters.Created}");
@@ -125,10 +129,17 @@ namespace PrivaPub.Services.Federation
return string.Join("\n", lines); return string.Join("\n", lines);
} }
public static string CheckRequest(HttpRequest request, SignatureParameters parameters, byte[] body) public static string CheckRequest(HttpRequest request, SignatureParameters parameters, byte[] body) =>
CheckRequest(request, parameters, body, DateTimeOffset.UtcNow);
public static string CheckRequest(HttpRequest request, SignatureParameters parameters, byte[] body, DateTimeOffset now)
{ {
if (parameters.Algorithm is not ("rsa-sha256" or "hs2019")) if (parameters.Algorithm is not ("rsa-sha256" or "hs2019"))
return $"unsupported signature algorithm '{parameters.Algorithm}'"; return $"unsupported signature algorithm '{parameters.Algorithm}'";
if (!parameters.Headers.Contains("(request-target)"))
return "(request-target) is not signed";
if (!parameters.Headers.Contains("host"))
return "host is not signed";
if (body is { Length: > 0 }) if (body is { Length: > 0 })
{ {
@@ -141,24 +152,48 @@ namespace PrivaPub.Services.Federation
return "the digest does not match the body"; return "the digest does not match the body";
} }
if (parameters.Headers.Contains("date")) var signsDate = parameters.Headers.Contains("date");
var signsCreated = parameters.Headers.Contains("(created)");
if (!signsDate && !signsCreated)
return "neither date nor (created) is signed";
if (signsDate)
{ {
if (!DateTimeOffset.TryParse(request.Headers["Date"].ToString(), CultureInfo.InvariantCulture, if (!DateTimeOffset.TryParse(request.Headers["Date"].ToString(), CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal, out var date)) DateTimeStyles.AssumeUniversal, out var date))
return "unreadable Date header"; return "unreadable Date header";
if ((DateTimeOffset.UtcNow - date).Duration() > AllowedClockSkew) if (!IsFresh(date, now))
return "the Date header is outside the allowed window"; return "the Date header is outside the allowed window";
} }
else if (!parameters.Headers.Contains("(created)"))
return "neither date nor (created) is signed";
if (!string.IsNullOrEmpty(parameters.Expires) && long.TryParse(parameters.Expires, out var expires) if (signsCreated)
&& DateTimeOffset.FromUnixTimeSeconds(expires) < DateTimeOffset.UtcNow - AllowedClockSkew) {
return "the signature has expired"; if (!long.TryParse(parameters.Created, NumberStyles.Integer, CultureInfo.InvariantCulture, out var created))
return "unreadable (created)";
if (!IsFresh(DateTimeOffset.FromUnixTimeSeconds(created), now))
return "(created) is outside the allowed window";
}
if (!string.IsNullOrEmpty(parameters.Expires) || parameters.Headers.Contains("(expires)"))
{
if (!long.TryParse(parameters.Expires, NumberStyles.Integer, CultureInfo.InvariantCulture, out var expires))
return "unreadable (expires)";
if (DateTimeOffset.FromUnixTimeSeconds(expires) < now - MaxClockSkew)
return "the signature has expired";
}
return default; return default;
} }
static bool IsFresh(DateTimeOffset signedAt, DateTimeOffset now) =>
signedAt >= now - MaxAge && signedAt <= now + MaxClockSkew;
static string RequestTarget(HttpRequest request)
{
var raw = request.HttpContext.Features.Get<IHttpRequestFeature>()?.RawTarget;
return string.IsNullOrEmpty(raw) || raw[0] != '/' ? $"{request.PathBase}{request.Path}{request.QueryString}" : raw;
}
public static bool Verify(string publicKeyPem, string signingString, byte[] signature) public static bool Verify(string publicKeyPem, string signingString, byte[] signature)
{ {
if (string.IsNullOrEmpty(publicKeyPem) || signingString == null) if (string.IsNullOrEmpty(publicKeyPem) || signingString == null)
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff. Show more