Compare commits

...
33 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 8f4d6cbdf9 A private fediverse on the workstation: PrivaPub against a real GoToSocial
Build / Build (push) Successful in 57s
Deploy / privapub.thepra.dev (push) Successful in 1m12s
tools/pasture/run.sh starts PrivaPub, GoToSocial and Mongo on one podman network behind Caddy's internal CA, and
interop.sh drives both through their own client APIs: follows (one to a locked account), posts, CW, replies, likes,
boosts, DMs, edits, deletes and unfollow. All 25 checks pass, three fresh runs in a row.

- Federation:AcceptAnyCertificate joins the two test-network switches; startup refuses all three in Production.
- WebFinger falls back to http only when AllowPlainHttp is on.
- A bootstrap logger, so a failure before the host is built is no longer silent.
- P4 is ticked in the roadmap, with what has not been run live (Lemmy, a Mastodon circle member).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 13:19:16 +02:00
thepraandClaude Opus 5.5 31542a181e Remote posts take their timeline place on arrival, and an unedited one has no edit date
Found by the first live run against GoToSocial:

- A remote post's id came from its published second plus random bytes, so a reply arriving in the same second as
  the post it answers could sort under it, and a late arrival landed behind a client's since_id. A post published
  within the last hour now gets an id for its arrival; backfill keeps its published time.
- NoteParser.Time returned default(DateTime) for a missing "updated", so every remote post was stored as edited in
  year one. Migration _006 clears the stored ones.
- published now carries milliseconds, so peers that derive ids from it (GoToSocial, Mastodon) keep our posts in
  order within a second.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 13:19:16 +02:00
thepraandClaude Opus 5.5 51f2c10c6f Community actors name their moderators the way Lemmy reads them
Build / Build (push) Successful in 41s
Deploy / privapub.thepra.dev (push) Successful in 1m1s
attributedTo points at /wardens and postingRestrictedToMods reflects the
posting policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:31:13 +02:00
thepraandClaude Opus 5.5 a5d9a89445 Communities follow FEP-1b12, circles federate to their members only
Communities:
- a post addressed to a community (to, cc or audience) is accepted
  according to its posting policy - followers, anyone, or moderators -
  and GroupDistributor announces the whole activity with `audience` to
  the community's followers, plus the object for new posts so Mastodon
  shows them; updates and deletes of community content are announced too;
- top-level posts are Pages with a name (the title, or a headline from
  the text); /flock counts members, /wardens lists moderators;
- a Mastodon client posts into a community by mentioning it, or into a
  remote group, which sets `audience`;
- an Announce of an activity from a remote group a persona follows (Lemmy)
  is followed through: the object is fetched from its own origin, kept with
  its AudienceURI, and fanned out to the group's local followers; updates
  are applied in place and deletes checked against the origin.

Circles stop being local-only: an undiscoverable Group actor whose follows
are all requests the owner approves; posts addressed to the circle and its
/flock and delivered to members' own inboxes, never announced, never
public; a remote member's post into the circle is accepted from members
only. SignedFetchAuthorizer serves circle posts and collections only to a
signed request from a member or a member server's instance actor - 404 for
anyone else. Circles never surface in search, lookups, mentions, account
ids or profile pages.

Federation:SecureMode requires a valid signature on every GET under
/peasants except the instance actor. Group forms take a posting policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:30:57 +02:00
thepraandClaude Opus 5.5 0ccbcd558f P3 is deployed
Build / Build (push) Successful in 32s
v1.5.0 is live; the media directory, libvips and the proxy's signature
check are in place on the box. No upload has been made in production yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:24:30 +02:00
thepraandClaude Opus 5.5 c00865d805 Located posts: local only, rounded, matched against their own radius
A post given a latitude and longitude becomes LocalGeo: its position is
rounded to two decimals (about a kilometre) and stored as a 2dsphere
point, its radius clamped to 1-50 km, and it is local only - no Create,
no delivery, no outbox, never in the Mastodon API or on a profile page.
/clientapi/post/nearby takes the viewer's position for the query only
(it is neither stored nor logged), runs $geoNear and keeps posts within
each post's own radius, minus authors the viewer blocks or mutes. A
located post cannot be direct or in a group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:24:30 +02:00
thepraandClaude Opus 5.5 28b581b6b1 FEDERATION.md and CLAUDE.md describe media, blocks, pins and reports
Build / Build (push) Successful in 35s
Deploy / privapub.thepra.dev (push) Successful in 52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:22:08 +02:00
thepraandClaude Opus 5.5 4a713f3fb6 Media: uploads stripped of metadata, attachments both ways, a remote media proxy
- /api/v1/media and /api/v2/media (and GET/PUT /api/v1/media/:id):
  images go through libvips (NetVips, its native build bundled):
  autorotated, every kind of metadata dropped (EXIF, GPS, XMP, IPTC,
  comments), capped at 4096 px, with a 640 px preview and a blurhash
  (own encoder, the reference algorithm); animated GIFs are re-encoded;
  video and audio are remuxed by ffmpeg with -map_metadata -1, never
  re-encoded, and a video gets a still preview. Files get random names
  under /var/lib/privapub/media, outside the web root deploys replace, and
  are served at /media/files with nosniff and a sandbox CSP.
- media_ids on create and edit (four at most, the persona's own, each used
  once); notes carry them as Document attachments with alt text, blurhash,
  focalPoint and size; inbound attachments were already kept.
- avatar and header uploads in update_credentials, cropped to 400x400 and
  1500x500, federated with Update{Person}.
- Remote media reaches clients only through /media/proxy/{hmac}/{url},
  fetched by the guarded client (no SVG, 40 MB cap) and cached outside the
  served root, trimmed to 5 GB; foreign avatars and headers use it too, so
  a client never contacts another server.
- MediaJanitor deletes uploads left unattached for a day.
- nginx accepts 100 MB bodies on the upload endpoints only (applied on Max).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:22:08 +02:00
thepraandClaude Opus 5.5 8f75317050 Blocks, mutes, bookmarks, pins and reports
- Blocks are per persona and never federate (a Block activity would tell
  the other server who blocked whom): the blocked account is removed as a
  follower, with a Reject{Follow} if it is remote, unfollowed, cleared from
  home and notifications, and refused with Reject if it follows again.
- Mutes (optionally timed, optionally sparing notifications) and per-
  persona domain blocks keep authors out of home timelines, notifications
  and every status list the API returns; the boosts of a hidden author's
  posts are hidden too.
- Bookmarks and pins (at most five, public or unlisted, own posts) with
  their Mastodon endpoints and flags; pinned posts are the actor's
  `featured` collection at /trophies, and `featuredTags` points at
  /tattoos.
- Reports: /api/v1/reports stores the report and, when forwarding to a
  remote account, sends Flag from the instance actor, so the reporting
  persona is never named to the other server. An inbound Flag about a local
  persona or its posts becomes a report; moderators list and resolve them
  under /clientapi/moderator/reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:13:57 +02:00
thepraandClaude Opus 5.5 525b5368a1 P2 is deployed
Build / Build (push) Successful in 39s
v1.4.0 serves OAuth and the Mastodon client API; tools/smoke/mastodon-api.sh
passes against production. The signed-in side was verified locally with two
personas of one login; no real client has logged into production yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:12:28 +02:00
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
157 changed files with 11697 additions and 912 deletions

No files matched your search

+1
View File
@@ -104,4 +104,5 @@ jobs:
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' \ 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") --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; } [ "$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"
+4
View File
@@ -398,3 +398,7 @@ FodyWeavers.xsd
# JetBrains Rider # JetBrains Rider
*.sln.iml *.sln.iml
PrivaPub/media-store/
PrivaPub/media-store-proxy/
tools/pasture/.publish/
+121 -29
View File
@@ -37,26 +37,19 @@ actor-scoped route gets a name in the same spirit, agreed with the owner, and a
| Thing | Route | | Thing | Route |
|---|---| |---|---|
| Actor | `/peasants/{name}` (`/users/{name}` 301-redirects here) | | Actor | `/peasants/{name}` (`/users/{name}` 301-redirects here; browsers are sent to `/@{name}`) |
| Inbox | `/peasants/{name}/mouth` | | Inbox | `/peasants/{name}/mouth` |
| Outbox | `/peasants/{name}/anus` | | Outbox | `/peasants/{name}/anus` (`?page=true[&max_id=]` for pages) |
| Shared inbox | `/human-centipede` (also `/peasants/{name}/human-centipede`) | | 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` | | Token refresh | `/clientapi/user/sniff/again` |
These are agreed for the roadmap and replace the conventional names still in the code today: Agreed for later phases: `/gossip`, `/drool`, `/echoes` (replies, likes, shares), `/trophies` (featured), `/tattoos`
(featured tags), `/flock` and `/wardens` (group members and moderators).
| Thing | Route | Replaces |
|---|---|---|
| Followers | `/groupies` | `/followers` |
| Following | `/stalking` | `/following` |
| Notes | `/scribbles/{id}` | `/posts/{id}` |
| Activities | `/grunts/{id}` | `/activities/{id}` |
| Replies, likes, shares | `/gossip`, `/drool`, `/echoes` | |
| Featured | `/trophies` | |
| Featured tags | `/tattoos` | |
| Group members | `/flock` | |
| Group moderators | `/wardens` | |
| DM context | `/whispers/{id}` | |
Routes other software looks up by name stay conventional: Routes other software looks up by name stay conventional:
- `/.well-known/*` and `/nodeinfo/*`; - `/.well-known/*` and `/nodeinfo/*`;
@@ -75,6 +68,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0
Group, Post (posts + DMs), Admin, Data Group, Post (posts + DMs), Admin, Data
Infrastructure/ Infrastructure/
Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out
Jobs/ JobQueue (leases), JobWorker, Backoff, HostCircuitBreaker
Ids/ PrivacyIds (day-only ids for personas and groups, arrival-ordered ids for remote posts)
Data/ Indexes (created at start), EntityMaps.Warm, Migrations/_NNN_*.cs Data/ Indexes (created at start), EntityMaps.Warm, Migrations/_NNN_*.cs
Cli/ AdminCommands (`PrivaPub admin promote|demote <root>`) Cli/ AdminCommands (`PrivaPub admin promote|demote <root>`)
RateLimiting.cs accounts (per client address) and inbox (per sending origin) policies RateLimiting.cs accounts (per client address) and inbox (per sending origin) policies
@@ -83,13 +78,30 @@ PrivaPub/ ASP.NET Core Web API, net10.0
WellKnownController (webfinger, nodeinfo), UsersController (redirect) WellKnownController (webfinger, nodeinfo), UsersController (redirect)
Actors/ LocalActorService (LocalActor, Keys, ReservedName), RemoteActorService Actors/ LocalActorService (LocalActor, Keys, ReservedName), RemoteActorService
(authoritative fetch, key verification, WebFinger), ActorDocument (parser) (authoritative fetch, key verification, WebFinger), ActorDocument (parser)
Objects/ Origin (same-origin rules), ContentSanitizer (HtmlSanitizer, Mastodon allowlist) Objects/ Origin, ActivityJson, NoteParser, Addressing, ContentSanitizer
Moderation/ DomainBlocks (suspend / silence / reject media)
Signing/ HttpSignatures (draft-cavage sign/verify) Signing/ HttpSignatures (draft-cavage sign/verify)
Inbox/ InboxService (Follow/Undo/Create/Delete/Update) Inbox/ InboxReceiver (verify, queue, 202) → InboxProcessor (job) → Handlers/{Follow,Accept,Reject,
Outbox/ DeliveryService + DeliveryWorker Undo,Create,Update,Delete,Like,Announce}; RemotePosts (build, fetch parents, FetchAncestors)
Rendering/ ActivityPubRenderer (JsonObject builders) 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)
Relationships/ RelationshipService (blocks, mutes, account domain blocks; Hidden), ReportService
Media/ MediaService (libvips, ffmpeg remux, blurhash), MediaProxy, MediaJanitor
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, … Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, …
Models/ Mongo entities: User/, Group/, Post/, Federation/, AppConfiguration Models/ Mongo entities: User/, Group/, Post/, Federation/, Jobs/, AppConfiguration
StaticServices/ DbEntities (Find<T> accessors), AuthTokenManager (JWT), PasswordHasher StaticServices/ DbEntities (Find<T> accessors), AuthTokenManager (JWT), PasswordHasher
Data/InitDb.cs first-run seeding (languages) Data/InitDb.cs first-run seeding (languages)
PrivaPub.ClientModels/ DTOs + validation resources shared with clients PrivaPub.ClientModels/ DTOs + validation resources shared with clients
@@ -117,7 +129,8 @@ cd PrivaPub && ASPNETCORE_ENVIRONMENT=Development \
Development config (`appsettings.Development.json`) binds HTTPS 7195 with HTTP/2 only; the overrides above make it 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 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. `Federation__AllowPrivateNetworks=true`, `Federation__AllowPlainHttp=true` and `Federation__AcceptAnyCertificate=true`,
which startup refuses in Production.
Promoting an admin on the box (signing up as "admin" grants nothing): Promoting an admin on the box (signing up as "admin" grants nothing):
@@ -154,11 +167,45 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
- the shared inbox is advertised in `endpoints.sharedInbox`. - the shared inbox is advertised in `endpoints.sharedInbox`.
7. **Remote HTML is sanitized before it is stored** (`ContentSanitizer`); `Post.ContentHtml` is what is shown, 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. `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 8. **Circles federate to members only.** A circle is an undiscoverable Group actor that takes follow requests (the owner
`IsLocalOnly`. Only communities are Group actors. approves); its posts are addressed to the circle and its `/flock`, delivered to members' personal inboxes, never
announced, and served only to a signed request from a member or a member's instance actor
(`SignedFetchAuthorizer`), 404 otherwise. Circles never appear in search, lookups, mentions or profile pages.
**Communities** are FEP-1b12 groups: `GroupDistributor` announces the whole activity (plus the object for new posts,
for Mastodon), top-level posts are `Page`s with a `name`, posting follows `Group.PostingPolicy`.
Located posts (`LocalGeo`) are the only local-only posts.
9. **A DM joins a conversation only by `DmGroup.ParticipantsKey`**, the exact set of its participants; a remote 9. **A DM joins a conversation only by `DmGroup.ParticipantsKey`**, the exact set of its participants; a remote
`context` decides nothing. `context` decides nothing. DMs are `Post`s with `Visibility = Direct` and a `ConversationId` (`DmPost` is legacy).
10. **Durable delivery:** activities go out through the delivery queue, never inline in a request. 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.
## Media invariants
1. **No upload keeps its metadata.** Images are re-encoded by libvips with `keep=none`; audio and video are remuxed with
`-map_metadata -1`. `MediaProcessingTests` checks EXIF and XMP are gone.
2. Files live under `Media:Root` (`/var/lib/privapub/media`), never in the published directory; the proxy cache is the
sibling `media-proxy`, which `/media/files` does not serve.
3. **A client never contacts a remote server for media:** every remote URL the API returns goes through
`IMediaProxy.Wrap`, an HMAC-signed `/media/proxy/` URL fetched by `IFederationHttp.GetMedia`.
## Privacy invariants ## Privacy invariants
@@ -169,6 +216,7 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
- **One username space:** personas, groups and the instance reserve their name in `ReservedName` (unique index) before - **One username space:** personas, groups and the instance reserve their name in `ReservedName` (unique index) before
they are saved; `LocalActorService.TryReserveUserName` is the only way to claim one. 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. - **Per-avatar state stays per avatar:** blocks, mutes, notifications, follows. Nothing may relate sibling avatars.
- **Blocks never federate,** and reports leave as `Flag` from the instance actor.
- **Location-ranged posts never federate.** - **Location-ranged posts never federate.**
## Data ## Data
@@ -179,6 +227,15 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
- **Collection name = class name, so never rename an entity class.** - **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. - `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. - New fields must be additive: a deploy rollback restores the binary, not the database.
- **Post ids are the timeline order, so they follow arrival, not `published`.** `PrivacyIds.Arrived` gives a remote post
published within the last hour (or in the future) a fresh `ObjectId`, which sorts after every post already stored,
and only backfill keeps a `published`-derived id. With `published` ids a reply arriving in the same second could sort
under the post it answers, and a late arrival landed behind a client's `since_id` and was never seen. `created_at`
comes from `CreationDate`, never from the id.
- **The same ordering problem exists on the other side, so `published` carries milliseconds**
(`ActivityPubRenderer.Timestamp`). GoToSocial (ULIDs) and Mastodon (Snowflakes) derive a remote status's id from
`published` at millisecond resolution. With whole seconds, two of our posts from the same second sorted at random
there.
- **Startup order:** `EntityMaps.Warm()` (every entity's class map, one at a time; two mapped at once throw "An item - **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`, 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 `_NNN_` order, each runs once), then `Indexes.Create()`. A new entity needs nothing; a new unique index needs a
@@ -196,6 +253,9 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
- Localised strings go through `IStringLocalizer<GenericRes>`. - Localised strings go through `IStringLocalizer<GenericRes>`.
- Every `Display`/`ErrorMessage` resource key must exist in `FieldsNameResource`/`ErrorsResource`, including the - 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. `Designer.cs`, which the CLI build doesn't regenerate. A missing key throws at validation time.
- **`cond ? value : default` with a value-type branch is the type's default, not null:** `false`, `0`, or year one
stored as an edit date on every remote post. Write `(T?)null`. It has shipped four times (`MastodonParams.Bool/Int`,
`NoteParser.Int`, `NoteParser.Time`).
- ActivityPub output is built with `System.Text.Json.Nodes` in `ActivityPubRenderer`, not typed models. Inbound - 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. 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 + - Libraries chosen for the roadmap: HtmlSanitizer, Markdig (`DisableHtml`), NSign (RFC 9421 inbound), OpenIddict +
@@ -214,15 +274,47 @@ Beyond the tests, verify by building, running locally, and exercising:
- the client API (sign up, create an avatar, a group, a post); - the client API (sign up, create an avatar, a group, a post);
- the ActivityPub endpoints with curl and `Accept: application/activity+json`. - the ActivityPub endpoints with curl and `Accept: application/activity+json`.
Interop is checked against real servers: Fediverse Pasture with podman on the workstation, verify.funfedi.dev, and Interop is checked against real servers, starting with the workstation's own pasture:
the owner's GoToSocial at social.arasaka.software. **Ask before acting from the owner's GoToSocial account.**
```bash
DOTNET=~/.dotnet/dotnet tools/pasture/run.sh up # podman: PrivaPub + the latest GoToSocial + Mongo, behind Caddy
tools/pasture/interop.sh # 25 checks, each side driven through its own Mastodon API
tools/pasture/run.sh down
```
- **Two sites on one podman network, one Caddy in front.** `privapub.test` and `gts.test` are network aliases of the
Caddy container, which serves both with its internal CA (`tls internal`). Both servers are told to accept any
certificate: PrivaPub through `appsettings.Pasture.json`, GoToSocial through `GTS_HTTP_CLIENT_TLS_INSECURE_SKIP_VERIFY`.
GoToSocial WebFingers and fetches over https only, so plain http between them is not an option.
- From the workstation, PrivaPub's API is `http://127.0.0.1:6971`. GoToSocial is reached as `https://gts.test:6443`
with `curl -k --resolve gts.test:6443:127.0.0.1`, because its sign-in cookie is bound to the host name.
- **GoToSocial's cached home timeline can stop taking new posts after its first read**, its owner's own included, while
a `min_id` query shows them all. So a delivery is checked by looking the object up by URI with `resolve=false`
(`on_gts`), which answers from GoToSocial's database and never fetches from us. A home-timeline check there proves
nothing, in either direction. A deleted status still turns up in that search as a "deleted status" stub, so a delete
is checked as a 404 on `/api/v1/statuses/{id}`.
- **GoToSocial creates its accounts locked**, so `interop.sh` approves alice's request through
`/api/v1/follow_requests`. That also checks our pending (`requested`) state and the manual Accept.
- The scenario covers:
- discovery and follows both ways;
- posts and CW;
- a reply and its notification;
- likes and boosts both ways;
- DMs both ways, and the DM staying off public timelines;
- edit, delete both ways, and unfollow.
- `run.sh up` replaces every container, Mongo included, so each run starts clean. To keep the data, republish into
`tools/pasture/.publish` and `podman restart pasture-privapub`; that is how a migration is tried on dirty data.
After the pasture, verify.funfedi.dev and the owner's GoToSocial at social.arasaka.software. **Ask before acting from
the owner's GoToSocial account.**
## Deploy ## Deploy
- **CI/CD:** push to `master` runs `build.yml` (build + tests) on the instance-wide `build` runner. A `v*` tag runs - **CI/CD:** push to `master` runs `build.yml` (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`, `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, 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). 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 - **The box:** Max (`nuvola.xyz`). Unit `privapub` runs as www-data from `/var/www/privapub.thepra.dev` with
`ASPNETCORE_ENVIRONMENT=Production`. `ASPNETCORE_ENVIRONMENT=Production`.
- **One-time root setup:** `deploy/max/setup.sh`, run through `../arasaka.software/tools/max/run.sh`. - **One-time root setup:** `deploy/max/setup.sh`, run through `../arasaka.software/tools/max/run.sh`.
+133
View File
@@ -0,0 +1,133 @@
# 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
## Tested against
- **GoToSocial 0.22.1, end to end.** It runs in a private network on the workstation (`tools/pasture/`) and is driven
through its own client API. Checked both ways:
- follows, including to a locked account;
- public posts with a content warning;
- replies with notifications;
- likes and boosts;
- direct messages;
- edits and deletes;
- unfollow.
- **Mastodon, Misskey, Lemmy and PeerTube, by unit tests only.** The tests feed the parser documents written in each
server's shape. No live exchange with any of them has run yet.
## 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** follows [FEP-1b12](https://codeberg.org/fediverse/fep/src/branch/main/fep/1b12/fep-1b12.md). Posts
addressed to it (in `to`, `cc` or `audience`) are accepted according to its posting policy (followers, anyone, or
moderators only) and the group `Announce`s the whole activity, with `audience` set, to its followers. A new post is
also announced as an object so Mastodon shows it. Updates and deletes of community content are announced too. A
top-level post is a `Page` with a `name`. Members are counted at `/flock`; moderators are listed at `/wardens`, which
the actor's `attributedTo` points to, with `postingRestrictedToMods` as Lemmy expects. A mention of a community posts into it.
- A **circle** is private. Its actor is not discoverable and every follow is a request. Its posts are addressed to the
circle and its members collection, delivered to each member's own inbox and never announced. They are served only
to a signed request from a member, or from the instance actor of a member's server; anyone else gets 404. A
Mastodon member's replies reach only the people they mention.
- Announces from **remote** groups (Lemmy communities) are followed through to the activity: the object is fetched
from its own origin, never taken from the announce.
## 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 |
| `Flag` | becomes a report for this server's moderators |
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
`Reject{Follow}`, `Like`, `Announce` and their `Undo`, `Flag`. A deleted post answers 410 with a `Tombstone`.
- **Attachments** are `Document`s with `mediaType`, `name` (alt text), `blurhash`, `focalPoint`, `width` and `height`.
Uploaded files have all metadata removed.
- **Pinned posts** are the actor's `featured` collection (`/trophies`); `featuredTags` is `/tattoos`.
- **Blocks are never sent.** A blocked account is sent `Reject{Follow}` if it followed, and is unfollowed.
- **Reports** are sent as `Flag` by the instance actor, never by the reporting account.
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.
## Local-only posts
Posts with a location (shown to nearby users of this server) never leave the server, in any form.
## 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
- Polls and custom emoji are not implemented.
- Remote media is fetched through this server's proxy when a local client displays it.
- 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; }
}
}
@@ -25,6 +25,7 @@ namespace PrivaPub.ClientModels.Group
public string InvitationPassword { get; set; } public string InvitationPassword { get; set; }
public bool IsCommunity { get; set; } public bool IsCommunity { get; set; }
public string PostingPolicy { get; set; }//followers (default), anyone, moderators
public bool IsDiscoverable { get; set; } = true; public bool IsDiscoverable { get; set; } = true;
public bool ManuallyApprovesMembers { get; set; } public bool ManuallyApprovesMembers { get; set; }
} }
@@ -20,6 +20,7 @@ namespace PrivaPub.ClientModels.Group
public bool RemoveInvitationPassword { get; set; } public bool RemoveInvitationPassword { get; set; }
public bool RegenerateInvitationCode { get; set; } public bool RegenerateInvitationCode { get; set; }
public bool? IsDiscoverable { get; set; } public bool? IsDiscoverable { get; set; }
public string PostingPolicy { get; set; }
public bool? ManuallyApprovesMembers { get; set; } public bool? ManuallyApprovesMembers { get; set; }
} }
} }
+1
View File
@@ -9,6 +9,7 @@ namespace PrivaPub.ClientModels.Group
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 IsCommunity { get; set; }
public string PostingPolicy { 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; }
@@ -19,6 +19,41 @@ 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
[Range(-90, 90, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
public double? Latitude { get; set; }
[Range(-180, 180, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
public double? Longitude { get; set; }
[Range(1, 50, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
public double? RangeKm { get; set; }
}
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
+4
View File
@@ -8,6 +8,7 @@ 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; }
@@ -15,6 +16,9 @@ namespace PrivaPub.ClientModels.Post
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 double? RangeKm { get; set; }
public double? DistanceKm { 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>());
}
}
}
+102
View File
@@ -0,0 +1,102 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Memory;
using MongoDB.Entities;
using NetVips;
using PrivaPub.Domain.Media;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Media;
using PrivaPub.Tests.Support;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public sealed class MediaFlowTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static byte[] Png(int width, int height)
{
using var image = (Image.Black(width, height, bands: 3) + new double[] { 10, 120, 200 }).Cast(Enums.BandFormat.Uchar);
return image.WriteToBuffer(".png");
}
static IFormFile Upload(byte[] bytes, string contentType) =>
new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "picture") { Headers = new HeaderDictionary(), ContentType = contentType };
[Fact]
public async Task An_upload_is_attached_once_and_federated_with_its_alt_text()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var (_, mallory) = await _harness.Persona("mallory");
var upload = await _harness.Media.Upload(alice, Upload(Png(300, 200), "image/png"), "a blue square", "0.25,-0.5", token);
Assert.True(upload.Ok);
Assert.True(File.Exists(Path.Combine(_harness.Media.Root, upload.Attachment.FilePath)));
var stolen = await _harness.Statuses.Publish(mallory, new StatusDraft { Text = "mine", MediaIds = new[] { upload.Attachment.ID } }, token);
Assert.False(stolen.Ok);
var posted = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "look", MediaIds = new[] { upload.Attachment.ID } }, token);
Assert.True(posted.Ok);
Assert.Equal(posted.Post.ID, (await DB.Default.Find<MediaAttachment>().OneAsync(upload.Attachment.ID, token)).PostId);
var note = ActivityPubRenderer.Note(posted.Post, alice, default, default);
var attachment = note["attachment"]![0]!;
Assert.Equal("a blue square", attachment["name"]!.GetValue<string>());
Assert.Equal(300, attachment["width"]!.GetValue<int>());
Assert.Equal(-0.5f, attachment["focalPoint"]![1]!.GetValue<float>());
Assert.StartsWith("https://privapub.test/media/files/", attachment["url"]!.GetValue<string>());
var reused = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "again", MediaIds = new[] { upload.Attachment.ID } }, token);
Assert.False(reused.Ok);
}
[Fact]
public async Task Unsupported_files_are_refused()
{
var (_, alice) = await _harness.Persona("alice");
var outcome = await _harness.Media.Upload(alice, Upload(System.Text.Encoding.UTF8.GetBytes("<svg/>"), "image/svg+xml"), default, default, TestContext.Current.CancellationToken);
Assert.False(outcome.Ok);
Assert.Equal(422, outcome.Status);
}
[Fact]
public async Task The_proxy_serves_signed_remote_media_and_nothing_else()
{
var token = TestContext.Current.CancellationToken;
var path = $"/files/{Guid.NewGuid():N}.png";
_harness.Peer.ServeFile(path, Png(10, 10), "image/png");
var proxy = new MediaProxy(_harness.Local, Peer.Http(), _harness.Media, new StaticOptions<MediaOptions>(new MediaOptions()));
var wrapped = proxy.Wrap(_harness.Peer.A + path);
var parts = new Uri(wrapped).AbsolutePath.Split('/');
var (file, contentType) = await proxy.Fetch(parts[3], parts[4], token);
var (tampered, _) = await proxy.Fetch(parts[3].Replace(parts[3][0], parts[3][0] == 'A' ? 'B' : 'A'), parts[4], token);
Assert.StartsWith("https://privapub.test/media/proxy/", wrapped);
Assert.Equal("image/png", contentType);
Assert.True(File.Exists(file));
Assert.StartsWith(_harness.Media.ProxyRoot, file);
Assert.Null(tampered);
}
}
}
@@ -0,0 +1,72 @@
using NetVips;
using PrivaPub.Domain.Media;
namespace PrivaPub.Tests.Domain
{
public class MediaProcessingTests
{
static byte[] JpegWithMetadata(int width, int height)
{
using var image = (Image.Black(width, height, bands: 3) + new double[] { 200, 40, 90 }).Cast(Enums.BandFormat.Uchar);
using var tagged = image.Mutate(m =>
{
m.Set(GValue.GStrType, "exif-ifd0-ImageDescription", "where I live");
m.Set(GValue.GStrType, "exif-ifd0-Artist", "Alice Smith");
m.Set(GValue.GStrType, "exif-ifd2-UserComment", "at home");
m.Set(GValue.BlobType, "xmp-data", System.Text.Encoding.UTF8.GetBytes("<x:xmpmeta xmlns:x='adobe:ns:meta/'><secret/></x:xmpmeta>"));
});
return tagged.WriteToBuffer(".jpg");
}
[Fact]
public void Uploaded_images_lose_every_kind_of_metadata()
{
var input = JpegWithMetadata(800, 600);
using (var original = Image.NewFromBuffer(input))
Assert.Contains("exif-data", original.GetFields());
var processed = MediaService.ProcessImage(input, 4096, 640, animated: false);
using var output = Image.NewFromBuffer(processed.Bytes);
var fields = output.GetFields();
Assert.DoesNotContain("exif-data", fields);
Assert.DoesNotContain("xmp-data", fields);
Assert.DoesNotContain("iptc-data", fields);
Assert.DoesNotContain(fields, f => f.StartsWith("exif-ifd"));
Assert.Equal((800, 600), (processed.Width, processed.Height));
Assert.Equal("image/jpeg", processed.ContentType);
using var preview = Image.NewFromBuffer(processed.Preview);
Assert.Equal(640, Math.Max(preview.Width, preview.Height));
Assert.DoesNotContain("exif-data", preview.GetFields());
}
[Fact]
public void Large_images_are_capped()
{
var processed = MediaService.ProcessImage(JpegWithMetadata(5000, 2500), 4096, 640, animated: false);
Assert.Equal((4096, 2048), (processed.Width, processed.Height));
}
[Fact]
public void A_blurhash_is_well_formed()
{
var processed = MediaService.ProcessImage(JpegWithMetadata(64, 64), 4096, 640, animated: false);
Assert.Equal(28, processed.Blurhash.Length);
Assert.Equal('L', processed.Blurhash[0]);
}
[Fact]
public void A_white_image_encodes_white_as_its_average()
{
var pixels = Enumerable.Range(0, 16 * 16).SelectMany(_ => new byte[] { 255, 255, 255 }).ToArray();
var hash = Blurhash.Encode(pixels, 16, 16);
Assert.Equal(28, hash.Length);
Assert.Equal("TSUA", hash[2..6]);
}
}
}
+77
View File
@@ -0,0 +1,77 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Outbox;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using System.Text.Json;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public sealed class NearbyTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
[Fact]
public async Task A_located_post_reaches_only_those_within_its_radius_and_never_leaves_the_server()
{
var token = TestContext.Current.CancellationToken;
var latitude = 10 + Random.Shared.NextDouble() * 50;
var longitude = 10 + Random.Shared.NextDouble() * 50;
var (aliceRoot, alice) = await _harness.Persona("alice");
var (bobRoot, bob) = await _harness.Persona("bob");
var (carolRoot, carol) = await _harness.Persona("carol");
var follower = new RemoteActor(_harness.Peer, "follower");
await _harness.FollowedBy(alice, follower);
var created = await _harness.Posts.InsertPost(aliceRoot, new InsertPostForm
{
AvatarId = alice.Id, Text = "free bread at the bakery", Latitude = latitude, Longitude = longitude, RangeKm = 5
}, token);
var post = await DB.Default.Find<Post>().OneAsync(((ViewPost)created.Data).Id, token);
var three = (List<ViewPost>)(await _harness.Posts.Nearby(bobRoot, bob.Id, latitude + 0.027, longitude, token)).Data;
var eight = (List<ViewPost>)(await _harness.Posts.Nearby(carolRoot, carol.Id, latitude + 0.072, longitude, token)).Data;
Assert.Equal(PostVisibility.LocalGeo, post.Visibility);
Assert.Equal(Math.Round(latitude, 2), post.Geo.Coordinates[1]);
Assert.Contains(three, p => p.Id == post.ID);
Assert.InRange(three.First(p => p.Id == post.ID).DistanceKm.Value, 2.0, 4.0);
Assert.DoesNotContain(eight, p => p.Id == post.ID);
Assert.False(await VisibilityPolicy.CanSee(post, bob.Id, token));
var outgoing = (await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver).ExecuteAsync(token))
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
.Where(p => p.Body.Contains(post.ID));
Assert.Empty(outgoing);
}
[Fact]
public async Task A_located_post_cannot_be_direct_or_in_a_group()
{
var (root, alice) = await _harness.Persona("alice");
var result = await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft
{
Text = "x", Latitude = 45, Longitude = 7, Visibility = PostVisibility.Direct
}, TestContext.Current.CancellationToken);
Assert.False(result.Ok);
}
}
}
+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);
}
}
}
+122
View File
@@ -0,0 +1,122 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels.Social;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public sealed class RelationshipTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
[Fact]
public async Task A_block_cuts_follows_both_ways_and_hides_the_account()
{
var token = TestContext.Current.CancellationToken;
var (aliceRoot, alice) = await _harness.Persona("alice");
var (bobRoot, bob) = await _harness.Persona("bob");
await _harness.Follows.Follow(aliceRoot, new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, token);
await _harness.Follows.Follow(bobRoot, new FollowForm { AvatarId = bob.Id, Target = alice.UserName }, token);
await _harness.Relationships.Block(alice, bob.Uri, bob.Id, token);
await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = $"hey @{alice.UserName}" }, token);
Assert.False(await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id || f.AvatarId == bob.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id || f.LocalActorId == bob.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.AuthorAccountId == bob.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_blocked_remote_account_is_refused_when_it_follows()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
await _harness.Relationships.Block(alice, mallory.Id, default, token);
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(mallory)}/follows/{Guid.NewGuid():N}", ["type"] = "Follow", ["actor"] = mallory.Id, ["object"] = alice.Uri
});
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id).ExecuteAnyAsync(token));
Assert.Equal("Reject", Assert.Single(await _harness.Outgoing(mallory.Id + "/inbox"))["type"]!.GetValue<string>());
}
[Fact]
public async Task A_muted_account_stays_out_of_home_and_notifications()
{
var token = TestContext.Current.CancellationToken;
var (aliceRoot, alice) = await _harness.Persona("alice");
var (bobRoot, bob) = await _harness.Persona("bob");
await _harness.Follows.Follow(aliceRoot, new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, token);
await _harness.Relationships.Mute(alice, bob.Uri, bob.Id, hideNotifications: true, default, token);
await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = $"hey @{alice.UserName}" }, token);
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id && n.Type == NotificationType.Mention).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_report_is_forwarded_by_the_instance_not_the_persona()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
var foreign = await _harness.Remote.GetActor(mallory.Id, refresh: false, token);
var report = await _harness.Reports.File(alice, foreign.ID, Array.Empty<string>(), "spam everywhere", "spam", forward: true, token);
Assert.True(report.Forwarded);
var flag = Assert.Single(await _harness.Outgoing(mallory.Id + "/inbox"));
Assert.Equal("Flag", flag["type"]!.GetValue<string>());
Assert.EndsWith("/peasants/privapub", flag["actor"]!.GetValue<string>());
Assert.DoesNotContain(alice.UserName, flag.ToJsonString());
Assert.DoesNotContain(alice.Id, flag.ToJsonString());
}
[Fact]
public async Task An_inbound_flag_becomes_a_report_for_moderators()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var reporter = new RemoteActor(_harness.Peer, "reporter");
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "bad post" }, token);
var post = await DB.Default.Find<PrivaPub.Models.Post.Post>().Match(p => p.GroupUserId == alice.Id).ExecuteSingleAsync(token);
await _harness.Deliver(reporter, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(reporter)}/flags/{Guid.NewGuid():N}", ["type"] = "Flag", ["actor"] = reporter.Id,
["content"] = "please look", ["object"] = new JsonArray(alice.Uri, post.ObjectURI)
});
var report = await DB.Default.Find<Report>().Match(r => r.TargetAccountId == alice.Id).ExecuteSingleAsync(token);
Assert.Equal(reporter.Id, report.ReporterActorURI);
Assert.Equal(new[] { post.ID }, report.PostIds);
Assert.Equal("please look", report.Comment);
}
}
}
+162
View File
@@ -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));
}
}
}
+198
View File
@@ -0,0 +1,198 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Social;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Signing;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class GroupTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
async Task<(GroupEntity Entity, PrivaPub.Federation.Actors.LocalActor Actor)> Group(GroupKind kind, PostingPolicy policy, string ownerId, params string[] remoteMembers)
{
var (privateKey, publicKey) = PrivaPub.Federation.Actors.Keys.NewKeyPair();
var group = new GroupEntity
{
UserName = $"{kind}{Guid.NewGuid():N}"[..20].ToLowerInvariant(),
Kind = kind,
PostingPolicy = policy,
PrivateKey = privateKey,
PublicKey = publicKey,
Members = new() { new GroupMember { AvatarId = ownerId, Role = GroupRole.Owner } }
};
group.Members.AddRange(remoteMembers.Select(m => new GroupMember { AvatarId = m, IsForeign = true }));
await DB.Default.SaveAsync(group);
return (group, _harness.Local.FromGroup(group));
}
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
static JsonObject Create(RemoteActor author, IEnumerable<string> to, string audience = default)
{
var note = new JsonObject
{
["id"] = $"{Origin(author)}/notes/{Guid.NewGuid():N}",
["type"] = "Note",
["attributedTo"] = author.Id,
["content"] = "<p>to the group</p>",
["to"] = new JsonArray(to.Select(t => (JsonNode)t).ToArray())
};
if (audience != default)
note["audience"] = audience;
return new JsonObject { ["id"] = $"{Origin(author)}/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Id, ["object"] = note };
}
[Fact]
public async Task A_follower_posting_to_a_community_is_announced_activity_and_object()
{
var token = TestContext.Current.CancellationToken;
var (_, owner) = await _harness.Persona("owner");
var (_, community) = await Group(GroupKind.Community, PostingPolicy.Followers, owner.Id);
var lemmy = new RemoteActor(_harness.Peer, "lemmy");
var stranger = new RemoteActor(_harness.Peer, "stranger");
await _harness.FollowedBy(community, lemmy);
await _harness.Deliver(lemmy, "/human-centipede", Create(lemmy, new[] { community.Uri, Addressing.Public }, community.Uri));
await _harness.Deliver(stranger, "/human-centipede", Create(stranger, new[] { community.Uri, Addressing.Public }, community.Uri));
Assert.True(await DB.Default.Find<Post>().Match(p => p.GroupId == community.Id && p.ActorURI == lemmy.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Post>().Match(p => p.GroupId == community.Id && p.ActorURI == stranger.Id).ExecuteAnyAsync(token));
var announces = (await _harness.Outgoing(lemmy.SharedInbox)).Where(a => a["actor"]!.GetValue<string>() == community.Uri).ToList();
Assert.Equal(2, announces.Count);
Assert.Contains(announces, a => a["object"] is JsonObject inner && inner["type"]!.GetValue<string>() == "Create" && a["audience"]!.GetValue<string>() == community.Uri);
Assert.Contains(announces, a => a["object"] is JsonValue);
}
[Fact]
public async Task An_open_community_takes_posts_from_anyone()
{
var token = TestContext.Current.CancellationToken;
var (_, owner) = await _harness.Persona("owner");
var (_, community) = await Group(GroupKind.Community, PostingPolicy.Anyone, owner.Id);
var stranger = new RemoteActor(_harness.Peer, "stranger");
await _harness.Deliver(stranger, "/human-centipede", Create(stranger, new[] { community.Uri, Addressing.Public }));
Assert.True(await DB.Default.Find<Post>().Match(p => p.GroupId == community.Id && p.ActorURI == stranger.Id).ExecuteAnyAsync(token));
}
[Fact]
public async Task Mentioning_a_community_posts_into_it_as_a_titled_page()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var (_, community) = await Group(GroupKind.Community, PostingPolicy.Anyone, alice.Id);
var follower = new RemoteActor(_harness.Peer, "follower");
await _harness.FollowedBy(community, follower);
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = $"@{community.UserName} a new thread", PlainText = true }, token);
Assert.Equal(community.Id, outcome.Post.GroupId);
var note = ActivityPubRenderer.Note(outcome.Post, alice, community, default);
Assert.Equal("Page", note["type"]!.GetValue<string>());
Assert.Equal(community.Uri, note["audience"]!.GetValue<string>());
Assert.False(string.IsNullOrEmpty(note["name"]!.GetValue<string>()));
Assert.Contains(await _harness.Outgoing(follower.SharedInbox), a => a["type"]!.GetValue<string>() == "Announce");
}
[Fact]
public async Task A_circle_post_goes_only_to_members_and_only_members_may_read_it()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var member = new RemoteActor(_harness.Peer, "member");
var outsider = new RemoteActor(_harness.Peer, "outsider");
var (circleEntity, circle) = await Group(GroupKind.Circle, PostingPolicy.Followers, alice.Id, member.Id);
await _harness.Remote.GetActor(member.Id, refresh: false, token);
var follower = new RemoteActor(_harness.Peer, "follower");
await _harness.FollowedBy(alice, follower);
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "just us", GroupId = circle.Id }, token);
Assert.Equal(PostVisibility.Circle, outcome.Post.Visibility);
var create = Assert.Single(await _harness.Outgoing(member.Id + "/inbox"));
Assert.Equal(new[] { circle.Uri, circle.Flock }, create["object"]!["to"]!.AsArray().Select(t => t!.GetValue<string>()));
Assert.DoesNotContain(Addressing.Public, create.ToJsonString());
Assert.Empty(await _harness.Outgoing(follower.SharedInbox));
Assert.Empty((await _harness.Outgoing(member.SharedInbox)).Where(a => a["type"]!.GetValue<string>() == "Announce"));
var authorizer = new SignedFetchAuthorizer(_harness.Remote);
var path = new Uri(outcome.Post.ObjectURI).AbsolutePath;
var asMember = await authorizer.Requester(member.Get(Harness.Host, path), token);
var asOutsider = await authorizer.Requester(outsider.Get(Harness.Host, path), token);
Assert.True(SignedFetchAuthorizer.MayReadCircle(circleEntity, asMember));
Assert.False(SignedFetchAuthorizer.MayReadCircle(circleEntity, asOutsider));
Assert.False(SignedFetchAuthorizer.MayReadCircle(circleEntity, default));
Assert.True(circle.IsCircle);
Assert.False(circle.Discoverable);
}
[Fact]
public async Task Only_circle_members_can_post_into_a_circle()
{
var token = TestContext.Current.CancellationToken;
var (aliceRoot, alice) = await _harness.Persona("alice");
var member = new RemoteActor(_harness.Peer, "member");
var outsider = new RemoteActor(_harness.Peer, "outsider");
var (_, circle) = await Group(GroupKind.Circle, PostingPolicy.Followers, alice.Id, member.Id);
await _harness.Deliver(member, "/human-centipede", Create(member, new[] { circle.Uri, circle.Flock }));
await _harness.Deliver(outsider, "/human-centipede", Create(outsider, new[] { circle.Uri, circle.Flock }));
var stored = await DB.Default.Find<Post>().Match(p => p.GroupId == circle.Id).ExecuteAsync(token);
Assert.Equal(member.Id, Assert.Single(stored).ActorURI);
Assert.Equal(PostVisibility.Circle, stored[0].Visibility);
Assert.True(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.PostId == stored[0].ID).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_followed_remote_community_announcing_a_post_puts_it_in_home()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var lemmyCommunity = new RemoteActor(_harness.Peer, "cats", type: "Group");
var poster = new RemoteActor(_harness.Peer, "poster");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = lemmyCommunity.Id }, token);
await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
var create = Create(poster, new[] { lemmyCommunity.Id, Addressing.Public }, lemmyCommunity.Id);
var noteId = create["object"]!["id"]!.GetValue<string>();
_harness.Peer.Serve(new Uri(noteId).AbsolutePath, create["object"]!.ToJsonString());
await _harness.Deliver(lemmyCommunity, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(lemmyCommunity)}/activities/announce/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = lemmyCommunity.Id,
["to"] = new JsonArray(Addressing.Public), ["object"] = create
});
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(token);
Assert.Equal(lemmyCommunity.Id, post.AudienceURI);
Assert.True(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(token));
}
}
}
+171 -25
View File
@@ -3,13 +3,20 @@ using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Domain.Timelines;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Federation.Inbox; using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Moderation;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Outbox;
using PrivaPub.Models; using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group; using PrivaPub.Models.Group;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Models.User; using PrivaPub.Models.User;
using PrivaPub.Models;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
using PrivaPub.Tests.Support; using PrivaPub.Tests.Support;
@@ -27,7 +34,9 @@ namespace PrivaPub.Tests.Federation
Peer _peer; Peer _peer;
LocalActorService _local; LocalActorService _local;
InboxService _inbox; InboxReceiver _receiver;
InboxProcessor _processor;
DomainBlocks _blocks;
public async ValueTask InitializeAsync() public async ValueTask InitializeAsync()
{ {
@@ -36,7 +45,19 @@ namespace PrivaPub.Tests.Federation
var cache = new MemoryCache(new MemoryCacheOptions()); var cache = new MemoryCache(new MemoryCacheOptions());
_local = new LocalActorService(new DbEntities(), new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = Base })); _local = new LocalActorService(new DbEntities(), new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = Base }));
var remote = new RemoteActorService(Peer.Http(cache), _local, cache, new DbEntities()); var remote = new RemoteActorService(Peer.Http(cache), _local, cache, new DbEntities());
_inbox = new InboxService(new DbEntities(), _local, remote, new DeliveryService(new DbEntities()), NullLogger<InboxService>.Instance); 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 GroupDistributor(delivery)),
new DeleteHandler(db, _local, remote, delivery, new GroupDistributor(delivery)),
new UpdateHandler(db, _local, remote, new GroupDistributor(delivery))
}, NullLogger<InboxProcessor>.Instance);
} }
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
@@ -77,6 +98,17 @@ namespace PrivaPub.Tests.Federation
}; };
} }
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); static string Origin(string uri) => new Uri(uri).GetLeftPart(UriPartial.Authority);
[Fact] [Fact]
@@ -88,15 +120,15 @@ namespace PrivaPub.Tests.Federation
var mallory = new RemoteActor(_peer, "mallory"); var mallory = new RemoteActor(_peer, "mallory");
var context = $"{_peer.A}/contexts/{Guid.NewGuid():N}"; var context = $"{_peer.A}/contexts/{Guid.NewGuid():N}";
var first = await _inbox.Receive(bob.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri, context)), alice, token); var first = await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri, context));
var injected = await _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, context)), alice, token); var injected = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, context));
Assert.Equal(202, first.StatusCode); Assert.Equal(202, first.StatusCode);
Assert.Equal(202, injected.StatusCode); Assert.Equal(202, injected.StatusCode);
var bobDm = await DB.Default.Find<DmPost>().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token); 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<DmPost>().Match(p => p.ActorURI == mallory.Id).ExecuteSingleAsync(token); var malloryDm = await DB.Default.Find<Post>().Match(p => p.ActorURI == mallory.Id).ExecuteSingleAsync(token);
Assert.NotEqual(bobDm.GroupId, malloryDm.GroupId); Assert.NotEqual(bobDm.ConversationId, malloryDm.ConversationId);
var bobConversation = await DB.Default.Find<DmGroup>().OneAsync(bobDm.GroupId, token); var bobConversation = await DB.Default.Find<DmGroup>().OneAsync(bobDm.ConversationId, token);
Assert.DoesNotContain(bobConversation.Members, m => m.AvatarId == mallory.Id); Assert.DoesNotContain(bobConversation.Members, m => m.AvatarId == mallory.Id);
} }
@@ -107,12 +139,12 @@ namespace PrivaPub.Tests.Federation
var alice = await LocalAvatar("alice"); var alice = await LocalAvatar("alice");
var bob = new RemoteActor(_peer, "bob"); var bob = new RemoteActor(_peer, "bob");
await _inbox.Receive(bob.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri)), alice, token); await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri));
await _inbox.Receive(bob.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri)), alice, token); await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri));
var dms = await DB.Default.Find<DmPost>().Match(p => p.ActorURI == bob.Id).ExecuteAsync(token); 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.Equal(2, dms.Count);
Assert.Single(dms.Select(d => d.GroupId).Distinct()); Assert.Single(dms.Select(d => d.ConversationId).Distinct());
} }
[Fact] [Fact]
@@ -124,7 +156,7 @@ namespace PrivaPub.Tests.Federation
var create = DirectCreate(mallory, alice.Uri); var create = DirectCreate(mallory, alice.Uri);
create["id"] = $"{_peer.B}/activities/{Guid.NewGuid():N}"; create["id"] = $"{_peer.B}/activities/{Guid.NewGuid():N}";
var result = await _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", create), alice, token); var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", create);
Assert.Equal(400, result.StatusCode); Assert.Equal(400, result.StatusCode);
} }
@@ -137,11 +169,10 @@ namespace PrivaPub.Tests.Federation
var mallory = new RemoteActor(_peer, "mallory"); var mallory = new RemoteActor(_peer, "mallory");
var victim = new RemoteActor(_peer, "victim"); var victim = new RemoteActor(_peer, "victim");
var result = await _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, attributedTo: victim.Id));
DirectCreate(mallory, alice.Uri, attributedTo: victim.Id)), alice, token);
Assert.Equal(400, result.StatusCode); Assert.Equal(400, result.StatusCode);
Assert.False(await DB.Default.Find<DmPost>().Match(p => p.ActorURI == victim.Id).ExecuteAnyAsync(token)); Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == victim.Id).ExecuteAnyAsync(token));
} }
[Fact] [Fact]
@@ -151,11 +182,121 @@ namespace PrivaPub.Tests.Federation
var alice = await LocalAvatar("alice"); var alice = await LocalAvatar("alice");
var mallory = new RemoteActor(_peer, "mallory"); var mallory = new RemoteActor(_peer, "mallory");
var result = await _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, objectOrigin: _peer.B));
DirectCreate(mallory, alice.Uri, objectOrigin: _peer.B)), alice, token);
Assert.Equal(202, result.StatusCode); Assert.Equal(202, result.StatusCode);
Assert.False(await DB.Default.Find<DmPost>().Match(p => p.ActorURI == mallory.Id).ExecuteAnyAsync(token)); 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] [Fact]
@@ -167,7 +308,7 @@ namespace PrivaPub.Tests.Federation
var request = mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri)); var request = mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri));
request.Headers["Signature"] = request.Headers["Signature"].ToString().Replace("signature=\"", "signature=\"AAAA"); request.Headers["Signature"] = request.Headers["Signature"].ToString().Replace("signature=\"", "signature=\"AAAA");
Assert.Equal(401, (await _inbox.Receive(request, alice, token)).StatusCode); Assert.Equal(401, (await _receiver.Receive(request, alice, token)).StatusCode);
} }
[Fact] [Fact]
@@ -178,11 +319,11 @@ namespace PrivaPub.Tests.Federation
var mallory = new RemoteActor(_peer, "mallory"); var mallory = new RemoteActor(_peer, "mallory");
foreach (var junk in new JsonNode[] { new JsonArray(1, 2), JsonValue.Create("x"), new JsonObject { ["type"] = "Create" } }) foreach (var junk in new JsonNode[] { new JsonArray(1, 2), JsonValue.Create("x"), new JsonObject { ["type"] = "Create" } })
Assert.Equal(400, (await _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", junk), alice, token)).StatusCode); Assert.Equal(400, (await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", junk)).StatusCode);
} }
[Fact] [Fact]
public async Task A_circle_is_not_a_federated_actor() public async Task A_circle_only_takes_follow_requests()
{ {
var token = TestContext.Current.CancellationToken; var token = TestContext.Current.CancellationToken;
var (privateKey, publicKey) = Keys.NewKeyPair(); var (privateKey, publicKey) = Keys.NewKeyPair();
@@ -198,8 +339,13 @@ namespace PrivaPub.Tests.Federation
["object"] = actor.Uri ["object"] = actor.Uri
}; };
Assert.False(actor.IsFederated); Assert.True(actor.IsCircle);
Assert.Equal(404, (await _inbox.Receive(bob.Post(Host, "/human-centipede", follow), default, token)).StatusCode); Assert.True(actor.ManuallyApprovesFollowers);
Assert.False(actor.Discoverable);
Assert.Equal(202, (await Deliver(bob, "/human-centipede", follow)).StatusCode);
var request = await DB.Default.Find<Follower>().Match(f => f.LocalActorId == circle.ID).ExecuteSingleAsync(token);
Assert.False(request.IsAccepted);
Assert.DoesNotContain(circle.Members, m => m.AvatarId == bob.Id);
} }
} }
} }
@@ -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,115 @@
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);
Assert.Null(note.Updated);
}
[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));
}
}
+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, 345, DateTimeKind.Utc)
};
var note = ActivityPubRenderer.Note(post, Alice, default, "https://m.example/users/bob/statuses/1");
Assert.Equal("https://privapub.test/peasants/alice/scribbles/p1", note["id"]!.GetValue<string>());
Assert.Equal("https://privapub.test/@alice/p1", note["url"]!.GetValue<string>());
Assert.Equal("<p><strong>On &lt;cats&gt;</strong></p><p>body</p>", note["content"]!.GetValue<string>());
Assert.Equal("On <cats>", note["summary"]!.GetValue<string>());
Assert.True(note["sensitive"]!.GetValue<bool>());
Assert.Equal("2026-09-30T17:45:12.345Z", note["published"]!.GetValue<string>());
Assert.Equal(new[] { ActivityPubRenderer.Public }, note["to"]!.AsArray().Select(n => n!.GetValue<string>()));
Assert.Equal(new[] { Alice.Followers, "https://m.example/users/bob" }, note["cc"]!.AsArray().Select(n => n!.GetValue<string>()));
var tags = note["tag"]!.AsArray();
Assert.Equal("@bob@m.example", tags[0]!["name"]!.GetValue<string>());
Assert.Equal("https://privapub.test/tags/cats", tags[1]!["href"]!.GetValue<string>());
Assert.Equal("https://m.example/users/bob/statuses/1", note["inReplyTo"]!.GetValue<string>());
Assert.NotNull(note["contentMap"]!["en"]);
}
[Fact]
public void A_warning_without_a_title_or_spoiler_gets_a_generic_summary()
{
var note = ActivityPubRenderer.Note(new Post { ID = "p2", ContentHtml = "<p>x</p>", HasContentWarning = true }, Alice, default, default);
Assert.Equal(ActivityPubRenderer.ContentWarning, note["summary"]!.GetValue<string>());
}
[Fact]
public void A_direct_note_is_addressed_to_its_recipients_only()
{
var post = new Post { ID = "p3", ContentHtml = "<p>psst</p>", Visibility = PostVisibility.Direct };
var note = ActivityPubRenderer.DirectNote(post, Alice, new[] { ("https://m.example/users/bob", "bob@m.example") },
Alice.ConversationUri("c1"));
Assert.Equal(new[] { "https://m.example/users/bob" }, note["to"]!.AsArray().Select(n => n!.GetValue<string>()));
Assert.Empty(note["cc"]!.AsArray());
Assert.Equal("https://privapub.test/peasants/alice/whispers/c1", note["context"]!.GetValue<string>());
Assert.StartsWith("<p><span class=\"h-card\"", note["content"]!.GetValue<string>());
Assert.Equal("Mention", note["tag"]![0]!["type"]!.GetValue<string>());
}
}
}
@@ -6,7 +6,9 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using PrivaPub.Federation.Moderation;
using PrivaPub.Infrastructure.Http; using PrivaPub.Infrastructure.Http;
using PrivaPub.Models.Federation;
namespace PrivaPub.Tests.Infrastructure namespace PrivaPub.Tests.Infrastructure
{ {
@@ -39,7 +41,7 @@ namespace PrivaPub.Tests.Infrastructure
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options)); .ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options));
var provider = services.BuildServiceProvider(); var provider = services.BuildServiceProvider();
return new FederationHttp(provider.GetRequiredService<IHttpClientFactory>(), new MemoryCache(new MemoryCacheOptions()), return new FederationHttp(provider.GetRequiredService<IHttpClientFactory>(), new MemoryCache(new MemoryCacheOptions()),
new StaticOptionsMonitor(options), NullLogger<FederationHttp>.Instance); new StaticOptionsMonitor(options), new StaticBlocks(), NullLogger<FederationHttp>.Instance);
} }
[Fact] [Fact]
@@ -91,6 +93,29 @@ namespace PrivaPub.Tests.Infrastructure
public void IsAllowed_takes_https_dns_names_only(string url, bool allowed) => public void IsAllowed_takes_https_dns_names_only(string url, bool allowed) =>
Assert.Equal(allowed, Client(allowTestNetwork: false).IsAllowed(new Uri(url))); 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> sealed class StaticOptionsMonitor : IOptionsMonitor<FederationOptions>
{ {
public StaticOptionsMonitor(FederationOptions value) => CurrentValue = value; public StaticOptionsMonitor(FederationOptions value) => CurrentValue = value;
+2 -1
View File
@@ -20,6 +20,8 @@ namespace PrivaPub.Tests.Infrastructure
{ {
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken; 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 actorUri = $"https://r.example/users/{Guid.NewGuid():N}";
var localId = Guid.NewGuid().ToString("N")[..24]; var localId = Guid.NewGuid().ToString("N")[..24];
await DB.Default.SaveAsync(new[] await DB.Default.SaveAsync(new[]
@@ -48,7 +50,6 @@ namespace PrivaPub.Tests.Infrastructure
{ {
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken; var token = TestContext.Current.CancellationToken;
await Indexes.Create(token);
var local = new LocalActorService(new DbEntities(), new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = "https://privapub.test" })); var local = new LocalActorService(new DbEntities(), new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = "https://privapub.test" }));
var name = $"n{Guid.NewGuid():N}"[..20]; var name = $"n{Guid.NewGuid():N}"[..20];
@@ -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);
}
}
}
@@ -29,5 +29,21 @@ namespace PrivaPub.Tests.Infrastructure
Assert.Equal("<p><strong>bold</strong> &lt;b&gt;raw&lt;/b&gt;</p>", migratedLocal.ContentHtml); Assert.Equal("<p><strong>bold</strong> &lt;b&gt;raw&lt;/b&gt;</p>", migratedLocal.ContentHtml);
Assert.Equal(ContentFormat.Markdown, migratedLocal.ContentFormat); 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,56 @@
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_post_arriving_live_sorts_after_everything_already_stored()
{
var local = ObjectId.GenerateNewId().ToString();
var reply = PrivacyIds.Arrived(DateTime.UtcNow.AddSeconds(-30));
var skewed = PrivacyIds.Arrived(DateTime.UtcNow.AddYears(50));
Assert.True(string.CompareOrdinal(local, reply) < 0);
Assert.True(string.CompareOrdinal(reply, skewed) < 0);
Assert.True(ObjectId.Parse(skewed).CreationTime < DateTime.UtcNow.AddMinutes(1));
}
[Fact]
public void A_backfilled_post_keeps_its_published_time() =>
Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), ObjectId.Parse(PrivacyIds.Arrived(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc))).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));
}
}
+141
View File
@@ -0,0 +1,141 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Entities;
using PrivaPub.Domain.Content;
using PrivaPub.Domain.Media;
using PrivaPub.Domain.Relationships;
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);
Groups = new GroupDistributor(Delivery);
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, Remote),
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups),
new DeleteHandler(Db, Local, Remote, Delivery, Groups),
new UpdateHandler(Db, Local, Remote, Groups),
new FlagHandler(Db, Local)
}, 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);
Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }),
Local, default, NullLogger<MediaService>.Instance);
Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout, Media, Groups);
Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer<GenericRes>(), NullLogger<PostsService>.Instance);
Timelines = new TimelineService(Db, new KeyLocalizer<GenericRes>());
Relationships = new RelationshipService(Db, Follows, Delivery);
Reports = new ReportService(Db, Local, Delivery);
}
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 MediaService Media { get; }
public GroupDistributor Groups { get; }
public Fanout Fanout { get; }
public RemotePosts RemotePosts { get; }
public TimelineService Timelines { get; }
public RelationshipService Relationships { get; }
public ReportService Reports { 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>();
}
}
+1
View File
@@ -32,6 +32,7 @@ namespace PrivaPub.Tests.Support
var connection = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGO") ?? "mongodb://127.0.0.1:27017"; var connection = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGO") ?? "mongodb://127.0.0.1:27017";
await DB.InitAsync(Database, MongoClientSettings.FromConnectionString(connection)); await DB.InitAsync(Database, MongoClientSettings.FromConnectionString(connection));
EntityMaps.Warm(); EntityMaps.Warm();
await Indexes.Create();
} }
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
+30 -2
View File
@@ -6,7 +6,9 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using PrivaPub.Federation.Moderation;
using PrivaPub.Infrastructure.Http; using PrivaPub.Infrastructure.Http;
using PrivaPub.Models.Federation;
using System.Collections.Concurrent; using System.Collections.Concurrent;
@@ -16,6 +18,8 @@ namespace PrivaPub.Tests.Support
{ {
readonly WebApplication _app; readonly WebApplication _app;
readonly ConcurrentDictionary<string, string> _documents = new(); readonly ConcurrentDictionary<string, string> _documents = new();
readonly ConcurrentDictionary<string, (int Status, TimeSpan Delay)> _answers = new();
readonly ConcurrentDictionary<string, (byte[] Bytes, string ContentType)> _files = new();
public int Port { get; } public int Port { get; }
public string A => $"http://127.0.0.1:{Port}"; public string A => $"http://127.0.0.1:{Port}";
@@ -38,6 +42,19 @@ namespace PrivaPub.Tests.Support
{ {
peer.Requests.Enqueue(new(context.Request.Method, context.Request.Path, context.Request.Headers["Signature"].ToString())); peer.Requests.Enqueue(new(context.Request.Method, context.Request.Path, context.Request.Headers["Signature"].ToString()));
var key = context.Request.Path.Value; var key = context.Request.Path.Value;
if (peer._files.TryGetValue(key, out var file))
{
context.Response.ContentType = file.ContentType;
await context.Response.Body.WriteAsync(file.Bytes);
return;
}
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)) if (!peer._documents.TryGetValue(key, out var document))
{ {
context.Response.StatusCode = StatusCodes.Status404NotFound; context.Response.StatusCode = StatusCodes.Status404NotFound;
@@ -53,7 +70,11 @@ namespace PrivaPub.Tests.Support
public void Serve(string path, string json) => _documents[path] = json; public void Serve(string path, string json) => _documents[path] = json;
public static FederationHttp Http(IMemoryCache cache = default) public void ServeFile(string path, byte[] bytes, string contentType) => _files[path] = (bytes, contentType);
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 options = new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true };
var services = new ServiceCollection(); var services = new ServiceCollection();
@@ -61,12 +82,19 @@ namespace PrivaPub.Tests.Support
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options)); .ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options));
return new FederationHttp(services.BuildServiceProvider().GetRequiredService<IHttpClientFactory>(), return new FederationHttp(services.BuildServiceProvider().GetRequiredService<IHttpClientFactory>(),
cache ?? new MemoryCache(new MemoryCacheOptions()), new StaticOptions<FederationOptions>(options), cache ?? new MemoryCache(new MemoryCacheOptions()), new StaticOptions<FederationOptions>(options),
NullLogger<FederationHttp>.Instance); blocks ?? new NoBlocks(), NullLogger<FederationHttp>.Instance);
} }
public async ValueTask DisposeAsync() => await _app.DisposeAsync(); 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 record HttpRequestRecord(string Method, string Path, string Signature);
public sealed class StaticOptions<T> : IOptionsMonitor<T> public sealed class StaticOptions<T> : IOptionsMonitor<T>
+21 -2
View File
@@ -14,21 +14,25 @@ namespace PrivaPub.Tests.Support
{ {
readonly RSA _key = RSA.Create(2048); readonly RSA _key = RSA.Create(2048);
public RemoteActor(Peer peer, string name, string origin = default) public RemoteActor(Peer peer, string name, string origin = default, string type = "Person")
{ {
Name = $"{name}{Guid.NewGuid():N}"[..20]; Name = $"{name}{Guid.NewGuid():N}"[..20];
Id = $"{origin ?? peer.A}/users/{Name}"; Id = $"{origin ?? peer.A}/users/{Name}";
Type = type;
peer.Serve($"/users/{Name}", Document().ToJsonString()); peer.Serve($"/users/{Name}", Document().ToJsonString());
} }
public string Type { get; }
public string Name { get; } public string Name { get; }
public string Id { get; } public string Id { get; }
public string KeyId => Id + "#main-key"; public string KeyId => Id + "#main-key";
public string SharedInbox => Id.Split("/users/")[0] + "/inbox";
public JsonObject Document() => new() public JsonObject Document() => new()
{ {
["id"] = Id, ["id"] = Id,
["type"] = "Person", ["type"] = Type,
["preferredUsername"] = Name, ["preferredUsername"] = Name,
["inbox"] = Id + "/inbox", ["inbox"] = Id + "/inbox",
["followers"] = Id + "/followers", ["followers"] = Id + "/followers",
@@ -40,6 +44,21 @@ namespace PrivaPub.Tests.Support
} }
}; };
public HttpRequest Get(string host, string path)
{
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
var signingString = $"(request-target): get {path}\nhost: {host}\ndate: {date}";
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
var context = new DefaultHttpContext();
context.Request.Method = "GET";
context.Request.Host = new HostString(host);
context.Request.Path = path;
context.Features.Get<IHttpRequestFeature>().RawTarget = path;
context.Request.Headers["Date"] = date;
context.Request.Headers["Signature"] = $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date\",signature=\"{signature}\"";
return context.Request;
}
public HttpRequest Post(string host, string path, JsonNode activity) public HttpRequest Post(string host, string path, JsonNode activity)
{ {
var body = Encoding.UTF8.GetBytes(activity.ToJsonString()); var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
@@ -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,399 @@
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.Media;
using PrivaPub.Domain.Privacy;
using PrivaPub.Domain.Relationships;
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;
readonly IRelationshipService _relationships;
public AccountsController(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors,
IFollowService follows, IOutboxPublisher outbox, IRelationshipService relationships)
{
_relationships = relationships;
_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"), RequestSizeLimit(20 * 1024 * 1024),
RequestFormLimits(MultipartBodyLengthLimit = 20 * 1024 * 1024)]
public async Task<IActionResult> UpdateCredentials([FromServices] IMediaService media, CancellationToken token)
{
var avatar = await _dbEntities.Avatars.MatchID(Me.Id).ExecuteFirstAsync(token);
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync(token);
if (form.Files["avatar"] is { } picture && await media.ProfileImage(picture, 400, 400, token) is { } pictureUrl)
avatar.PictureURL = pictureUrl;
if (form.Files["header"] is { } header && await media.ProfileImage(header, 1500, 500, token) is { } headerUrl)
avatar.ThumbnailURL = headerUrl;
}
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, IsCircle: false, 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)
{
if (local == default)
return Json(Array.Empty<Status>());
var pinIds = (await DB.Default.Find<Pin>().Match(p => p.AvatarId == local.Id).Sort(p => p.ID, Order.Descending).ExecuteAsync(token)).Select(p => p.PostId).ToList();
var pinnedPosts = await _dbEntities.Posts.Match(p => pinIds.Contains(p.ID) && !p.DeletedAt.HasValue).Match(VisibilityPolicy.IsPublic).ExecuteAsync(token);
return Json(await _mapper.Statuses(pinIds.Select(id => pinnedPosts.FirstOrDefault(p => p.ID == id)).Where(p => p != default).ToList(), MyId, token));
}
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));
}
[HttpPost("/api/v1/accounts/{id}/block"), Scope("write:blocks")]
public async Task<IActionResult> Block(string id, CancellationToken token)
{
var (local, remote) = await Find(id, token);
if (local == default && remote == default || local?.Id == MyId)
return NotFoundError();
await _relationships.Block(Me, local?.Uri ?? remote.ActorURI, id, token);
return Json(await Relationship(id, token));
}
[HttpPost("/api/v1/accounts/{id}/unblock"), Scope("write:blocks")]
public async Task<IActionResult> Unblock(string id, CancellationToken token)
{
var (local, remote) = await Find(id, token);
if (local == default && remote == default)
return NotFoundError();
await _relationships.Unblock(Me, local?.Uri ?? remote.ActorURI, token);
return Json(await Relationship(id, token));
}
[HttpPost("/api/v1/accounts/{id}/mute"), Scope("write:mutes")]
public async Task<IActionResult> Mute(string id, CancellationToken token)
{
var (local, remote) = await Find(id, token);
if (local == default && remote == default || local?.Id == MyId)
return NotFoundError();
var duration = Params.Int("duration") is { } seconds and > 0 ? TimeSpan.FromSeconds(seconds) : (TimeSpan?)null;
await _relationships.Mute(Me, local?.Uri ?? remote.ActorURI, id, Params.Bool("notifications") != false, duration, token);
return Json(await Relationship(id, token));
}
[HttpPost("/api/v1/accounts/{id}/unmute"), Scope("write:mutes")]
public async Task<IActionResult> Unmute(string id, CancellationToken token)
{
var (local, remote) = await Find(id, token);
if (local == default && remote == default)
return NotFoundError();
await _relationships.Unmute(Me, local?.Uri ?? remote.ActorURI, token);
return Json(await Relationship(id, token));
}
[HttpGet("/api/v1/blocks"), Scope("read:blocks")]
public async Task<IActionResult> Blocks(CancellationToken token)
{
var blocks = await Page.From(Params, Limit(40, 80)).Fetch(DB.Default.Find<Block>().Match(b => b.AvatarId == MyId), b => b.ID, token);
var accounts = await _mapper.Accounts(blocks.Select(b => b.TargetAccountId), token);
Link("/api/v1/blocks", blocks.LastOrDefault()?.ID, blocks.FirstOrDefault()?.ID);
return Json(blocks.Select(b => accounts.GetValueOrDefault(b.TargetAccountId)).Where(a => a != default).ToList());
}
[HttpGet("/api/v1/mutes"), Scope("read:mutes")]
public async Task<IActionResult> Mutes(CancellationToken token)
{
var mutes = await Page.From(Params, Limit(40, 80)).Fetch(DB.Default.Find<Mute>().Match(m => m.AvatarId == MyId), m => m.ID, token);
var accounts = await _mapper.Accounts(mutes.Select(m => m.TargetAccountId), token);
Link("/api/v1/mutes", mutes.LastOrDefault()?.ID, mutes.FirstOrDefault()?.ID);
return Json(mutes.Select(m => accounts.GetValueOrDefault(m.TargetAccountId)).Where(a => a != default).ToList());
}
[HttpGet("/api/v1/domain_blocks"), Scope("read:blocks")]
public async Task<IActionResult> DomainBlocks(CancellationToken token) =>
Json((await DB.Default.Find<AccountDomainBlock>().Match(b => b.AvatarId == MyId).Sort(b => b.Domain, Order.Ascending).ExecuteAsync(token)).Select(b => b.Domain).ToList());
[HttpPost("/api/v1/domain_blocks"), Scope("write:blocks")]
public async Task<IActionResult> BlockDomain(CancellationToken token)
{
await _relationships.BlockDomain(Me, Params.Get("domain"), token);
return Json(new { });
}
[HttpDelete("/api/v1/domain_blocks"), Scope("write:blocks")]
public async Task<IActionResult> UnblockDomain(CancellationToken token)
{
await _relationships.UnblockDomain(Me, Params.Get("domain"), token);
return Json(new { });
}
[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, IsCircle: false } 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);
var blocking = uri != default && await DB.Default.Find<Block>().Match(b => b.AvatarId == MyId && b.TargetActorURI == uri).ExecuteAnyAsync(token);
var mute = uri == default ? default : await DB.Default.Find<Mute>().Match(m => m.AvatarId == MyId && m.TargetActorURI == uri).ExecuteFirstAsync(token);
var blockedBy = local != default && await DB.Default.Find<Block>().Match(b => b.AvatarId == local.Id && b.TargetActorURI == Me.Uri).ExecuteAnyAsync(token);
var domainBlocked = remote != default && await DB.Default.Find<AccountDomainBlock>().Match(b => b.AvatarId == MyId && b.Domain == remote.Domain).ExecuteAnyAsync(token);
return new Relationship
{
Id = id,
Blocking = blocking,
BlockedBy = blockedBy,
Muting = mute != default && (mute.ExpiresAt == default || mute.ExpiresAt > DateTime.UtcNow),
MutingNotifications = mute?.HideNotifications == true,
DomainBlocking = domainBlocked,
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,91 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Domain.Media;
using PrivaPub.Models.Media;
using System.Globalization;
namespace PrivaPub.Api.Mastodon.Controllers
{
public class MediaController : MastodonController
{
const long UploadLimit = 100L * 1024 * 1024;
readonly IMediaService _media;
readonly IMediaProxy _proxy;
public MediaController(IMediaService media, IMediaProxy proxy)
{
_media = media;
_proxy = proxy;
}
[HttpPost("/api/v1/media"), HttpPost("/api/v2/media"), Scope("write:media"), RequestSizeLimit(UploadLimit),
RequestFormLimits(MultipartBodyLengthLimit = UploadLimit)]
public async Task<IActionResult> Upload(CancellationToken token)
{
if (!Request.HasFormContentType)
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: File can't be blank");
var form = await Request.ReadFormAsync(token);
var outcome = await _media.Upload(Me, form.Files["file"], form["description"], form["focus"], token);
return outcome.Ok ? Json(View(outcome.Attachment)) : Error(outcome.Status, outcome.Error);
}
[HttpGet("/api/v1/media/{id}"), Scope("write:media")]
public async Task<IActionResult> Get(string id, CancellationToken token)
{
var attachment = await DB.Default.Find<MediaAttachment>().Match(m => m.ID == id && m.OwnerAvatarId == MyId).ExecuteFirstAsync(token);
return attachment == default ? NotFoundError() : Json(View(attachment));
}
[HttpPut("/api/v1/media/{id}"), Scope("write:media")]
public async Task<IActionResult> Update(string id, CancellationToken token)
{
var attachment = await DB.Default.Find<MediaAttachment>().Match(m => m.ID == id && m.OwnerAvatarId == MyId).ExecuteFirstAsync(token);
if (attachment == default)
return NotFoundError();
if (Params.Has("description"))
attachment.Description = Params.Get("description")?.Trim() is { Length: > 0 } description ? description[..Math.Min(description.Length, 1500)] : default;
if (Params.Get("focus")?.Split(',') is [var x, var y]
&& float.TryParse(x, NumberStyles.Float, CultureInfo.InvariantCulture, out var fx) && float.TryParse(y, NumberStyles.Float, CultureInfo.InvariantCulture, out var fy))
attachment.Focus = new[] { Math.Clamp(fx, -1, 1), Math.Clamp(fy, -1, 1) };
await DB.Default.SaveAsync(attachment, token);
return Json(View(attachment));
}
[HttpGet("/media/proxy/{signature}/{encoded}"), AllowAnonymous, ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> Proxy(string signature, string encoded, CancellationToken token)
{
var (path, contentType) = await _proxy.Fetch(signature, encoded, token);
if (path == default)
return NotFound();
Response.Headers["X-Content-Type-Options"] = "nosniff";
Response.Headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
Response.Headers["Cache-Control"] = "public, max-age=604800";
return PhysicalFile(path, contentType);
}
object View(MediaAttachment attachment) => new
{
id = attachment.ID,
type = attachment.Kind,
url = _media.Url(attachment.FilePath),
preview_url = _media.Url(attachment.PreviewPath ?? attachment.FilePath),
remote_url = default(string),
text_url = default(string),
meta = new
{
original = attachment.Width.HasValue && attachment.Height > 0
? new { width = attachment.Width, height = attachment.Height, size = $"{attachment.Width}x{attachment.Height}", aspect = (double)attachment.Width / attachment.Height.Value }
: default,
focus = attachment.Focus is { Length: 2 } ? new { x = attachment.Focus[0], y = attachment.Focus[1] } : default
},
description = attachment.Description,
blurhash = attachment.Blurhash
};
}
}
@@ -0,0 +1,42 @@
using Microsoft.AspNetCore.Mvc;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Relationships;
namespace PrivaPub.Api.Mastodon.Controllers
{
public class ReportsController : MastodonController
{
readonly IReportService _reports;
readonly MastodonMapper _mapper;
public ReportsController(IReportService reports, MastodonMapper mapper)
{
_reports = reports;
_mapper = mapper;
}
[HttpPost("/api/v1/reports"), Scope("write:reports")]
public async Task<IActionResult> Create(CancellationToken token)
{
var report = await _reports.File(Me, Params.Get("account_id"), Params.List("status_ids"), Params.Get("comment"), Params.Get("category"),
Params.Bool("forward") == true, token);
if (report == default)
return NotFoundError();
return Json(new
{
id = report.ID,
action_taken = false,
action_taken_at = default(string),
category = report.Category,
comment = report.Comment ?? string.Empty,
forwarded = report.Forwarded,
created_at = MastodonJson.Time(report.CreatedAt),
status_ids = report.PostIds,
rule_ids = Array.Empty<string>(),
target_account = await _mapper.Account(report.TargetAccountId, token)
});
}
}
}
@@ -0,0 +1,137 @@
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, IsCircle: false })
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/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,328 @@
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;
public const int MaxPins = 5;
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.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,
MediaIds = Params.List("media_ids")
}, 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)
{
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"),
MediaIds = Params.Has("media_ids") ? Params.List("media_ids") : default
}, 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 async Task<IActionResult> Bookmark(string id, CancellationToken token)
{
var post = await Visible(id, token);
if (post == default)
return NotFoundError();
try
{
await DB.Default.SaveAsync(new Models.Social.Bookmark { AvatarId = MyId, PostId = post.ID }, token);
}
catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey)
{
}
return Json(await _mapper.Status(post, MyId, token));
}
[HttpPost("/api/v1/statuses/{id}/unbookmark"), Scope("write:bookmarks")]
public async Task<IActionResult> Unbookmark(string id, CancellationToken token)
{
await DB.Default.DeleteAsync<Models.Social.Bookmark>(b => b.AvatarId == MyId && b.PostId == id);
return await Unchanged(id, token);
}
[HttpGet("/api/v1/bookmarks"), Scope("read:bookmarks")]
public async Task<IActionResult> Bookmarks(CancellationToken token)
{
var bookmarks = await Page.From(Params, Limit()).Fetch(DB.Default.Find<Models.Social.Bookmark>().Match(b => b.AvatarId == MyId), b => b.ID, token);
var ids = bookmarks.Select(b => b.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 postId in ids.Where(posts.ContainsKey))
if (await VisibilityPolicy.CanSee(posts[postId], MyId, token))
visible.Add(posts[postId]);
Link("/api/v1/bookmarks", bookmarks.LastOrDefault()?.ID, bookmarks.FirstOrDefault()?.ID);
return Json(await _mapper.Statuses(visible, MyId, 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 async Task<IActionResult> Pin(string id, CancellationToken token)
{
var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.ReblogOfPostId == null)
.ExecuteFirstAsync(token);
if (post == default)
return NotFoundError();
if (post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Only public posts can be pinned");
if (await DB.Default.CountAsync<Models.Social.Pin>(p => p.AvatarId == MyId, token) >= MaxPins)
return Error(StatusCodes.Status422UnprocessableEntity, $"Validation failed: You can pin at most {MaxPins} posts");
try
{
await DB.Default.SaveAsync(new Models.Social.Pin { AvatarId = MyId, PostId = post.ID }, token);
}
catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey)
{
}
return Json(await _mapper.Status(post, MyId, token));
}
[HttpPost("/api/v1/statuses/{id}/unpin"), Scope("write:accounts")]
public async Task<IActionResult> Unpin(string id, CancellationToken token)
{
await DB.Default.DeleteAsync<Models.Social.Pin>(p => p.AvatarId == MyId && p.PostId == id);
return await 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,278 @@
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Domain.Media;
using PrivaPub.Federation.Actors;
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.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;
readonly IMediaProxy _proxy;
public MastodonMapper(DbEntities dbEntities, ILocalActorService localActors, IMediaProxy proxy = default)
{
_dbEntities = dbEntities;
_localActors = localActors;
_proxy = proxy;
}
string Proxied(string url) => _proxy == default ? url : _proxy.Wrap(url);
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 = Proxied(foreign.PictureURL) ?? MissingAvatar,
AvatarStatic = Proxied(foreign.PictureURL) ?? MissingAvatar,
Header = Proxied(foreign.ThumbnailURL) ?? MissingHeader,
HeaderStatic = Proxied(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 && g.Kind == GroupKind.Community).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 bookmarked = viewerId == default
? new HashSet<string>()
: (await DB.Default.Find<Bookmark>().Match(b => b.AvatarId == viewerId && ids.Contains(b.PostId)).ExecuteAsync(token)).Select(b => b.PostId).ToHashSet();
var pinned = (await DB.Default.Find<Pin>().Match(p => ids.Contains(p.PostId)).ExecuteAsync(token)).Select(p => p.PostId).ToHashSet();
var hidden = viewerId == default
? new HashSet<string>()
: await Domain.Relationships.Hidden.AuthorsHiddenFrom(viewerId, all.Select(p => p.ActorURI), forNotifications: false, token);
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),
Bookmarked = bookmarked.Contains(post.ID),
Pinned = pinned.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)
{
if (hidden.Contains(post.ActorURI) || post.ReblogOfPostId != default && originals.TryGetValue(post.ReblogOfPostId, out var hiddenOriginal)
&& hidden.Contains(hiddenOriginal.ActorURI))
continue;
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 };
}
MediaAttachment Media(PostMedia media) => new()
{
Id = media.AttachmentId ?? 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 ?? Proxied(media.RemoteURL),
PreviewUrl = media.PreviewURL ?? media.URL ?? Proxied(media.RemoteURL),
RemoteUrl = media.RemoteURL,
Description = media.Description,
Blurhash = media.Blurhash,
Meta = new
{
original = media.Width.HasValue && media.Height > 0
? new { width = media.Width, height = media.Height, size = $"{media.Width}x{media.Height}", aspect = (double)media.Width / media.Height.Value }
: default,
focus = media.Focus is { Length: 2 } ? new { x = media.Focus[0], y = media.Focus[1] } : default
}
};
}
}
@@ -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
};
}
}
@@ -1,10 +1,47 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.Extensions;
using PrivaPub.Models.Social;
namespace PrivaPub.Controllers.ClientToServer namespace PrivaPub.Controllers.ClientToServer
{ {
[ApiController, [ApiController,
Route("clientapi/moderator")] Route("clientapi/moderator"),
Authorize(Policy = Policies.IsModerator)]
public class ModeratorController : ControllerBase public class ModeratorController : ControllerBase
{ {
[HttpGet, Route("/clientapi/moderator/reports")]
public async Task<IActionResult> Reports([FromQuery] bool resolved, CancellationToken token) =>
Ok((await DB.Default.Find<Report>().Match(r => r.IsResolved == resolved).Sort(r => r.ID, Order.Descending).Limit(100).ExecuteAsync(token))
.Select(r => new
{
r.ID,
Reporter = r.ReporterAvatarId != default ? "local" : r.ReporterActorURI,
r.TargetAccountId,
r.TargetActorURI,
r.PostIds,
r.ObjectURIs,
r.Category,
r.Comment,
r.Forwarded,
r.IsResolved,
r.CreatedAt,
r.ResolvedAt
}));
[HttpPost, Route("/clientapi/moderator/reports/{id}/resolve")]
public async Task<IActionResult> Resolve(string id, CancellationToken token)
{
var result = await DB.Default.Update<Report>().MatchID(id)
.Modify(r => r.IsResolved, true)
.Modify(r => r.ResolvedAt, DateTime.UtcNow)
.Modify(r => r.ResolvedBy, User.GetUserId())
.ExecuteAsync(token);
return result.MatchedCount == 0 ? NotFound() : Ok();
}
} }
} }
@@ -32,6 +32,14 @@ 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));
[HttpGet, Route("/clientapi/post/nearby")]
public async Task<IActionResult> Nearby([FromQuery] string avatarId, [FromQuery] double latitude, [FromQuery] double longitude, CancellationToken token) =>
Answer(await _postsService.Nearby(User.GetUserId(), avatarId, latitude, longitude, 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));
@@ -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);
}
}
+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, IsCircle: false, 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();
}
}
+91
View File
@@ -0,0 +1,91 @@
namespace PrivaPub.Domain.Media
{
public static class Blurhash
{
const string Characters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~";
public static string Encode(byte[] rgb, int width, int height, int componentsX = 4, int componentsY = 3)
{
var factors = new double[componentsY, componentsX, 3];
for (var j = 0; j < componentsY; j++)
for (var i = 0; i < componentsX; i++)
{
var normalisation = i == 0 && j == 0 ? 1 : 2;
double r = 0, g = 0, b = 0;
for (var y = 0; y < height; y++)
for (var x = 0; x < width; x++)
{
var basis = normalisation * Math.Cos(Math.PI * i * x / width) * Math.Cos(Math.PI * j * y / height);
var offset = (y * width + x) * 3;
r += basis * ToLinear(rgb[offset]);
g += basis * ToLinear(rgb[offset + 1]);
b += basis * ToLinear(rgb[offset + 2]);
}
var scale = 1.0 / (width * height);
factors[j, i, 0] = r * scale;
factors[j, i, 1] = g * scale;
factors[j, i, 2] = b * scale;
}
var hash = new System.Text.StringBuilder();
hash.Append(Base83((componentsX - 1) + (componentsY - 1) * 9, 1));
double maximum = 0;
for (var j = 0; j < componentsY; j++)
for (var i = 0; i < componentsX; i++)
if (i != 0 || j != 0)
for (var c = 0; c < 3; c++)
maximum = Math.Max(maximum, Math.Abs(factors[j, i, c]));
double maximumValue;
if (componentsX * componentsY > 1)
{
var quantised = (int)Math.Max(0, Math.Min(82, Math.Floor(maximum * 166 - 0.5)));
maximumValue = (quantised + 1) / 166.0;
hash.Append(Base83(quantised, 1));
}
else
{
maximumValue = 1;
hash.Append(Base83(0, 1));
}
var dc = (ToSrgb(factors[0, 0, 0]) << 16) + (ToSrgb(factors[0, 0, 1]) << 8) + ToSrgb(factors[0, 0, 2]);
hash.Append(Base83(dc, 4));
for (var j = 0; j < componentsY; j++)
for (var i = 0; i < componentsX; i++)
{
if (i == 0 && j == 0)
continue;
int Quantise(double value) => (int)Math.Max(0, Math.Min(18, Math.Floor(SignPow(value / maximumValue, 0.5) * 9 + 9.5)));
var ac = Quantise(factors[j, i, 0]) * 19 * 19 + Quantise(factors[j, i, 1]) * 19 + Quantise(factors[j, i, 2]);
hash.Append(Base83(ac, 2));
}
return hash.ToString();
}
static double ToLinear(byte value)
{
var v = value / 255.0;
return v <= 0.04045 ? v / 12.92 : Math.Pow((v + 0.055) / 1.055, 2.4);
}
static int ToSrgb(double value)
{
var v = Math.Max(0, Math.Min(1, value));
return v <= 0.0031308 ? (int)(v * 12.92 * 255 + 0.5) : (int)((1.055 * Math.Pow(v, 1 / 2.4) - 0.055) * 255 + 0.5);
}
static double SignPow(double value, double exponent) => Math.CopySign(Math.Pow(Math.Abs(value), exponent), value);
static string Base83(int value, int length)
{
var result = new char[length];
for (var i = 1; i <= length; i++)
{
var digit = value / (int)Math.Pow(83, length - i) % 83;
result[i - 1] = Characters[digit];
}
return new string(result);
}
}
}
+13
View File
@@ -0,0 +1,13 @@
namespace PrivaPub.Domain.Media
{
public class MediaOptions
{
public string Root { get; set; }
public long MaxImageBytes { get; set; } = 16 * 1024 * 1024;
public long MaxVideoBytes { get; set; } = 99 * 1024 * 1024;
public long MaxProxiedBytes { get; set; } = 40 * 1024 * 1024;
public long ProxyCacheBytes { get; set; } = 5L * 1024 * 1024 * 1024;
public int MaxImageSide { get; set; } = 4096;
public int PreviewSide { get; set; } = 640;
}
}
+160
View File
@@ -0,0 +1,160 @@
using Microsoft.Extensions.Options;
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Models.Media;
using System.Security.Cryptography;
using System.Text;
namespace PrivaPub.Domain.Media
{
public interface IMediaProxy
{
string Wrap(string remoteUrl);
Task<(string Path, string ContentType)> Fetch(string signature, string encodedUrl, CancellationToken token);
}
public class MediaProxy : IMediaProxy
{
readonly ILocalActorService _localActors;
readonly IFederationHttp _http;
readonly IMediaService _media;
readonly IOptionsMonitor<MediaOptions> _options;
byte[] _key;
public MediaProxy(ILocalActorService localActors, IFederationHttp http, IMediaService media, IOptionsMonitor<MediaOptions> options)
{
_localActors = localActors;
_http = http;
_media = media;
_options = options;
}
byte[] Key => _key ??= LoadKey();
public string Wrap(string remoteUrl)
{
if (string.IsNullOrEmpty(remoteUrl) || remoteUrl.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
return remoteUrl;
var encoded = Base64Url(Encoding.UTF8.GetBytes(remoteUrl));
return $"{_localActors.BaseAddress}/media/proxy/{Sign(remoteUrl)}/{encoded}";
}
public async Task<(string Path, string ContentType)> Fetch(string signature, string encodedUrl, CancellationToken token)
{
string url;
try
{
url = Encoding.UTF8.GetString(FromBase64Url(encodedUrl));
}
catch (FormatException)
{
return default;
}
if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(signature ?? string.Empty), Encoding.ASCII.GetBytes(Sign(url))))
return default;
var name = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(url)));
var directory = System.IO.Path.Combine(_media.ProxyRoot, name[..2]);
var path = System.IO.Path.Combine(directory, name);
var typePath = path + ".type";
if (File.Exists(path) && File.Exists(typePath))
{
File.SetLastWriteTimeUtc(path, DateTime.UtcNow);
return (path, await File.ReadAllTextAsync(typePath, token));
}
var (bytes, contentType) = await _http.GetMedia(url, _options.CurrentValue.MaxProxiedBytes, token);
if (bytes == default)
return default;
Directory.CreateDirectory(directory);
await File.WriteAllBytesAsync(path, bytes, token);
await File.WriteAllTextAsync(typePath, contentType, token);
return (path, contentType);
}
string Sign(string url) => Base64Url(HMACSHA256.HashData(Key, Encoding.UTF8.GetBytes(url))[..16]);
static byte[] LoadKey()
{
var secret = DB.Default.Find<MediaSecret>().ExecuteFirstAsync().GetAwaiter().GetResult();
if (secret == default)
{
secret = new MediaSecret { Key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) };
DB.Default.SaveAsync(secret).GetAwaiter().GetResult();
secret = DB.Default.Find<MediaSecret>().ExecuteFirstAsync().GetAwaiter().GetResult();
}
return Convert.FromBase64String(secret.Key);
}
static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
static byte[] FromBase64Url(string value)
{
var padded = value.Replace('-', '+').Replace('_', '/');
return Convert.FromBase64String(padded + new string('=', (4 - padded.Length % 4) % 4));
}
}
public class MediaJanitor : BackgroundService
{
static readonly TimeSpan Interval = TimeSpan.FromHours(1);
static readonly TimeSpan UnattachedLifetime = TimeSpan.FromDays(1);
readonly IMediaService _media;
readonly IOptionsMonitor<MediaOptions> _options;
readonly ILogger<MediaJanitor> _logger;
public MediaJanitor(IMediaService media, IOptionsMonitor<MediaOptions> options, ILogger<MediaJanitor> logger)
{
_media = media;
_options = options;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(Interval, stoppingToken);
var cutoff = DateTime.UtcNow - UnattachedLifetime;
foreach (var stale in await DB.Default.Find<MediaAttachment>().Match(m => m.PostId == null && m.CreatedAt < cutoff).Limit(500).ExecuteAsync(stoppingToken))
await _media.Delete(stale);
TrimProxyCache();
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "{Service} pass failed", nameof(MediaJanitor));
}
}
}
void TrimProxyCache()
{
var directory = new DirectoryInfo(_media.ProxyRoot);
if (!directory.Exists)
return;
var files = directory.EnumerateFiles("*", SearchOption.AllDirectories).Where(f => f.Extension != ".type").OrderBy(f => f.LastWriteTimeUtc).ToList();
var total = files.Sum(f => f.Length);
foreach (var file in files)
{
if (total <= _options.CurrentValue.ProxyCacheBytes)
break;
total -= file.Length;
file.Delete();
var type = new FileInfo(file.FullName + ".type");
if (type.Exists)
type.Delete();
}
}
}
}
+263
View File
@@ -0,0 +1,263 @@
using FFMpegCore;
using Microsoft.Extensions.Options;
using MongoDB.Entities;
using NetVips;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Media;
using System.Globalization;
using System.Security.Cryptography;
namespace PrivaPub.Domain.Media
{
public sealed record MediaOutcome(MediaAttachment Attachment, int Status = StatusCodes.Status200OK, string Error = default)
{
public bool Ok => Error == default;
public static MediaOutcome Fail(int status, string error) => new(default, status, error);
}
public sealed record ProcessedImage(byte[] Bytes, string Extension, string ContentType, int Width, int Height, byte[] Preview, string Blurhash);
public interface IMediaService
{
string Root { get; }
string ProxyRoot { get; }
string Url(string relativePath);
Task<MediaOutcome> Upload(LocalActor owner, IFormFile file, string description, string focus, CancellationToken token);
Task<string> ProfileImage(IFormFile file, int width, int height, CancellationToken token);
Task Delete(MediaAttachment attachment);
}
public class MediaService : IMediaService
{
static readonly string[] ImageTypes = { "image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "image/heif", "image/avif" };
static readonly Dictionary<string, string> AvTypes = new()
{
["video/mp4"] = "mp4", ["video/quicktime"] = "mp4", ["video/webm"] = "webm",
["audio/mpeg"] = "mp3", ["audio/mp3"] = "mp3", ["audio/ogg"] = "ogg", ["audio/wav"] = "wav", ["audio/x-wav"] = "wav",
["audio/mp4"] = "m4a", ["audio/x-m4a"] = "m4a", ["audio/flac"] = "flac", ["audio/webm"] = "webm"
};
readonly IOptionsMonitor<MediaOptions> _options;
readonly ILocalActorService _localActors;
readonly IWebHostEnvironment _environment;
readonly ILogger<MediaService> _logger;
public MediaService(IOptionsMonitor<MediaOptions> options, ILocalActorService localActors, IWebHostEnvironment environment, ILogger<MediaService> logger)
{
_options = options;
_localActors = localActors;
_environment = environment;
_logger = logger;
}
public string Root => Path.GetFullPath(_options.CurrentValue.Root ?? Path.Combine(_environment.ContentRootPath, "media-store"));
public string ProxyRoot => Root.TrimEnd(Path.DirectorySeparatorChar) + "-proxy";
public string Url(string relativePath) => relativePath == default ? default : $"{_localActors.BaseAddress}/media/files/{relativePath.Replace('\\', '/')}";
public async Task<MediaOutcome> Upload(LocalActor owner, IFormFile file, string description, string focus, CancellationToken token)
{
if (file == default || file.Length == 0)
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File can't be blank");
var options = _options.CurrentValue;
var contentType = file.ContentType?.Split(';')[0].Trim().ToLowerInvariant();
var attachment = new MediaAttachment
{
OwnerAvatarId = owner.Id,
Description = Clean(description, 1500),
Focus = Focus(focus)
};
if (ImageTypes.Contains(contentType))
{
if (file.Length > options.MaxImageBytes)
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File is too big");
await using var stream = file.OpenReadStream();
using var buffer = new MemoryStream();
await stream.CopyToAsync(buffer, token);
ProcessedImage processed;
try
{
processed = ProcessImage(buffer.ToArray(), options.MaxImageSide, options.PreviewSide, contentType == "image/gif");
}
catch (VipsException ex)
{
_logger.LogInformation("Refused an image upload: {Error}", ex.Message);
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file is not a readable image");
}
attachment.Kind = contentType == "image/gif" ? "gifv" : "image";
attachment.ContentType = processed.ContentType;
attachment.FilePath = await Save(processed.Bytes, processed.Extension, token);
attachment.PreviewPath = await Save(processed.Preview, "jpg", token);
attachment.Width = processed.Width;
attachment.Height = processed.Height;
attachment.Blurhash = processed.Blurhash;
attachment.Size = processed.Bytes.Length;
}
else if (contentType != default && AvTypes.TryGetValue(contentType, out var extension))
{
if (file.Length > options.MaxVideoBytes)
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File is too big");
var outcome = await ProcessAv(file, extension, contentType, attachment, token);
if (!outcome.Ok)
return outcome;
}
else
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File type is not supported");
await DB.Default.SaveAsync(attachment, token);
return new MediaOutcome(attachment);
}
public async Task<string> ProfileImage(IFormFile file, int width, int height, CancellationToken token)
{
if (file == default || file.Length == 0 || file.Length > _options.CurrentValue.MaxImageBytes)
return default;
await using var stream = file.OpenReadStream();
using var buffer = new MemoryStream();
await stream.CopyToAsync(buffer, token);
try
{
using var image = Image.ThumbnailBuffer(buffer.ToArray(), width, height: height, crop: Enums.Interesting.Centre, size: Enums.Size.Down);
using var flat = Flatten(image);
return Url(await Save(flat.WriteToBuffer(".jpg[Q=85,keep=none]"), "jpg", token));
}
catch (VipsException)
{
return default;
}
}
public Task Delete(MediaAttachment attachment)
{
foreach (var path in new[] { attachment.FilePath, attachment.PreviewPath }.Where(p => p != default))
{
var full = Path.Combine(Root, path);
if (File.Exists(full))
File.Delete(full);
}
return DB.Default.DeleteAsync<MediaAttachment>(attachment.ID);
}
public static ProcessedImage ProcessImage(byte[] input, int maxSide, int previewSide, bool animated)
{
using var loaded = animated ? Image.NewFromBuffer(input, kwargs: new VOption { { "n", -1 } }) : Image.NewFromBuffer(input);
using var rotated = animated ? loaded.Copy() : loaded.Autorot();
var pageHeight = animated && rotated.Contains("page-height") ? (int)rotated.Get("page-height") : rotated.Height;
var longest = Math.Max(rotated.Width, pageHeight);
byte[] bytes;
string extension, contentType;
int width, height;
if (animated)
{
bytes = rotated.WriteToBuffer(".gif");
(extension, contentType, width, height) = ("gif", "image/gif", rotated.Width, pageHeight);
}
else
{
using var resized = longest > maxSide ? rotated.ThumbnailImage(maxSide, height: maxSide, size: Enums.Size.Down) : rotated.Copy();
var keepsAlpha = resized.HasAlpha();
bytes = keepsAlpha ? resized.WriteToBuffer(".png[keep=none]") : resized.WriteToBuffer(".jpg[Q=88,keep=none]");
(extension, contentType, width, height) = keepsAlpha ? ("png", "image/png", resized.Width, resized.Height) : ("jpg", "image/jpeg", resized.Width, resized.Height);
}
using var firstFrame = animated ? rotated.Crop(0, 0, rotated.Width, pageHeight) : rotated.Copy();
using var preview = firstFrame.ThumbnailImage(previewSide, height: previewSide, size: Enums.Size.Down);
using var previewFlat = Flatten(preview);
var previewBytes = previewFlat.WriteToBuffer(".jpg[Q=80,keep=none]");
using var tiny = firstFrame.ThumbnailImage(32, height: 32);
using var tinyFlat = Flatten(tiny);
var pixels = tinyFlat.WriteToMemory();
return new ProcessedImage(bytes, extension, contentType, width, height, previewBytes, Blurhash.Encode(pixels, tinyFlat.Width, tinyFlat.Height));
}
static Image Flatten(Image image)
{
var srgb = image.Interpretation == Enums.Interpretation.Srgb ? image.Copy() : image.Colourspace(Enums.Interpretation.Srgb);
var flat = srgb.HasAlpha() ? srgb.Flatten(background: new double[] { 255, 255, 255 }) : srgb.Copy();
srgb.Dispose();
var bands = flat.Bands > 3 ? flat.ExtractBand(0, n: 3) : flat.Bands == 1 ? flat.Bandjoin(flat, flat) : flat.Copy();
flat.Dispose();
using var cast = bands;
return bands.Format == Enums.BandFormat.Uchar ? bands.Copy() : bands.Cast(Enums.BandFormat.Uchar);
}
async Task<MediaOutcome> ProcessAv(IFormFile file, string extension, string contentType, MediaAttachment attachment, CancellationToken token)
{
var temp = Path.Combine(Path.GetTempPath(), $"privapub-{Guid.NewGuid():N}");
var input = temp + ".in";
var output = temp + "." + extension;
try
{
await using (var target = File.Create(input))
await file.CopyToAsync(target, token);
var probe = await FFProbe.AnalyseAsync(input, cancellationToken: token);
var isVideo = contentType.StartsWith("video/") && probe.PrimaryVideoStream != default;
await FFMpegArguments.FromFileInput(input)
.OutputToFile(output, true, o => o.WithCustomArgument("-map 0 -map_metadata -1 -map_chapters -1 -c copy" + (extension is "mp4" or "m4a" ? " -movflags +faststart" : string.Empty)))
.CancellableThrough(token)
.ProcessAsynchronously();
attachment.Kind = isVideo ? "video" : "audio";
attachment.ContentType = extension switch { "mp4" => "video/mp4", "webm" => isVideo ? "video/webm" : "audio/webm", "m4a" => "audio/mp4", "mp3" => "audio/mpeg", _ => contentType };
attachment.FilePath = await Save(await File.ReadAllBytesAsync(output, token), extension, token);
attachment.Size = new FileInfo(output).Length;
if (isVideo)
{
attachment.Width = probe.PrimaryVideoStream.Width;
attachment.Height = probe.PrimaryVideoStream.Height;
var frame = temp + ".png";
await FFMpeg.SnapshotAsync(input, frame, captureTime: TimeSpan.FromSeconds(Math.Min(1, probe.Duration.TotalSeconds / 2)));
var still = ProcessImage(await File.ReadAllBytesAsync(frame, token), 640, _options.CurrentValue.PreviewSide, false);
attachment.PreviewPath = await Save(still.Preview, "jpg", token);
attachment.Blurhash = still.Blurhash;
File.Delete(frame);
}
return new MediaOutcome(attachment);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogInformation(ex, "Refused an audio or video upload");
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file could not be processed");
}
finally
{
foreach (var path in new[] { input, output })
if (File.Exists(path))
File.Delete(path);
}
}
async Task<string> Save(byte[] bytes, string extension, CancellationToken token)
{
var now = DateTime.UtcNow;
var relative = Path.Combine(now.ToString("yyyy", CultureInfo.InvariantCulture), now.ToString("MM", CultureInfo.InvariantCulture),
$"{Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16))}.{extension}");
var full = Path.Combine(Root, relative);
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
await File.WriteAllBytesAsync(full, bytes, token);
return relative.Replace('\\', '/');
}
static string Clean(string value, int max) =>
string.IsNullOrWhiteSpace(value) ? default : value.Trim().Length <= max ? value.Trim() : value.Trim()[..max];
static float[] Focus(string focus)
{
var parts = focus?.Split(',');
if (parts is not { Length: 2 }
|| !float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x)
|| !float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y))
return default;
return new[] { Math.Clamp(x, -1, 1), Math.Clamp(y, -1, 1) };
}
}
}
@@ -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
};
}
}
}
@@ -0,0 +1,169 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Domain.Social;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Social;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
namespace PrivaPub.Domain.Relationships
{
public interface IRelationshipService
{
Task Block(LocalActor me, string targetActorUri, string targetAccountId, CancellationToken token);
Task Unblock(LocalActor me, string targetActorUri, CancellationToken token);
Task Mute(LocalActor me, string targetActorUri, string targetAccountId, bool hideNotifications, TimeSpan? duration, CancellationToken token);
Task Unmute(LocalActor me, string targetActorUri, CancellationToken token);
Task BlockDomain(LocalActor me, string domain, CancellationToken token);
Task UnblockDomain(LocalActor me, string domain, CancellationToken token);
}
public class RelationshipService : IRelationshipService
{
readonly DbEntities _dbEntities;
readonly IFollowService _follows;
readonly IDeliveryService _delivery;
public RelationshipService(DbEntities dbEntities, IFollowService follows, IDeliveryService delivery)
{
_dbEntities = dbEntities;
_follows = follows;
_delivery = delivery;
}
public async Task Block(LocalActor me, string targetActorUri, string targetAccountId, CancellationToken token)
{
try
{
await DB.Default.SaveAsync(new Block { AvatarId = me.Id, TargetAccountId = targetAccountId, TargetActorURI = targetActorUri }, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
}
await _follows.UnfollowAs(me, targetActorUri, token);
var follower = await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id && f.ActorURI == targetActorUri).ExecuteFirstAsync(token);
if (follower != default)
{
await DB.Default.DeleteAsync<Follower>(follower.ID);
if (!targetActorUri.StartsWith(me.BaseAddress + "/", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(follower.FollowActivityURI))
{
var reject = new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = me.ActivityUri($"reject-{follower.ID}-{DateTime.UtcNow.Ticks}"),
["type"] = "Reject",
["actor"] = me.Uri,
["object"] = new JsonObject { ["id"] = follower.FollowActivityURI, ["type"] = "Follow", ["actor"] = targetActorUri, ["object"] = me.Uri }
};
await _delivery.Enqueue(me, new[] { follower.InboxURL }, reject, token);
}
else
await DB.Default.DeleteAsync<Following>(f => f.TargetActorURI == me.Uri && f.AvatarId == targetAccountId);
}
await Forget(me, targetAccountId, token);
}
public async Task Unblock(LocalActor me, string targetActorUri, CancellationToken token) =>
await DB.Default.DeleteAsync<Block>(b => b.AvatarId == me.Id && b.TargetActorURI == targetActorUri);
public async Task Mute(LocalActor me, string targetActorUri, string targetAccountId, bool hideNotifications, TimeSpan? duration, CancellationToken token)
{
await DB.Default.Update<Mute>()
.Match(m => m.AvatarId == me.Id && m.TargetActorURI == targetActorUri)
.Modify(m => m.TargetAccountId, targetAccountId)
.Modify(m => m.HideNotifications, hideNotifications)
.Modify(m => m.ExpiresAt, duration.HasValue && duration > TimeSpan.Zero ? DateTime.UtcNow + duration : null)
.Modify(b => b.SetOnInsert(m => m.CreatedAt, DateTime.UtcNow))
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
await Forget(me, targetAccountId, token);
}
public async Task Unmute(LocalActor me, string targetActorUri, CancellationToken token) =>
await DB.Default.DeleteAsync<Mute>(m => m.AvatarId == me.Id && m.TargetActorURI == targetActorUri);
public async Task BlockDomain(LocalActor me, string domain, CancellationToken token)
{
domain = domain?.Trim().ToLowerInvariant();
if (string.IsNullOrEmpty(domain) || Uri.CheckHostName(domain) != UriHostNameType.Dns)
return;
try
{
await DB.Default.SaveAsync(new AccountDomainBlock { AvatarId = me.Id, Domain = domain }, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
}
foreach (var following in await _dbEntities.Followings.Match(f => f.AvatarId == me.Id && !f.TargetIsLocal).ExecuteAsync(token))
if (Hidden.HostMatches(following.TargetActorURI, domain))
await _follows.UnfollowAs(me, following.TargetActorURI, token);
foreach (var follower in await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id).ExecuteAsync(token))
if (Hidden.HostMatches(follower.ActorURI, domain))
await DB.Default.DeleteAsync<Follower>(follower.ID);
}
public async Task UnblockDomain(LocalActor me, string domain, CancellationToken token)
{
domain = domain?.Trim().ToLowerInvariant();
await DB.Default.DeleteAsync<AccountDomainBlock>(b => b.AvatarId == me.Id && b.Domain == domain);
}
static async Task Forget(LocalActor me, string targetAccountId, CancellationToken token)
{
if (string.IsNullOrEmpty(targetAccountId))
return;
await DB.Default.DeleteAsync<TimelineEntry>(e => e.AvatarId == me.Id && e.AuthorAccountId == targetAccountId);
await DB.Default.DeleteAsync<Notification>(n => n.AvatarId == me.Id && n.FromAccountId == targetAccountId);
}
}
public static class Hidden
{
public static bool HostMatches(string actorUri, string domain) =>
Uri.TryCreate(actorUri, UriKind.Absolute, out var uri)
&& (uri.Host.Equals(domain, StringComparison.OrdinalIgnoreCase) || uri.Host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase));
public static async Task<HashSet<string>> AuthorsHiddenFrom(string avatarId, IEnumerable<string> actorUris, bool forNotifications, CancellationToken token)
{
var uris = actorUris.Where(u => !string.IsNullOrEmpty(u)).Distinct().ToList();
var hidden = new HashSet<string>(StringComparer.Ordinal);
if (string.IsNullOrEmpty(avatarId) || uris.Count == 0)
return hidden;
var now = DateTime.UtcNow;
foreach (var block in await DB.Default.Find<Block>().Match(b => b.AvatarId == avatarId && uris.Contains(b.TargetActorURI)).ExecuteAsync(token))
hidden.Add(block.TargetActorURI);
foreach (var mute in await DB.Default.Find<Mute>().Match(m => m.AvatarId == avatarId && uris.Contains(m.TargetActorURI)).ExecuteAsync(token))
if ((mute.ExpiresAt == null || mute.ExpiresAt > now) && (!forNotifications || mute.HideNotifications))
hidden.Add(mute.TargetActorURI);
var domains = await DB.Default.Find<AccountDomainBlock>().Match(b => b.AvatarId == avatarId).ExecuteAsync(token);
foreach (var uri in uris)
if (domains.Any(d => HostMatches(uri, d.Domain)))
hidden.Add(uri);
return hidden;
}
public static async Task<HashSet<string>> RecipientsHiding(IEnumerable<string> avatarIds, string actorUri, CancellationToken token)
{
var ids = avatarIds.Distinct().ToList();
var hiding = new HashSet<string>(StringComparer.Ordinal);
if (ids.Count == 0 || string.IsNullOrEmpty(actorUri))
return hiding;
var now = DateTime.UtcNow;
foreach (var block in await DB.Default.Find<Block>().Match(b => ids.Contains(b.AvatarId) && b.TargetActorURI == actorUri).ExecuteAsync(token))
hiding.Add(block.AvatarId);
foreach (var mute in await DB.Default.Find<Mute>().Match(m => ids.Contains(m.AvatarId) && m.TargetActorURI == actorUri).ExecuteAsync(token))
if (mute.ExpiresAt == null || mute.ExpiresAt > now)
hiding.Add(mute.AvatarId);
foreach (var block in await DB.Default.Find<AccountDomainBlock>().Match(b => ids.Contains(b.AvatarId)).ExecuteAsync(token))
if (HostMatches(actorUri, block.Domain))
hiding.Add(block.AvatarId);
return hiding;
}
}
}
@@ -0,0 +1,75 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
namespace PrivaPub.Domain.Relationships
{
public interface IReportService
{
Task<Report> File(LocalActor reporter, string targetAccountId, IReadOnlyList<string> postIds, string comment, string category, bool forward,
CancellationToken token);
}
public class ReportService : IReportService
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
public ReportService(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery)
{
_dbEntities = dbEntities;
_localActors = localActors;
_delivery = delivery;
}
public async Task<Report> File(LocalActor reporter, string targetAccountId, IReadOnlyList<string> postIds, string comment, string category,
bool forward, CancellationToken token)
{
var localTarget = await _dbEntities.Avatars.MatchID(targetAccountId).ExecuteFirstAsync(token);
var remoteTarget = localTarget == default ? await _dbEntities.ForeignAvatars.MatchID(targetAccountId).ExecuteFirstAsync(token) : default;
if (localTarget == default && remoteTarget == default)
return default;
var targetUri = localTarget != default ? _localActors.FromAvatar(localTarget).Uri : remoteTarget.ActorURI;
var posts = postIds.Count == 0
? new List<Models.Post.Post>()
: await _dbEntities.Posts.Match(p => postIds.Contains(p.ID) && p.ActorURI == targetUri).ExecuteAsync(token);
var report = new Report
{
ReporterAvatarId = reporter.Id,
TargetAccountId = targetAccountId,
TargetActorURI = targetUri,
PostIds = posts.Select(p => p.ID).ToList(),
ObjectURIs = posts.Select(p => p.ObjectURI).ToList(),
Comment = comment?.Trim(),
Category = category is "spam" or "legal" or "violation" ? category : "other",
Forwarded = forward && remoteTarget != default
};
await DB.Default.SaveAsync(report, token);
if (report.Forwarded)
{
var instance = await _localActors.GetInstanceActor(token);
var flag = new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = instance.ActivityUri($"flag-{report.ID}"),
["type"] = "Flag",
["actor"] = instance.Uri,
["content"] = report.Comment ?? string.Empty,
["object"] = new JsonArray(report.ObjectURIs.Prepend(targetUri).Select(u => (JsonNode)u).ToArray())
};
await _delivery.Enqueue(instance, new[] { remoteTarget.SharedInboxURL ?? remoteTarget.InboxURL }, flag, token);
}
return report;
}
}
}
+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.IsCircle || 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
};
}
}
+34
View File
@@ -0,0 +1,34 @@
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;
if ((await Relationships.Hidden.AuthorsHiddenFrom(avatarId, new[] { fromActorUri }, forNotifications: true, token)).Count > 0)
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)
{
}
}
}
}
+597
View File
@@ -0,0 +1,597 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Domain.Content;
using PrivaPub.Domain.Media;
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.Media;
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 IReadOnlyList<string> MediaIds { get; init; }
public double? Latitude { get; init; }
public double? Longitude { get; init; }
public double? RangeKm { get; init; }
}
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;
readonly IMediaService _media;
readonly IGroupDistributor _groups;
public StatusService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IContentRenderer content, IOutboxPublisher outbox, IFanout fanout, IMediaService media, IGroupDistributor groups)
{
_media = media;
_groups = groups;
_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)
{
var media = await Media(author, draft.MediaIds, default, token);
if (media == default)
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid");
if (string.IsNullOrWhiteSpace(draft.Text) && media.Count == 0)
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 || !await MayPost(groupEntity, author, token))
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 located = draft.Latitude.HasValue || draft.Longitude.HasValue;
if (located && (draft.Latitude is not (>= -90 and <= 90) || draft.Longitude is not (>= -180 and <= 180) || group != default
|| draft.Visibility == PostVisibility.Direct))
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: A located post needs a valid position and no group or recipients");
var rendered = draft.PlainText ? await _content.PlainText(draft.Text ?? string.Empty, token) : await _content.Markdown(draft.Text ?? string.Empty, token);
string audienceUri = default;
if (group == default && !located && draft.Visibility is PostVisibility.Public or PostVisibility.Unlisted)
foreach (var mention in rendered.Mentions)
{
if (mention.IsLocal)
{
var mentioned = await _dbEntities.Groups.MatchID(mention.AccountId).ExecuteFirstAsync(token);
if (mentioned is { DeletionAt: null, Kind: GroupKind.Community } && await MayPost(mentioned, author, token))
{
group = _localActors.FromGroup(mentioned);
break;
}
}
else if (await _dbEntities.ForeignAvatars.MatchID(mention.AccountId).ExecuteFirstAsync(token) is { AvatarType: Models.User.AvatarType.Group } remoteGroup)
{
audienceUri = remoteGroup.ActorURI;
break;
}
}
var isLocalOnly = located;
var visibility = located ? PostVisibility.LocalGeo
: group is { IsCircle: true } ? 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(),
Media = media.Select(ToPostMedia).ToList(),
AudienceURI = audienceUri,
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);
if (located)
{
post.Geo = new Coordinates2D(Math.Round(draft.Longitude.Value, 2), Math.Round(draft.Latitude.Value, 2));
post.RangeKm = (float)Math.Clamp(draft.RangeKm ?? 5, 1, 50);
}
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);
await Attach(media, post.ID, 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 (create != default && group is { IsCircle: false } && visibility is PostVisibility.Public or PostVisibility.Unlisted)
await _groups.Announce(group, create, post.ObjectURI, isNewPost: string.IsNullOrEmpty(post.InReplyToURI), 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");
var media = draft.MediaIds == default ? default : await Media(author, draft.MediaIds, post.ID, token);
if (draft.MediaIds != default && media == default)
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid");
if (string.IsNullOrWhiteSpace(draft.Text) && (media ?? new List<MediaAttachment>()).Count == 0 && post.Media.Count == 0)
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 ?? string.Empty, token) : await _content.Markdown(draft.Text ?? string.Empty, token);
if (media != default)
{
post.Media = media.Select(ToPostMedia).ToList();
await Attach(media, post.ID, 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);
if (group is { IsCircle: false })
await _groups.Announce(group, update, post.ObjectURI, isNewPost: false, 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);
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
if (group is { IsCircle: false })
await _groups.Announce(group, delete, post.ObjectURI, isNewPost: false, 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;
}
async Task<bool> MayPost(Models.Group.Group group, LocalActor author, CancellationToken token)
{
var member = group.Members.FirstOrDefault(m => !m.IsForeign && m.AvatarId == author.Id);
if (group.Kind == GroupKind.Circle)
return member != default;
return group.PostingPolicy switch
{
PostingPolicy.Anyone => true,
PostingPolicy.Moderators => member?.Role is GroupRole.Owner or GroupRole.Moderator,
_ => member != default || await _dbEntities.Followings
.Match(f => f.AvatarId == author.Id && f.TargetAccountId == group.ID && f.State == FollowState.Accepted).ExecuteAnyAsync(token)
};
}
async Task<List<MediaAttachment>> Media(LocalActor author, IReadOnlyList<string> ids, string postId, CancellationToken token)
{
if (ids == default || ids.Count == 0)
return new List<MediaAttachment>();
if (ids.Count > 4)
return default;
var wanted = ids.Distinct().ToList();
var found = await DB.Default.Find<MediaAttachment>()
.Match(m => wanted.Contains(m.ID) && m.OwnerAvatarId == author.Id && (m.PostId == null || m.PostId == postId))
.ExecuteAsync(token);
return found.Count == wanted.Count ? wanted.Select(id => found.First(m => m.ID == id)).ToList() : default;
}
static async Task Attach(IEnumerable<MediaAttachment> media, string postId, CancellationToken token)
{
var ids = media.Select(m => m.ID).ToList();
if (ids.Count > 0)
await DB.Default.Update<MediaAttachment>().Match(m => ids.Contains(m.ID))
.Modify(m => m.PostId, postId).Modify(m => m.AttachedAt, DateTime.UtcNow).ExecuteAsync(token);
}
PostMedia ToPostMedia(MediaAttachment attachment) => new()
{
AttachmentId = attachment.ID,
ContentType = attachment.ContentType,
URL = _media.Url(attachment.FilePath),
PreviewURL = _media.Url(attachment.PreviewPath ?? attachment.FilePath),
Description = attachment.Description,
Blurhash = attachment.Blurhash,
Width = attachment.Width,
Height = attachment.Height,
Focus = attachment.Focus
};
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>();
}
}
+97
View File
@@ -0,0 +1,97 @@
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);
if (!string.IsNullOrEmpty(post.AudienceURI))
foreach (var member in await _dbEntities.Followings.Match(f => f.TargetActorURI == post.AudienceURI && f.State == FollowState.Accepted).ExecuteAsync(token))
recipients.Add(member.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;
}
recipients.ExceptWith(await Relationships.Hidden.RecipientsHiding(recipients, post.ActorURI, token));
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
};
}
}
@@ -18,6 +18,8 @@ namespace PrivaPub.Federation.Actors
public string Url { get; init; } public string Url { get; init; }
public string Inbox { get; init; } public string Inbox { get; init; }
public string Outbox { get; init; } public string Outbox { get; init; }
public string Followers { get; init; }
public string Following { get; init; }
public string SharedInbox { get; init; } public string SharedInbox { get; init; }
public string Icon { get; init; } public string Icon { get; init; }
public bool Discoverable { get; init; } = true; public bool Discoverable { get; init; } = true;
@@ -45,6 +47,8 @@ namespace PrivaPub.Federation.Actors
Url = RemoteActorService.Text(root, "url") ?? id, Url = RemoteActorService.Text(root, "url") ?? id,
Inbox = Origin.Same(inbox, id) ? inbox : default, Inbox = Origin.Same(inbox, id) ? inbox : default,
Outbox = RemoteActorService.Text(root, "outbox"), 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, SharedInbox = root.TryGetProperty("endpoints", out var endpoints) ? RemoteActorService.Text(endpoints, "sharedInbox") : default,
Icon = root.TryGetProperty("icon", out var icon) ? RemoteActorService.Text(icon, "url") : default, Icon = root.TryGetProperty("icon", out var icon) ? RemoteActorService.Text(icon, "url") : default,
Discoverable = !root.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False, Discoverable = !root.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False,
@@ -29,20 +29,33 @@ namespace PrivaPub.Federation.Actors
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 IsFederated { get; init; } = true;
public bool IsCircle { get; init; }
public bool PostingRestrictedToModerators { get; init; }
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 Featured => $"{Uri}/trophies";
public string FeaturedTags => $"{Uri}/tattoos";
public string Flock => $"{Uri}/flock";
public string Wardens => $"{Uri}/wardens";
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
@@ -190,6 +203,12 @@ namespace PrivaPub.Federation.Actors
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
}; };
@@ -204,9 +223,10 @@ namespace PrivaPub.Federation.Actors
ThumbnailURL = group.ThumbnailURL, ThumbnailURL = group.ThumbnailURL,
PrivateKeyPem = group.PrivateKey, PrivateKeyPem = group.PrivateKey,
PublicKeyPem = group.PublicKey, PublicKeyPem = group.PublicKey,
Discoverable = group.IsDiscoverable, Discoverable = group.Kind == GroupKind.Community && group.IsDiscoverable,
ManuallyApprovesFollowers = group.ManuallyApprovesMembers, ManuallyApprovesFollowers = group.Kind == GroupKind.Circle || group.ManuallyApprovesMembers,
IsFederated = group.Kind == GroupKind.Community, IsCircle = group.Kind == GroupKind.Circle,
PostingRestrictedToModerators = group.PostingPolicy == PostingPolicy.Moderators,
Published = group.CreationDate, Published = group.CreationDate,
BaseAddress = BaseAddress BaseAddress = BaseAddress
}; };
@@ -6,6 +6,7 @@ using PrivaPub.StaticServices;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Signing; using PrivaPub.Federation.Signing;
@@ -32,13 +33,16 @@ namespace PrivaPub.Federation.Actors
readonly ILocalActorService _localActors; readonly ILocalActorService _localActors;
readonly IMemoryCache _cache; readonly IMemoryCache _cache;
readonly DbEntities _dbEntities; readonly DbEntities _dbEntities;
readonly IOptionsMonitor<FederationOptions> _options;
public RemoteActorService(IFederationHttp http, ILocalActorService localActors, IMemoryCache cache, DbEntities dbEntities) public RemoteActorService(IFederationHttp http, ILocalActorService localActors, IMemoryCache cache, DbEntities dbEntities,
IOptionsMonitor<FederationOptions> options = default)
{ {
_http = http; _http = http;
_localActors = localActors; _localActors = localActors;
_cache = cache; _cache = cache;
_dbEntities = dbEntities; _dbEntities = dbEntities;
_options = options;
} }
public async Task<FetchedJson> FetchObject(string uri, CancellationToken token) public async Task<FetchedJson> FetchObject(string uri, CancellationToken token)
@@ -122,8 +126,11 @@ namespace PrivaPub.Federation.Actors
if (parts is not { Length: 2 } || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1])) if (parts is not { Length: 2 } || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1]))
return default; return default;
var url = $"https://{parts[1]}/.well-known/webfinger?resource={Uri.EscapeDataString($"acct:{parts[0]}@{parts[1]}")}"; var query = $"/.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); using var fetched = await _http.GetJson($"https://{parts[1]}{query}", "application/jrd+json, application/json", sign: default, token)
?? (_options?.CurrentValue.AllowPlainHttp == true
? await _http.GetJson($"http://{parts[1]}{query}", "application/jrd+json, application/json", sign: default, token)
: default);
if (fetched == default) if (fetched == default)
return default; return default;
var document = fetched.Document; var document = fetched.Document;
@@ -163,6 +170,8 @@ namespace PrivaPub.Federation.Actors
.Modify(a => a.Domain, new Uri(actor.Id).Authority) .Modify(a => a.Domain, new Uri(actor.Id).Authority)
.Modify(a => a.InboxURL, actor.Inbox) .Modify(a => a.InboxURL, actor.Inbox)
.Modify(a => a.OutboxURL, actor.Outbox) .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.SharedInboxURL, actor.SharedInbox)
.Modify(a => a.PictureURL, actor.Icon) .Modify(a => a.PictureURL, actor.Icon)
.Modify(a => a.IsDiscoverable, actor.Discoverable) .Modify(a => a.IsDiscoverable, actor.Discoverable)
@@ -1,35 +1,47 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post; using PostEntity = PrivaPub.Models.Post.Post;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering; using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Inbox; using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Signing;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Models.Group;
using PrivaPub.Infrastructure; using PrivaPub.Infrastructure;
namespace PrivaPub.Federation.Controllers namespace PrivaPub.Federation.Controllers
{ {
[ApiController, Route("peasants")] [ApiController, Route("peasants")]
public class PeasantsController : ControllerBase public class PeasantsController : ControllerBase, IAsyncActionFilter
{ {
const string ActivityContentType = "application/activity+json; charset=utf-8"; const string ActivityContentType = "application/activity+json; charset=utf-8";
const int OutboxSize = 20; const int OutboxSize = 20;
readonly ILocalActorService _localActors; readonly ILocalActorService _localActors;
readonly IInboxService _inbox; readonly IInboxReceiver _inbox;
readonly DbEntities _dbEntities; readonly DbEntities _dbEntities;
readonly ILogger<PeasantsController> _logger; readonly ILogger<PeasantsController> _logger;
readonly ISignedFetchAuthorizer _fetches;
readonly IOptionsMonitor<FederationOptions> _federation;
public PeasantsController(ILocalActorService localActors, IInboxService inbox, DbEntities dbEntities, public PeasantsController(ILocalActorService localActors, IInboxReceiver inbox, DbEntities dbEntities,
ILogger<PeasantsController> logger) ILogger<PeasantsController> logger, ISignedFetchAuthorizer fetches, IOptionsMonitor<FederationOptions> federation)
{ {
_fetches = fetches;
_federation = federation;
_localActors = localActors; _localActors = localActors;
_inbox = inbox; _inbox = inbox;
_dbEntities = dbEntities; _dbEntities = dbEntities;
@@ -40,26 +52,35 @@ namespace PrivaPub.Federation.Controllers
public async Task<IActionResult> GetActor(string actor, CancellationToken token) public async Task<IActionResult> GetActor(string actor, CancellationToken token)
{ {
var local = await _localActors.FindByUserName(actor, token); var local = await _localActors.FindByUserName(actor, token);
return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.Actor(local)); 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")] [HttpGet, Route("{actor}/anus")]
public async Task<IActionResult> Outbox(string actor, CancellationToken token) 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); var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true }) if (local is not { IsFederated: true })
return NotFound(); return NotFound();
var total = local.Kind == LocalActorKind.Group if (!page)
? await DB.Default.CountAsync<PostEntity>(p => p.GroupId == local.Id && !p.IsLocalOnly, token) {
: await DB.Default.CountAsync<PostEntity>(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.IsLocalOnly, token); 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 latest = await (local.Kind == LocalActorKind.Group var query = local.Kind == LocalActorKind.Group
? _dbEntities.Posts.Match(p => p.GroupId == local.Id && !p.IsLocalOnly) ? _dbEntities.Posts.Match(p => p.GroupId == local.Id).Match(VisibilityPolicy.IsPublic)
: _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.IsLocalOnly)) : _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy).Match(VisibilityPolicy.IsPublic);
.Sort(p => p.CreationDate, Order.Descending) if (!string.IsNullOrEmpty(maxId))
.Limit(OutboxSize) query.Match(f => f.Lt(p => p.ID, maxId));
.ExecuteAsync(token); var latest = await query.Sort(p => p.ID, Order.Descending).Limit(OutboxSize).ExecuteAsync(token);
var items = new List<JsonNode>(); var items = new List<JsonNode>();
foreach (var post in latest) foreach (var post in latest)
@@ -69,15 +90,21 @@ namespace PrivaPub.Federation.Controllers
items.Add(ActivityPubRenderer.Announce(local, post.ObjectURI, $"announce-{post.ID}")); items.Add(ActivityPubRenderer.Announce(local, post.ObjectURI, $"announce-{post.ID}"));
continue; continue;
} }
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token); if (post.ReblogOfPostId != default)
var note = ActivityPubRenderer.Note(post, local, group, default); {
items.Add(ActivityPubRenderer.Create(local, note, $"create-{post.ID}")); if (await AnnounceFor(post, token) is { } announce)
items.Add(announce);
continue;
}
items.Add(await CreateFor(post, local, token));
} }
return Activity(ActivityPubRenderer.OrderedCollection(local.Outbox, (int)total, items)); 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}/followers")] [HttpGet, Route("{actor}/groupies")]
public async Task<IActionResult> Followers(string actor, CancellationToken token) public async Task<IActionResult> Followers(string actor, CancellationToken token)
{ {
var local = await _localActors.FindByUserName(actor, token); var local = await _localActors.FindByUserName(actor, token);
@@ -88,33 +115,107 @@ namespace PrivaPub.Federation.Controllers
return Activity(ActivityPubRenderer.OrderedCollection(local.Followers, (int)count, default)); return Activity(ActivityPubRenderer.OrderedCollection(local.Followers, (int)count, default));
} }
[HttpGet, Route("{actor}/following")] [HttpGet, Route("{actor}/stalking")]
public async Task<IActionResult> Following(string actor, CancellationToken token) public async Task<IActionResult> Following(string actor, CancellationToken token)
{ {
var local = await _localActors.FindByUserName(actor, token); var local = await _localActors.FindByUserName(actor, token);
return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, Enumerable.Empty<JsonNode>())); return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, default));
} }
[HttpGet, Route("{actor}/posts/{postId}")] [HttpGet, Route("{actor}/flock")]
public async Task<IActionResult> Post(string actor, string postId, CancellationToken token) public async Task<IActionResult> Members(string actor, CancellationToken token)
{ {
var local = await _localActors.FindByUserName(actor, token); var local = await _localActors.FindByUserName(actor, token);
if (local == default || local.Kind != LocalActorKind.Person) if (local is not { IsFederated: true, Kind: LocalActorKind.Group })
return NotFound(); return NotFound();
var group = await _dbEntities.Groups.MatchID(local.Id).ExecuteFirstAsync(token);
if (local.IsCircle && !SignedFetchAuthorizer.MayReadCircle(group, await _fetches.Requester(Request, token)))
return NotFound();
return Activity(ActivityPubRenderer.OrderedCollection(local.Flock, group.Members.Count, default));
}
var post = await _dbEntities.Posts [HttpGet, Route("{actor}/wardens")]
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.IsLocalOnly) public async Task<IActionResult> Moderators(string actor, CancellationToken token)
.ExecuteFirstAsync(token); {
if (post == default) var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true, Kind: LocalActorKind.Group })
return NotFound(); return NotFound();
var group = await _dbEntities.Groups.MatchID(local.Id).ExecuteFirstAsync(token);
if (local.IsCircle && !SignedFetchAuthorizer.MayReadCircle(group, await _fetches.Requester(Request, token)))
return NotFound();
var moderators = new List<JsonNode>();
foreach (var member in group.Members.Where(m => !m.IsForeign && m.Role is GroupRole.Owner or GroupRole.Moderator))
if (await _localActors.FindById(LocalActorKind.Person, member.AvatarId, token) is { } moderator)
moderators.Add(moderator.Uri);
return Activity(ActivityPubRenderer.OrderedCollection(local.Wardens, moderators.Count, moderators));
}
[HttpGet, Route("{actor}/trophies")]
public async Task<IActionResult> Featured(string actor, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true })
return NotFound();
var pinIds = (await DB.Default.Find<Pin>().Match(p => p.AvatarId == local.Id).Sort(p => p.ID, Order.Descending).ExecuteAsync(token))
.Select(p => p.PostId).ToList();
var posts = pinIds.Count == 0
? new List<PostEntity>()
: await _dbEntities.Posts.Match(p => pinIds.Contains(p.ID) && p.GroupUserId == local.Id && p.ReblogOfPostId == null)
.Match(VisibilityPolicy.IsPublic).ExecuteAsync(token);
var notes = new List<JsonNode>();
foreach (var id in pinIds)
if (posts.FirstOrDefault(p => p.ID == id) is { } post)
notes.Add(ActivityPubRenderer.Note(post, local, default, post.InReplyToURI));
return Activity(ActivityPubRenderer.OrderedCollection(local.Featured, notes.Count, notes));
}
[HttpGet, Route("{actor}/tattoos")]
public async Task<IActionResult> FeaturedTags(string actor, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
return local is not { IsFederated: true }
? NotFound()
: Activity(ActivityPubRenderer.OrderedCollection(local.FeaturedTags, 0, Enumerable.Empty<JsonNode>()));
}
[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 && await CirclePost(actor, postId, token) is { } circlePost)
{
var circleNote = ActivityPubRenderer.Note(circlePost.Post, circlePost.Author, circlePost.Circle, circlePost.Post.InReplyToURI);
circleNote["@context"] = ActivityPubRenderer.Context();
return Activity(circleNote);
}
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 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, post.InReplyToURI);
var note = ActivityPubRenderer.Note(post, local, group, replyTo); note["@context"] = ActivityPubRenderer.Context();
note["@context"] = ActivityPubRenderer.ActivityStreams;
return Activity(note); 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)] [HttpPost, Route("{actor}/mouth"), EnableRateLimiting(RateLimiting.Inbox)]
public async Task<IActionResult> Inbox(string actor, CancellationToken token) public async Task<IActionResult> Inbox(string actor, CancellationToken token)
{ {
@@ -132,13 +233,98 @@ namespace PrivaPub.Federation.Controllers
public async Task<IActionResult> SharedInbox(CancellationToken token) => public async Task<IActionResult> SharedInbox(CancellationToken token) =>
Answer(await _inbox.Receive(Request, default, token)); Answer(await _inbox.Receive(Request, default, token));
async Task<string> ReplyTarget(string answeringToPostId, CancellationToken token) async Task<(LocalActor Actor, PostEntity Post)> PublicPost(string actor, string postId, CancellationToken token)
{ {
if (string.IsNullOrEmpty(answeringToPostId)) var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true, Kind: LocalActorKind.Person })
return default; return default;
if (answeringToPostId.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) var post = await _dbEntities.Posts
return answeringToPostId; .Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && p.ReblogOfPostId == null)
return (await _dbEntities.Posts.MatchID(answeringToPostId).ExecuteFirstAsync(token))?.ObjectURI; .Match(VisibilityPolicy.IsPublic)
.ExecuteFirstAsync(token);
return (local, post);
}
async Task<(LocalActor Author, LocalActor Circle, PostEntity Post)?> CirclePost(string actor, string postId, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local is not { Kind: LocalActorKind.Person })
return default;
var post = await _dbEntities.Posts
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.Visibility == PostVisibility.Circle)
.ExecuteFirstAsync(token);
var circle = post == default ? default : await _dbEntities.Groups.MatchID(post.GroupId).ExecuteFirstAsync(token);
if (circle == default || !SignedFetchAuthorizer.MayReadCircle(circle, await _fetches.Requester(Request, token)))
return default;
return (local, _localActors.FromGroup(circle), 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);
}
[NonAction]
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method)
&& !string.Equals(context.RouteData.Values["actor"] as string, LocalActorService.InstanceUserName, StringComparison.OrdinalIgnoreCase)
&& await _fetches.Requester(Request, HttpContext.RequestAborted) == default)
{
context.Result = StatusCode(StatusCodes.Status401Unauthorized);
return;
}
await next();
} }
IActionResult Answer(InboxResult result) IActionResult Answer(InboxResult result)
@@ -3,12 +3,14 @@ using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Models.User; using PrivaPub.Models.User;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post; using PostEntity = PrivaPub.Models.Post.Post;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
namespace PrivaPub.Federation.Controllers namespace PrivaPub.Federation.Controllers
@@ -49,10 +51,12 @@ namespace PrivaPub.Federation.Controllers
var document = new JsonObject var document = new JsonObject
{ {
["subject"] = $"acct:{actor.Handle}", ["subject"] = $"acct:{actor.Handle}",
["aliases"] = new JsonArray(actor.Uri), ["aliases"] = actor.Kind == LocalActorKind.Application ? new JsonArray(actor.Uri) : new JsonArray(actor.HtmlUrl, actor.Uri),
["links"] = new JsonArray( ["links"] = actor.Kind == LocalActorKind.Application
new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = actor.Uri }, ? 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 }) : 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"); return Content(document.ToJsonString(), "application/jrd+json; charset=utf-8");
} }
@@ -62,24 +66,36 @@ namespace PrivaPub.Federation.Controllers
{ {
var document = new JsonObject var document = new JsonObject
{ {
["links"] = new JsonArray(new JsonObject ["links"] = new JsonArray(
{ new JsonObject
["rel"] = "http://nodeinfo.diaspora.software/ns/schema/2.0", {
["href"] = $"{_localActors.BaseAddress}/nodeinfo/2.0" ["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"); return Content(document.ToJsonString(), "application/json; charset=utf-8");
} }
[HttpGet, Route("/nodeinfo/2.0")] [HttpGet, Route("/nodeinfo/{version:regex(^2\\.[[01]]$)}")]
public async Task<IActionResult> NodeInfo(CancellationToken token) public async Task<IActionResult> NodeInfo(string version, CancellationToken token)
{ {
var users = await DB.Default.CountAsync<Avatar>(a => !a.DeletionAt.HasValue, 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 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 var document = new JsonObject
{ {
["version"] = "2.0", ["version"] = version,
["software"] = new JsonObject { ["name"] = "privapub", ["version"] = BuildInfo.Ref }, ["software"] = software,
["protocols"] = new JsonArray("activitypub"), ["protocols"] = new JsonArray("activitypub"),
["services"] = new JsonObject { ["inbound"] = new JsonArray(), ["outbound"] = new JsonArray() }, ["services"] = new JsonObject { ["inbound"] = new JsonArray(), ["outbound"] = new JsonArray() },
["openRegistrations"] = true, ["openRegistrations"] = true,
@@ -88,9 +104,15 @@ namespace PrivaPub.Federation.Controllers
["users"] = new JsonObject { ["total"] = users }, ["users"] = new JsonObject { ["total"] = users },
["localPosts"] = posts ["localPosts"] = posts
}, },
["metadata"] = new JsonObject() ["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/2.0#\"; charset=utf-8"); 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,171 @@
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;
readonly IRemoteActorService _remoteActors;
public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout, IRemoteActorService remoteActors)
{
_remoteActors = remoteActors;
_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")
{
await GroupActivity(inner, actor, token);
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.Arrived(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);
}
async Task GroupActivity(JsonNode inner, ForeignAvatar group, CancellationToken token)
{
if (group.AvatarType != AvatarType.Group
|| !await _dbEntities.Followings.Match(f => f.TargetActorURI == group.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token))
return;
var objectUri = Id(inner["object"]);
switch (Value(inner, "type"))
{
case "Create" when objectUri != default:
if (await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token))
return;
var post = await _remotePosts.StoreContext(objectUri, 0, token);
if (post == default)
return;
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.AudienceURI, group.ActorURI).ExecuteAsync(token);
post.AudienceURI = group.ActorURI;
await _fanout.Distribute(post, token);
break;
case "Update" when objectUri != default:
var stored = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI && !p.DeletedAt.HasValue)
.ExecuteFirstAsync(token);
if (stored == default)
return;
using (var fetched = await _remoteActors.FetchObject(objectUri, token))
{
var note = fetched == default ? default : NoteParser.Parse(System.Text.Json.Nodes.JsonNode.Parse(fetched.Root.GetRawText()));
if (note == default || note.AttributedTo != stored.ActorURI)
return;
stored.Revisions.Add(new PostRevision
{
Title = stored.Title,
SpoilerText = stored.SpoilerText,
ContentHtml = stored.ContentHtml,
HasContentWarning = stored.HasContentWarning,
EditedAt = stored.EditedAt ?? stored.CreationDate
});
stored.Title = note.Title;
stored.SpoilerText = note.SpoilerText;
stored.HasContentWarning = note.Sensitive;
stored.Text = note.ContentHtml;
stored.ContentHtml = note.ContentHtml;
stored.Tags = note.Tags.ToList();
stored.Media = note.Attachments.ToList();
stored.EditedAt = note.Updated ?? DateTime.UtcNow;
await DB.Default.SaveAsync(stored, token);
}
break;
case "Delete" when objectUri != default:
var deleted = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI).ExecuteFirstAsync(token);
if (deleted == default)
return;
using (var check = await _remoteActors.FetchObject(objectUri, token))
if (check != default && Value(System.Text.Json.Nodes.JsonNode.Parse(check.Root.GetRawText()), "type") != "Tombstone")
return;
await DB.Default.DeleteAsync<PostEntity>(deleted.ID);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == deleted.ID);
break;
}
}
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,191 @@
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;
readonly IGroupDistributor _groups;
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups)
{
_groups = groups;
_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 circle = localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: true });
if (circle != default)
{
if (!await IsCircleMember(circle, author.ActorURI, token))
return;
visibility = PostVisibility.Circle;
}
var group = circle ?? (visibility is PostVisibility.Public or PostVisibility.Unlisted
? localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: false })
: default);
if (group is { IsCircle: false } && !await MayPost(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 is { IsCircle: false })
await _groups.Announce(group, activity.AsObject(), note.Id, isNewPost: string.IsNullOrEmpty(note.InReplyTo), token);
}
async Task<bool> IsCircleMember(LocalActor circle, string actorUri, CancellationToken token) =>
await _dbEntities.Groups.Match(g => g.ID == circle.Id && g.Members.Any(m => m.IsForeign && m.AvatarId == actorUri)).ExecuteAnyAsync(token);
async Task<bool> MayPost(LocalActor community, string actorUri, CancellationToken token)
{
var entity = await _dbEntities.Groups.MatchID(community.Id).ExecuteFirstAsync(token);
return entity?.PostingPolicy switch
{
PostingPolicy.Anyone => true,
PostingPolicy.Followers => await IsAcceptedFollower(community, actorUri, token),
_ => false
};
}
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,80 @@
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;
readonly IGroupDistributor _groups;
public DeleteHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IGroupDistributor groups)
{
_groups = groups;
_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);
if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community)
await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token);
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,66 @@
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.Objects.ActivityJson;
namespace PrivaPub.Federation.Inbox.Handlers
{
public class FlagHandler : IActivityHandler
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
public FlagHandler(DbEntities dbEntities, ILocalActorService localActors)
{
_dbEntities = dbEntities;
_localActors = localActors;
}
public string Type => "Flag";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{
var objects = activity["object"] switch
{
JsonArray array => array.Select(Id).Where(i => i != default).ToList(),
JsonNode single when Id(single) is { } id => new List<string> { id },
_ => new List<string>()
};
LocalActor target = default;
var postUris = new List<string>();
foreach (var uri in objects.Take(50))
{
var local = await _localActors.FindByUri(uri, token);
if (local is { Kind: LocalActorKind.Person } && uri.TrimEnd('/') == local.Uri)
target ??= local;
else
postUris.Add(uri);
}
var posts = postUris.Count == 0
? new List<Models.Post.Post>()
: await _dbEntities.Posts.Match(p => postUris.Contains(p.ObjectURI) && !p.IsFederatedCopy).ExecuteAsync(token);
if (target == default && posts.Count > 0)
target = await _localActors.FindById(LocalActorKind.Person, posts[0].GroupUserId, token);
if (target == default)
return;
await DB.Default.SaveAsync(new Report
{
ReporterActorURI = actor.ActorURI,
TargetAccountId = target.Id,
TargetActorURI = target.Uri,
PostIds = posts.Where(p => p.GroupUserId == target.Id).Select(p => p.ID).ToList(),
ObjectURIs = posts.Where(p => p.GroupUserId == target.Id).Select(p => p.ObjectURI).ToList(),
Comment = Value(activity, "content")
}, token);
}
}
}
@@ -0,0 +1,93 @@
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;
if (target.Kind == LocalActorKind.Person
&& await DB.Default.Find<Models.Social.Block>().Match(b => b.AvatarId == target.Id && b.TargetActorURI == follower.ActorURI).ExecuteAnyAsync(token))
{
var reject = new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = target.ActivityUri($"reject-{Guid.NewGuid():N}"),
["type"] = "Reject",
["actor"] = target.Uri,
["object"] = follow.DeepClone()
};
await _delivery.Enqueue(target, new[] { follower.InboxURL }, reject, token);
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,89 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
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;
readonly IGroupDistributor _groups;
public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IGroupDistributor groups)
{
_groups = groups;
_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);
if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(Models.Federation.LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community)
await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, 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;
}
}
}
-413
View File
@@ -1,413 +0,0 @@
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using DmPostEntity = PrivaPub.Models.Post.DmPost;
using GroupEntity = PrivaPub.Models.Group.Group;
using PostEntity = PrivaPub.Models.Post.Post;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Signing;
using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
namespace PrivaPub.Federation.Inbox
{
public sealed record InboxResult(int StatusCode, string Error = default);
public interface IInboxService
{
Task<InboxResult> Receive(HttpRequest request, LocalActor recipient, CancellationToken token);
}
public class InboxService : IInboxService
{
const int MaxBodyBytes = 1024 * 1024;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IDeliveryService _delivery;
readonly ILogger<InboxService> _logger;
public InboxService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors,
IDeliveryService delivery, ILogger<InboxService> logger)
{
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
_delivery = delivery;
_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");
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 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");
_logger.LogInformation("Inbox {Recipient}: {Type} from {Actor}", recipient?.Handle ?? "shared", type, actorUri);
return type switch
{
"Follow" => await Follow(activity, keyOwner, token),
"Undo" => await Undo(activity, keyOwner, token),
"Create" => await Create(activity, keyOwner, token),
"Delete" => await Delete(activity, keyOwner, token),
"Update" => await Update(activity, keyOwner, token),
_ => new(StatusCodes.Status202Accepted)
};
}
async Task<InboxResult> Follow(JsonNode follow, ForeignAvatar follower, CancellationToken token)
{
var target = await _localActors.FindByUri(Id(follow["object"]), token);
if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application)
return new(StatusCodes.Status404NotFound, "no such local actor");
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 (!record.IsAccepted)
return new(StatusCodes.Status202Accepted);
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 new(StatusCodes.Status202Accepted);
}
async Task<InboxResult> Undo(JsonNode undo, ForeignAvatar actor, CancellationToken token)
{
var inner = undo["object"];
var innerType = inner is JsonObject ? Value(inner, "type") : default;
var innerId = Id(inner);
if (inner is JsonObject && Id(inner["actor"]) != actor.ActorURI)
return new(StatusCodes.Status400BadRequest, "an actor can only undo its own activities");
if (innerType is not (null or "Follow"))
return new(StatusCodes.Status202Accepted);
var followers = await _dbEntities.Followers.Match(f => f.ActorURI == actor.ActorURI).ExecuteAsync(token);
var targetUri = inner is JsonObject ? Id(inner["object"]) : default;
foreach (var follower in followers)
{
var matchesActivity = innerId != default && follower.FollowActivityURI == innerId;
var target = targetUri == default ? default : await _localActors.FindByUri(targetUri, token);
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);
}
return new(StatusCodes.Status202Accepted);
}
async Task<InboxResult> Create(JsonNode create, ForeignAvatar author, CancellationToken token)
{
var note = create["object"];
if (note is not JsonObject || !Origin.Same(Id(note), author.ActorURI))
{
using var fetched = await _remoteActors.FetchObject(Id(note), token);
note = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText());
}
if (note is not JsonObject || Value(note, "type") is not ("Note" or "Article" or "Page" or "Question"))
return new(StatusCodes.Status202Accepted);
var objectUri = Id(note);
if (string.IsNullOrEmpty(objectUri) || Id(note["attributedTo"]) != author.ActorURI)
return new(StatusCodes.Status400BadRequest, "the object is not attributed to the actor");
var addressed = Addresses(note).Concat(Addresses(create)).Distinct(StringComparer.Ordinal).ToList();
var isPublic = addressed.Contains(ActivityPubRenderer.Public) || addressed.Contains("as:Public") || addressed.Contains("Public");
var inReplyTo = Id(note["inReplyTo"]);
var localTargets = new List<LocalActor>();
foreach (var uri in addressed.Concat(new[] { Id(note["audience"]) }).Where(u => u != default).Distinct())
{
var local = await _localActors.FindByUri(uri, token);
if (local is { IsFederated: true } && localTargets.All(l => l.Id != local.Id))
localTargets.Add(local);
}
if (isPublic || addressed.Any(a => a == author.ActorURI + "/followers" || a.EndsWith("/followers")))
{
var group = localTargets.FirstOrDefault(t => t.Kind == LocalActorKind.Group);
if (group != default && !await IsAcceptedFollower(group, author.ActorURI, token))
group = default;
if (group == default && localTargets.All(t => t.Kind != LocalActorKind.Person))
return new(StatusCodes.Status202Accepted);
if (await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token))
return new(StatusCodes.Status202Accepted);
var html = ContentSanitizer.Html(Value(note, "content"));
var post = new PostEntity
{
ObjectURI = objectUri,
ActorURI = author.ActorURI,
GroupId = group?.Id,
Title = Value(note, "summary") ?? Value(note, "name"),
Text = html,
ContentHtml = html,
ContentFormat = ContentFormat.Html,
HasContentWarning = note["sensitive"] is JsonValue sensitive && sensitive.TryGetValue<bool>(out var s) && s,
AnsweringToPostId = await LocalPostId(inReplyTo, token) ?? inReplyTo,
IsFederatedCopy = true,
CreationDate = DateTime.TryParse(Value(note, "published"), out var published) ? published.ToUniversalTime() : DateTime.UtcNow
};
await DB.Default.SaveAsync(post, token);
if (group != default)
{
var announce = ActivityPubRenderer.Announce(group, objectUri, $"announce-{post.ID}");
await _delivery.EnqueueToFollowers(group, announce, token);
}
return new(StatusCodes.Status202Accepted);
}
var recipients = localTargets.Where(t => t.Kind == LocalActorKind.Person).ToList();
if (recipients.Count == 0)
return new(StatusCodes.Status202Accepted);
if (await _dbEntities.DmPosts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token))
return new(StatusCodes.Status202Accepted);
var participants = recipients.Select(r => r.Uri)
.Concat(addressed.Where(a => a != ActivityPubRenderer.Public && !a.EndsWith("/followers") && !a.EndsWith("/following")))
.Append(author.ActorURI)
.ToList();
var context = Value(note, "context") ?? Value(note, "conversation");
var dmGroup = await FindOrCreateDmGroup(participants, Origin.Same(context, author.ActorURI) ? context : default, token);
var dmHtml = ContentSanitizer.Html(Value(note, "content"));
var dm = new DmPostEntity
{
ObjectURI = objectUri,
ActorURI = author.ActorURI,
GroupId = dmGroup.ID,
Title = Value(note, "summary"),
Text = dmHtml,
ContentHtml = dmHtml,
ContentFormat = ContentFormat.Html,
HasContentWarning = note["sensitive"] is JsonValue dmSensitive && dmSensitive.TryGetValue<bool>(out var ds) && ds,
AnsweringToPostId = inReplyTo,
IsFederatedCopy = true,
CreationDate = DateTime.TryParse(Value(note, "published"), out var dmPublished) ? dmPublished.ToUniversalTime() : DateTime.UtcNow
};
await DB.Default.SaveAsync(dm, token);
await DB.Default.Update<DmGroup>().MatchID(dmGroup.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token);
return new(StatusCodes.Status202Accepted);
}
async Task<InboxResult> Delete(JsonNode delete, ForeignAvatar actor, CancellationToken token)
{
var objectUri = Id(delete["object"]);
if (!Origin.Same(objectUri, actor.ActorURI))
return new(StatusCodes.Status202Accepted);
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);
}
return new(StatusCodes.Status202Accepted);
}
await DB.Default.DeleteAsync<PostEntity>(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI);
await DB.Default.DeleteAsync<DmPostEntity>(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI);
return new(StatusCodes.Status202Accepted);
}
async Task<InboxResult> Update(JsonNode update, ForeignAvatar actor, CancellationToken token)
{
var inner = update["object"];
if (Id(inner) == actor.ActorURI)
{
await _remoteActors.GetActor(actor.ActorURI, refresh: true, token);
return new(StatusCodes.Status202Accepted);
}
if (inner is not JsonObject || Id(inner["attributedTo"]) != actor.ActorURI || !Origin.Same(Id(inner), actor.ActorURI))
return new(StatusCodes.Status202Accepted);
var objectUri = Id(inner);
var text = ContentSanitizer.Html(Value(inner, "content"));
await DB.Default.Update<PostEntity>()
.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI)
.Modify(p => p.Text, text)
.Modify(p => p.ContentHtml, text)
.Modify(p => p.UpdateDate, DateTime.UtcNow)
.ExecuteAsync(token);
await DB.Default.Update<DmPostEntity>()
.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI)
.Modify(p => p.Text, text)
.Modify(p => p.ContentHtml, text)
.Modify(p => p.UpdateDate, DateTime.UtcNow)
.ExecuteAsync(token);
return new(StatusCodes.Status202Accepted);
}
async Task<bool> IsAcceptedFollower(LocalActor group, string actorUri, CancellationToken token) =>
await _dbEntities.Followers
.Match(f => f.LocalActorId == group.Id && f.LocalActorKind == LocalActorKind.Group && f.ActorURI == actorUri && f.IsAccepted)
.ExecuteAnyAsync(token);
async Task AddForeignMember(string groupId, string actorUri, CancellationToken token)
{
var group = await _dbEntities.Groups.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);
}
async Task RemoveForeignMember(string groupId, string actorUri, CancellationToken token)
{
var group = await _dbEntities.Groups.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);
}
async Task<DmGroup> FindOrCreateDmGroup(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 dmGroup = new DmGroup { Members = members, ConversationURI = context, ParticipantsKey = key };
await DB.Default.SaveAsync(dmGroup, token);
return dmGroup;
}
async Task<string> LocalPostId(string uri, CancellationToken token)
{
if (string.IsNullOrEmpty(uri) || !uri.StartsWith(_localActors.BaseAddress + "/peasants/", StringComparison.OrdinalIgnoreCase))
return default;
var id = uri[(uri.LastIndexOf('/') + 1)..];
return await _dbEntities.Posts.MatchID(id).ExecuteAnyAsync(token) ? id : default;
}
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;
}
}
}
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;
}
}
+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.Arrived(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;
}
}
}
+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
: (DateTime?)null;
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];
}
}
}
+70 -96
View File
@@ -10,6 +10,10 @@ using System.Text.Json.Nodes;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Signing; using PrivaPub.Federation.Signing;
using PrivaPub.Infrastructure.Http; using PrivaPub.Infrastructure.Http;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Jobs;
using System.Text.Json;
namespace PrivaPub.Federation.Outbox namespace PrivaPub.Federation.Outbox
{ {
@@ -20,31 +24,38 @@ namespace PrivaPub.Federation.Outbox
Task<IReadOnlyList<string>> FollowerInboxes(LocalActor actor, CancellationToken token); Task<IReadOnlyList<string>> FollowerInboxes(LocalActor actor, CancellationToken token);
} }
public sealed record DeliveryPayload(string SignerId, LocalActorKind SignerKind, string Inbox, string Body);
public class DeliveryService : IDeliveryService public class DeliveryService : IDeliveryService
{ {
readonly DbEntities _dbEntities; readonly DbEntities _dbEntities;
readonly IJobQueue _queue;
public DeliveryService(DbEntities dbEntities) public DeliveryService(DbEntities dbEntities, IJobQueue queue)
{ {
_dbEntities = dbEntities; _dbEntities = dbEntities;
_queue = queue;
} }
public async Task Enqueue(LocalActor signer, IEnumerable<string> inboxes, JsonObject activity, CancellationToken token) public async Task Enqueue(LocalActor signer, IEnumerable<string> inboxes, JsonObject activity, CancellationToken token)
{ {
var body = activity.ToJsonString(); var body = activity.ToJsonString();
var deliveries = inboxes 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)) .Where(i => !string.IsNullOrEmpty(i) && !i.StartsWith(signer.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.Ordinal) .Distinct(StringComparer.Ordinal)
.Select(inbox => new Delivery .Select(inbox => Uri.TryCreate(inbox, UriKind.Absolute, out var uri) ? (inbox, uri) : default)
.Where(target => target.uri != default)
.Select(target => new Job
{ {
SignerId = signer.Id, Kind = JobKind.Deliver,
SignerKind = signer.Kind, Host = target.uri.Host.ToLowerInvariant(),
InboxURL = inbox, DedupeKey = activityId == default ? default : $"{activityId}|{target.inbox}",
Body = body Payload = JsonSerializer.Serialize(new DeliveryPayload(signer.Id, signer.Kind, target.inbox, body))
}) })
.ToList(); .ToList();
if (deliveries.Count > 0) if (jobs.Count > 0)
await DB.Default.SaveAsync(deliveries, token); await _queue.EnqueueMany(jobs, token);
} }
public async Task EnqueueToFollowers(LocalActor signer, JsonObject activity, CancellationToken token, IEnumerable<string> extraInboxes = default) public async Task EnqueueToFollowers(LocalActor signer, JsonObject activity, CancellationToken token, IEnumerable<string> extraInboxes = default)
@@ -64,118 +75,81 @@ namespace PrivaPub.Federation.Outbox
} }
} }
public class DeliveryWorker : BackgroundService public class DeliveryJobHandler : IJobHandler
{ {
const int MaxAttempts = 8; readonly ILocalActorService _actors;
static readonly TimeSpan Poll = TimeSpan.FromSeconds(3);
readonly IServiceProvider _services;
readonly IFederationHttp _http; readonly IFederationHttp _http;
readonly ILogger<DeliveryWorker> _logger; readonly IHostCircuitBreaker _breaker;
readonly ILogger<DeliveryJobHandler> _logger;
public DeliveryWorker(IServiceProvider services, IFederationHttp http, ILogger<DeliveryWorker> logger) public DeliveryJobHandler(ILocalActorService actors, IFederationHttp http, IHostCircuitBreaker breaker, ILogger<DeliveryJobHandler> logger)
{ {
_services = services; _actors = actors;
_http = http; _http = http;
_breaker = breaker;
_logger = logger; _logger = logger;
} }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) public JobKind Kind => JobKind.Deliver;
{ public int Concurrency => 8;
while (!stoppingToken.IsCancellationRequested) public int MaxAttempts => 16;
{ public int PerHostLimit => 2;
try
{
await DeliverDue(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
_logger.LogError(ex, "{Worker} pass failed", nameof(DeliveryWorker));
}
await Task.Delay(Poll, stoppingToken);
}
}
async Task DeliverDue(CancellationToken token) public async Task<JobOutcome> Handle(Job job, CancellationToken token)
{ {
using var scope = _services.CreateScope(); var payload = JsonSerializer.Deserialize<DeliveryPayload>(job.Payload);
var dbEntities = scope.ServiceProvider.GetRequiredService<DbEntities>(); if (payload == default || !Uri.TryCreate(payload.Inbox, UriKind.Absolute, out var inbox) || !_http.IsAllowed(inbox))
var actors = scope.ServiceProvider.GetRequiredService<ILocalActorService>(); return JobOutcome.Dead("not a deliverable inbox");
var now = DateTime.UtcNow;
var due = await dbEntities.Deliveries
.Match(d => !d.DeliveredAt.HasValue && !d.AbandonedAt.HasValue && d.NextAttemptAt <= now)
.Sort(d => d.NextAttemptAt, Order.Ascending)
.Limit(20)
.ExecuteAsync(token);
foreach (var delivery in due) var unavailableUntil = await _breaker.UnavailableUntil(job.Host, token);
{ if (unavailableUntil.HasValue)
var signer = await actors.FindById(delivery.SignerKind, delivery.SignerId, token); return JobOutcome.Defer(unavailableUntil.Value, "the host is unavailable");
if (signer == default)
{ var signer = await _actors.FindById(payload.SignerKind, payload.SignerId, token);
delivery.AbandonedAt = DateTime.UtcNow; if (signer == default)
delivery.LastError = "the signing actor no longer exists"; return JobOutcome.Dead("the signing actor no longer exists");
await DB.Default.SaveAsync(delivery, token);
continue; var body = Encoding.UTF8.GetBytes(payload.Body);
} using var request = new HttpRequestMessage(HttpMethod.Post, inbox) { Content = new ByteArrayContent(body) };
await Deliver(delivery, signer, token); request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(RemoteActorService.ActivityJson);
await DB.Default.SaveAsync(delivery, token); HttpSignatures.Sign(request, signer, body);
}
}
async Task Deliver(Delivery delivery, LocalActor signer, CancellationToken token)
{
delivery.Attempts++;
try try
{ {
if (!Uri.TryCreate(delivery.InboxURL, UriKind.Absolute, out var inbox) || !_http.IsAllowed(inbox))
{
delivery.AbandonedAt = DateTime.UtcNow;
delivery.LastError = "not a deliverable inbox";
return;
}
var body = Encoding.UTF8.GetBytes(delivery.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);
using var response = await _http.Send(request, token); using var response = await _http.Send(request, token);
var status = (int)response.StatusCode;
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
delivery.DeliveredAt = DateTime.UtcNow; await _breaker.Succeeded(job.Host, token);
delivery.LastError = default; return JobOutcome.Done;
return;
} }
if (response.StatusCode == HttpStatusCode.TooManyRequests)
delivery.LastError = $"{(int)response.StatusCode} {response.ReasonPhrase}";
if (response.StatusCode is HttpStatusCode.Gone or HttpStatusCode.NotFound or HttpStatusCode.BadRequest or HttpStatusCode.Forbidden)
{ {
delivery.AbandonedAt = DateTime.UtcNow; var retryAfter = response.Headers.RetryAfter?.Delta ?? (response.Headers.RetryAfter?.Date - DateTimeOffset.UtcNow);
_logger.LogWarning("Delivery {Id} to {Inbox} abandoned: {Status}", delivery.ID, delivery.InboxURL, delivery.LastError); return retryAfter > TimeSpan.Zero
return; ? 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 (Exception ex) when (ex is HttpRequestException or BlockedDestinationException or TaskCanceledException && !token.IsCancellationRequested) catch (BlockedDestinationException ex)
{ {
delivery.LastError = ex.Message; return JobOutcome.Dead(ex.Message);
} }
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !token.IsCancellationRequested)
if (delivery.Attempts >= MaxAttempts)
{ {
delivery.AbandonedAt = DateTime.UtcNow; await _breaker.Failed(job.Host, ex.GetType().Name, token);
_logger.LogWarning("Delivery {Id} to {Inbox} abandoned after {Attempts} attempts: {Error}", return JobOutcome.Retry(ex.Message);
delivery.ID, delivery.InboxURL, delivery.Attempts, delivery.LastError);
return;
} }
delivery.NextAttemptAt = DateTime.UtcNow.AddMinutes(Math.Pow(2, delivery.Attempts));
} }
static TimeSpan Min(TimeSpan a, TimeSpan b) => a < b ? a : b;
} }
} }
@@ -0,0 +1,48 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
namespace PrivaPub.Federation.Outbox
{
public interface IGroupDistributor
{
Task Announce(LocalActor group, JsonObject activity, string objectUri, bool isNewPost, CancellationToken token);
}
public class GroupDistributor : IGroupDistributor
{
readonly IDeliveryService _delivery;
public GroupDistributor(IDeliveryService delivery)
{
_delivery = delivery;
}
public async Task Announce(LocalActor group, JsonObject activity, string objectUri, bool isNewPost, CancellationToken token)
{
if (group is not { IsCircle: false, Kind: Models.Federation.LocalActorKind.Group })
return;
var embedded = (JsonObject)activity.DeepClone();
embedded.Remove("@context");
embedded["audience"] ??= group.Uri;
var key = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(activity["id"]?.GetValue<string>() ?? embedded.ToJsonString())))[..24];
var announce = new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = group.ActivityUri($"announce-{key}"),
["type"] = "Announce",
["actor"] = group.Uri,
["to"] = new JsonArray(ActivityPubRenderer.Public),
["cc"] = new JsonArray(group.Followers),
["audience"] = group.Uri,
["object"] = embedded
};
await _delivery.EnqueueToFollowers(group, announce, token);
if (isNewPost && !string.IsNullOrEmpty(objectUri))
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, objectUri, $"announce-object-{key}"), token);
}
}
}
@@ -0,0 +1,102 @@
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 == PostVisibility.LocalGeo || post.IsLocalOnly)
return Array.Empty<string>();
if (post.Visibility == PostVisibility.Circle)
return await CircleMembers(post.GroupId, token);
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);
}
return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
}
async Task<IReadOnlyList<string>> CircleMembers(string groupId, CancellationToken token)
{
var circle = string.IsNullOrEmpty(groupId) ? default : await _dbEntities.Groups.MatchID(groupId).ExecuteFirstAsync(token);
if (circle == default)
return Array.Empty<string>();
var remote = circle.Members.Where(m => m.IsForeign).Select(m => m.AvatarId).ToList();
if (remote.Count == 0)
return Array.Empty<string>();
return (await _dbEntities.ForeignAvatars.Match(a => remote.Contains(a.ActorURI)).ExecuteAsync(token))
.Select(a => a.InboxURL).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);
}
}
}
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff. Show more