Compare commits

...
19 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 fdcf5176bc P6: emoji reactions in and out
Build / Build (push) Successful in 35s
Deploy / privapub.thepra.dev (push) Successful in 57s
- Incoming reactions in all three shapes: Misskey and Sharkey Likes carrying an emoji (`content` or
  `_misskey_reaction`), Pleroma, Akkoma and Iceshrimp.NET EmojiReacts, and their Undo. Unicode reactions are one
  grapheme; custom ones need their Emoji tag and keep its image; `:name@host:` is read as `name`. A Like whose
  content is a heart stays a favourite.
- Reactions are kept per account and emoji on our posts and on remote ones we hold, and the author of a local post
  gets a `pleroma:emoji_reaction` notification with the emoji.
- Personas react through Pleroma's API (PUT/DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji, GET for the list),
  which sends an EmojiReact (or its Undo) to the post's author.
- Statuses carry `emoji_reactions` (read by Phanpy) and `pleroma.emoji_reactions`, with counts and whether the viewer
  reacted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 18:39:18 +02:00
thepraandClaude Opus 5.5 7be6749a23 P6: custom emoji, fuller remote profiles, and polls both ways
Build / Build (push) Successful in 39s
Deploy / privapub.thepra.dev (push) Successful in 57s
- Custom emoji (Emoji tags) on posts, display names, bios and profile fields, at most 64 per object, proxied, in
  Status.emojis and Account.emojis.
- Remote profiles keep their header, profile fields, locked flag, published date, movedTo, indexable, memorial and
  image descriptions (a locked GoToSocial account no longer shows as open).
- Polls: incoming Questions (Mastodon, Misskey, Pleroma, GoToSocial shapes) with counts, voters, end and closed;
  our own polls from the Mastodon API go out as Questions; votes in are counted once per voter and never become
  replies; personas vote on other servers' polls with one Note per choice; counts refresh with an Update at most every
  three minutes; a poll closes on time and tells its voters and its author. GET /api/v1/polls/:id and POST
  /api/v1/polls/:id/votes.
- An Update without a newer `updated` only refreshes poll, video, audio and event details and leaves no revision.

Checked live against GoToSocial: each side's poll reaches the other as a poll and each side's vote is counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 18:35:20 +02:00
thepraandClaude Opus 5.5 81de470f64 P5 done: a key we cannot fetch for now gets 503, and follow/like/block ids stay private on purpose
Build / Build (push) Successful in 59s
Deploy / privapub.thepra.dev (push) Successful in 1m11s
- When a sender's key cannot be fetched because its server timed out or answered 5xx, the inbox answers 503 with
  Retry-After: 300 instead of 401, so Mastodon 4.7 retries rather than switching to RFC 9421 signatures we do not
  verify yet. The fetcher's failure cache now remembers whether a failure was temporary.
- Follow, Like, Block, Accept, Reject and Undo ids are deliberately not dereferenceable: serving them would publish
  who follows, likes and blocks whom. They are always sent with their object embedded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 18:25:18 +02:00
thepraandClaude Opus 5.5 b691c6766d P5: downvotes, private messages from Lemmy, late Creates of deleted posts, and typed details for clients
Build / Build (push) Successful in 35s
Deploy / privapub.thepra.dev (push) Successful in 55s
- Dislike and its Undo are kept as downvotes (Lemmy, PieFed, Mbin, Friendica) and shown with favourites as votes.
- ChatMessage (Lemmy 0.19, Mbin, PieFed to those two) arrives as a direct message.
- A deleted object's id is remembered for 90 days, so a Create that arrives after its Delete cannot bring the post
  back; the post's ObjectRecord goes with it.
- A Join of one of our objects is answered with Ignore, as FEP-8a8e asks of a server without RSVP.
- A 503 with Retry-After is waited out like a 429 instead of counting as a failure of the host (GoToSocial throttling,
  Mastodon's temporary key failures).
- Status.privapub carries what a Mastodon Status cannot: object type, title, excerpt, cover, the author's source,
  link, video, audio and event details, and up/down votes, with every media URL proxied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 17:58:22 +02:00
thepraandClaude Opus 5.5 fc111d8c46 P5: remote objects are read in every shape, and videos, events, audio and links keep their own details
Build / Build (push) Successful in 35s
Deploy / privapub.thepra.dev (push) Successful in 52s
- ObjectShapes reads `url`, `icon` and `image` as a value, a Link or an array; Markdown `content` (PeerTube) is
  rendered; a missing `mediaType` is inferred from the extension or the attachment type (Bridgy); alt text comes from
  `name`, `summary` or their language maps; thumbnails come from attachment `icon`, object `icon[]`, `image` or a Loops
  `preview`; durations are ISO 8601 or seconds; per-attachment `sensitive` is kept; the language falls back to
  `@context` `@language` (Pleroma); titles and excerpts are plain text; hashtags normalise with NFKC like Mastodon.
- Typed details on Post: Link (Lemmy link posts, Mbin `source` URLs, Mastodon 4.7 Link attachments with an FEP-8967
  publisher preview), Video (PeerTube files with their streams, HLS playlist, poster, captions, chapters, licence,
  channel, live state, comment policy), Audio (Funkwhale), Event (Mobilizon, Gancio, Hubzilla: times, zone, floating
  time, places and addresses, online link, capacity, status), the cover image, and the author's own source text.
- Mastodon API: a card built from those details without fetching anything, an event's "when · where" line, attachment
  thumbnails and durations, and no media file offered as a preview image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 17:53:59 +02:00
thepraandClaude Opus 5.5 dcd024100a Every remote object keeps its raw form and how it reached us, for the client's details view
Build / Build (push) Successful in 36s
- ObjectRecord, one per stored remote object: the raw JSON (up to 256 KB, always hashed), delivered or fetched,
  refetched from origin or not, the activity that brought it (or caused the fetch), shared or personal inbox, the
  signature's key, algorithm and signed headers, received time, published and updated, the delivering activity's
  @context, and up to ten later revisions from Update.
- Delivery details travel from InboxReceiver through the inbox job to the handlers as Arrival.Current.
- A host is described from its NodeInfo when we first hear from it, at most weekly (DescribeInstance job), never when
  someone opens the details view.
- GET /api/privapub/v1/statuses/:id/provenance and /api/privapub/v1/instances/:host, with the extensions an object
  used detected from its raw form (044f quotes, interaction policies, contexts, proofs, Misskey fields, MFM, FEP-8967
  links, emoji, polls, language maps, url variants, Markdown content).

Checked live: a GoToSocial reply shows as delivered to the shared inbox, signed hs2019 with GoToSocial's fragment-less
key id, with its interaction policy detected, and gts.test is described as gotosocial 0.22.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 17:49:22 +02:00
thepraandClaude Opus 5.5 bb9bd71391 P5, first batch: summaries stop hiding articles, personas get reachable names, blocks federate
Build / Build (push) Successful in 50s
Deploy / privapub.thepra.dev (push) Successful in 1m4s
- A remote `summary` is a content warning only on a Note or Question, or when `sensitive` is set. On Articles,
  Events, Videos, Pages and Audio it is an excerpt (WordPress teasers, Mobilizon dates and places, Mbin titles) and
  is now kept as `Post.Excerpt` instead of hiding the post. `Post.ObjectType` records the remote type, and the
  Mastodon API shows a remote non-Note object's title above its body again.
- Persona usernames must match `^[a-z0-9_]+$`, as groups already did; a name outside it was unreachable from
  Mastodon and Misskey.
- `postingRestrictedToMods` is defined in our JSON-LD context (Iceshrimp.NET drops undefined terms).
- `Vary: Accept` on actor and object URLs.
- Owner decision: blocks federate. A block sends `Block`, an unblock `Undo{Block}`; checked live against GoToSocial.
- Owner decision: a persona's and a group's `published`, and the day in new ids, is a random day up to two weeks
  before creation, so personas made the same day no longer share a date. Migration _007 gives existing ones theirs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 17:39:58 +02:00
thepraandClaude Opus 5.5 4c94254502 The owner's six decisions on what PrivaPub reveals: previews, blocks, authorship, views, bridging, reactions
Build / Build (push) Successful in 57s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 17:32:59 +02:00
thepraandClaude Opus 5.5 829eae3ccf Interop research: every major platform's wire shapes, gotchas, and what PrivaPub still drops
Build / Build (push) Successful in 1m22s
docs/INTEROP.md collects five research passes from 2026-10-01: Mastodon 4.7 and GoToSocial 0.22, the Misskey and
Pleroma families, the threadiverse (Lemmy 0.19/1.0, PieFed, Mbin, NodeBB), media and long-form (PeerTube, Loops,
Pixelfed, WordPress, Ghost, events, audio, books, Threads, Flipboard, Bridgy Fed), and cross-cutting FEPs and
signatures. Each claim was checked against source code or live fetches, with dates and versions.

It is checked against our own code: what is already right, three cheap things that are wrong today (summary read as a
CW on every type, unchecked usernames, an undefined context term), what the rich client needs kept (a post kind with
typed payloads, raw capture and provenance for the details view), and the six privacy choices the owner has to make.

The roadmap's P5 becomes P5 to P8, ordered by that evidence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 14:01:57 +02:00
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
124 changed files with 7469 additions and 228 deletions

No files matched your search

+4
View File
@@ -398,3 +398,7 @@ FodyWeavers.xsd
# JetBrains Rider
*.sln.iml
PrivaPub/media-store/
PrivaPub/media-store-proxy/
tools/pasture/.publish/
+101 -7
View File
@@ -1,7 +1,9 @@
# CLAUDE.md
Guidance for working in this repository. `docs/ROADMAP.md` holds the owner's decisions and the phased plan to full
ActivityPub interop; read it before changing anything federation-, privacy- or API-shaped.
ActivityPub interop; read it before changing anything federation-, privacy- or API-shaped. `docs/INTEROP.md` is the
per-platform evidence behind phases P5 to P8: what each peer sends, what it expects, and what PrivaPub still drops.
Check the platform's section there before writing a parser or a renderer, and add to it whatever you learn.
## What this is
@@ -69,7 +71,7 @@ PrivaPub/ ASP.NET Core Web API, net10.0
Infrastructure/
Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out
Jobs/ JobQueue (leases), JobWorker, Backoff, HostCircuitBreaker
Ids/ PrivacyIds (day-only ids for personas and groups, published-time ids for remote posts)
Ids/ PrivacyIds (day-only ids for personas and groups, arrival-ordered ids for remote posts)
Data/ Indexes (created at start), EntityMaps.Warm, Migrations/_NNN_*.cs
Cli/ AdminCommands (`PrivaPub admin promote|demote <root>`)
RateLimiting.cs accounts (per client address) and inbox (per sending origin) policies
@@ -90,6 +92,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0
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
@@ -127,7 +131,8 @@ cd PrivaPub && ASPNETCORE_ENVIRONMENT=Development \
Development config (`appsettings.Development.json`) binds HTTPS 7195 with HTTP/2 only; the overrides above make it
curl-able. Outbound fetches only go to https DNS names resolving to public addresses; a test network (Pasture) sets
`Federation__AllowPrivateNetworks=true` and `Federation__AllowPlainHttp=true`, which startup refuses in Production.
`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):
@@ -164,8 +169,13 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
- the shared inbox is advertised in `endpoints.sharedInbox`.
7. **Remote HTML is sanitized before it is stored** (`ContentSanitizer`); `Post.ContentHtml` is what is shown,
`ContentFormat` says what `Text` holds. Remote names are plain text.
8. **Circles never federate.** A circle's actor, collections, WebFinger and inbox answer 404, and its posts are
`IsLocalOnly`. Only communities are Group actors.
8. **Circles federate to members only.** A circle is an undiscoverable Group actor that takes follow requests (the owner
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
`context` decides nothing. DMs are `Post`s with `Visibility = Direct` and a `ConversationId` (`DmPost` is legacy).
10. **Nothing slow happens inside a request.** Deliveries and inbox processing are `Job`s (`Infrastructure/Jobs`):
@@ -189,6 +199,19 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
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.
7. **What a Mastodon `Status` cannot say goes in `Status.privapub`** (`PrivaPubStatus`): object type, title, excerpt,
cover, the author's source, link, video, audio and event details, and up/down votes. Every media URL in it goes
through the proxy; only page links (`link.url`, an event's online link) point at the remote site, because following
one is the reader's choice.
## Media invariants
1. **No upload keeps its metadata.** Images are re-encoded by libvips with `keep=none`; audio and video are remuxed with
`-map_metadata -1`. `MediaProcessingTests` checks EXIF and XMP are gone.
2. Files live under `Media:Root` (`/var/lib/privapub/media`), never in the published directory; the proxy cache is the
sibling `media-proxy`, which `/media/files` does not serve.
3. **A client never contacts a remote server for media:** every remote URL the API returns goes through
`IMediaProxy.Wrap`, an HMAC-signed `/media/proxy/` URL fetched by `IFederationHttp.GetMedia`.
## Privacy invariants
@@ -198,7 +221,17 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
equal to the login.
- **One username space:** personas, groups and the instance reserve their name in `ReservedName` (unique index) before
they are saved; `LocalActorService.TryReserveUserName` is the only way to claim one.
- **Usernames are `Constants.UserNameRegex`** (`^[a-z0-9_]+$`): the intersection of what Mastodon and Misskey accept. A
name outside it creates a persona nobody on those servers can reach.
- **An actor's `published` is `PublishedOn`**, a random whole day up to two weeks before creation, so personas made the
same day do not share a date. New persona and group ids carry that same day (`GenerateNewID`), because local clients
see ids. Never emit `CreatedAt`.
- **Per-avatar state stays per avatar:** blocks, mutes, notifications, follows. Nothing may relate sibling avatars.
- **Blocks federate** (owner decision, 2026-10-01): a block is sent as `Block` from the blocking avatar, an unblock as
`Undo{Block}`. Reports still leave as `Flag` from the instance actor, never from the reporting avatar.
- **What PrivaPub reveals is the owner's call.** Previews, blocks, website authorship, views, bridging and reactions were
decided in `docs/ROADMAP.md` ("Owner decisions on what PrivaPub reveals"). Anything new that tells another server
something about an avatar gets the same treatment: ask, then record it there.
- **Location-ranged posts never federate.**
## Data
@@ -209,6 +242,33 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
- **Collection name = class name, so never rename an entity class.**
- `Entity.ID` is a 24-character lowercase hex string; `GenerateNewID()` returns `object`, so cast it.
- New fields must be additive: a deploy rollback restores the binary, not the database.
- **Every stored remote object has an `ObjectRecord`** (raw JSON up to 256 KB plus its hash; delivered or fetched; the
activity, inbox, key and signed headers; received time; up to 10 later revisions). Write it right after the post's
save: `CreateHandler` (delivered) and `RemotePosts.StoreContext` (fetched) do, and `UpdateHandler` appends a
revision. Delivery details reach them through `Arrival.Current`, which `InboxProcessor` sets for the handler's
duration. The raw form lives outside `Post` so timelines never load it. Read through
`/api/privapub/v1/statuses/:id/provenance` and `/api/privapub/v1/instances/:host`.
- **Remote objects are parsed for every shape in `ObjectShapes`:** `url`/`icon`/`image` as a value, an object or an
array; Markdown `content`; missing `mediaType`s inferred; thumbnails from any of their five places; and the typed
`Link`, `Video`, `Audio` and `Event` details. A Mastodon API card is built from those, never by fetching the linked page
(that fetch is the owner's decision 1, for P6).
- **An `Update` is an edit only when its `updated` is newer than ours** (`UpdateHandler.IsEdit`). Otherwise it refreshes
the poll, video, audio and event details and nothing else, and leaves no revision: Mastodon and Misskey refresh poll
counts with bare Updates.
- **A poll vote is a `Note` with a `name`, an `inReplyTo` that is a poll we hold, and no content.** `CreateHandler`
hands it to `PollService.Receive` before anything else, so it never becomes a reply. Our votes on other servers' polls
go only to the poll's author, without `published` (PieFed counts a vote only then).
- **A server is described on arrival, never on read.** The first record from a host enqueues `DescribeInstance` (its
NodeInfo, at most once a week, into `RemoteInstance`), so opening the details view tells nobody anything.
- **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
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
@@ -226,6 +286,9 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
- Localised strings go through `IStringLocalizer<GenericRes>`.
- Every `Display`/`ErrorMessage` resource key must exist in `FieldsNameResource`/`ErrorsResource`, including the
`Designer.cs`, which the CLI build doesn't regenerate. A missing key throws at validation time.
- **`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
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 +
@@ -244,8 +307,39 @@ Beyond the tests, verify by building, running locally, and exercising:
- the client API (sign up, create an avatar, a group, a post);
- the ActivityPub endpoints with curl and `Accept: application/activity+json`.
Interop is checked against real servers: Fediverse Pasture with podman on the workstation, verify.funfedi.dev, and
the owner's GoToSocial at social.arasaka.software. **Ask before acting from the owner's GoToSocial account.**
Interop is checked against real servers, starting with the workstation's own pasture:
```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
+73 -14
View File
@@ -10,14 +10,31 @@ PrivaPub is an ActivityPub server written in C#. This document follows
- [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)
- [FEP-1b12: Group federation](https://codeberg.org/fediverse/fep/src/branch/main/fep/1b12/fep-1b12.md) (communities; see "Groups")
Planned: FEP-1b12 (communities), FEP-8fcf (followers synchronisation), FEP-5feb (`indexable`), FEP-7628 (Move),
FEP-044f (quotes).
Planned (see `docs/ROADMAP.md`, phases P5 to P8, and the per-platform notes in `docs/INTEROP.md`): FEP-044f (quotes),
FEP-9967 (polls), FEP-c0e0 (emoji reactions), FEP-9098 (custom emoji), FEP-7888 and FEP-f228 (threads), FEP-7628 (Move),
FEP-8fcf (followers synchronisation), FEP-5feb (`indexable`), FEP-8967 (link attachments), FEP-521a and FEP-8b32 (keys
and integrity proofs), FEP-ae0c (relays).
## Actors
@@ -39,7 +56,8 @@ actor document, a collection, NodeInfo or a delivery relates two avatars of the
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`.
- `published` on an actor is a whole day, chosen at random up to two weeks before the account was made, so two
personas made on the same day do not share a date. `indexable` is `false`.
- 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.
@@ -47,10 +65,18 @@ The names are the project's own and are stable; resolve actors through WebFinger
A group is either a **community** or a **circle**.
- A community is a `Group` actor. It accepts `Follow` and re-shares (`Announce`) posts from its followers that address
it. Full FEP-1b12 behaviour (announcing activities, `audience`, moderation) is planned.
- A circle is private and does not federate yet: its actor, collections and WebFinger answer 404, and its posts are never
delivered.
- 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
@@ -61,23 +87,51 @@ Received:
| `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 |
| `Create{Note, Article, Page, Question, Video, Audio, Event, ChatMessage, …}` | 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 |
| `Dislike` | counted as a downvote (Lemmy, PieFed, Mbin, Friendica); `Undo` takes it back |
| `EmojiReact`, `Like` with an emoji `content` | an emoji reaction (FEP-c0e0; Pleroma, Akkoma, Iceshrimp.NET, Misskey, Sharkey), Unicode or a custom emoji from its `tag`; a `Like` whose content is ❤ stays a favourite; `Undo` takes it back |
| `Join` | answered with `Ignore`: PrivaPub hosts no events yet (FEP-8a8e) |
| `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin |
| `Delete` | deletes the object, or the actor and its follows |
| `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back |
| `Flag` | becomes a report for this server's moderators |
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
`Announce` (communities). A deleted post answers 410 with a `Tombstone`.
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll votes, `EmojiReact` and its `Undo`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
`Reject{Follow}`, `Like`, `Announce` and their `Undo`, `Flag`. 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 sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it
followed); an unblock sends `Undo{Block}`.
- **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
tags.
**Polls** (FEP-9967) go out as a `Question` with `oneOf` or `anyOf`, each option's count in `replies.totalItems`,
`endTime`, `votersCount`, and `closed` once it has ended. Incoming votes are `Create{Note{name, inReplyTo}}` with no
content, one per choice, counted once per voter. Counts are refreshed with an `Update{Question}` at most every three
minutes. Our own votes on other servers' polls are sent the same way to the poll's author only, without `published`.
An incoming `Update` without a newer `updated` only refreshes counts and details; it is never recorded as an edit.
**Custom emoji** (`Emoji` tags) are read on posts, display names, bios and profile fields, at most 64 per object.
**Profiles** keep their header, profile fields, `manuallyApprovesFollowers`, `published`, `movedTo`, `indexable`,
`memorial` and avatar and header descriptions. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show
`name`. A content warning without its own text uses the title, or "Content warning".
Received `summary` is a content warning only on a `Note` or `Question`, or when `sensitive` is `true`. On an `Article`,
`Page`, `Event`, `Video` or `Audio` it is an excerpt or description, kept as such and never hidden.
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
@@ -89,12 +143,17 @@ followers-only posts are recognised by the author's own `followers` collection.
- **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.
- **Keys we cannot fetch for now.** When a sender's key cannot be fetched because its server timed out or answered
5xx, the inbox answers 503 with `Retry-After: 300` rather than 401.
- **Activity ids.** `Create` and `Announce` ids dereference. `Follow`, `Like`, `Block` and the `Accept`, `Reject` and
`Undo` that answer them do not, because serving them would reveal who follows, likes and blocks whom; they are always
sent with their object embedded.
- **Delivery.** Failed deliveries are retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused,
starting at an hour and growing to a week.
## Known limitations
- Avatars cannot send likes or boosts yet (that arrives with the Mastodon client API), and media uploads and polls are not
implemented.
- Our own posts carry no custom emoji: emoji are received and shown, not offered.
- Remote media is fetched through this server's proxy when a local client displays it.
- Collections expose counts, not members.
- Only `rsa-sha256`-style keys are verified. RFC 9421 signatures are planned.
+1
View File
@@ -9,5 +9,6 @@
public const int GroupInvitationLength = 64;
public const string PasswordRegex = @"^(?=.*)(?=.*[a-z])(?=.*[A-Z])(?!.*\s).*$";
public const string UserNameRegex = "^[a-z0-9_]+$";
}
}
@@ -12,7 +12,7 @@ namespace PrivaPub.ClientModels.Group
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(32, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
RegularExpression("^[a-z0-9_]+$", ErrorMessageResourceName = "EmptySpacesNotAllowed", ErrorMessageResourceType = typeof(ErrorsResource)),
RegularExpression(Constants.UserNameRegex, ErrorMessageResourceName = "InvalidUserName", ErrorMessageResourceType = typeof(ErrorsResource)),
NoWhiteSpaces]
public string UserName { get; set; }
@@ -25,6 +25,7 @@ namespace PrivaPub.ClientModels.Group
public string InvitationPassword { get; set; }
public bool IsCommunity { get; set; }
public string PostingPolicy { get; set; }//followers (default), anyone, moderators
public bool IsDiscoverable { get; set; } = true;
public bool ManuallyApprovesMembers { get; set; }
}
@@ -20,6 +20,7 @@ namespace PrivaPub.ClientModels.Group
public bool RemoveInvitationPassword { get; set; }
public bool RegenerateInvitationCode { get; set; }
public bool? IsDiscoverable { get; set; }
public string PostingPolicy { 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 Handle { get; set; }
public bool IsCommunity { get; set; }
public string PostingPolicy { get; set; }
public bool IsDiscoverable { get; set; }
public bool ManuallyApprovesMembers { get; set; }
public bool IsOwner { get; set; }
+1 -1
View File
@@ -12,7 +12,7 @@ namespace PrivaPub.ClientModels
public string InvitationCode { get; set; }
[StringLength(32, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
RegularExpression("^[a-z0-9_]+$", ErrorMessageResourceName = "EmptySpacesNotAllowed", ErrorMessageResourceType = typeof(ErrorsResource))]
RegularExpression(Constants.UserNameRegex, ErrorMessageResourceName = "InvalidUserName", ErrorMessageResourceType = typeof(ErrorsResource))]
public string AvatarUserName { get; set; }
[StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
@@ -24,6 +24,15 @@ namespace PrivaPub.ClientModels.Post
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
+2
View File
@@ -17,6 +17,8 @@ namespace PrivaPub.ClientModels.Post
public bool IsFederatedCopy { 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
@@ -105,6 +105,15 @@ namespace PrivaPub.ClientModels.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Use only lower-case letters, digits and underscores..
/// </summary>
public static string InvalidUserName {
get {
return ResourceManager.GetString("InvalidUserName", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Invalid password, upper-case, lower-case and number characters required..
/// </summary>
@@ -132,6 +132,9 @@
<data name="InvalidEmail" xml:space="preserve">
<value>{0} è invalida.</value>
</data>
<data name="InvalidUserName" xml:space="preserve">
<value>Usa solo lettere minuscole, cifre e trattini bassi.</value>
</data>
<data name="InvalidPassword" xml:space="preserve">
<value>Password non valida, deve avere almeno un carattere grande, un carattere piccolo ed un numero.</value>
</data>
@@ -132,6 +132,9 @@
<data name="InvalidEmail" xml:space="preserve">
<value>{0} is invalid.</value>
</data>
<data name="InvalidUserName" xml:space="preserve">
<value>Use only lower-case letters, digits and underscores.</value>
</data>
<data name="InvalidPassword" xml:space="preserve">
<value>Invalid password, upper-case, lower-case and number characters required.</value>
</data>
@@ -20,6 +20,7 @@ namespace PrivaPub.ClientModels.User.Avatar
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(Constants.MaxAvatarNameLength, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource)),
RegularExpression(Constants.UserNameRegex, ErrorMessageResourceName = "InvalidUserName", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = nameof(UserName), ResourceType = typeof(FieldsNameResource))]
public string UserName { get; set; }//preferredUsername
+29
View File
@@ -0,0 +1,29 @@
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Models.Post;
namespace PrivaPub.Tests.Api
{
public class StatusContentTests
{
[Fact]
public void A_remote_article_shows_its_title_above_its_body() =>
Assert.Equal("<p><strong>Rain &amp; sun</strong></p><p>body</p>", MastodonMapper.Content(new Post
{
IsFederatedCopy = true, ObjectType = "Article", Title = "Rain & sun", ContentHtml = "<p>body</p>"
}));
[Fact]
public void A_remote_note_keeps_its_content_alone() =>
Assert.Equal("<p>body</p>", MastodonMapper.Content(new Post
{
IsFederatedCopy = true, ObjectType = "Note", Title = "a poll choice", ContentHtml = "<p>body</p>"
}));
[Fact]
public void A_remote_event_without_a_body_shows_its_excerpt() =>
Assert.Equal("<p><strong>Meetup</strong></p><p>Saturday &lt;10:00&gt;</p>", MastodonMapper.Content(new Post
{
IsFederatedCopy = true, ObjectType = "Event", Title = "Meetup", Excerpt = "Saturday <10:00>", ContentHtml = string.Empty
}));
}
}
+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);
}
}
}
+146
View File
@@ -0,0 +1,146 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels.Social;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public sealed class RelationshipTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
[Fact]
public async Task A_block_cuts_follows_both_ways_and_hides_the_account()
{
var token = TestContext.Current.CancellationToken;
var (aliceRoot, alice) = await _harness.Persona("alice");
var (bobRoot, bob) = await _harness.Persona("bob");
await _harness.Follows.Follow(aliceRoot, new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, token);
await _harness.Follows.Follow(bobRoot, new FollowForm { AvatarId = bob.Id, Target = alice.UserName }, token);
await _harness.Relationships.Block(alice, bob.Uri, bob.Id, token);
await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = $"hey @{alice.UserName}" }, token);
Assert.False(await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id || f.AvatarId == bob.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id || f.LocalActorId == bob.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.AuthorAccountId == bob.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_blocked_remote_account_is_refused_when_it_follows()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
await _harness.Relationships.Block(alice, mallory.Id, default, token);
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(mallory)}/follows/{Guid.NewGuid():N}", ["type"] = "Follow", ["actor"] = mallory.Id, ["object"] = alice.Uri
});
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id).ExecuteAnyAsync(token));
var sent = (await _harness.Outgoing(mallory.Id + "/inbox")).Select(a => a["type"]!.GetValue<string>()).ToList();
Assert.Equal(new[] { "Block", "Reject" }, sent.Order());
}
[Fact]
public async Task A_block_is_told_to_the_blocked_server_and_so_is_the_unblock()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(mallory)}/follows/{Guid.NewGuid():N}", ["type"] = "Follow", ["actor"] = mallory.Id, ["object"] = alice.Uri
});
await _harness.Relationships.Block(alice, mallory.Id, default, token);
await _harness.Relationships.Unblock(alice, mallory.Id, token);
var sent = await _harness.Outgoing(mallory.Id + "/inbox");
var block = Assert.Single(sent, a => a["type"]!.GetValue<string>() == "Block");
Assert.Equal(mallory.Id, block["object"]!.GetValue<string>());
Assert.Equal(alice.Uri, block["actor"]!.GetValue<string>());
var undo = Assert.Single(sent, a => a["type"]!.GetValue<string>() == "Undo");
Assert.Equal(block["id"]!.GetValue<string>(), undo["object"]!["id"]!.GetValue<string>());
Assert.False(await DB.Default.Find<Block>().Match(b => b.AvatarId == alice.Id).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_muted_account_stays_out_of_home_and_notifications()
{
var token = TestContext.Current.CancellationToken;
var (aliceRoot, alice) = await _harness.Persona("alice");
var (bobRoot, bob) = await _harness.Persona("bob");
await _harness.Follows.Follow(aliceRoot, new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, token);
await _harness.Relationships.Mute(alice, bob.Uri, bob.Id, hideNotifications: true, default, token);
await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = $"hey @{alice.UserName}" }, token);
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id && n.Type == NotificationType.Mention).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_report_is_forwarded_by_the_instance_not_the_persona()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
var foreign = await _harness.Remote.GetActor(mallory.Id, refresh: false, token);
var report = await _harness.Reports.File(alice, foreign.ID, Array.Empty<string>(), "spam everywhere", "spam", forward: true, token);
Assert.True(report.Forwarded);
var flag = Assert.Single(await _harness.Outgoing(mallory.Id + "/inbox"));
Assert.Equal("Flag", flag["type"]!.GetValue<string>());
Assert.EndsWith("/peasants/privapub", flag["actor"]!.GetValue<string>());
Assert.DoesNotContain(alice.UserName, flag.ToJsonString());
Assert.DoesNotContain(alice.Id, flag.ToJsonString());
}
[Fact]
public async Task An_inbound_flag_becomes_a_report_for_moderators()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var reporter = new RemoteActor(_harness.Peer, "reporter");
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "bad post" }, token);
var post = await DB.Default.Find<PrivaPub.Models.Post.Post>().Match(p => p.GroupUserId == alice.Id).ExecuteSingleAsync(token);
await _harness.Deliver(reporter, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(reporter)}/flags/{Guid.NewGuid():N}", ["type"] = "Flag", ["actor"] = reporter.Id,
["content"] = "please look", ["object"] = new JsonArray(alice.Uri, post.ObjectURI)
});
var report = await DB.Default.Find<Report>().Match(r => r.TargetAccountId == alice.Id).ExecuteSingleAsync(token);
Assert.Equal(reporter.Id, report.ReporterActorURI);
Assert.Equal(new[] { post.ID }, report.PostIds);
Assert.Equal("please look", report.Comment);
}
}
}
@@ -0,0 +1,41 @@
using PrivaPub.Federation.Actors;
using System.Text.Json;
namespace PrivaPub.Tests.Federation
{
public class ActorProfileTests
{
[Fact]
public void Reads_a_mastodon_profile_with_its_header_fields_emoji_and_lock()
{
using var json = JsonDocument.Parse("""
{
"id": "https://m.example/users/ann", "type": "Person", "preferredUsername": "ann", "name": "Ann :sparkle:",
"inbox": "https://m.example/users/ann/inbox", "manuallyApprovesFollowers": true, "indexable": true, "memorial": false,
"published": "2023-04-01T00:00:00Z", "movedTo": "https://n.example/users/ann",
"icon": { "type": "Image", "url": "https://m.example/a.png", "summary": "a cat" },
"image": { "type": "Image", "url": "https://m.example/h.png", "name": "mountains" },
"tag": [{ "type": "Emoji", "name": ":sparkle:", "icon": { "type": "Image", "url": "https://m.example/emoji/sparkle.png" } }],
"attachment": [
{ "type": "PropertyValue", "name": "Site", "value": "<a href=\"https://ann.example\" rel=\"me\">ann.example</a><script>x</script>" },
{ "type": "PropertyValue", "name": "Pronouns", "value": "she/her" }
]
}
""");
var actor = ActorDocument.Parse(json.RootElement);
Assert.True(actor.Locked);
Assert.True(actor.Indexable);
Assert.Equal("https://m.example/h.png", actor.Header);
Assert.Equal("a cat", actor.IconDescription);
Assert.Equal("mountains", actor.HeaderDescription);
Assert.Equal("https://n.example/users/ann", actor.MovedTo);
Assert.Equal(new DateTime(2023, 4, 1, 0, 0, 0, DateTimeKind.Utc), actor.Published);
Assert.Equal("sparkle", Assert.Single(actor.Emojis).Shortcode);
Assert.Equal(new[] { "Site", "Pronouns" }, actor.Fields.Keys);
Assert.DoesNotContain("<script>", actor.Fields["Site"]);
}
}
}
+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));
}
}
}
@@ -0,0 +1,139 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class InboundRoutingTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
static string NewId(RemoteActor actor, string kind) => $"{Origin(actor)}/{kind}/{Guid.NewGuid():N}";
async Task<Post> LocalPost(string text)
{
var (root, alice) = await _harness.Persona("alice");
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = text }, TestContext.Current.CancellationToken);
return await DB.Default.Find<Post>().Match(p => p.GroupUserId == alice.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken);
}
[Fact]
public async Task A_downvote_counts_and_its_undo_takes_it_back()
{
var token = TestContext.Current.CancellationToken;
var post = await LocalPost("vote on me");
var lemmy = new RemoteActor(_harness.Peer, "voter");
var dislike = new JsonObject { ["id"] = NewId(lemmy, "activities/dislike"), ["type"] = "Dislike", ["actor"] = lemmy.Id, ["object"] = post.ObjectURI };
await _harness.Deliver(lemmy, "/human-centipede", dislike);
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).DownvotesCount);
await _harness.Deliver(lemmy, "/human-centipede", new JsonObject
{
["id"] = NewId(lemmy, "activities/undo"), ["type"] = "Undo", ["actor"] = lemmy.Id, ["object"] = dislike.DeepClone()
});
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).DownvotesCount);
}
[Fact]
public async Task A_delete_that_arrives_first_keeps_the_post_from_coming_back()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
var noteId = NewId(mallory, "notes");
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = NewId(mallory, "activities/delete"), ["type"] = "Delete", ["actor"] = mallory.Id,
["object"] = new JsonObject { ["id"] = noteId, ["type"] = "Tombstone" }
});
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = NewId(mallory, "activities/create"), ["type"] = "Create", ["actor"] = mallory.Id,
["object"] = new JsonObject
{
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = mallory.Id, ["content"] = "<p>late</p>",
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(alice.Uri),
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" })
}
});
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_lemmy_private_message_arrives_as_a_direct_message()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var lemmy = new RemoteActor(_harness.Peer, "pm");
var messageId = NewId(lemmy, "private_message");
await _harness.Deliver(lemmy, "/human-centipede", new JsonObject
{
["id"] = NewId(lemmy, "activities/create"), ["type"] = "Create", ["actor"] = lemmy.Id, ["to"] = new JsonArray(alice.Uri),
["object"] = new JsonObject
{
["id"] = messageId, ["type"] = "ChatMessage", ["attributedTo"] = lemmy.Id, ["to"] = new JsonArray(alice.Uri),
["content"] = "<p>psst</p>", ["mediaType"] = "text/html", ["published"] = DateTime.UtcNow.ToString("O")
}
});
var message = await DB.Default.Find<Post>().Match(p => p.ObjectURI == messageId).ExecuteFirstAsync(token);
Assert.NotNull(message);
Assert.Equal(PostVisibility.Direct, message.Visibility);
Assert.NotNull(message.ConversationId);
}
[Fact]
public async Task An_unreachable_key_asks_the_sender_to_retry_instead_of_refusing()
{
var (_, alice) = await _harness.Persona("alice");
var offline = new RemoteActor(_harness.Peer, "offline", origin: "http://127.0.0.1:9");
var result = await _harness.Deliver(offline, "/human-centipede", new JsonObject
{
["id"] = NewId(offline, "follows"), ["type"] = "Follow", ["actor"] = offline.Id, ["object"] = alice.Uri
});
Assert.Equal(503, result.StatusCode);
Assert.Equal(300, result.RetryAfterSeconds);
}
[Fact]
public async Task A_join_of_a_local_post_is_answered_with_ignore()
{
var post = await LocalPost("not an event");
var mobilizon = new RemoteActor(_harness.Peer, "joiner");
await _harness.Deliver(mobilizon, "/human-centipede", new JsonObject
{
["id"] = NewId(mobilizon, "join"), ["type"] = "Join", ["actor"] = mobilizon.Id, ["object"] = post.ObjectURI
});
var answer = Assert.Single(await _harness.Outgoing(mobilizon.Id + "/inbox"));
Assert.Equal("Ignore", answer["type"]!.GetValue<string>());
}
}
}
@@ -8,6 +8,8 @@ using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Moderation;
using PrivaPub.Domain.Social;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
using PrivaPub.Infrastructure.Jobs;
@@ -53,10 +55,10 @@ namespace PrivaPub.Tests.Federation
_processor = new InboxProcessor(remote, new IActivityHandler[]
{
new FollowHandler(db, _local, remote, delivery),
new UndoHandler(db, _local),
new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue)),
new DeleteHandler(db, _local, remote, delivery),
new UpdateHandler(db, _local, remote)
new UndoHandler(db, _local, new Reactions(db, delivery)),
new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue, new ObjectRecords(queue)), new GroupDistributor(delivery), new ObjectRecords(queue), new PollService(db, _local, delivery, queue)),
new DeleteHandler(db, _local, remote, delivery, new GroupDistributor(delivery)),
new UpdateHandler(db, _local, remote, new GroupDistributor(delivery), new ObjectRecords(queue))
}, NullLogger<InboxProcessor>.Instance);
}
@@ -323,7 +325,7 @@ namespace PrivaPub.Tests.Federation
}
[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 (privateKey, publicKey) = Keys.NewKeyPair();
@@ -339,8 +341,13 @@ namespace PrivaPub.Tests.Federation
["object"] = actor.Uri
};
Assert.False(actor.IsFederated);
Assert.Equal(404, (await Deliver(bob, "/human-centipede", follow)).StatusCode);
Assert.True(actor.IsCircle);
Assert.True(actor.ManuallyApprovesFollowers);
Assert.False(actor.Discoverable);
Assert.Equal(202, (await Deliver(bob, "/human-centipede", follow)).StatusCode);
var request = await DB.Default.Find<Follower>().Match(f => f.LocalActorId == circle.ID).ExecuteSingleAsync(token);
Assert.False(request.IsAccepted);
Assert.DoesNotContain(circle.Members, m => m.AvatarId == bob.Id);
}
}
}
@@ -54,6 +54,7 @@ namespace PrivaPub.Tests.Federation
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]
@@ -89,6 +90,28 @@ namespace PrivaPub.Tests.Federation
Assert.Equal("https://k.example/notes/8", note.QuoteUri);
}
[Theory]
[InlineData("Article", false, false)]
[InlineData("Event", false, false)]
[InlineData("Video", false, false)]
[InlineData("Page", false, false)]
[InlineData("Note", false, true)]
[InlineData("Question", false, true)]
[InlineData("Article", true, true)]
public void A_summary_warns_only_on_notes_or_when_marked_sensitive(string type, bool sensitive, bool warns)
{
var note = NoteParser.Parse(JsonNode.Parse($$"""
{
"id": "https://w.example/?p=12", "type": "{{type}}", "name": "Twelve", "summary": "Saturday, 10:00 at the library",
"sensitive": {{(sensitive ? "true" : "false")}}, "attributedTo": "https://w.example/author", "content": "<p>body</p>"
}
"""));
Assert.Equal(warns, note.Sensitive);
Assert.Equal(warns ? "Saturday, 10:00 at the library" : null, note.SpoilerText);
Assert.Equal(warns ? null : "Saturday, 10:00 at the library", note.Excerpt);
}
[Theory]
[InlineData("""{ "id": "https://x.example/1", "type": "Person" }""")]
[InlineData("""{ "id": "not a uri", "type": "Note" }""")]
@@ -0,0 +1,224 @@
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
public class ObjectShapesTests
{
static NoteDocument Parse(string json) => NoteParser.Parse(JsonNode.Parse(json));
[Fact]
public void Keeps_a_peertube_video_with_its_files_poster_captions_and_markdown_description()
{
var note = Parse("""
{
"id": "https://tube.example/videos/watch/1", "type": "Video", "name": "A *talk*", "duration": "PT6299S",
"mediaType": "text/markdown", "content": "**Bold** words", "sensitive": false, "views": 14771, "state": 1,
"isLiveBroadcast": false, "commentsPolicy": 2, "downloadEnabled": true,
"licence": { "identifier": "5", "name": "Attribution - Share Alike" }, "category": { "identifier": "2", "name": "Films" },
"attributedTo": [{ "type": "Person", "id": "https://tube.example/accounts/ann" }, { "type": "Group", "id": "https://tube.example/video-channels/films" }],
"icon": [
{ "type": "Image", "url": "https://tube.example/thumb-280.jpg", "mediaType": "image/jpeg", "width": 280, "height": 157 },
{ "type": "Image", "url": "https://tube.example/thumb-1920.jpg", "mediaType": "image/jpeg", "width": 1920, "height": 1080 },
{ "type": "Image", "url": "https://tube.example/thumb-850.jpg", "mediaType": "image/jpeg", "width": 850, "height": 480 }
],
"subtitleLanguage": [{ "identifier": "en", "url": [{ "mediaType": "text/vtt", "href": "https://tube.example/en.vtt" }] }],
"url": [
{ "type": "Link", "mediaType": "text/html", "href": "https://tube.example/w/abc" },
{ "type": "Link", "mediaType": "video/mp4", "href": "https://tube.example/720.mp4", "height": 720, "width": 1280, "fps": 24, "size": 942586378,
"attachment": [{ "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "audio" }, { "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "video" }] },
{ "type": "Link", "mediaType": "application/x-mpegURL", "href": "https://tube.example/master.m3u8" },
{ "type": "Link", "mediaType": "application/x-bittorrent;x-scheme-handler/magnet", "href": "magnet:?xt=urn:btih:abc" }
]
}
""");
Assert.Equal("https://tube.example/w/abc", note.Url);
Assert.Equal("A *talk*", note.Title);
Assert.Contains("<strong>Bold</strong>", note.ContentHtml);
Assert.Equal("https://tube.example/thumb-850.jpg", note.CoverURL);
Assert.Equal(6299, note.Video.DurationSeconds);
var file = Assert.Single(note.Video.Variants);
Assert.Equal(new[] { "audio", "video" }, file.Streams);
Assert.Equal(942586378, file.Size);
Assert.Equal("https://tube.example/master.m3u8", note.Video.PlaylistURL);
Assert.Equal(2, note.Video.CommentsPolicy);
Assert.Equal("Attribution - Share Alike", note.Video.Licence);
Assert.Equal("https://tube.example/video-channels/films", note.Video.ChannelURI);
Assert.Equal("https://tube.example/en.vtt", Assert.Single(note.Video.Captions).Url);
Assert.Equal("https://tube.example/accounts/ann", note.AttributedTo);
}
[Fact]
public void Keeps_a_mobilizon_event_with_its_time_place_and_online_link()
{
var note = Parse("""
{
"id": "https://events.example/events/9", "type": "Event", "name": "Repair café",
"summary": "<p>Saturday at the library</p>", "content": "<p>Bring broken things.</p>",
"startTime": "2026-10-03T10:00:00+02:00", "endTime": "2026-10-03T13:00:00+02:00", "timezone": "Europe/Rome",
"joinMode": "free", "participantCount": 12, "maximumAttendeeCapacity": 40, "ical:status": "CONFIRMED",
"location": { "type": "Place", "name": "Biblioteca", "latitude": 45.07, "longitude": 7.68,
"address": { "type": "PostalAddress", "streetAddress": "Via Roma 1", "addressLocality": "Torino", "addressCountry": "IT" } },
"attachment": [{ "type": "Link", "name": "Website", "href": "https://meet.example/repair" }],
"attributedTo": "https://events.example/@club"
}
""");
Assert.Null(note.SpoilerText);
Assert.False(note.Sensitive);
Assert.Equal("Saturday at the library", note.Excerpt);
Assert.Equal(new DateTime(2026, 10, 3, 8, 0, 0, DateTimeKind.Utc), note.Event.Start);
Assert.Equal("Via Roma 1, Torino, IT", Assert.Single(note.Event.Places).Address);
Assert.Equal("https://meet.example/repair", note.Event.OnlineURL);
Assert.Equal(40, note.Event.Capacity);
Assert.Equal("CONFIRMED", note.Event.Status);
Assert.Equal("2026-10-03 10:00 – 13:00 (Europe/Rome) · Biblioteca", MastodonMapper.EventLine(note.Event));
}
[Fact]
public void Keeps_a_lemmy_link_post_as_a_link_with_its_thumbnail()
{
var note = Parse("""
{
"id": "https://lemmy.example/post/5", "type": "Page", "name": "A good read", "attributedTo": "https://lemmy.example/u/bo",
"attachment": [{ "type": "Link", "href": "https://news.example/story", "mediaType": "text/html" }],
"image": { "type": "Image", "url": "https://lemmy.example/pictrs/thumb.jpg" },
"language": { "identifier": "en", "name": "English" }
}
""");
Assert.Equal("https://news.example/story", note.Link.Href);
Assert.Equal("https://lemmy.example/pictrs/thumb.jpg", note.Link.ImageURL);
Assert.False(note.Link.FromPublisher);
Assert.Empty(note.Attachments);
}
[Fact]
public void Takes_a_publisher_supplied_preview_from_a_link_attachment()
{
var note = Parse("""
{
"id": "https://m.example/statuses/1", "type": "Note", "attributedTo": "https://m.example/users/a", "content": "<p>look</p>",
"attachment": [{ "type": "Link", "href": "https://news.example/a",
"preview": { "type": "Article", "name": "Headline &amp; more", "summary": "<p>What happened</p>", "image": { "type": "Image", "url": "https://news.example/a.jpg" } } }]
}
""");
Assert.Equal("Headline & more", note.Link.Title);
Assert.Equal("What happened", note.Link.Description);
Assert.Equal("https://news.example/a.jpg", note.Link.ImageURL);
Assert.True(note.Link.FromPublisher);
}
[Fact]
public void Reads_media_in_every_shape_and_infers_what_is_missing()
{
var note = Parse("""
{
"id": "https://bsky.brid.gy/convert/ap/at://did:plc:x/post/1", "type": "Note", "attributedTo": "https://bsky.brid.gy/ap/did:plc:x",
"content": "<p>pics</p>",
"@context": ["https://www.w3.org/ns/activitystreams", { "@language": "de" }],
"attachment": [
{ "type": "Image", "url": "https://cdn.example/a.jpeg", "summary": "alt from summary" },
{ "type": "Document", "url": [{ "type": "Link", "href": "https://cdn.example/clip", "mediaType": "video/mp4" }],
"icon": { "type": "Image", "url": "https://cdn.example/clip.jpg" }, "duration": "PT12.4S", "sensitive": true },
{ "type": "Image", "url": "https://cdn.example/nameless" }
]
}
""");
Assert.Equal("de", note.Language);
Assert.Equal("image/jpeg", note.Attachments[0].ContentType);
Assert.Equal("alt from summary", note.Attachments[0].Description);
Assert.Equal("https://cdn.example/clip", note.Attachments[1].RemoteURL);
Assert.Equal("video/mp4", note.Attachments[1].ContentType);
Assert.Equal("https://cdn.example/clip.jpg", note.Attachments[1].RemotePreviewURL);
Assert.Equal(12.4, note.Attachments[1].DurationSeconds);
Assert.True(note.Attachments[1].Sensitive);
Assert.Equal("image/*", note.Attachments[2].ContentType);
}
[Fact]
public void Keeps_a_funkwhale_track_and_a_loops_poster()
{
var track = Parse("""
{
"id": "https://music.example/federation/music/uploads/1", "type": "Audio", "name": "Song", "attributedTo": "https://music.example/@band",
"summary": "#rock #live", "duration": 241, "position": 3, "album": "https://music.example/albums/2", "license": "https://creativecommons.org/licenses/by/4.0/",
"url": [{ "type": "Link", "mediaType": "text/html", "href": "https://music.example/library/tracks/1" },
{ "type": "Link", "mediaType": "audio/mpeg", "href": "https://music.example/listen/1.mp3", "bitrate": 320000, "size": 9640000 }]
}
""");
var loop = Parse("""
{
"id": "https://loops.example/v/1", "type": "Note", "attributedTo": "https://loops.example/@dancer", "content": "",
"preview": { "type": "Image", "url": { "href": "https://loops.example/poster.jpg", "mediaType": "image/jpeg" } },
"attachment": [{ "type": "Document", "mediaType": "video/mp4", "url": "https://loops.example/v/1.mp4", "width": 720, "height": 1280 }]
}
""");
Assert.Equal("#rock #live", track.Excerpt);
Assert.Null(track.SpoilerText);
Assert.Equal(241, track.Audio.DurationSeconds);
Assert.Equal(3, track.Audio.Position);
Assert.Equal("https://music.example/listen/1.mp3", Assert.Single(track.Audio.Variants).Url);
Assert.Equal("https://loops.example/poster.jpg", loop.CoverURL);
}
[Fact]
public void Keeps_custom_emoji_and_drops_ones_that_could_not_render()
{
var note = Parse("""
{
"id": "https://k.example/notes/1", "type": "Note", "attributedTo": "https://k.example/users/1", "content": ":blobcat: hi :bad:",
"tag": [
{ "type": "Emoji", "name": ":blobcat:", "updated": "2026-01-01T00:00:00Z", "icon": { "type": "Image", "mediaType": "image/png", "url": "https://k.example/emoji/blobcat.png" } },
{ "type": "Emoji", "name": ":blobcat:", "icon": { "type": "Image", "url": "https://k.example/emoji/duplicate.png" } },
{ "type": "Emoji", "name": ":bad name:", "icon": { "type": "Image", "url": "https://k.example/emoji/x.png" } },
{ "type": "Emoji", "name": ":nourl:", "icon": { "type": "Image", "url": "data:image/png;base64,AAAA" } }
]
}
""");
var emoji = Assert.Single(note.Emojis);
Assert.Equal("blobcat", emoji.Shortcode);
Assert.Equal("https://k.example/emoji/blobcat.png", emoji.URL);
}
[Fact]
public void Reads_polls_from_mastodon_misskey_and_closed_ones()
{
var mastodon = Parse("""
{ "id": "https://m.example/q/1", "type": "Question", "attributedTo": "https://m.example/u", "content": "?", "endTime": "2026-10-02T00:00:00Z", "votersCount": 7,
"oneOf": [{ "type": "Note", "name": "a", "replies": { "type": "Collection", "totalItems": 4 } }, { "type": "Note", "name": "b", "replies": { "type": "Collection", "totalItems": 3 } }] }
""");
var misskey = Parse("""
{ "id": "https://k.example/q/1", "type": "Question", "attributedTo": "https://k.example/u", "_misskey_content": "?",
"anyOf": [{ "type": "Note", "name": "x", "_misskey_votes": 2 }, { "type": "Note", "name": "y", "_misskey_votes": 5 }] }
""");
var closed = Parse("""
{ "id": "https://g.example/q/1", "type": "Question", "attributedTo": "https://g.example/u", "content": "?", "closed": "2026-09-30T12:00:00Z",
"oneOf": [{ "type": "Note", "name": "p" }, { "type": "Note", "name": "q" }] }
""");
Assert.False(mastodon.Poll.Multiple);
Assert.Equal(new[] { 4, 3 }, mastodon.Poll.Options.Select(o => o.Votes));
Assert.Equal(7, mastodon.Poll.VotersCount);
Assert.True(misskey.Poll.Multiple);
Assert.Equal(new[] { 2, 5 }, misskey.Poll.Options.Select(o => o.Votes));
Assert.Null(misskey.Poll.VotersCount);
Assert.Equal(new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), closed.Poll.ClosedAt);
}
[Fact]
public void Normalises_hashtags_like_mastodon() =>
Assert.Equal("fedi".Normalize(System.Text.NormalizationForm.FormKC).ToLowerInvariant(), Assert.Single(Parse("""
{ "id": "https://x.example/1", "type": "Note", "attributedTo": "https://x.example/u", "content": "",
"tag": [{ "type": "Hashtag", "name": "#FEDI" }] }
""").Tags));
}
}
+138
View File
@@ -0,0 +1,138 @@
using MongoDB.Entities;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class PollTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
static string NewId(RemoteActor actor, string kind) => $"{Origin(actor)}/{kind}/{Guid.NewGuid():N}";
async Task Follows(RemoteActor follower, string target) =>
await _harness.Deliver(follower, "/human-centipede", new JsonObject
{
["id"] = NewId(follower, "follows"), ["type"] = "Follow", ["actor"] = follower.Id, ["object"] = target
});
JsonObject Vote(RemoteActor voter, string question, string owner, string choice) => new()
{
["id"] = NewId(voter, "activities"), ["type"] = "Create", ["actor"] = voter.Id, ["to"] = new JsonArray(owner),
["object"] = new JsonObject
{
["id"] = NewId(voter, "votes"), ["type"] = "Note", ["name"] = choice, ["attributedTo"] = voter.Id,
["inReplyTo"] = question, ["to"] = new JsonArray(owner)
}
};
[Fact]
public async Task A_local_poll_goes_out_as_a_question_and_counts_each_remote_voter_once()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
await Follows(mallory, alice.Uri);
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "which?", Poll = new PollDraft(new[] { "tea", "coffee" }, 3600, false, false) }, token);
Assert.True(outcome.Ok);
var question = (await _harness.Outgoing(mallory.Id + "/inbox")).Single(a => a["type"]!.GetValue<string>() == "Create")["object"]!;
Assert.Equal("Question", question["type"]!.GetValue<string>());
Assert.Equal(new[] { "tea", "coffee" }, question["oneOf"]!.AsArray().Select(o => o!["name"]!.GetValue<string>()));
await _harness.Deliver(mallory, "/human-centipede", Vote(mallory, outcome.Post.ObjectURI, alice.Uri, "coffee"));
await _harness.Deliver(mallory, "/human-centipede", Vote(mallory, outcome.Post.ObjectURI, alice.Uri, "tea"));
var poll = (await DB.Default.Find<Post>().OneAsync(outcome.Post.ID, token)).Poll;
Assert.Equal(new[] { 0, 1 }, poll.Options.Select(o => o.Votes));
Assert.Equal(1, poll.VotersCount);
Assert.False(await DB.Default.Find<Post>().Match(p => p.InReplyToURI == outcome.Post.ObjectURI).ExecuteAnyAsync(token));
Assert.True(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.PollRefresh && j.Payload == outcome.Post.ID).ExecuteAnyAsync(token));
Assert.True(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.PollClose && j.Payload == outcome.Post.ID).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_persona_votes_on_a_remote_poll_and_a_count_refresh_is_not_an_edit()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
var questionId = NewId(mallory, "questions");
JsonObject Question(int first, int second) => new()
{
["id"] = questionId, ["type"] = "Question", ["attributedTo"] = mallory.Id, ["content"] = "<p>pick</p>",
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(alice.Uri),
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" }),
["endTime"] = DateTime.UtcNow.AddDays(1).ToString("O"),
["anyOf"] = new JsonArray(
new JsonObject { ["type"] = "Note", ["name"] = "red", ["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = first } },
new JsonObject { ["type"] = "Note", ["name"] = "blue", ["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = second } })
};
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = NewId(mallory, "activities"), ["type"] = "Create", ["actor"] = mallory.Id, ["object"] = Question(3, 1)
});
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == questionId).ExecuteFirstAsync(token);
Assert.True(post.Poll.Multiple);
Assert.True((await _harness.Polls.Vote(alice, post, new[] { 0, 1 }, token)).Ok);
Assert.False((await _harness.Polls.Vote(alice, post, new[] { 0 }, token)).Ok);
var votes = (await _harness.Outgoing(mallory.Id + "/inbox")).Where(a => a["type"]!.GetValue<string>() == "Create").Select(a => a["object"]!).ToList();
Assert.Equal(new[] { "red", "blue" }, votes.Select(v => v["name"]!.GetValue<string>()).Order().Reverse());
Assert.All(votes, v => Assert.Equal(questionId, v["inReplyTo"]!.GetValue<string>()));
Assert.All(votes, v => Assert.Null(v["published"]));
Assert.Equal(new[] { 0, 1 }, (await _harness.Polls.OwnVotes(post.ID, alice.Id, token)).Order());
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = NewId(mallory, "activities"), ["type"] = "Update", ["actor"] = mallory.Id, ["object"] = Question(9, 5)
});
var refreshed = await DB.Default.Find<Post>().OneAsync(post.ID, token);
Assert.Equal(new[] { 9, 5 }, refreshed.Poll.Options.Select(o => o.Votes));
Assert.Empty(refreshed.Revisions);
Assert.Null(refreshed.EditedAt);
}
[Fact]
public async Task Closing_a_poll_tells_its_voters_and_its_author()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var (_, bob) = await _harness.Persona("bob");
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "quick", Poll = new PollDraft(new[] { "yes", "no" }, 300, false, false) }, token);
Assert.True((await _harness.Polls.Vote(bob, outcome.Post, new[] { 0 }, token)).Ok);
Assert.False((await _harness.Polls.Vote(alice, outcome.Post, new[] { 0 }, token)).Ok);
var job = await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.PollClose && j.Payload == outcome.Post.ID).ExecuteFirstAsync(token);
await new PollCloseJob(_harness.Db, _harness.Local, _harness.Outbox).Handle(job, token);
Assert.NotNull((await DB.Default.Find<Post>().OneAsync(outcome.Post.ID, token)).Poll.ClosedAt);
var told = await DB.Default.Find<Notification>().Match(n => n.Type == NotificationType.Poll && n.PostId == outcome.Post.ID).ExecuteAsync(token);
Assert.Equal(new[] { alice.Id, bob.Id }.Order(), told.Select(n => n.AvatarId).Order());
Assert.False((await _harness.Polls.Vote(bob, await DB.Default.Find<Post>().OneAsync(outcome.Post.ID, token), new[] { 1 }, token)).Ok);
}
}
}
@@ -0,0 +1,85 @@
using MongoDB.Entities;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class ProvenanceTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
[Fact]
public async Task A_delivered_post_keeps_its_raw_object_how_it_came_and_every_edit()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
var origin = new Uri(mallory.Id).GetLeftPart(UriPartial.Authority);
var note = new JsonObject
{
["id"] = $"{origin}/notes/{Guid.NewGuid():N}",
["type"] = "Note",
["attributedTo"] = mallory.Id,
["content"] = "<p>hi</p>",
["_misskey_quote"] = $"{origin}/notes/elsewhere",
["to"] = new JsonArray(Addressing.Public),
["cc"] = new JsonArray(alice.Uri),
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" })
};
var create = new JsonObject { ["id"] = $"{origin}/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = mallory.Id, ["object"] = note.DeepClone() };
await _harness.Deliver(mallory, "/human-centipede", create);
var edited = note.DeepClone().AsObject();
edited["content"] = "<p>hi again</p>";
edited["updated"] = DateTime.UtcNow.ToString("O");
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = $"{origin}/activities/{Guid.NewGuid():N}", ["type"] = "Update", ["actor"] = mallory.Id, ["object"] = edited
});
var record = await DB.Default.Find<ObjectRecord>().Match(r => r.ObjectURI == note["id"]!.GetValue<string>()).ExecuteFirstAsync(token);
Assert.NotNull(record);
Assert.Equal(ObjectPath.Delivered, record.Path);
Assert.False(record.Refetched);
Assert.Equal("shared", record.Inbox);
Assert.Equal(mallory.KeyId, record.KeyId);
Assert.Equal(create["id"]!.GetValue<string>(), record.ActivityId);
Assert.Contains("digest", record.SignedHeaders);
Assert.Equal("<p>hi</p>", JsonNode.Parse(record.Raw)!["content"]!.GetValue<string>());
Assert.Contains("legacy-quote", ObjectFeatures.Detect(JsonNode.Parse(record.Raw)!.AsObject()));
Assert.Equal("<p>hi again</p>", JsonNode.Parse(Assert.Single(record.Revisions).Raw)!["content"]!.GetValue<string>());
Assert.True(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.DescribeInstance && j.Payload == record.Host).ExecuteAnyAsync(token));
}
[Fact]
public void An_object_larger_than_the_cap_keeps_only_its_hash()
{
var big = new JsonObject { ["content"] = new string('x', ObjectRecords.MaxRawBytes + 1) };
var (text, hash, bytes, truncated) = ObjectRecords.Capture(big);
Assert.Null(text);
Assert.True(truncated);
Assert.True(bytes > ObjectRecords.MaxRawBytes);
Assert.Equal(64, hash.Length);
}
}
}
+119
View File
@@ -0,0 +1,119 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.Domain.Social;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
public class ReactionNamesTests
{
[Theory]
[InlineData(":blobcat@k.example:", "blobcat")]
[InlineData("👍🏽", "👍🏽")]
[InlineData("❤️", "❤️")]
[InlineData("nope", null)]
[InlineData("👍👍", null)]
[InlineData(":unknown:", null)]
public void Normalises_what_peers_send(string content, string expected) =>
Assert.Equal(expected, Reactions.Normalise(content, new List<CustomEmoji> { new() { Shortcode = "blobcat", URL = "https://k.example/e.png" } })?.Name);
}
[Trait("Category", "Integration")]
public sealed class ReactionTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
static string NewId(RemoteActor actor, string kind) => $"{Origin(actor)}/{kind}/{Guid.NewGuid():N}";
async Task<Post> LocalPost()
{
var (root, alice) = await _harness.Persona("alice");
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "react to me" }, TestContext.Current.CancellationToken);
return await DB.Default.Find<Post>().Match(p => p.GroupUserId == alice.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken);
}
[Fact]
public async Task Misskey_and_pleroma_reactions_are_kept_apart_from_favourites()
{
var token = TestContext.Current.CancellationToken;
var post = await LocalPost();
var misskey = new RemoteActor(_harness.Peer, "kitty");
var pleroma = new RemoteActor(_harness.Peer, "pleroma");
await _harness.Deliver(misskey, "/human-centipede", new JsonObject
{
["id"] = NewId(misskey, "likes"), ["type"] = "Like", ["actor"] = misskey.Id, ["object"] = post.ObjectURI, ["content"] = ":blobcat:",
["_misskey_reaction"] = ":blobcat:",
["tag"] = new JsonArray(new JsonObject { ["type"] = "Emoji", ["name"] = ":blobcat:", ["icon"] = new JsonObject { ["type"] = "Image", ["url"] = $"{Origin(misskey)}/emoji/blobcat.png" } })
});
await _harness.Deliver(misskey, "/human-centipede", new JsonObject
{
["id"] = NewId(misskey, "likes"), ["type"] = "Like", ["actor"] = misskey.Id, ["object"] = post.ObjectURI, ["content"] = "❤"
});
var fire = new JsonObject { ["id"] = NewId(pleroma, "activities"), ["type"] = "EmojiReact", ["actor"] = pleroma.Id, ["object"] = post.ObjectURI, ["content"] = "🔥" };
await _harness.Deliver(pleroma, "/human-centipede", fire);
var kept = await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID).ExecuteAsync(token);
Assert.Equal(new HashSet<string> { "blobcat", "🔥" }, kept.Select(r => r.Emoji).ToHashSet());
Assert.Equal($"{Origin(misskey)}/emoji/blobcat.png", kept.Single(r => r.Emoji == "blobcat").EmojiURL);
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
Assert.True(await DB.Default.Find<Notification>().Match(n => n.Type == NotificationType.Reaction && n.Emoji == "🔥").ExecuteAnyAsync(token));
await _harness.Deliver(pleroma, "/human-centipede", new JsonObject
{
["id"] = NewId(pleroma, "activities"), ["type"] = "Undo", ["actor"] = pleroma.Id, ["object"] = fire.DeepClone()
});
Assert.Equal("blobcat", Assert.Single(await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID).ExecuteAsync(token)).Emoji);
}
[Fact]
public async Task A_persona_reacts_to_a_remote_post_and_takes_it_back()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
var noteId = NewId(mallory, "notes");
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = NewId(mallory, "activities"), ["type"] = "Create", ["actor"] = mallory.Id,
["object"] = new JsonObject
{
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = mallory.Id, ["content"] = "<p>hi</p>",
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(alice.Uri),
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" })
}
});
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(token);
Assert.True(await _harness.Reactions.React(alice, post, "🎉", token));
Assert.False(await _harness.Reactions.React(alice, post, "not an emoji", token));
Assert.True(await _harness.Reactions.Unreact(alice, post, "🎉", token));
var sent = await _harness.Outgoing(mallory.Id + "/inbox");
var react = Assert.Single(sent, a => a["type"]!.GetValue<string>() == "EmojiReact");
Assert.Equal("🎉", react["content"]!.GetValue<string>());
Assert.Equal(noteId, react["object"]!.GetValue<string>());
Assert.Equal(react["id"]!.GetValue<string>(), Assert.Single(sent, a => a["type"]!.GetValue<string>() == "Undo")["object"]!["id"]!.GetValue<string>());
}
}
}
+2 -2
View File
@@ -57,7 +57,7 @@ namespace PrivaPub.Tests.Federation
Language = "en",
Mentions = new() { new PostMention { ActorURI = "https://m.example/users/bob", Handle = "@bob@m.example" } },
Tags = new() { "cats" },
CreationDate = new DateTime(2026, 9, 30, 17, 45, 12, DateTimeKind.Utc)
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");
@@ -67,7 +67,7 @@ namespace PrivaPub.Tests.Federation
Assert.Equal("<p><strong>On &lt;cats&gt;</strong></p><p>body</p>", note["content"]!.GetValue<string>());
Assert.Equal("On <cats>", note["summary"]!.GetValue<string>());
Assert.True(note["sensitive"]!.GetValue<bool>());
Assert.Equal("2026-09-30T17:45:12Z", note["published"]!.GetValue<string>());
Assert.Equal("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();
@@ -12,15 +12,18 @@ namespace PrivaPub.Tests.Infrastructure
[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();
var avatar = new Avatar();
var first = (string)avatar.GenerateNewID();
var second = (string)new Avatar { PublishedOn = avatar.PublishedOn }.GenerateNewID();
var group = new GroupEntity();
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(avatar.PublishedOn, a.CreationTime);
Assert.InRange(a.CreationTime, DateTime.UtcNow.Date.AddDays(-13), DateTime.UtcNow.Date);
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);
Assert.Equal(group.PublishedOn, ObjectId.Parse((string)group.GenerateNewID()).CreationTime);
}
[Fact]
@@ -33,6 +36,33 @@ namespace PrivaPub.Tests.Infrastructure
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_persona_publishes_a_day_within_two_weeks_before_its_creation()
{
var created = new DateTime(2026, 10, 1, 15, 30, 0, DateTimeKind.Utc);
var days = Enumerable.Range(0, 200).Select(_ => PrivacyIds.PublishedDay(created)).ToList();
Assert.All(days, d => Assert.Equal(TimeSpan.Zero, d.TimeOfDay));
Assert.All(days, d => Assert.InRange(d, created.Date.AddDays(-13), created.Date));
Assert.True(days.Distinct().Count() > 1);
}
[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));
+30 -8
View File
@@ -5,6 +5,8 @@ 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;
@@ -14,6 +16,7 @@ using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Federation.Outbox;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.User;
@@ -39,26 +42,38 @@ namespace PrivaPub.Tests.Support
Remote = new RemoteActorService(Peer.Http(cache), Local, cache, Db);
Delivery = new DeliveryService(Db, Queue);
Fanout = new Fanout(Db);
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue);
Groups = new GroupDistributor(Delivery);
Records = new ObjectRecords(Queue);
Polls = new PollService(Db, Local, Delivery, Queue);
Reactions = new Reactions(Db, Delivery);
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue, Records);
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance);
Processor = new InboxProcessor(Remote, new IActivityHandler[]
{
new FollowHandler(Db, Local, Remote, Delivery),
new AcceptHandler(Db, Local),
new RejectHandler(Db, Local),
new UndoHandler(Db, Local),
new LikeHandler(Db),
new AnnounceHandler(Db, Local, RemotePosts, Fanout),
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts),
new DeleteHandler(Db, Local, Remote, Delivery),
new UpdateHandler(Db, Local, Remote)
new UndoHandler(Db, Local, Reactions),
new LikeHandler(Db, Reactions),
new EmojiReactHandler(Db, Reactions),
new DislikeHandler(Db),
new JoinHandler(Db, Local, Delivery),
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote),
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls),
new DeleteHandler(Db, Local, Remote, Delivery, Groups),
new UpdateHandler(Db, Local, Remote, Groups, Records),
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);
Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout);
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, Polls);
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; }
@@ -74,9 +89,16 @@ namespace PrivaPub.Tests.Support
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 ObjectRecords Records { get; }
public PollService Polls { get; }
public Reactions Reactions { 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
+9
View File
@@ -19,6 +19,7 @@ namespace PrivaPub.Tests.Support
readonly WebApplication _app;
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 string A => $"http://127.0.0.1:{Port}";
@@ -41,6 +42,12 @@ namespace PrivaPub.Tests.Support
{
peer.Requests.Enqueue(new(context.Request.Method, context.Request.Path, context.Request.Headers["Signature"].ToString()));
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)
@@ -63,6 +70,8 @@ namespace PrivaPub.Tests.Support
public void Serve(string path, string json) => _documents[path] = json;
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)
+20 -2
View File
@@ -14,13 +14,16 @@ namespace PrivaPub.Tests.Support
{
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];
Id = $"{origin ?? peer.A}/users/{Name}";
Type = type;
peer.Serve($"/users/{Name}", Document().ToJsonString());
}
public string Type { get; }
public string Name { get; }
public string Id { get; }
public string KeyId => Id + "#main-key";
@@ -29,7 +32,7 @@ namespace PrivaPub.Tests.Support
public JsonObject Document() => new()
{
["id"] = Id,
["type"] = "Person",
["type"] = Type,
["preferredUsername"] = Name,
["inbox"] = Id + "/inbox",
["followers"] = Id + "/followers",
@@ -41,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)
{
var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
@@ -5,7 +5,9 @@ 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;
@@ -27,10 +29,12 @@ namespace PrivaPub.Api.Mastodon.Controllers
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)
IFollowService follows, IOutboxPublisher outbox, IRelationshipService relationships)
{
_relationships = relationships;
_mapper = mapper;
_dbEntities = dbEntities;
_localActors = localActors;
@@ -42,10 +46,19 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/accounts/verify_credentials"), Scope("read:accounts")]
public async Task<IActionResult> VerifyCredentials(CancellationToken token) => Json(await _mapper.Local(Me, withSource: true, token));
[HttpPatch("/api/v1/accounts/update_credentials"), Scope("write:accounts")]
public async Task<IActionResult> UpdateCredentials(CancellationToken token)
[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"))
@@ -101,7 +114,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
{
var local = await _localActors.FindByUserName(parts[0], token);
return local is { IsFederated: true, Kind: not LocalActorKind.Application } ? Json(await _mapper.Local(local, false, token)) : NotFoundError();
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();
@@ -136,7 +149,13 @@ namespace PrivaPub.Api.Mastodon.Controllers
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)
@@ -220,6 +239,83 @@ namespace PrivaPub.Api.Mastodon.Controllers
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)
{
@@ -269,7 +365,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (avatar is { DeletionAt: null })
return (_localActors.FromAvatar(avatar), default);
var group = await _dbEntities.Groups.MatchID(id).ExecuteFirstAsync(token);
if (group is { DeletionAt: null } && _localActors.FromGroup(group) is { IsFederated: true } community)
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));
}
@@ -280,9 +376,18 @@ namespace PrivaPub.Api.Mastodon.Controllers
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,
@@ -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,54 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
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 PollsController : MastodonController
{
readonly DbEntities _dbEntities;
readonly MastodonMapper _mapper;
readonly IPollService _polls;
public PollsController(DbEntities dbEntities, MastodonMapper mapper, IPollService polls)
{
_dbEntities = dbEntities;
_mapper = mapper;
_polls = polls;
}
[HttpGet("/api/v1/polls/{id}"), Scope("read:statuses", requiresUser: false), AllowAnonymous]
public async Task<IActionResult> Get(string id, CancellationToken token)
{
var post = await Visible(id, token);
return post?.Poll == default ? NotFoundError() : Json(_mapper.Poll(post, MyId, await _polls.OwnVotes(post.ID, MyId, token)));
}
[HttpPost("/api/v1/polls/{id}/votes"), Scope("write:statuses")]
public async Task<IActionResult> Vote(string id, CancellationToken token)
{
var post = await Visible(id, token);
if (post?.Poll == default)
return NotFoundError();
var choices = Params.List("choices").Select(c => int.TryParse(c, out var index) ? index : -1).ToList();
var outcome = await _polls.Vote(Me, post, choices, token);
if (!outcome.Ok)
return Error(outcome.Status, outcome.Error);
return Json(_mapper.Poll(outcome.Post, MyId, await _polls.OwnVotes(post.ID, 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;
}
}
}
@@ -0,0 +1,181 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Api.Mastodon.Controllers
{
public class ProvenanceController : MastodonController
{
readonly DbEntities _dbEntities;
public ProvenanceController(DbEntities dbEntities) => _dbEntities = dbEntities;
[HttpGet("/api/privapub/v1/statuses/{id}/provenance"), Scope("read:statuses", requiresUser: false), AllowAnonymous]
public async Task<IActionResult> Status(string id, CancellationToken token)
{
var post = await _dbEntities.Posts.Match(p => p.ID == id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
if (post?.ReblogOfPostId != default)
post = await _dbEntities.Posts.Match(p => p.ID == post.ReblogOfPostId && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
if (post == default || post.Visibility == PostVisibility.LocalGeo || !await VisibilityPolicy.CanSee(post, MyId, token))
return NotFoundError();
if (!post.IsFederatedCopy)
return Json(new Provenance { ObjectUri = post.ObjectURI, Url = post.Url, Path = "local" });
var record = await DB.Default.Find<ObjectRecord>().Match(r => r.PostId == post.ID).ExecuteFirstAsync(token)
?? await DB.Default.Find<ObjectRecord>().Match(r => r.ObjectURI == post.ObjectURI).ExecuteFirstAsync(token);
var host = record?.Host ?? (Uri.TryCreate(post.ObjectURI, UriKind.Absolute, out var uri) ? uri.Host.ToLowerInvariant() : default);
var instance = host == default ? default : await DB.Default.Find<RemoteInstance>().Match(i => i.Host == host).ExecuteFirstAsync(token);
return Json(Describe(post, record, instance));
}
[HttpGet("/api/privapub/v1/instances/{host}"), Scope("read", requiresUser: false), AllowAnonymous]
public async Task<IActionResult> Instance(string host, CancellationToken token)
{
host = host?.Trim().ToLowerInvariant();
var instance = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == host).ExecuteFirstAsync(token);
return instance == default ? NotFoundError() : Json(Describe(instance));
}
static Provenance Describe(PostEntity post, ObjectRecord record, RemoteInstance instance)
{
var raw = record?.Raw == default ? default : JsonNode.Parse(record.Raw) as JsonObject;
return new Provenance
{
ObjectUri = post.ObjectURI,
ObjectType = record?.ObjectType ?? post.ObjectType,
Url = post.Url,
Path = record == default ? "unrecorded" : record.Path == ObjectPath.Delivered ? "delivered" : "fetched",
Refetched = record?.Refetched == true,
Activity = record?.ActivityId == default ? default : new ProvenanceActivity
{
Id = record.ActivityId, Type = record.ActivityType, Actor = record.ActivityActorURI
},
Inbox = record?.Inbox,
Signature = record?.KeyId == default ? default : new ProvenanceSignature
{
Scheme = record.SignatureScheme, KeyId = record.KeyId, Algorithm = record.Algorithm, Headers = record.SignedHeaders
},
ReceivedAt = record == default ? default : MastodonJson.Time(record.ReceivedAt),
Published = record?.Published is { } published ? MastodonJson.Time(published) : default,
Updated = record?.Updated is { } updated ? MastodonJson.Time(updated) : default,
Extensions = ObjectFeatures.Detect(raw),
ContextNamespaces = ObjectFeatures.Namespaces(raw, record?.ActivityContext),
Raw = raw,
RawBytes = record?.RawBytes ?? 0,
RawHash = record?.RawHash,
RawTruncated = record?.RawTruncated == true,
Revisions = record?.Revisions.Select(r => new ProvenanceRevision
{
ActivityId = r.ActivityId,
Updated = r.Updated is { } at ? MastodonJson.Time(at) : default,
ReceivedAt = MastodonJson.Time(r.ReceivedAt),
RawHash = r.RawHash,
Raw = r.Raw == default ? default : JsonNode.Parse(r.Raw)
}).ToList() ?? new(),
Instance = instance == default ? default : Describe(instance)
};
}
static InstanceDescription Describe(RemoteInstance instance) => new()
{
Host = instance.Host,
Software = instance.Software,
Version = instance.SoftwareVersion,
NodeName = instance.NodeName,
Protocols = instance.Protocols ?? new(),
OpenRegistrations = instance.OpenRegistrations,
DescribedAt = instance.DescribedAt is { } described ? MastodonJson.Time(described) : default,
DescriptionError = instance.DescriptionError,
NodeInfo = instance.NodeInfo == default ? default : JsonNode.Parse(instance.NodeInfo),
Delivery = new InstanceDelivery
{
ConsecutiveFailures = instance.ConsecutiveFailures,
UnavailableUntil = instance.UnavailableUntil is { } until ? MastodonJson.Time(until) : default,
LastSuccessAt = instance.LastSuccessAt is { } success ? MastodonJson.Time(success) : default,
LastFailureAt = instance.LastFailureAt is { } failure ? MastodonJson.Time(failure) : default
}
};
public class Provenance
{
public string ObjectUri { get; set; }
public string ObjectType { get; set; }
public string Url { get; set; }
public string Path { get; set; }
public bool Refetched { get; set; }
public ProvenanceActivity Activity { get; set; }
public string Inbox { get; set; }
public ProvenanceSignature Signature { get; set; }
public string ReceivedAt { get; set; }
public string Published { get; set; }
public string Updated { get; set; }
public List<string> Extensions { get; set; } = new();
public List<string> ContextNamespaces { get; set; } = new();
public JsonNode Raw { get; set; }
public int RawBytes { get; set; }
public string RawHash { get; set; }
public bool RawTruncated { get; set; }
public List<ProvenanceRevision> Revisions { get; set; } = new();
public InstanceDescription Instance { get; set; }
}
public class ProvenanceActivity
{
public string Id { get; set; }
public string Type { get; set; }
public string Actor { get; set; }
}
public class ProvenanceSignature
{
public string Scheme { get; set; }
public string KeyId { get; set; }
public string Algorithm { get; set; }
public List<string> Headers { get; set; } = new();
}
public class ProvenanceRevision
{
public string ActivityId { get; set; }
public string Updated { get; set; }
public string ReceivedAt { get; set; }
public string RawHash { get; set; }
public JsonNode Raw { get; set; }
}
public class InstanceDescription
{
public string Host { get; set; }
public string Software { get; set; }
public string Version { get; set; }
public string NodeName { get; set; }
public List<string> Protocols { get; set; } = new();
public bool? OpenRegistrations { get; set; }
public string DescribedAt { get; set; }
public string DescriptionError { get; set; }
public JsonNode NodeInfo { get; set; }
public InstanceDelivery Delivery { get; set; }
}
public class InstanceDelivery
{
public int ConsecutiveFailures { get; set; }
public string UnavailableUntil { get; set; }
public string LastSuccessAt { get; set; }
public string LastFailureAt { get; set; }
}
}
}
@@ -0,0 +1,76 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Privacy;
using PrivaPub.Domain.Social;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.StaticServices;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Api.Mastodon.Controllers
{
public class ReactionsController : MastodonController
{
readonly DbEntities _dbEntities;
readonly MastodonMapper _mapper;
readonly IReactions _reactions;
public ReactionsController(DbEntities dbEntities, MastodonMapper mapper, IReactions reactions)
{
_dbEntities = dbEntities;
_mapper = mapper;
_reactions = reactions;
}
[HttpGet("/api/v1/pleroma/statuses/{id}/reactions"), Scope("read:statuses", requiresUser: false), AllowAnonymous]
public async Task<IActionResult> List(string id, CancellationToken token)
{
var post = await Visible(id, token);
if (post == default)
return NotFoundError();
var reactions = await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID).ExecuteAsync(token);
var accounts = await _mapper.Accounts(reactions.Select(r => r.AccountId), token);
return Json(reactions.GroupBy(r => r.Emoji).Select(group => new
{
name = group.Key,
count = group.Count(),
me = MyId != default && group.Any(r => r.AccountId == MyId),
url = _mapper.ProxiedUrl(group.First().EmojiURL),
accounts = group.Select(r => accounts.GetValueOrDefault(r.AccountId)).Where(a => a != default).ToList()
}).OrderByDescending(r => r.count).ToList());
}
[HttpPut("/api/v1/pleroma/statuses/{id}/reactions/{emoji}"), Scope("write:favourites")]
public async Task<IActionResult> React(string id, string emoji, CancellationToken token)
{
var post = await Visible(id, token);
if (post == default)
return NotFoundError();
if (!await _reactions.React(Me, post, Uri.UnescapeDataString(emoji), token))
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: That emoji cannot be used here");
return Json(await _mapper.Status(post, MyId, token));
}
[HttpDelete("/api/v1/pleroma/statuses/{id}/reactions/{emoji}"), Scope("write:favourites")]
public async Task<IActionResult> Unreact(string id, string emoji, CancellationToken token)
{
var post = await Visible(id, token);
if (post == default)
return NotFoundError();
await _reactions.Unreact(Me, post, Uri.UnescapeDataString(emoji), token);
return 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 && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
return post != default && post.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(post, MyId, token) ? post : default;
}
}
}
@@ -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)
});
}
}
}
@@ -56,7 +56,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
{
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 })
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));
@@ -112,18 +112,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/followed_tags"), Scope("read:follows")]
public IActionResult FollowedTags() => Json(Array.Empty<object>());
[HttpGet("/api/v1/blocks"), Scope("read:blocks")]
public IActionResult Blocks() => Json(Array.Empty<object>());
[HttpGet("/api/v1/mutes"), Scope("read:mutes")]
public IActionResult Mutes() => Json(Array.Empty<object>());
[HttpGet("/api/v1/domain_blocks"), Scope("read:blocks")]
public IActionResult DomainBlocks() => Json(Array.Empty<string>());
[HttpGet("/api/v1/bookmarks"), Scope("read:bookmarks")]
public IActionResult Bookmarks() => Json(Array.Empty<object>());
[HttpGet("/api/v1/endorsements"), Scope("read:accounts")]
public IActionResult Endorsements() => Json(Array.Empty<object>());
@@ -18,6 +18,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
public class StatusesController : MastodonController
{
const int MaxContext = 200;
public const int MaxPins = 5;
readonly IStatusService _statuses;
readonly MastodonMapper _mapper;
@@ -43,11 +44,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (existing != default)
return Json(await _mapper.Status(existing, MyId, token));
}
if (Params.List("media_ids").Count > 0)
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not supported yet");
if (Params.Has("poll[options]"))
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Polls are not supported yet");
var outcome = await _statuses.Publish(Me, new StatusDraft
{
Text = Params.Get("status"),
@@ -56,7 +52,12 @@ namespace PrivaPub.Api.Mastodon.Controllers
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
Language = Params.Get("language") ?? Me.Settings.DefaultLanguage,
MediaIds = Params.List("media_ids"),
Poll = Params.Has("poll[options]")
? new PollDraft(Params.List("poll[options]"), Params.Int("poll[expires_in]") ?? 0, Params.Bool("poll[multiple]") == true,
Params.Bool("poll[hide_totals]") == true)
: default
}, token);
if (!outcome.Ok)
return Error(outcome.Status, outcome.Error);
@@ -87,15 +88,14 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpPut("/api/v1/statuses/{id}"), Scope("write:statuses")]
public async Task<IActionResult> Edit(string id, CancellationToken token)
{
if (Params.List("media_ids").Count > 0)
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not supported yet");
var outcome = await _statuses.Edit(Me, id, new StatusDraft
{
Text = Params.Get("status"),
PlainText = true,
SpoilerText = Params.Get("spoiler_text"),
Sensitive = Params.Bool("sensitive") ?? false,
Language = Params.Get("language")
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);
}
@@ -227,10 +227,41 @@ namespace PrivaPub.Api.Mastodon.Controllers
}
[HttpPost("/api/v1/statuses/{id}/bookmark"), Scope("write:bookmarks")]
public Task<IActionResult> Bookmark(string id, CancellationToken token) => Unchanged(id, token);
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 Task<IActionResult> Unbookmark(string id, CancellationToken token) => Unchanged(id, token);
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);
@@ -239,10 +270,32 @@ namespace PrivaPub.Api.Mastodon.Controllers
public Task<IActionResult> Unmute(string id, CancellationToken token) => Unchanged(id, token);
[HttpPost("/api/v1/statuses/{id}/pin"), Scope("write:accounts")]
public IActionResult Pin(string id) => Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Pinning is not supported yet");
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 Task<IActionResult> Unpin(string id, CancellationToken token) => Unchanged(id, token);
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)
{
@@ -162,7 +162,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
[NotificationType.FollowRequest] = "follow_request",
[NotificationType.Favourite] = "favourite",
[NotificationType.Reblog] = "reblog",
[NotificationType.Update] = "update"
[NotificationType.Update] = "update",
[NotificationType.Poll] = "poll",
[NotificationType.Reaction] = "pleroma:emoji_reaction"
};
readonly MastodonMapper _mapper;
@@ -233,7 +235,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
CreatedAt = MastodonJson.Time(n.CreatedAt),
GroupKey = $"ungrouped-{n.ID}",
Account = accounts[n.FromAccountId],
Status = n.PostId == default ? default : statuses[n.PostId]
Status = n.PostId == default ? default : statuses[n.PostId],
Emoji = n.Emoji == default ? default : n.EmojiURL == default ? n.Emoji : $":{n.Emoji}:",
EmojiUrl = _mapper.ProxiedUrl(n.EmojiURL)
})
.ToList();
}
+178 -5
View File
@@ -25,12 +25,24 @@ namespace PrivaPub.Api.Mastodon.Entities
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<CustomEmojiEntity> Emojis { get; set; } = new();
public List<object> Roles { get; set; } = new();
public bool? Memorial { get; set; }
public string AvatarDescription { get; set; } = string.Empty;
public string HeaderDescription { get; set; } = string.Empty;
public List<Field> Fields { get; set; } = new();
public Source Source { get; set; }
}
public class CustomEmojiEntity
{
public string Shortcode { get; set; }
public string Url { get; set; }
public string StaticUrl { get; set; }
public bool VisibleInPicker { get; set; }
public string Category { get; set; }
}
public class Field
{
public string Name { get; set; }
@@ -49,6 +61,162 @@ namespace PrivaPub.Api.Mastodon.Entities
public string AttributionDomains { get; set; }
}
public class PrivaPubStatus
{
public string ObjectType { get; set; }
public string Title { get; set; }
public string Excerpt { get; set; }
public string Cover { get; set; }
public PrivaPubSource Source { get; set; }
public PrivaPubLink Link { get; set; }
public PrivaPubVideo Video { get; set; }
public PrivaPubAudio Audio { get; set; }
public PrivaPubEvent Event { get; set; }
public PrivaPubVotes Votes { get; set; } = new();
}
public class PrivaPubSource
{
public string Content { get; set; }
public string MediaType { get; set; }
}
public class PrivaPubLink
{
public string Url { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public string Image { get; set; }
public bool FromPublisher { get; set; }
}
public class PrivaPubVariant
{
public string Url { get; set; }
public string MediaType { get; set; }
public int? Width { get; set; }
public int? Height { get; set; }
public double? Fps { get; set; }
public long? Size { get; set; }
public List<string> Streams { get; set; } = new();
}
public class PrivaPubVideo
{
public double? Duration { get; set; }
public List<PrivaPubVariant> Variants { get; set; } = new();
public bool IsLive { get; set; }
public int? State { get; set; }
public long? Views { get; set; }
public int? CommentsPolicy { get; set; }
public bool? DownloadEnabled { get; set; }
public string Licence { get; set; }
public string Category { get; set; }
public string Channel { get; set; }
public List<PrivaPubCaption> Captions { get; set; } = new();
}
public class PrivaPubCaption
{
public string Language { get; set; }
public string Url { get; set; }
}
public class PrivaPubAudio
{
public double? Duration { get; set; }
public List<PrivaPubVariant> Variants { get; set; } = new();
public string Album { get; set; }
public int? Position { get; set; }
public string Licence { get; set; }
}
public class PrivaPubEvent
{
public string Start { get; set; }
public string End { get; set; }
public string TimeZone { get; set; }
public bool Floating { get; set; }
public List<PrivaPubPlace> Places { get; set; } = new();
public string OnlineUrl { get; set; }
public string JoinMode { get; set; }
public int? Participants { get; set; }
public int? Capacity { get; set; }
public string Status { get; set; }
public string Category { get; set; }
}
public class PrivaPubPlace
{
public string Name { get; set; }
public string Address { get; set; }
public double? Latitude { get; set; }
public double? Longitude { get; set; }
public string Url { get; set; }
}
public class PrivaPubVotes
{
public int Up { get; set; }
public int Down { get; set; }
}
public class EmojiReactionEntity
{
public string Name { get; set; }
public int Count { get; set; }
public bool Me { get; set; }
public string Url { get; set; }
public string StaticUrl { get; set; }
}
public class PleromaStatus
{
public List<EmojiReactionEntity> EmojiReactions { get; set; } = new();
}
public class PollEntity
{
public string Id { get; set; }
public string ExpiresAt { get; set; }
public bool Expired { get; set; }
public bool Multiple { get; set; }
public int VotesCount { get; set; }
public int? VotersCount { get; set; }
public bool? Voted { get; set; }
public List<int> OwnVotes { get; set; }
public List<PollOptionEntity> Options { get; set; } = new();
public List<CustomEmojiEntity> Emojis { get; set; } = new();
}
public class PollOptionEntity
{
public string Title { get; set; }
public int? VotesCount { get; set; }
}
public class PreviewCard
{
public string Url { get; set; }
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Type { get; set; } = "link";
public string AuthorName { get; set; } = string.Empty;
public string AuthorUrl { get; set; } = string.Empty;
public string ProviderName { get; set; } = string.Empty;
public string ProviderUrl { get; set; } = string.Empty;
public string Html { get; set; } = string.Empty;
public int Width { get; set; }
public int Height { get; set; }
public string Image { get; set; }
public string ImageDescription { get; set; } = string.Empty;
public string EmbedUrl { get; set; } = string.Empty;
public string Blurhash { get; set; }
public string Language { get; set; }
public string PublishedAt { get; set; }
public List<object> Authors { get; set; } = new();
}
public class Status
{
public string Id { get; set; }
@@ -61,7 +229,7 @@ namespace PrivaPub.Api.Mastodon.Entities
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 List<CustomEmojiEntity> Emojis { get; set; } = new();
public int RepliesCount { get; set; }
public int ReblogsCount { get; set; }
public int FavouritesCount { get; set; }
@@ -76,12 +244,15 @@ namespace PrivaPub.Api.Mastodon.Entities
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 PreviewCard Card { get; set; }
public PollEntity 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 PrivaPubStatus Privapub { get; set; }
public List<EmojiReactionEntity> EmojiReactions { get; set; } = new();
public PleromaStatus Pleroma { get; set; }
}
public class MediaAttachment
@@ -150,6 +321,8 @@ namespace PrivaPub.Api.Mastodon.Entities
public string GroupKey { get; set; }
public Account Account { get; set; }
public Status Status { get; set; }
public string Emoji { get; set; }
public string EmojiUrl { get; set; }
}
public class Context
@@ -166,7 +339,7 @@ namespace PrivaPub.Api.Mastodon.Entities
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 List<CustomEmojiEntity> Emojis { get; set; } = new();
}
public class StatusSource
+239 -17
View File
@@ -2,14 +2,18 @@ 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.Domain.Statuses;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Globalization;
using System.Net;
using GroupEntity = PrivaPub.Models.Group.Group;
@@ -21,13 +25,19 @@ namespace PrivaPub.Api.Mastodon.Mappers
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IMediaProxy _proxy;
public MastodonMapper(DbEntities dbEntities, ILocalActorService localActors)
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);
public string ProxiedUrl(string url) => Proxied(url);
string MissingAvatar => $"{_localActors.BaseAddress}/media/missing-avatar.png";
string MissingHeader => $"{_localActors.BaseAddress}/media/missing-header.png";
@@ -96,15 +106,22 @@ namespace PrivaPub.Api.Mastodon.Mappers
DisplayName = foreign.Name ?? foreign.UserName ?? string.Empty,
Bot = foreign.AvatarType is AvatarType.Service or AvatarType.Application,
Group = foreign.AvatarType == AvatarType.Group,
Locked = foreign.IsLocked,
Discoverable = foreign.IsDiscoverable,
CreatedAt = MastodonJson.Day(foreign.CreatedAt),
Indexable = foreign.IsIndexable,
Memorial = foreign.IsMemorial ? true : default(bool?),
CreatedAt = MastodonJson.Day(foreign.Published ?? foreign.CreatedAt),
Emojis = Emojis(foreign.Emojis),
Fields = foreign.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(),
AvatarDescription = foreign.PictureDescription ?? string.Empty,
HeaderDescription = foreign.HeaderDescription ?? string.Empty,
Note = foreign.Biography ?? string.Empty,
Url = foreign.Url ?? foreign.ActorURI,
Uri = foreign.ActorURI,
Avatar = foreign.PictureURL ?? MissingAvatar,
AvatarStatic = foreign.PictureURL ?? MissingAvatar,
Header = foreign.ThumbnailURL ?? MissingHeader,
HeaderStatic = foreign.ThumbnailURL ?? MissingHeader
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) =>
@@ -119,7 +136,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
foreach (var avatar in await _dbEntities.Avatars.Match(a => wanted.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token))
accounts[avatar.ID] = await Local(_localActors.FromAvatar(avatar), false, token);
foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue).ExecuteAsync(token))
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);
@@ -147,6 +164,31 @@ namespace PrivaPub.Api.Mastodon.Mappers
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 reactions = (await DB.Default.Find<Reaction>().Match(r => ids.Contains(r.PostId)).ExecuteAsync(token))
.GroupBy(r => r.PostId)
.ToDictionary(g => g.Key, g => g.GroupBy(r => r.Emoji)
.Select(e => new EmojiReactionEntity
{
Name = e.Key,
Count = e.Count(),
Me = viewerId != default && e.Any(r => r.AccountId == viewerId),
Url = Proxied(e.First().EmojiURL),
StaticUrl = Proxied(e.First().EmojiURL)
})
.OrderByDescending(e => e.Count)
.ToList());
var pollIds = all.Where(p => p.Poll != default).Select(p => p.ID).ToList();
var ownVotes = viewerId == default || pollIds.Count == 0
? new Dictionary<string, List<int>>()
: (await DB.Default.Find<PollVote>().Match(v => pollIds.Contains(v.PostId) && v.VoterAccountId == viewerId && v.IsLocalVoter).ExecuteAsync(token))
.GroupBy(v => v.PostId).ToDictionary(g => g.Key, g => g.Select(v => v.Choice).ToList());
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))
@@ -172,11 +214,19 @@ namespace PrivaPub.Api.Mastodon.Mappers
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(),
Card = Card(post),
Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)),
Emojis = Emojis(post.Emojis),
Privapub = Extension(post),
EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new(),
Pleroma = new PleromaStatus { EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new() },
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()
};
@@ -186,6 +236,9 @@ namespace PrivaPub.Api.Mastodon.Mappers
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;
@@ -207,9 +260,157 @@ namespace PrivaPub.Api.Mastodon.Mappers
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}";
if (post.IsFederatedCopy && string.IsNullOrWhiteSpace(content) && !string.IsNullOrEmpty(post.Excerpt))
content = $"<p>{WebUtility.HtmlEncode(post.Excerpt)}</p>";
if (post.Event is { } happening && EventLine(happening) is { } line)
content = $"<p>{WebUtility.HtmlEncode(line)}</p>{content}";
var titled = !string.IsNullOrEmpty(post.Title) && (!post.IsFederatedCopy || post.ObjectType is not (null or "Note" or "Question"));
return titled
? $"<p><strong>{WebUtility.HtmlEncode(post.Title)}</strong></p>{content}"
: content ?? string.Empty;
}
public static string EventLine(EventDetails happening)
{
if (happening.Start is not { } start)
return default;
var zone = Zone(happening.FloatingTime ? default : happening.TimeZone);
var local = zone == default ? start : TimeZoneInfo.ConvertTimeFromUtc(start, zone);
var when = local.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
if (happening.End is { } end && end > start)
{
var localEnd = zone == default ? end : TimeZoneInfo.ConvertTimeFromUtc(end, zone);
when += " – " + localEnd.ToString(localEnd.Date == local.Date ? "HH:mm" : "yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
}
when += happening.FloatingTime ? " (local time)" : zone == default ? " UTC" : $" ({happening.TimeZone})";
var where = happening.Places.Select(p => p.Name ?? p.Address).FirstOrDefault(p => p != default)
?? (happening.OnlineURL != default ? "online" : default);
return where == default ? when : $"{when} · {where}";
}
static TimeZoneInfo Zone(string id)
{
if (string.IsNullOrWhiteSpace(id))
return default;
try
{
return TimeZoneInfo.FindSystemTimeZoneById(id);
}
catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
{
return default;
}
}
public PollEntity Poll(PostEntity post, string viewerId, IReadOnlyCollection<int> ownVotes)
{
if (post.Poll is not { } poll)
return default;
var expired = PollService.Ended(poll);
var hidden = poll.HideTotals && !expired && post.GroupUserId != viewerId;
return new PollEntity
{
Id = post.ID,
ExpiresAt = poll.ExpiresAt is { } ends ? MastodonJson.Time(ends) : default,
Expired = expired,
Multiple = poll.Multiple,
VotesCount = poll.Options.Sum(o => o.Votes),
VotersCount = poll.Multiple ? poll.VotersCount : poll.VotersCount ?? poll.Options.Sum(o => o.Votes),
Voted = viewerId == default ? default(bool?) : ownVotes?.Count > 0 || post.GroupUserId == viewerId,
OwnVotes = viewerId == default ? default : ownVotes?.ToList() ?? new List<int>(),
Options = poll.Options.Select(o => new PollOptionEntity { Title = o.Title, VotesCount = hidden ? default(int?) : o.Votes }).ToList(),
Emojis = Emojis(post.Emojis.Where(e => poll.Options.Any(o => o.Title.Contains($":{e.Shortcode}:"))))
};
}
List<CustomEmojiEntity> Emojis(IEnumerable<CustomEmoji> emojis) =>
emojis?.Select(e => new CustomEmojiEntity { Shortcode = e.Shortcode, Url = Proxied(e.URL), StaticUrl = Proxied(e.URL) }).ToList() ?? new();
PrivaPubStatus Extension(PostEntity post) => new()
{
ObjectType = post.ObjectType,
Title = post.IsFederatedCopy ? post.Title : default,
Excerpt = post.Excerpt,
Cover = Proxied(post.CoverURL),
Source = post.Source == default ? default : new PrivaPubSource { Content = post.Source.Content, MediaType = post.Source.MediaType },
Link = post.Link == default ? default : new PrivaPubLink
{
Url = post.Link.Href, Title = post.Link.Title, Description = post.Link.Description, Image = Proxied(post.Link.ImageURL), FromPublisher = post.Link.FromPublisher
},
Video = post.Video == default ? default : new PrivaPubVideo
{
Duration = post.Video.DurationSeconds,
Variants = post.Video.Variants.Select(Variant).ToList(),
IsLive = post.Video.IsLive,
State = post.Video.State,
Views = post.Video.Views,
CommentsPolicy = post.Video.CommentsPolicy,
DownloadEnabled = post.Video.DownloadEnabled,
Licence = post.Video.Licence,
Category = post.Video.Category,
Channel = post.Video.ChannelURI,
Captions = post.Video.Captions.Select(c => new PrivaPubCaption { Language = c.Language, Url = Proxied(c.Url) }).ToList()
},
Audio = post.Audio == default ? default : new PrivaPubAudio
{
Duration = post.Audio.DurationSeconds,
Variants = post.Audio.Variants.Select(Variant).ToList(),
Album = post.Audio.Album,
Position = post.Audio.Position,
Licence = post.Audio.Licence
},
Event = post.Event == default ? default : new PrivaPubEvent
{
Start = post.Event.Start is { } start ? MastodonJson.Time(start) : default,
End = post.Event.End is { } end ? MastodonJson.Time(end) : default,
TimeZone = post.Event.TimeZone,
Floating = post.Event.FloatingTime,
Places = post.Event.Places.Select(p => new PrivaPubPlace { Name = p.Name, Address = p.Address, Latitude = p.Latitude, Longitude = p.Longitude, Url = p.Url }).ToList(),
OnlineUrl = post.Event.OnlineURL,
JoinMode = post.Event.JoinMode,
Participants = post.Event.ParticipantCount,
Capacity = post.Event.Capacity,
Status = post.Event.Status,
Category = post.Event.Category
},
Votes = new PrivaPubVotes { Up = post.FavouritesCount, Down = post.DownvotesCount }
};
PrivaPubVariant Variant(MediaVariant variant) => new()
{
Url = Proxied(variant.Url),
MediaType = variant.MediaType,
Width = variant.Width,
Height = variant.Height,
Fps = variant.Fps,
Size = variant.Size,
Streams = variant.Streams
};
PreviewCard Card(PostEntity post)
{
if (!post.IsFederatedCopy)
return default;
if (post.Link is { } link)
return Card(link.Href, link.Title ?? post.Title, link.Description ?? (post.Media.Count == 0 ? post.Excerpt : default), link.ImageURL);
if (post.ObjectType is "Article" or "Event" or "Video" or "Audio" && post.Url != default && post.Media.Count == 0)
return Card(post.Url, post.Title, post.Excerpt, post.CoverURL);
return default;
}
PreviewCard Card(string url, string title, string description, string image)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
return default;
return new PreviewCard
{
Url = url,
Title = title ?? uri.Host,
Description = description ?? string.Empty,
ProviderName = uri.Host,
ProviderUrl = uri.GetLeftPart(UriPartial.Authority),
Image = Proxied(image)
};
}
public static string Visibility(PostVisibility visibility) => visibility switch
@@ -232,9 +433,31 @@ namespace PrivaPub.Api.Mastodon.Mappers
return new StatusMention { Id = account.ID, Username = account.UserName, Acct = $"{account.UserName}@{account.Domain}", Url = account.Url ?? account.ActorURI };
}
static MediaAttachment Media(PostMedia media) => new()
static Dictionary<string, object> Meta(PostMedia media)
{
Id = media.Id.ToString("N"),
var original = new Dictionary<string, object>();
if (media.Width > 0 && media.Height > 0)
{
original["width"] = media.Width;
original["height"] = media.Height;
original["size"] = $"{media.Width}x{media.Height}";
original["aspect"] = (double)media.Width.Value / media.Height.Value;
}
if (media.DurationSeconds is { } seconds)
original["duration"] = seconds;
var meta = new Dictionary<string, object>();
if (original.Count > 0)
meta["original"] = original;
if (media.DurationSeconds is { } length)
meta["length"] = TimeSpan.FromSeconds(length).ToString(@"h\:mm\:ss\.ff", CultureInfo.InvariantCulture);
if (media.Focus is { Length: 2 })
meta["focus"] = new { x = media.Focus[0], y = media.Focus[1] };
return meta;
}
MediaAttachment Media(PostMedia media) => new()
{
Id = media.AttachmentId ?? media.Id.ToString("N"),
Type = media.ContentType switch
{
{ } type when type.StartsWith("image/gif") => "gifv",
@@ -243,14 +466,13 @@ namespace PrivaPub.Api.Mastodon.Mappers
{ } type when type.StartsWith("audio/") => "audio",
_ => "unknown"
},
Url = media.URL ?? media.RemoteURL,
PreviewUrl = media.URL ?? media.RemoteURL,
Url = media.URL ?? Proxied(media.RemoteURL),
PreviewUrl = media.PreviewURL ?? Proxied(media.RemotePreviewURL)
?? (media.ContentType?.StartsWith("image/") == true || media.ContentType == default ? media.URL ?? Proxied(media.RemoteURL) : default),
RemoteUrl = media.RemoteURL,
Description = media.Description,
Blurhash = media.Blurhash,
Meta = media.Width.HasValue && media.Height.HasValue
? new { original = new { width = media.Width, height = media.Height, aspect = (double)media.Width / media.Height.Value } }
: default
Meta = Meta(media)
};
}
}
@@ -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
{
[ApiController,
Route("clientapi/moderator")]
Route("clientapi/moderator"),
Authorize(Policy = Policies.IsModerator)]
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,10 @@ namespace PrivaPub.Controllers.ClientToServer
public async Task<IActionResult> Insert(InsertPostForm form, CancellationToken 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));
+1 -1
View File
@@ -122,7 +122,7 @@ namespace PrivaPub.Domain.Content
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
{
var local = await _localActors.FindByUserName(parts[0], token);
return local is { IsFederated: true, Kind: not LocalActorKind.Application }
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;
}
+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,205 @@
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)
{
var block = new Block { AvatarId = me.Id, TargetAccountId = targetAccountId, TargetActorURI = targetActorUri };
try
{
await DB.Default.SaveAsync(block, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
block = await DB.Default.Find<Block>().Match(b => b.AvatarId == me.Id && b.TargetActorURI == targetActorUri).ExecuteFirstAsync(token);
}
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);
if (block != default)
await Tell(me, targetActorUri, BlockActivity(me, block), token);
}
public async Task Unblock(LocalActor me, string targetActorUri, CancellationToken token)
{
var block = await DB.Default.Find<Block>().Match(b => b.AvatarId == me.Id && b.TargetActorURI == targetActorUri).ExecuteFirstAsync(token);
if (block == default)
return;
await DB.Default.DeleteAsync<Block>(block.ID);
await Tell(me, targetActorUri, new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = me.ActivityUri($"undo-block-{block.ID}"),
["type"] = "Undo",
["actor"] = me.Uri,
["object"] = BlockActivity(me, block)
}, token);
}
static JsonObject BlockActivity(LocalActor me, Block block) => new()
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = me.ActivityUri($"block-{block.ID}"),
["type"] = "Block",
["actor"] = me.Uri,
["object"] = block.TargetActorURI,
["to"] = new JsonArray(block.TargetActorURI)
};
async Task Tell(LocalActor me, string targetActorUri, JsonObject activity, CancellationToken token)
{
if (targetActorUri.StartsWith(me.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
return;
var target = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == targetActorUri).ExecuteFirstAsync(token);
if (!string.IsNullOrEmpty(target?.InboxURL))
await _delivery.Enqueue(me, new[] { target.InboxURL }, activity, token);
}
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;
}
}
}
+1 -1
View File
@@ -95,7 +95,7 @@ namespace PrivaPub.Domain.Social
var (local, remote) = await ResolveTarget(target, token);
if (local == default && remote == default)
return default;
if (local != default && (local.Id == follower.Id || !local.IsFederated || local.Kind == LocalActorKind.Application))
if (local != default && (local.Id == follower.Id || !local.IsFederated || local.IsCircle || local.Kind == LocalActorKind.Application))
return default;
var targetUri = local?.Uri ?? remote.ActorURI;
+7 -3
View File
@@ -8,9 +8,11 @@ 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)
CancellationToken token, string emoji = default, string emojiUrl = default)
{
if (string.IsNullOrEmpty(avatarId) || avatarId == fromAccountId)
if (string.IsNullOrEmpty(avatarId) || avatarId == fromAccountId && type != NotificationType.Poll)
return;
if ((await Relationships.Hidden.AuthorsHiddenFrom(avatarId, new[] { fromActorUri }, forNotifications: true, token)).Count > 0)
return;
try
{
@@ -21,7 +23,9 @@ namespace PrivaPub.Domain.Social
FromAccountId = fromAccountId,
FromActorURI = fromActorUri,
PostId = postId,
DedupeKey = $"{type}|{avatarId}|{fromActorUri}|{postId}"
Emoji = emoji,
EmojiURL = emojiUrl,
DedupeKey = emoji == default ? $"{type}|{avatarId}|{fromActorUri}|{postId}" : $"{type}|{avatarId}|{fromActorUri}|{postId}|{emoji}"
}, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
+179
View File
@@ -0,0 +1,179 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Globalization;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Domain.Social
{
public interface IReactions
{
Task Receive(PostEntity post, ForeignAvatar actor, string content, JsonNode tags, string activityId, CancellationToken token);
Task<bool> Withdraw(ForeignAvatar actor, string activityId, string objectUri, string content, CancellationToken token);
Task<bool> React(LocalActor me, PostEntity post, string emoji, CancellationToken token);
Task<bool> Unreact(LocalActor me, PostEntity post, string emoji, CancellationToken token);
}
public class Reactions : IReactions
{
static readonly string[] Hearts = { "❤", "❤️", "♥", "♥️" };
readonly DbEntities _dbEntities;
readonly IDeliveryService _delivery;
public Reactions(DbEntities dbEntities, IDeliveryService delivery)
{
_dbEntities = dbEntities;
_delivery = delivery;
}
public static bool IsHeart(string content) => content == default || Hearts.Contains(content.Trim());
public async Task Receive(PostEntity post, ForeignAvatar actor, string content, JsonNode tags, string activityId, CancellationToken token)
{
if (!LikeHandler.MaySee(post, actor) || Normalise(content, ObjectShapes.Emojis(tags)) is not { } emoji)
return;
try
{
await DB.Default.SaveAsync(new Reaction
{
PostId = post.ID, AccountId = actor.ID, ActorURI = actor.ActorURI, Emoji = emoji.Name, EmojiURL = emoji.Url, ActivityURI = activityId
}, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return;
}
if (!post.IsFederatedCopy)
await Notifications.Add(post.GroupUserId, NotificationType.Reaction, actor.ID, actor.ActorURI, post.ID, token, emoji.Name, emoji.Url);
}
public async Task<bool> Withdraw(ForeignAvatar actor, string activityId, string objectUri, string content, CancellationToken token)
{
var reaction = activityId == default
? default
: await DB.Default.Find<Reaction>().Match(r => r.ActivityURI == activityId && r.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
if (reaction == default && objectUri != default && Normalise(content, new()) is { } emoji)
{
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
reaction = post == default
? default
: await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID && r.ActorURI == actor.ActorURI && r.Emoji == emoji.Name).ExecuteFirstAsync(token);
}
if (reaction == default)
return false;
await DB.Default.DeleteAsync<Reaction>(reaction.ID);
return true;
}
public async Task<bool> React(LocalActor me, PostEntity post, string emoji, CancellationToken token)
{
var known = post.Emojis.Select(e => (e.Shortcode, e.URL))
.Concat((await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID && r.EmojiURL != null).ExecuteAsync(token)).Select(r => (r.Emoji, r.EmojiURL)))
.Select(e => new Models.Post.CustomEmoji { Shortcode = e.Item1, URL = e.Item2 })
.ToList();
if (Normalise(emoji, known) is not { } picked)
return false;
var reaction = new Reaction
{
PostId = post.ID, AccountId = me.Id, ActorURI = me.Uri, IsLocal = true, Emoji = picked.Name, EmojiURL = picked.Url,
ActivityURI = me.ActivityUri($"react-{post.ID}-{Guid.NewGuid():N}")
};
try
{
await DB.Default.SaveAsync(reaction, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return true;
}
if (!post.IsFederatedCopy)
await Notifications.Add(post.GroupUserId, NotificationType.Reaction, me.Id, me.Uri, post.ID, token, picked.Name, picked.Url);
else
await Tell(me, post, Activity(me, post, reaction), token);
return true;
}
public async Task<bool> Unreact(LocalActor me, PostEntity post, string emoji, CancellationToken token)
{
var name = Normalise(emoji, post.Emojis.ToList(), requireKnownCustom: false)?.Name;
var reaction = name == default
? default
: await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID && r.ActorURI == me.Uri && r.Emoji == name).ExecuteFirstAsync(token);
if (reaction == default)
return false;
await DB.Default.DeleteAsync<Reaction>(reaction.ID);
if (post.IsFederatedCopy)
await Tell(me, post, new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = me.ActivityUri($"undo-{reaction.ID}"),
["type"] = "Undo",
["actor"] = me.Uri,
["object"] = Activity(me, post, reaction)
}, token);
return true;
}
static JsonObject Activity(LocalActor me, PostEntity post, Reaction reaction)
{
var react = new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = reaction.ActivityURI,
["type"] = "EmojiReact",
["actor"] = me.Uri,
["object"] = post.ObjectURI,
["to"] = new JsonArray(post.ActorURI),
["content"] = reaction.EmojiURL == default ? reaction.Emoji : $":{reaction.Emoji}:"
};
if (reaction.EmojiURL != default)
react["tag"] = new JsonArray(new JsonObject
{
["type"] = "Emoji",
["name"] = $":{reaction.Emoji}:",
["icon"] = new JsonObject { ["type"] = "Image", ["url"] = reaction.EmojiURL }
});
return react;
}
async Task Tell(LocalActor me, PostEntity post, JsonObject activity, CancellationToken token)
{
var author = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == post.ActorURI).ExecuteFirstAsync(token);
if (!string.IsNullOrEmpty(author?.InboxURL))
await _delivery.Enqueue(me, new[] { author.InboxURL }, activity, token);
}
public sealed record Picked(string Name, string Url);
public static Picked Normalise(string content, List<Models.Post.CustomEmoji> emojis, bool requireKnownCustom = true)
{
var text = content?.Trim();
if (string.IsNullOrEmpty(text) || text.Length > 100)
return default;
if (text.StartsWith(':') && text.EndsWith(':') && text.Length > 2)
{
var shortcode = text.Trim(':');
var bare = shortcode.Split('@')[0];
var emoji = emojis.FirstOrDefault(e => e.Shortcode == shortcode) ?? emojis.FirstOrDefault(e => e.Shortcode.Split('@')[0] == bare);
if (emoji == default)
return requireKnownCustom ? default : new Picked(bare, default);
return new Picked(bare, emoji.URL);
}
if (text.Any(c => c < 128 || char.IsWhiteSpace(c)) || new StringInfo(text).LengthInTextElements != 1)
return default;
return new Picked(text, default);
}
}
}
+300
View File
@@ -0,0 +1,300 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Domain.Social;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Domain.Statuses
{
public sealed record PollDraft(IReadOnlyList<string> Options, int ExpiresIn, bool Multiple, bool HideTotals);
public interface IPollService
{
string Invalid(PollDraft draft);
PostPoll Create(PollDraft draft);
Task Scheduled(PostEntity post, CancellationToken token);
Task<StatusOutcome> Vote(LocalActor me, PostEntity post, IReadOnlyList<int> choices, CancellationToken token);
Task Receive(PostEntity post, ForeignAvatar voter, string choice, string activityId, CancellationToken token);
Task<HashSet<int>> OwnVotes(string postId, string avatarId, CancellationToken token);
}
public class PollService : IPollService
{
public const int MaxOptions = 4;
public const int MaxOptionLength = 50;
public const int MinExpiration = 300;
public const int MaxExpiration = 2_629_746;
static readonly TimeSpan RefreshEvery = TimeSpan.FromMinutes(3);
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
readonly IJobQueue _queue;
public PollService(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery, IJobQueue queue)
{
_dbEntities = dbEntities;
_localActors = localActors;
_delivery = delivery;
_queue = queue;
}
public string Invalid(PollDraft draft)
{
var options = draft.Options.Select(o => o?.Trim()).ToList();
if (options.Count is < 2 or > MaxOptions)
return $"Validation failed: A poll needs between 2 and {MaxOptions} options";
if (options.Any(o => string.IsNullOrEmpty(o) || o.Length > MaxOptionLength))
return $"Validation failed: Poll options must be between 1 and {MaxOptionLength} characters";
if (options.Distinct(StringComparer.Ordinal).Count() != options.Count)
return "Validation failed: Poll options must be unique";
if (draft.ExpiresIn is < MinExpiration or > MaxExpiration)
return "Validation failed: The poll's duration is out of range";
return default;
}
public PostPoll Create(PollDraft draft) => new()
{
Options = draft.Options.Select(o => new PollOption { Title = o.Trim() }).ToList(),
Multiple = draft.Multiple,
HideTotals = draft.HideTotals,
ExpiresAt = DateTime.UtcNow.AddSeconds(draft.ExpiresIn),
VotersCount = 0
};
public async Task Scheduled(PostEntity post, CancellationToken token)
{
if (post.Poll?.ExpiresAt is not { } ends)
return;
await _queue.EnqueueMany(new[]
{
new Job { Kind = JobKind.PollClose, Payload = post.ID, Host = LocalHost, DedupeKey = $"poll-close|{post.ID}", RunAt = ends }
}, token);
}
public async Task<StatusOutcome> Vote(LocalActor me, PostEntity post, IReadOnlyList<int> choices, CancellationToken token)
{
var poll = post.Poll;
if (poll == default)
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
if (Ended(poll))
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The poll has already ended");
if (!post.IsFederatedCopy && post.GroupUserId == me.Id)
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: You cannot vote in your own poll");
var picked = choices.Distinct().ToList();
if (picked.Count == 0 || picked.Any(c => c < 0 || c >= poll.Options.Count) || !poll.Multiple && picked.Count > 1)
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The choices are invalid");
if (await DB.Default.Find<PollVote>().Match(v => v.PostId == post.ID && v.ActorURI == me.Uri).ExecuteAnyAsync(token))
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: You have already voted on this poll");
foreach (var choice in picked)
await DB.Default.SaveAsync(new PollVote
{
PostId = post.ID, VoterAccountId = me.Id, ActorURI = me.Uri, IsLocalVoter = true, Choice = choice,
ActivityURI = me.Uri + $"#votes/{post.ID}/{choice}"
}, token);
await Count(post, picked, newVoter: true, token);
if (post.IsFederatedCopy)
await SendVotes(me, post, picked, token);
else
await Refreshed(post, token);
await Scheduled(post, token);
return new StatusOutcome(await _dbEntities.Posts.MatchID(post.ID).ExecuteFirstAsync(token));
}
public async Task Receive(PostEntity post, ForeignAvatar voter, string choice, string activityId, CancellationToken token)
{
var poll = post.Poll;
if (poll == default || post.IsFederatedCopy || Ended(poll) || !LikeHandler.MaySee(post, voter))
return;
var index = poll.Options.FindIndex(o => o.Title == choice);
if (index < 0)
return;
var earlier = await DB.Default.Find<PollVote>().Match(v => v.PostId == post.ID && v.ActorURI == voter.ActorURI).ExecuteAsync(token);
if (earlier.Count > 0 && (!poll.Multiple || earlier.Any(v => v.Choice == index)))
return;
try
{
await DB.Default.SaveAsync(new PollVote
{
PostId = post.ID, VoterAccountId = voter.ID, ActorURI = voter.ActorURI, Choice = index, ActivityURI = activityId
}, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return;
}
await Count(post, new[] { index }, newVoter: earlier.Count == 0, token);
await Refreshed(post, token);
}
public async Task<HashSet<int>> OwnVotes(string postId, string avatarId, CancellationToken token) =>
avatarId == default
? new HashSet<int>()
: (await DB.Default.Find<PollVote>().Match(v => v.PostId == postId && v.VoterAccountId == avatarId && v.IsLocalVoter).ExecuteAsync(token))
.Select(v => v.Choice).ToHashSet();
public static bool Ended(PostPoll poll) => poll.ClosedAt.HasValue || poll.ExpiresAt <= DateTime.UtcNow;
async Task Count(PostEntity post, IReadOnlyList<int> choices, bool newVoter, CancellationToken token)
{
var update = DB.Default.Update<PostEntity>().MatchID(post.ID);
foreach (var choice in choices)
update = update.Modify(b => b.Inc($"{nameof(PostEntity.Poll)}.{nameof(PostPoll.Options)}.{choice}.{nameof(PollOption.Votes)}", 1));
if (newVoter && post.Poll.VotersCount.HasValue)
update = update.Modify(b => b.Inc($"{nameof(PostEntity.Poll)}.{nameof(PostPoll.VotersCount)}", 1));
await update.ExecuteAsync(token);
}
async Task SendVotes(LocalActor me, PostEntity post, IReadOnlyList<int> choices, CancellationToken token)
{
var owner = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == post.ActorURI).ExecuteFirstAsync(token);
if (string.IsNullOrEmpty(owner?.InboxURL))
return;
foreach (var choice in choices)
{
var voteId = me.Uri + $"#votes/{post.ID}/{choice}";
await _delivery.Enqueue(me, new[] { owner.InboxURL }, new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = voteId + "/activity",
["type"] = "Create",
["actor"] = me.Uri,
["to"] = new JsonArray(owner.ActorURI),
["object"] = new JsonObject
{
["id"] = voteId,
["type"] = "Note",
["name"] = post.Poll.Options[choice].Title,
["attributedTo"] = me.Uri,
["to"] = new JsonArray(owner.ActorURI),
["inReplyTo"] = post.ObjectURI
}
}, token);
}
}
async Task Refreshed(PostEntity post, CancellationToken token)
{
var bucket = DateTime.UtcNow.Ticks / RefreshEvery.Ticks;
await _queue.EnqueueMany(new[]
{
new Job
{
Kind = JobKind.PollRefresh, Payload = post.ID, Host = LocalHost, DedupeKey = $"poll-refresh|{post.ID}|{bucket}",
RunAt = new DateTime((bucket + 1) * RefreshEvery.Ticks, DateTimeKind.Utc)
}
}, token);
}
string LocalHost => new Uri(_localActors.BaseAddress).Host;
}
public class PollRefreshJob : IJobHandler
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IOutboxPublisher _outbox;
public PollRefreshJob(DbEntities dbEntities, ILocalActorService localActors, IOutboxPublisher outbox)
{
_dbEntities = dbEntities;
_localActors = localActors;
_outbox = outbox;
}
public virtual JobKind Kind => JobKind.PollRefresh;
public int Concurrency => 1;
public int MaxAttempts => 3;
public int PerHostLimit => 1;
public virtual async Task<JobOutcome> Handle(Job job, CancellationToken token)
{
var post = await _dbEntities.Posts.MatchID(job.Payload).ExecuteFirstAsync(token);
if (post?.Poll == default || post.IsFederatedCopy || post.DeletedAt.HasValue)
return JobOutcome.Done;
var author = await _localActors.FindById(LocalActorKind.Person, post.GroupUserId, token);
if (author == default)
return JobOutcome.Done;
await Closing(post, author, token);
if (!post.IsLocalOnly)
await _outbox.Publish(author, post, Update(post, author), token);
return JobOutcome.Done;
}
protected virtual Task Closing(PostEntity post, LocalActor author, CancellationToken token) => Task.CompletedTask;
static JsonObject Update(PostEntity post, LocalActor author)
{
var question = post.Visibility == PostVisibility.Direct
? ActivityPubRenderer.DirectNote(post, author, Array.Empty<(string, string)>(), post.ContextURI)
: ActivityPubRenderer.Note(post, author, default, post.InReplyToURI);
question["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray());
question["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray());
return new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = author.ActivityUri($"update-{post.ID}-poll-{DateTime.UtcNow.Ticks}"),
["type"] = "Update",
["actor"] = author.Uri,
["to"] = question["to"]!.DeepClone(),
["cc"] = question["cc"]!.DeepClone(),
["object"] = question
};
}
}
public class PollCloseJob : PollRefreshJob
{
readonly DbEntities _posts;
public PollCloseJob(DbEntities dbEntities, ILocalActorService localActors, IOutboxPublisher outbox) : base(dbEntities, localActors, outbox)
{
_posts = dbEntities;
}
public override JobKind Kind => JobKind.PollClose;
public override async Task<JobOutcome> Handle(Job job, CancellationToken token)
{
var post = await _posts.Posts.MatchID(job.Payload).ExecuteFirstAsync(token);
if (post?.Poll == default || post.DeletedAt.HasValue)
return JobOutcome.Done;
if (!post.IsFederatedCopy)
return await base.Handle(job, token);
await Notify(post, post.AuthorAccountId, post.ActorURI, token);
return JobOutcome.Done;
}
protected override async Task Closing(PostEntity post, LocalActor author, CancellationToken token)
{
post.Poll.ClosedAt ??= DateTime.UtcNow;
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.Poll.ClosedAt, post.Poll.ClosedAt).ExecuteAsync(token);
await Notify(post, author.Id, author.Uri, token);
}
public static async Task Notify(PostEntity post, string authorAccountId, string authorUri, CancellationToken token)
{
var voters = await DB.Default.Find<PollVote>().Match(v => v.PostId == post.ID && v.IsLocalVoter).ExecuteAsync(token);
foreach (var voter in voters.Select(v => v.VoterAccountId).Distinct())
await Notifications.Add(voter, NotificationType.Poll, authorAccountId, authorUri, post.ID, token);
if (!post.IsFederatedCopy)
await Notifications.Add(post.GroupUserId, NotificationType.Poll, post.GroupUserId, authorUri, post.ID, token);
}
}
}
+126 -11
View File
@@ -2,6 +2,7 @@ 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;
@@ -10,6 +11,7 @@ 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;
@@ -33,6 +35,11 @@ namespace PrivaPub.Domain.Statuses
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 PollDraft Poll { get; init; }
}
public sealed record StatusOutcome(PostEntity Post, int Status = StatusCodes.Status200OK, string Error = default)
@@ -61,10 +68,16 @@ namespace PrivaPub.Domain.Statuses
readonly IContentRenderer _content;
readonly IOutboxPublisher _outbox;
readonly IFanout _fanout;
readonly IMediaService _media;
readonly IGroupDistributor _groups;
readonly IPollService _polls;
public StatusService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IContentRenderer content, IOutboxPublisher outbox, IFanout fanout)
IContentRenderer content, IOutboxPublisher outbox, IFanout fanout, IMediaService media, IGroupDistributor groups, IPollService polls)
{
_polls = polls;
_media = media;
_groups = groups;
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
@@ -76,14 +89,19 @@ namespace PrivaPub.Domain.Statuses
public async Task<StatusOutcome> Publish(LocalActor author, StatusDraft draft, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(draft.Text))
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 && draft.Poll == default)
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Text can't be blank");
if (draft.Poll != default && _polls.Invalid(draft.Poll) is { } invalidPoll)
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, invalidPoll);
LocalActor group = default;
if (!string.IsNullOrEmpty(draft.GroupId))
{
var groupEntity = await _dbEntities.Groups.MatchID(draft.GroupId).ExecuteFirstAsync(token);
if (groupEntity == default || groupEntity.DeletionAt.HasValue || !groupEntity.Members.Any(m => !m.IsForeign && m.AvatarId == author.Id))
if (groupEntity == default || groupEntity.DeletionAt.HasValue || !await MayPost(groupEntity, author, token))
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Group not found");
group = _localActors.FromGroup(groupEntity);
}
@@ -94,9 +112,35 @@ namespace PrivaPub.Domain.Statuses
if (parent != default && !await VisibilityPolicy.CanSee(parent, author.Id, token))
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
var rendered = draft.PlainText ? await _content.PlainText(draft.Text, token) : await _content.Markdown(draft.Text, token);
var isLocalOnly = group is { IsFederated: false };
var visibility = isLocalOnly ? PostVisibility.Circle : draft.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo ? PostVisibility.Public : draft.Visibility;
var 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,
@@ -112,15 +156,23 @@ namespace PrivaPub.Domain.Statuses
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
ActorURI = author.Uri,
Poll = draft.Poll == default ? default : _polls.Create(draft.Poll)
};
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)
@@ -150,13 +202,15 @@ namespace PrivaPub.Domain.Statuses
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);
await _polls.Scheduled(post, token);
if (create != default)
await _outbox.Publish(author, post, create, token);
if (group is { IsFederated: true } && visibility is PostVisibility.Public or PostVisibility.Unlisted)
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, post.ObjectURI, $"announce-{post.ID}"), token);
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);
}
@@ -165,7 +219,10 @@ namespace PrivaPub.Domain.Statuses
var post = await Own(author, postId, token);
if (post == default)
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
if (string.IsNullOrWhiteSpace(draft.Text))
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
@@ -180,7 +237,12 @@ namespace PrivaPub.Domain.Statuses
post.Revisions.RemoveRange(0, post.Revisions.Count - MaxRevisions);
var plain = draft.PlainText || post.ContentFormat == ContentFormat.Plain;
var rendered = plain ? await _content.PlainText(draft.Text, token) : await _content.Markdown(draft.Text, token);
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;
@@ -214,6 +276,8 @@ namespace PrivaPub.Domain.Statuses
["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);
}
@@ -244,6 +308,9 @@ namespace PrivaPub.Domain.Statuses
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);
}
@@ -461,6 +528,54 @@ namespace PrivaPub.Domain.Statuses
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,
+4
View File
@@ -40,6 +40,9 @@ namespace PrivaPub.Domain.Timelines
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);
@@ -53,6 +56,7 @@ namespace PrivaPub.Domain.Timelines
break;
}
recipients.ExceptWith(await Relationships.Hidden.RecipientsHiding(recipients, post.ActorURI, token));
foreach (var avatarId in recipients)
{
try
+50 -1
View File
@@ -1,6 +1,9 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
namespace PrivaPub.Federation.Actors
{
@@ -23,6 +26,16 @@ namespace PrivaPub.Federation.Actors
public string SharedInbox { get; init; }
public string Icon { get; init; }
public bool Discoverable { get; init; } = true;
public bool Indexable { get; init; }
public bool Locked { get; init; }
public bool Memorial { get; init; }
public string Header { get; init; }
public string IconDescription { get; init; }
public string HeaderDescription { get; init; }
public string MovedTo { get; init; }
public DateTime? Published { get; init; }
public List<CustomEmoji> Emojis { get; init; } = new();
public Dictionary<string, string> Fields { get; init; } = new();
public IReadOnlyList<ActorKey> Keys { get; init; } = Array.Empty<ActorKey>();
public ActorKey Key(string keyId) => Keys.FirstOrDefault(k => k.Id == keyId);
@@ -52,10 +65,46 @@ namespace PrivaPub.Federation.Actors
SharedInbox = root.TryGetProperty("endpoints", out var endpoints) ? RemoteActorService.Text(endpoints, "sharedInbox") : default,
Icon = root.TryGetProperty("icon", out var icon) ? RemoteActorService.Text(icon, "url") : default,
Discoverable = !root.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False,
Keys = ParseKeys(root, id)
Keys = ParseKeys(root, id),
Indexable = Flag(root, "indexable"),
Locked = Flag(root, "manuallyApprovesFollowers"),
Memorial = Flag(root, "memorial"),
Header = root.TryGetProperty("image", out var image) ? ObjectShapes.Image(JsonNode.Parse(image.GetRawText())) : default,
IconDescription = root.TryGetProperty("icon", out var described) ? Alt(described) : default,
HeaderDescription = root.TryGetProperty("image", out var headerImage) ? Alt(headerImage) : default,
MovedTo = RemoteActorService.Text(root, "movedTo"),
Published = DateTimeOffset.TryParse(RemoteActorService.Text(root, "published"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var published)
? published.UtcDateTime
: default(DateTime?),
Emojis = root.TryGetProperty("tag", out var tags) ? ObjectShapes.Emojis(JsonNode.Parse(tags.GetRawText())) : new(),
Fields = root.TryGetProperty("attachment", out var attachment) ? ParseFields(JsonNode.Parse(attachment.GetRawText())) : new()
};
}
const int MaxFields = 16;
static bool Flag(JsonElement root, string property) =>
root.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.True;
static string Alt(JsonElement image) =>
image.ValueKind == JsonValueKind.Object ? ObjectShapes.Text(RemoteActorService.Text(image, "name") ?? RemoteActorService.Text(image, "summary"), 1500) : default;
static Dictionary<string, string> ParseFields(JsonNode attachment)
{
var fields = new Dictionary<string, string>();
foreach (var field in ObjectShapes.Objects(attachment).Where(a => ActivityJson.Value(a, "type") is "PropertyValue" or "Note"))
{
var name = ObjectShapes.Text(ActivityJson.Value(field, "name"), 255);
var value = ActivityJson.Value(field, "value") ?? ActivityJson.Value(field, "content");
if (name == default || value == default || fields.ContainsKey(name))
continue;
fields[name] = ContentSanitizer.Html(value.Length <= 2048 ? value : value[..2048]);
if (fields.Count == MaxFields)
break;
}
return fields;
}
static List<ActorKey> ParseKeys(JsonElement root, string actorId)
{
var keys = new List<ActorKey>();
@@ -29,6 +29,8 @@ namespace PrivaPub.Federation.Actors
public bool Discoverable { get; init; } = true;
public bool ManuallyApprovesFollowers { get; init; }
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();
@@ -42,6 +44,10 @@ namespace PrivaPub.Federation.Actors
public string Outbox => $"{Uri}/anus";
public string Followers => $"{Uri}/groupies";
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 Domain => new Uri(BaseAddress).Authority;
public string Handle => $"{UserName}@{Domain}";
@@ -196,7 +202,7 @@ namespace PrivaPub.Federation.Actors
ThumbnailURL = avatar.ThumbnailURL,
PrivateKeyPem = avatar.PrivateKey,
PublicKeyPem = avatar.PublicKey,
Published = avatar.CreatedAt,
Published = avatar.PublishedOn,
Fields = avatar.Fields ?? new Dictionary<string, string>(),
ManuallyApprovesFollowers = avatar.Settings?.IsLocked == true,
Discoverable = avatar.Settings?.IsDiscoverable != false,
@@ -217,10 +223,11 @@ namespace PrivaPub.Federation.Actors
ThumbnailURL = group.ThumbnailURL,
PrivateKeyPem = group.PrivateKey,
PublicKeyPem = group.PublicKey,
Discoverable = group.IsDiscoverable,
ManuallyApprovesFollowers = group.ManuallyApprovesMembers,
IsFederated = group.Kind == GroupKind.Community,
Published = group.CreationDate,
Discoverable = group.Kind == GroupKind.Community && group.IsDiscoverable,
ManuallyApprovesFollowers = group.Kind == GroupKind.Circle || group.ManuallyApprovesMembers,
IsCircle = group.Kind == GroupKind.Circle,
PostingRestrictedToModerators = group.PostingPolicy == PostingPolicy.Moderators,
Published = group.PublishedOn,
BaseAddress = BaseAddress
};
}
@@ -6,6 +6,7 @@ using PrivaPub.StaticServices;
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Signing;
@@ -18,6 +19,7 @@ namespace PrivaPub.Federation.Actors
Task<FetchedJson> FetchObject(string uri, CancellationToken token);
Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token);
Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token);
bool KeyTemporarilyUnavailable(string keyId) => false;
Task<string> ResolveHandle(string handle, CancellationToken token);
}
@@ -32,13 +34,16 @@ namespace PrivaPub.Federation.Actors
readonly ILocalActorService _localActors;
readonly IMemoryCache _cache;
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;
_localActors = localActors;
_cache = cache;
_dbEntities = dbEntities;
_options = options;
}
public async Task<FetchedJson> FetchObject(string uri, CancellationToken token)
@@ -116,14 +121,19 @@ namespace PrivaPub.Federation.Actors
return await Upsert(actor, key, token);
}
public bool KeyTemporarilyUnavailable(string keyId) => Origin.Of(keyId) != default && _http.FailedTemporarily(StripFragment(keyId));
public async Task<string> ResolveHandle(string handle, CancellationToken token)
{
var parts = handle?.TrimStart('@').Split('@');
if (parts is not { Length: 2 } || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1]))
return default;
var url = $"https://{parts[1]}/.well-known/webfinger?resource={Uri.EscapeDataString($"acct:{parts[0]}@{parts[1]}")}";
using var fetched = await _http.GetJson(url, "application/jrd+json, application/json", sign: default, token);
var query = $"/.well-known/webfinger?resource={Uri.EscapeDataString($"acct:{parts[0]}@{parts[1]}")}";
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)
return default;
var document = fetched.Document;
@@ -167,7 +177,17 @@ namespace PrivaPub.Federation.Actors
.Modify(a => a.FollowingURL, actor.Following)
.Modify(a => a.SharedInboxURL, actor.SharedInbox)
.Modify(a => a.PictureURL, actor.Icon)
.Modify(a => a.ThumbnailURL, actor.Header)
.Modify(a => a.PictureDescription, actor.IconDescription)
.Modify(a => a.HeaderDescription, actor.HeaderDescription)
.Modify(a => a.IsDiscoverable, actor.Discoverable)
.Modify(a => a.IsIndexable, actor.Indexable)
.Modify(a => a.IsLocked, actor.Locked)
.Modify(a => a.IsMemorial, actor.Memorial)
.Modify(a => a.MovedToURL, Origin.Of(actor.MovedTo) == default ? default : actor.MovedTo)
.Modify(a => a.Published, actor.Published)
.Modify(a => a.Emojis, actor.Emojis)
.Modify(a => a.Fields, actor.Fields)
.Modify(a => a.AvatarType, Enum.TryParse<AvatarType>(actor.Type, out var type) ? type : AvatarType.Person)
.Modify(a => a.PublicKeyId, key?.Id)
.Modify(a => a.PublicKey, key?.Pem)
@@ -1,10 +1,13 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.RateLimiting;
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
@@ -14,12 +17,15 @@ using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Signing;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Models.Group;
using PrivaPub.Infrastructure;
namespace PrivaPub.Federation.Controllers
{
[ApiController, Route("peasants")]
public class PeasantsController : ControllerBase
public class PeasantsController : ControllerBase, IAsyncActionFilter
{
const string ActivityContentType = "application/activity+json; charset=utf-8";
const int OutboxSize = 20;
@@ -28,10 +34,14 @@ namespace PrivaPub.Federation.Controllers
readonly IInboxReceiver _inbox;
readonly DbEntities _dbEntities;
readonly ILogger<PeasantsController> _logger;
readonly ISignedFetchAuthorizer _fetches;
readonly IOptionsMonitor<FederationOptions> _federation;
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;
_inbox = inbox;
_dbEntities = dbEntities;
@@ -112,10 +122,72 @@ namespace PrivaPub.Federation.Controllers
return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, default));
}
[HttpGet, Route("{actor}/flock")]
public async Task<IActionResult> Members(string actor, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true, Kind: LocalActorKind.Group })
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));
}
[HttpGet, Route("{actor}/wardens")]
public async Task<IActionResult> Moderators(string actor, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true, Kind: LocalActorKind.Group })
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())
@@ -173,6 +245,20 @@ namespace PrivaPub.Federation.Controllers
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);
@@ -228,10 +314,27 @@ namespace PrivaPub.Federation.Controllers
&& !accept.Contains("ld+json", StringComparison.OrdinalIgnoreCase);
}
[NonAction]
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
if (HttpMethods.IsGet(Request.Method))
Response.Headers.Vary = "Accept";
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)
{
if (result.Error != default)
_logger.LogInformation("Inbox refused with {Status}: {Error}", result.StatusCode, result.Error);
if (result.RetryAfterSeconds is { } seconds)
Response.Headers.RetryAfter = seconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
return result.Error == default ? StatusCode(result.StatusCode) : StatusCode(result.StatusCode, result.Error);
}
+14
View File
@@ -0,0 +1,14 @@
namespace PrivaPub.Federation.Inbox
{
public sealed record Arrival(string ActivityId, string ActivityType, string ActorURI, string Inbox, string KeyId, string Algorithm,
IReadOnlyList<string> SignedHeaders, DateTime ReceivedAt, string Context = default)
{
static readonly AsyncLocal<Arrival> current = new();
public static Arrival Current
{
get => current.Value;
set => current.Value = value;
}
}
}
@@ -26,9 +26,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly ILocalActorService _localActors;
readonly IRemotePosts _remotePosts;
readonly IFanout _fanout;
readonly IRemoteActorService _remoteActors;
public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout)
public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout, IRemoteActorService remoteActors)
{
_remoteActors = remoteActors;
_dbEntities = dbEntities;
_localActors = localActors;
_remotePosts = remotePosts;
@@ -41,7 +43,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
{
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)
@@ -66,7 +71,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
: DateTime.UtcNow;
var reblog = new PostEntity
{
ID = PrivacyIds.At(published),
ID = PrivacyIds.Arrived(published),
ObjectURI = announceId,
ActivityURI = announceId,
ActorURI = actor.ActorURI,
@@ -96,6 +101,66 @@ namespace PrivaPub.Federation.Inbox.Handlers
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(),
@@ -4,6 +4,7 @@ using MongoDB.Entities;
using PrivaPub.Domain.Timelines;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Moderation;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
using PrivaPub.Infrastructure.Ids;
@@ -33,10 +34,16 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly IDomainBlocks _domainBlocks;
readonly IFanout _fanout;
readonly IRemotePosts _remotePosts;
readonly IGroupDistributor _groups;
readonly IObjectRecords _records;
readonly IPollService _polls;
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts)
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups, IObjectRecords records, IPollService polls)
{
_records = records;
_polls = polls;
_groups = groups;
_fanout = fanout;
_remotePosts = remotePosts;
_dbEntities = dbEntities;
@@ -51,7 +58,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
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))
var refetched = node is not JsonObject || !Origin.Same(Id(node), author.ActorURI);
if (refetched)
{
using var fetched = await _remoteActors.FetchObject(Id(node), token);
node = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText());
@@ -59,7 +67,17 @@ namespace PrivaPub.Federation.Inbox.Handlers
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))
if (note.Title != default && note.InReplyTo != default && ObjectShapes.Text(note.ContentHtml, 1) == default)
{
var question = await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && p.Poll != null).ExecuteFirstAsync(token);
if (question != default)
{
await _polls.Receive(question, author, note.Title, Id(activity), token);
return;
}
}
if (await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id).ExecuteAnyAsync(token)
|| await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == note.Id).ExecuteAnyAsync(token))
return;
var to = note.To.Concat(Strings(activity["to"])).Distinct(StringComparer.Ordinal).ToList();
@@ -85,10 +103,17 @@ namespace PrivaPub.Federation.Inbox.Handlers
var parent = string.IsNullOrEmpty(note.InReplyTo)
? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
var group = visibility is PostVisibility.Public or PostVisibility.Unlisted
? localTargets.FirstOrDefault(t => t.Kind == LocalActorKind.Group)
: default;
if (group != default && !await IsAcceptedFollower(group, author.ActorURI, token))
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;
@@ -122,17 +147,29 @@ namespace PrivaPub.Federation.Inbox.Handlers
{
return;
}
await _records.Record(note, post, ObjectPath.Delivered, refetched, 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 (conversation != default)
await DB.Default.Update<DmGroup>().MatchID(conversation.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token);
if (group != default)
{
var announce = ActivityPubRenderer.Announce(group, note.Id, $"announce-{post.ID}");
await _delivery.EnqueueToFollowers(group, announce, token);
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)
@@ -26,9 +26,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IDeliveryService _delivery;
readonly IGroupDistributor _groups;
public DeleteHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery)
public DeleteHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IGroupDistributor groups)
{
_groups = groups;
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
@@ -62,11 +65,19 @@ namespace PrivaPub.Federation.Inbox.Handlers
return;
}
await DB.Default.Update<DeletedObject>()
.Match(d => d.ObjectURI == objectUri)
.Modify(d => d.DeletedAt, DateTime.UtcNow)
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
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<ObjectRecord>(r => r.PostId == post.ID || r.ObjectURI == objectUri);
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,47 @@
using MongoDB.Driver;
using MongoDB.Entities;
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 DislikeHandler : IActivityHandler
{
readonly DbEntities _dbEntities;
public DislikeHandler(DbEntities dbEntities)
{
_dbEntities = dbEntities;
}
public string Type => "Dislike";
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 || !LikeHandler.MaySee(post, actor))
return;
try
{
await DB.Default.SaveAsync(new Downvote { 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.DownvotesCount, 1)).ExecuteAsync(token);
}
}
}
@@ -0,0 +1,34 @@
using PrivaPub.Domain.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 EmojiReactHandler : IActivityHandler
{
readonly DbEntities _dbEntities;
readonly IReactions _reactions;
public EmojiReactHandler(DbEntities dbEntities, IReactions reactions)
{
_dbEntities = dbEntities;
_reactions = reactions;
}
public string Type => "EmojiReact";
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)
await _reactions.Receive(post, actor, Value(activity, "content"), activity["tag"], Id(activity), 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);
}
}
}
@@ -46,6 +46,20 @@ namespace PrivaPub.Federation.Inbox.Handlers
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)
@@ -0,0 +1,48 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
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 JoinHandler : IActivityHandler
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
public JoinHandler(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery)
{
_dbEntities = dbEntities;
_localActors = localActors;
_delivery = delivery;
}
public string Type => "Join";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{
var objectUri = Id(activity["object"]);
if (objectUri == default || !objectUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
return;
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
var owner = post == default ? default : await _localActors.FindById(LocalActorKind.Person, post.GroupUserId, token);
if (owner == default || string.IsNullOrEmpty(actor.InboxURL))
return;
await _delivery.Enqueue(owner, new[] { actor.InboxURL }, new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = owner.ActivityUri($"ignore-{Guid.NewGuid():N}"),
["type"] = "Ignore",
["actor"] = owner.Uri,
["object"] = activity.DeepClone()
}, token);
}
}
}
@@ -18,10 +18,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
public class LikeHandler : IActivityHandler
{
readonly DbEntities _dbEntities;
readonly IReactions _reactions;
public LikeHandler(DbEntities dbEntities)
public LikeHandler(DbEntities dbEntities, IReactions reactions)
{
_dbEntities = dbEntities;
_reactions = reactions;
}
public string Type => "Like";
@@ -34,6 +36,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
: await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
if (post == default || !MaySee(post, actor))
return;
var content = Value(activity, "_misskey_reaction") ?? Value(activity, "content");
if (!Reactions.IsHeart(content))
{
await _reactions.Receive(post, actor, content, activity["tag"], Id(activity), token);
return;
}
try
{
@@ -1,5 +1,6 @@
using MongoDB.Entities;
using PrivaPub.Domain.Social;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Social;
@@ -17,11 +18,13 @@ namespace PrivaPub.Federation.Inbox.Handlers
{
public class UndoHandler : IActivityHandler
{
readonly IReactions _reactions;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
public UndoHandler(DbEntities dbEntities, ILocalActorService localActors)
public UndoHandler(DbEntities dbEntities, ILocalActorService localActors, IReactions reactions)
{
_reactions = reactions;
_dbEntities = dbEntities;
_localActors = localActors;
}
@@ -40,6 +43,29 @@ namespace PrivaPub.Federation.Inbox.Handlers
await UndoLike(inner, innerId, actor, token);
if (innerType is null or "Announce")
await UndoAnnounce(innerId, actor, token);
if (innerType is null or "Dislike")
await UndoDislike(inner, innerId, actor, token);
if (innerType is null or "Like" or "EmojiReact")
await _reactions.Withdraw(actor, innerId, inner is JsonObject ? Id(inner["object"]) : default,
inner is JsonObject ? Value(inner, "_misskey_reaction") ?? Value(inner, "content") : default, token);
}
async Task UndoDislike(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token)
{
var downvote = innerId == default
? default
: await DB.Default.Find<Downvote>().Match(d => d.ActivityURI == innerId && d.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
if (downvote == default && inner is JsonObject && Id(inner["object"]) is { } objectUri)
{
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
downvote = post == default
? default
: await DB.Default.Find<Downvote>().Match(d => d.PostId == post.ID && d.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
}
if (downvote == default)
return;
await DB.Default.DeleteAsync<Downvote>(downvote.ID);
await DB.Default.Update<PostEntity>().MatchID(downvote.PostId).Modify(b => b.Inc(p => p.DownvotesCount, -1)).ExecuteAsync(token);
}
async Task UndoFollow(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token)
@@ -2,6 +2,7 @@ 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;
@@ -21,9 +22,14 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IGroupDistributor _groups;
readonly IObjectRecords _records;
public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors)
public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IGroupDistributor groups,
IObjectRecords records)
{
_records = records;
_groups = groups;
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
@@ -49,6 +55,17 @@ namespace PrivaPub.Federation.Inbox.Handlers
if (post == default || post.DeletedAt.HasValue)
return;
post.Poll = note.Poll ?? post.Poll;
post.Video = note.Video ?? post.Video;
post.Audio = note.Audio ?? post.Audio;
post.Event = note.Event ?? post.Event;
if (!IsEdit(note, post))
{
await DB.Default.SaveAsync(post, token);
await _records.Revise(note, Id(activity), token);
return;
}
post.Revisions.Add(new PostRevision
{
Title = post.Title,
@@ -69,6 +86,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
post.Title = note.Title;
post.SpoilerText = note.SpoilerText;
post.Excerpt = note.Excerpt;
post.Source = note.Source;
post.Emojis = note.Emojis.ToList();
post.CoverURL = note.CoverURL;
post.Link = note.Link;
post.HasContentWarning = note.Sensitive;
post.Text = note.ContentHtml;
post.ContentHtml = note.ContentHtml;
@@ -79,6 +101,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
post.EditedAt = note.Updated ?? DateTime.UtcNow;
post.UpdateDate = DateTime.UtcNow;
await DB.Default.SaveAsync(post, token);
}
await _records.Revise(note, Id(activity), 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);
}
static bool IsEdit(NoteDocument note, PostEntity post) =>
note.Updated is { } updated && updated > (post.EditedAt ?? post.CreationDate);
}
}
@@ -46,7 +46,16 @@ namespace PrivaPub.Federation.Inbox
if (actor == default)
return JobOutcome.Retry("the actor could not be loaded");
Arrival.Current = new Arrival(Id(activity), type, actor.ActorURI, payload.Inbox, payload.KeyId, payload.Algorithm,
payload.SignedHeaders ?? Array.Empty<string>(), payload.ReceivedAt ?? job.CreatedAt, activity["@context"]?.ToJsonString());
try
{
await handler.Handle(activity, actor, token);
}
finally
{
Arrival.Current = default;
}
_logger.LogInformation("Processed {Type} {Id} from {Actor}", type, Id(activity), actor.ActorURI);
return JobOutcome.Done;
}
+10 -3
View File
@@ -13,9 +13,10 @@ using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Inbox
{
public sealed record InboxResult(int StatusCode, string Error = default);
public sealed record InboxResult(int StatusCode, string Error = default, int? RetryAfterSeconds = default);
public sealed record InboxPayload(string ActorURI, string Activity);
public sealed record InboxPayload(string ActorURI, string Activity, string Inbox = default, string KeyId = default, string Algorithm = default,
string[] SignedHeaders = default, DateTime? ReceivedAt = default);
public interface IInboxReceiver
{
@@ -24,6 +25,8 @@ namespace PrivaPub.Federation.Inbox
public class InboxReceiver : IInboxReceiver
{
const int KeyRetrySeconds = 300;
const int MaxBodyBytes = 1024 * 1024;
readonly ILocalActorService _localActors;
@@ -87,6 +90,8 @@ namespace PrivaPub.Federation.Inbox
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
{
keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token);
if (keyOwner == default && _remoteActors.KeyTemporarilyUnavailable(parameters.KeyId))
return new(StatusCodes.Status503ServiceUnavailable, "the signing key could not be fetched; try again later", KeyRetrySeconds);
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
return new(StatusCodes.Status401Unauthorized, "the signature does not verify");
}
@@ -99,7 +104,9 @@ namespace PrivaPub.Federation.Inbox
return shapeProblem;
var activityId = Id(activity);
await _queue.Enqueue(JobKind.ProcessInbox, JsonSerializer.Serialize(new InboxPayload(actorUri, activity.ToJsonString())),
var payload = new InboxPayload(actorUri, activity.ToJsonString(), recipient == default ? "shared" : "personal", parameters.KeyId,
parameters.Algorithm, parameters.Headers, DateTime.UtcNow);
await _queue.Enqueue(JobKind.ProcessInbox, JsonSerializer.Serialize(payload),
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);
+17 -3
View File
@@ -6,6 +6,7 @@ using PrivaPub.Federation.Moderation;
using PrivaPub.Federation.Objects;
using PrivaPub.Infrastructure.Ids;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
@@ -37,10 +38,12 @@ namespace PrivaPub.Federation.Inbox
readonly IRemoteActorService _remoteActors;
readonly IDomainBlocks _domainBlocks;
readonly IJobQueue _queue;
readonly IObjectRecords _records;
public RemotePosts(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDomainBlocks domainBlocks,
IJobQueue queue)
IJobQueue queue, IObjectRecords records)
{
_records = records;
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
@@ -60,7 +63,7 @@ namespace PrivaPub.Federation.Inbox
return new PostEntity
{
ID = PrivacyIds.At(note.Published),
ID = PrivacyIds.Arrived(note.Published),
ObjectURI = note.Id,
ActorURI = author.ActorURI,
AuthorAccountId = author.ID,
@@ -70,8 +73,18 @@ namespace PrivaPub.Federation.Inbox
Url = note.Url,
ContextURI = note.Context,
QuoteURI = note.QuoteUri,
ObjectType = note.Type,
Title = note.Title,
SpoilerText = note.SpoilerText,
Excerpt = note.Excerpt,
Source = note.Source,
Poll = note.Poll,
Emojis = note.Emojis.ToList(),
CoverURL = note.CoverURL,
Link = note.Link,
Video = note.Video,
Audio = note.Audio,
Event = note.Event,
HasContentWarning = note.Sensitive,
Text = note.ContentHtml,
ContentHtml = note.ContentHtml,
@@ -103,7 +116,7 @@ namespace PrivaPub.Federation.Inbox
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)
if (existing != default || depth > MaxDepth || await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == objectUri).ExecuteAnyAsync(token))
return existing;
using var fetched = await _remoteActors.FetchObject(objectUri, token);
@@ -129,6 +142,7 @@ namespace PrivaPub.Federation.Inbox
{
return await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
}
await _records.Record(note, post, ObjectPath.Fetched, refetched: false, 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,
@@ -0,0 +1,84 @@
using MongoDB.Entities;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Jobs;
using System.Text.Json;
namespace PrivaPub.Federation.Objects
{
public class InstanceDescriber : IJobHandler
{
const int MaxNodeInfoChars = 64 * 1024;
static readonly string[] Schemas =
{
"http://nodeinfo.diaspora.software/ns/schema/2.1",
"http://nodeinfo.diaspora.software/ns/schema/2.0"
};
readonly IFederationHttp _http;
public InstanceDescriber(IFederationHttp http) => _http = http;
public JobKind Kind => JobKind.DescribeInstance;
public int Concurrency => 1;
public int MaxAttempts => 2;
public int PerHostLimit => 1;
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
{
var host = job.Payload;
using var links = await _http.GetJson($"https://{host}/.well-known/nodeinfo", "application/json", sign: default, token);
var href = links == default ? default : Schemas.Select(schema => LinkFor(links.Root, schema)).FirstOrDefault(link => link != default);
if (href == default)
return await Described(host, default, "no NodeInfo", token);
using var nodeInfo = await _http.GetJson(href, "application/json", sign: default, token);
return nodeInfo == default ? await Described(host, default, "NodeInfo unreadable", token) : await Described(host, nodeInfo.Root, default, token);
}
static async Task<JobOutcome> Described(string host, JsonElement? nodeInfo, string error, CancellationToken token)
{
var update = DB.Default.Update<RemoteInstance>()
.Match(i => i.Host == host)
.Modify(i => i.DescribedAt, DateTime.UtcNow)
.Modify(i => i.DescriptionError, error)
.Option(o => o.IsUpsert = true);
if (nodeInfo is { } root)
{
var raw = root.GetRawText();
update = update
.Modify(i => i.Software, Text(root, "software", "name"))
.Modify(i => i.SoftwareVersion, Text(root, "software", "version"))
.Modify(i => i.NodeName, Text(root, "metadata", "nodeName"))
.Modify(i => i.Protocols, root.TryGetProperty("protocols", out var protocols) && protocols.ValueKind == JsonValueKind.Array
? protocols.EnumerateArray().Where(p => p.ValueKind == JsonValueKind.String).Select(p => p.GetString()).Take(16).ToList()
: new List<string>())
.Modify(i => i.OpenRegistrations, root.TryGetProperty("openRegistrations", out var open) && open.ValueKind is JsonValueKind.True or JsonValueKind.False
? open.GetBoolean()
: default(bool?))
.Modify(i => i.NodeInfo, raw.Length <= MaxNodeInfoChars ? raw : default);
}
await update.ExecuteAsync(token);
return JobOutcome.Done;
}
static string LinkFor(JsonElement root, string schema) =>
root.TryGetProperty("links", out var links) && links.ValueKind == JsonValueKind.Array
? links.EnumerateArray()
.Where(l => l.ValueKind == JsonValueKind.Object && l.TryGetProperty("rel", out var rel) && rel.ValueKind == JsonValueKind.String && rel.GetString() == schema)
.Select(l => l.TryGetProperty("href", out var href) && href.ValueKind == JsonValueKind.String ? href.GetString() : default)
.FirstOrDefault(h => h != default && h.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
: default;
static string Text(JsonElement root, string parent, string name)
{
if (!root.TryGetProperty(parent, out var section) || section.ValueKind != JsonValueKind.Object
|| !section.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.String)
return default;
var text = value.GetString();
return text.Length <= 200 ? text : text[..200];
}
}
}
+60 -30
View File
@@ -1,6 +1,7 @@
using PrivaPub.Models.Post;
using System.Globalization;
using System.Text;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
@@ -11,10 +12,12 @@ namespace PrivaPub.Federation.Objects
{
public string Id { get; init; }
public string Type { get; init; }
public JsonObject Raw { get; init; }
public string AttributedTo { get; init; }
public string ContentHtml { get; init; }
public string Title { get; init; }
public string SpoilerText { get; init; }
public string Excerpt { get; init; }
public bool Sensitive { get; init; }
public string Language { get; init; }
public string Url { get; init; }
@@ -29,11 +32,19 @@ namespace PrivaPub.Federation.Objects
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 PostSource Source { get; init; }
public PostPoll Poll { get; init; }
public IReadOnlyList<CustomEmoji> Emojis { get; init; } = Array.Empty<CustomEmoji>();
public string CoverURL { get; init; }
public PostLink Link { get; init; }
public VideoDetails Video { get; init; }
public AudioDetails Audio { get; init; }
public EventDetails Event { get; init; }
}
public static class NoteParser
{
static readonly string[] NoteTypes = { "Note", "Article", "Page", "Question", "Event", "Video", "Audio", "Image" };
static readonly string[] NoteTypes = { "Note", "Article", "Page", "Question", "Event", "Video", "Audio", "Image", "ChatMessage" };
const int MaxAttachments = 16;
const int MaxTags = 64;
@@ -48,16 +59,21 @@ namespace PrivaPub.Federation.Objects
var (contentHtml, language) = Content(note);
var summary = Value(note, "summary");
var excerpt = ObjectShapes.Text(summary, 1000)
?? (note["preview"] is JsonObject preview && Value(preview, "type") == "Note" ? ObjectShapes.Text(Value(preview, "content"), 1000) : default);
var sensitive = note["sensitive"] is JsonValue flag && flag.TryGetValue<bool>(out var marked) && marked;
var summaryWarns = type is "Note" or "Question" || sensitive;
return new NoteDocument
{
Id = id,
Type = type,
Raw = note,
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),
Title = ObjectShapes.Text(Value(note, "name"), 500),
SpoilerText = summaryWarns ? Plain(summary, 500) : default,
Excerpt = summaryWarns ? default : excerpt,
Sensitive = sensitive || summaryWarns && !string.IsNullOrWhiteSpace(summary),
Language = language,
Url = UrlOf(note["url"]) ?? id,
InReplyTo = Id(note["inReplyTo"]),
@@ -73,12 +89,20 @@ namespace PrivaPub.Federation.Objects
.DistinctBy(m => m.ActorURI)
.Take(MaxTags)
.ToList(),
Tags = Tags(note, "Hashtag").Select(t => Value(t, "name")?.TrimStart('#').ToLowerInvariant())
Tags = Tags(note, "Hashtag").Select(t => Value(t, "name")?.TrimStart('#').Normalize(NormalizationForm.FormKC).ToLowerInvariant())
.Where(t => !string.IsNullOrEmpty(t))
.Distinct()
.Take(MaxTags)
.ToList(),
Attachments = Attachments(note)
Attachments = Attachments(note),
Source = ObjectShapes.Source(note),
Poll = ObjectShapes.Poll(note),
Emojis = ObjectShapes.Emojis(note["tag"]),
CoverURL = ObjectShapes.Cover(note),
Link = ObjectShapes.Link(note),
Video = ObjectShapes.Video(note),
Audio = ObjectShapes.Audio(note),
Event = ObjectShapes.Event(note)
};
}
@@ -95,7 +119,9 @@ namespace PrivaPub.Federation.Objects
}
if (string.IsNullOrEmpty(content) && Value(note, "_misskey_content") is { } misskey)
content = System.Net.WebUtility.HtmlEncode(misskey).Replace("\n", "<br>");
return (content, language);
if (!string.IsNullOrEmpty(content) && Value(note, "mediaType")?.StartsWith("text/markdown", StringComparison.OrdinalIgnoreCase) == true)
content = Rendering.ActivityPubRenderer.Html(content);
return (content, language ?? ObjectShapes.Language(note));
}
static string Attribution(JsonNode node) => node switch
@@ -115,33 +141,37 @@ namespace PrivaPub.Federation.Objects
_ => 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
static List<PostMedia> Attachments(JsonObject note) =>
ObjectShapes.Objects(note["attachment"])
.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
})
.Select(Attachment)
.Where(m => Origin.Of(m.RemoteURL) != default)
.Take(MaxAttachments)
.ToList();
static PostMedia Attachment(JsonObject attachment)
{
var (url, linkMediaType) = ObjectShapes.MediaLink(attachment["url"] ?? attachment["href"]);
return new PostMedia
{
RemoteURL = url,
ContentType = ObjectShapes.InferMediaType(Value(attachment, "mediaType") ?? linkMediaType, url, Value(attachment, "type")),
RemotePreviewURL = ObjectShapes.Image(attachment["icon"]),
DurationSeconds = ObjectShapes.Seconds(attachment["duration"]),
Sensitive = attachment["sensitive"] is JsonValue flag && flag.TryGetValue<bool>(out var marked) ? marked : default(bool?),
Description = Plain(Value(attachment, "name") ?? Value(attachment, "summary") ?? FirstOf(attachment["nameMap"]) ?? FirstOf(attachment["summaryMap"]), 1500),
Blurhash = Value(attachment, "blurhash"),
Width = Int(attachment["width"]),
Height = Int(attachment["height"]),
Focus = attachment["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
};
}
static string FirstOf(JsonNode map) =>
map is JsonObject languages ? languages.Select(l => l.Value is JsonValue v && v.TryGetValue<string>(out var text) ? text : default).FirstOrDefault(t => t != default) : default;
static string UrlOf(JsonNode node) => node switch
{
JsonValue value when value.TryGetValue<string>(out var text) => text,
@@ -163,7 +193,7 @@ namespace PrivaPub.Federation.Objects
static DateTime? Time(string value) =>
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)
? parsed.UtcDateTime
: default;
: (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;
@@ -0,0 +1,68 @@
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Objects
{
public static class ObjectFeatures
{
public static List<string> Detect(JsonObject raw)
{
var found = new List<string>();
if (raw == default)
return found;
void When(bool condition, string feature)
{
if (condition)
found.Add(feature);
}
When(raw.ContainsKey("quote"), "fep-044f-quote");
When(raw.ContainsKey("quoteAuthorization"), "fep-044f-authorization");
When(raw.ContainsKey("_misskey_quote") || raw.ContainsKey("quoteUrl") || raw.ContainsKey("quoteUri"), "legacy-quote");
When(raw.ContainsKey("interactionPolicy"), "interaction-policy");
When(raw.ContainsKey("context"), "context");
When(raw.ContainsKey("proof"), "fep-8b32-proof");
When(raw.Any(p => p.Key.StartsWith("_misskey_", StringComparison.Ordinal)), "misskey");
When(raw["source"] is JsonObject, "source:" + (Value(raw["source"], "mediaType") ?? "unknown"));
When(raw["source"] is JsonValue, "source:url");
When(raw.ContainsKey("htmlMfm"), "fep-c16b-mfm");
When(raw.ContainsKey("formerRepresentations"), "former-representations");
When(raw.ContainsKey("searchableBy"), "fep-268d-searchable-by");
When(raw.ContainsKey("audience"), "fep-1b12-audience");
When(Items(raw["attachment"]).Any(a => Value(a, "type") == "Link"), "fep-8967-link");
When(Items(raw["tag"]).Any(t => Value(t, "type") == "Emoji"), "custom-emoji");
When(Value(raw, "type") == "Question", "poll");
When(raw["contentMap"] is JsonObject, "content-map");
When(raw["url"] is JsonArray, "url-variants");
var contentType = Value(raw, "mediaType");
When(contentType is not (null or "text/html"), "content:" + contentType);
return found;
}
public static List<string> Namespaces(JsonObject raw, string activityContext = default) =>
Namespaces(raw?["@context"] ?? (activityContext == default ? default : JsonNode.Parse(activityContext)));
static List<string> Namespaces(JsonNode context) => context switch
{
JsonValue single when single.TryGetValue<string>(out var url) => new List<string> { url },
JsonArray array => array.SelectMany(entry => entry switch
{
JsonValue value when value.TryGetValue<string>(out var url) => new[] { url },
JsonObject terms => terms.Where(t => t.Value is JsonValue v && v.TryGetValue<string>(out var target) && target.EndsWith('#'))
.Select(t => t.Key + "=" + t.Value!.GetValue<string>()),
_ => Enumerable.Empty<string>()
})
.Take(32)
.ToList(),
_ => new List<string>()
};
static IEnumerable<JsonObject> Items(JsonNode node) => node switch
{
JsonArray array => array.OfType<JsonObject>(),
JsonObject single => new[] { single },
_ => Enumerable.Empty<JsonObject>()
};
}
}
@@ -0,0 +1,112 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Federation.Inbox;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Objects
{
public interface IObjectRecords
{
Task Record(NoteDocument note, PostEntity post, ObjectPath path, bool refetched, CancellationToken token);
Task Revise(NoteDocument note, string activityId, CancellationToken token);
}
public class ObjectRecords : IObjectRecords
{
public const int MaxRawBytes = 256 * 1024;
const int MaxRevisions = 10;
static readonly TimeSpan DescriptionAge = TimeSpan.FromDays(7);
readonly IJobQueue _queue;
public ObjectRecords(IJobQueue queue) => _queue = queue;
public async Task Record(NoteDocument note, PostEntity post, ObjectPath path, bool refetched, CancellationToken token)
{
var arrival = Arrival.Current;
var raw = Capture(note.Raw);
var host = new Uri(note.Id).Host.ToLowerInvariant();
var record = new ObjectRecord
{
ObjectURI = note.Id,
PostId = post.ID,
ObjectType = note.Type,
Host = host,
Raw = raw.Text,
RawHash = raw.Hash,
RawBytes = raw.Bytes,
RawTruncated = raw.Truncated,
ActivityContext = note.Raw?.ContainsKey("@context") == true || arrival?.Context?.Length > 16 * 1024 ? default : arrival?.Context,
Path = path,
Refetched = refetched,
ActivityId = arrival?.ActivityId,
ActivityType = arrival?.ActivityType,
ActivityActorURI = arrival?.ActorURI,
Inbox = path == ObjectPath.Delivered ? arrival?.Inbox : default,
SignatureScheme = arrival?.KeyId == default ? default : "draft-cavage",
KeyId = arrival?.KeyId,
Algorithm = arrival?.Algorithm,
SignedHeaders = arrival?.SignedHeaders?.ToList() ?? new(),
ReceivedAt = arrival?.ReceivedAt ?? DateTime.UtcNow,
Published = note.Published,
Updated = note.Updated
};
try
{
await DB.Default.SaveAsync(record, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return;
}
await Describe(host, token);
}
public async Task Revise(NoteDocument note, string activityId, CancellationToken token)
{
var raw = Capture(note.Raw);
var revision = new ObjectRevision
{
Raw = raw.Text,
RawHash = raw.Hash,
RawTruncated = raw.Truncated,
ActivityId = activityId,
Updated = note.Updated
};
await DB.Default.Update<ObjectRecord>()
.Match(r => r.ObjectURI == note.Id)
.Modify(b => b.PushEach(r => r.Revisions, new[] { revision }, -MaxRevisions))
.Modify(r => r.Updated, note.Updated)
.ExecuteAsync(token);
}
async Task Describe(string host, CancellationToken token)
{
var known = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == host).ExecuteFirstAsync(token);
if (known?.DescribedAt > DateTime.UtcNow - DescriptionAge)
return;
var week = ISOWeek.GetYear(DateTime.UtcNow) + "-" + ISOWeek.GetWeekOfYear(DateTime.UtcNow);
await _queue.Enqueue(JobKind.DescribeInstance, host, host, $"describe|{host}|{week}", token);
}
public static (string Text, string Hash, int Bytes, bool Truncated) Capture(JsonNode raw)
{
if (raw == default)
return default;
var text = raw.ToJsonString();
var bytes = Encoding.UTF8.GetBytes(text);
var hash = Convert.ToHexStringLower(SHA256.HashData(bytes));
return bytes.Length > MaxRawBytes ? (default, hash, bytes.Length, true) : (text, hash, bytes.Length, false);
}
}
}
+344
View File
@@ -0,0 +1,344 @@
using PrivaPub.Models.Post;
using System.Globalization;
using System.Net;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Xml;
using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Objects
{
public static partial class ObjectShapes
{
const int MaxVariants = 20;
const int MaxCaptions = 20;
const int MaxPlaces = 4;
const int CoverMaxWidth = 1280;
const int MaxEmojis = 64;
const int MaxPollOptions = 20;
static readonly Dictionary<string, string> MediaTypesByExtension = new(StringComparer.OrdinalIgnoreCase)
{
[".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png", [".gif"] = "image/gif", [".webp"] = "image/webp",
[".avif"] = "image/avif", [".heic"] = "image/heic", [".svg"] = "image/svg+xml",
[".mp4"] = "video/mp4", [".m4v"] = "video/mp4", [".webm"] = "video/webm", [".mov"] = "video/quicktime", [".mkv"] = "video/x-matroska",
[".mp3"] = "audio/mpeg", [".ogg"] = "audio/ogg", [".oga"] = "audio/ogg", [".opus"] = "audio/opus", [".m4a"] = "audio/mp4",
[".flac"] = "audio/flac", [".wav"] = "audio/wav", [".m3u8"] = "application/x-mpegURL"
};
public static IEnumerable<JsonObject> Objects(JsonNode node) => node switch
{
JsonArray array => array.OfType<JsonObject>(),
JsonObject single => new[] { single },
_ => Enumerable.Empty<JsonObject>()
};
public static (string Url, string MediaType) MediaLink(JsonNode node)
{
switch (node)
{
case JsonValue value when value.TryGetValue<string>(out var text):
return (text, default);
case JsonObject link when Value(link, "href") is { } href:
return (href, Value(link, "mediaType"));
case JsonObject wrapper when wrapper["url"] is { } inner:
var (url, mediaType) = MediaLink(inner);
return (url, mediaType ?? Value(wrapper, "mediaType"));
case JsonArray array:
var links = array.Select(MediaLink).Where(l => l.Url != default).ToList();
return links.FirstOrDefault(l => l.MediaType?.StartsWith("text/html", StringComparison.OrdinalIgnoreCase) != true);
default:
return default;
}
}
public static string InferMediaType(string declared, string url, string objectType)
{
if (!string.IsNullOrWhiteSpace(declared) && declared.Contains('/'))
return declared.Trim();
if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && MediaTypesByExtension.TryGetValue(Path.GetExtension(uri.AbsolutePath), out var byExtension))
return byExtension;
return objectType switch
{
"Image" => "image/*",
"Video" => "video/*",
"Audio" => "audio/*",
_ => default
};
}
public static string Image(JsonNode node)
{
var candidates = node switch
{
JsonValue value when value.TryGetValue<string>(out var text) => new[] { (Url: text, Width: (int?)default) },
JsonArray array => array.Select(item => (Url: MediaLink(item).Url, Width: Int(item as JsonObject, "width"))).ToArray(),
JsonObject single => new[] { (Url: MediaLink(single).Url, Width: Int(single, "width")) },
_ => Array.Empty<(string Url, int? Width)>()
};
var usable = candidates.Where(c => Origin.Of(c.Url) != default).ToList();
return usable.Where(c => c.Width <= CoverMaxWidth).OrderByDescending(c => c.Width).Select(c => c.Url).FirstOrDefault()
?? usable.Select(c => c.Url).FirstOrDefault();
}
public static List<CustomEmoji> Emojis(JsonNode tags) =>
Objects(tags)
.Where(t => Value(t, "type") == "Emoji")
.Select(t => new CustomEmoji
{
Shortcode = Value(t, "name")?.Trim(':'),
URL = MediaLink(t["icon"]).Url,
Updated = Moment(Value(t, "updated"))
})
.Where(e => !string.IsNullOrEmpty(e.Shortcode) && e.Shortcode.Length <= 100 && Shortcode().IsMatch(e.Shortcode) && Origin.Of(e.URL) != default)
.DistinctBy(e => e.Shortcode)
.Take(MaxEmojis)
.ToList();
public static PostPoll Poll(JsonObject note)
{
if (Value(note, "type") != "Question")
return default;
var multiple = note["anyOf"] is JsonArray;
var options = Objects(note[multiple ? "anyOf" : "oneOf"])
.Select(o => new PollOption
{
Title = Text(Value(o, "name"), 200),
Votes = o["replies"] is JsonObject replies && Int(replies, "totalItems") is { } total ? total : Int(o, "_misskey_votes") ?? 0
})
.Where(o => o.Title != default)
.Take(MaxPollOptions)
.ToList();
if (options.Count < 2)
return default;
var closedAt = note["closed"] switch
{
JsonValue value when value.TryGetValue<bool>(out var closed) => closed ? Moment(Value(note, "endTime")) ?? DateTime.UtcNow : default(DateTime?),
JsonValue value when value.TryGetValue<string>(out var at) => Moment(at),
_ => default
};
return new PostPoll
{
Options = options,
Multiple = multiple,
ExpiresAt = Moment(Value(note, "endTime")),
ClosedAt = closedAt,
VotersCount = Int(note, "votersCount")
};
}
public static string Cover(JsonObject note) =>
Image(note["image"]) ?? Image(note["icon"]) ?? (note["preview"] is JsonObject preview && Value(preview, "type") == "Image" ? Image(preview) : default);
public static double? Seconds(JsonNode node)
{
switch (node)
{
case JsonValue value when value.TryGetValue<double>(out var number):
return number >= 0 && number < 1e7 ? number : default;
case JsonValue value when value.TryGetValue<string>(out var text) && !string.IsNullOrWhiteSpace(text):
try
{
var span = XmlConvert.ToTimeSpan(text.Trim());
return span >= TimeSpan.Zero ? span.TotalSeconds : default;
}
catch (FormatException)
{
return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds) && seconds >= 0 ? seconds : default;
}
default:
return default;
}
}
public static string Text(string html, int max)
{
if (string.IsNullOrWhiteSpace(html))
return default;
var text = WebUtility.HtmlDecode(Tags().Replace(html, " "));
text = Spaces().Replace(text, " ").Trim();
if (text.Length == 0)
return default;
return text.Length <= max ? text : text[..max];
}
public static string Language(JsonObject note) =>
note["@context"] is JsonArray context
? context.OfType<JsonObject>().Select(entry => Value(entry, "@language")).FirstOrDefault(language => !string.IsNullOrWhiteSpace(language))
: default;
public static PostSource Source(JsonObject note) => note["source"] is JsonObject source && Value(source, "content") is { } content
? new PostSource { Content = content.Length <= 64 * 1024 ? content : content[..(64 * 1024)], MediaType = Value(source, "mediaType") }
: default;
public static PostLink Link(JsonObject note)
{
var attached = Objects(note["attachment"]).FirstOrDefault(a => Value(a, "type") == "Link" && IsWeb(Value(a, "href")));
if (attached != default)
{
var preview = attached["preview"] as JsonObject;
return new PostLink
{
Href = Value(attached, "href"),
MediaType = Value(attached, "mediaType"),
Title = Text(Value(preview, "name") ?? Value(attached, "name"), 300),
Description = Text(Value(preview, "summary") ?? Value(preview, "content"), 1000),
ImageURL = preview == default ? Image(note["image"]) : Image(preview["image"]) ?? Image(preview["icon"]),
FromPublisher = preview != default
};
}
if (note["source"] is JsonValue source && source.TryGetValue<string>(out var sourceUrl) && IsWeb(sourceUrl))
return new PostLink { Href = sourceUrl, ImageURL = Image(note["image"]) };
return default;
}
public static VideoDetails Video(JsonObject note)
{
if (Value(note, "type") != "Video")
return default;
var links = Objects(note["url"]).ToList();
return new VideoDetails
{
DurationSeconds = Seconds(note["duration"]),
Variants = Variants(links, "video/", "audio/"),
PlaylistURL = links.Where(l => Value(l, "mediaType")?.Equals("application/x-mpegURL", StringComparison.OrdinalIgnoreCase) == true)
.Select(l => Value(l, "href")).FirstOrDefault(IsWeb),
IsLive = Bool(note, "isLiveBroadcast") == true,
State = Int(note, "state"),
Views = Long(note, "views"),
CommentsPolicy = Int(note, "commentsPolicy"),
DownloadEnabled = Bool(note, "downloadEnabled"),
Licence = Named(note["licence"] ?? note["license"]),
Category = Named(note["category"]),
ChannelURI = Objects(note["attributedTo"]).Where(a => Value(a, "type") == "Group").Select(a => Id(a)).FirstOrDefault(),
EmbedURL = Value(note, "embedUrl") is { } embed && IsWeb(embed) ? embed : default,
ChaptersURL = Value(note, "hasParts") is { } chapters && IsWeb(chapters) ? chapters : default,
Captions = Objects(note["subtitleLanguage"])
.Select(c => new Caption
{
Language = Value(c, "identifier"),
Url = Objects(c["url"]).Where(u => Value(u, "mediaType") == "text/vtt").Select(u => Value(u, "href")).FirstOrDefault(IsWeb)
?? (Value(c, "url") is { } plain && IsWeb(plain) ? plain : default)
})
.Where(c => c.Url != default)
.Take(MaxCaptions)
.ToList()
};
}
public static AudioDetails Audio(JsonObject note)
{
if (Value(note, "type") != "Audio")
return default;
return new AudioDetails
{
DurationSeconds = Seconds(note["duration"]),
Variants = Variants(Objects(note["url"]).ToList(), "audio/"),
Album = Named(note["album"]),
Position = Int(note, "position"),
Licence = Named(note["license"] ?? note["licence"])
};
}
public static EventDetails Event(JsonObject note)
{
if (Value(note, "type") != "Event")
return default;
var start = Value(note, "startTime");
var places = Objects(note["location"]).Select(Place).Where(p => p != default).Take(MaxPlaces).ToList();
return new EventDetails
{
Start = Moment(start),
End = Moment(Value(note, "endTime")),
TimeZone = Value(note, "timezone"),
FloatingTime = start?.EndsWith("-00:00", StringComparison.Ordinal) == true,
Places = places,
OnlineURL = places.Select(p => p.Url).FirstOrDefault(u => u != default)
?? Objects(note["attachment"]).Where(a => Value(a, "type") == "Link" && Value(a, "name") == "Website").Select(a => Value(a, "href")).FirstOrDefault(IsWeb),
JoinMode = Value(note, "joinMode"),
ParticipantCount = Int(note, "participantCount"),
Capacity = Int(note, "maximumAttendeeCapacity"),
Status = Value(note, "ical:status") ?? Value(note, "eventStatus") ?? Value(note, "status"),
Category = Named(note["category"])
};
}
static EventPlace Place(JsonObject location)
{
var type = Value(location, "type");
if (type == "VirtualLocation")
return Value(location, "url") is { } url && IsWeb(url) ? new EventPlace { Name = Text(Value(location, "name"), 200), Url = url } : default;
var address = location["address"] switch
{
JsonValue value when value.TryGetValue<string>(out var text) => text,
JsonObject postal => string.Join(", ", new[] { "streetAddress", "postalCode", "addressLocality", "addressRegion", "addressCountry" }
.Select(field => Value(postal, field)).Where(part => !string.IsNullOrWhiteSpace(part))),
_ => default
};
var place = new EventPlace
{
Name = Text(Value(location, "name") ?? Value(location, "content"), 200),
Address = Text(address, 300),
Latitude = Double(location, "latitude"),
Longitude = Double(location, "longitude")
};
return place.Name == default && place.Address == default && place.Latitude == default ? default : place;
}
static List<MediaVariant> Variants(List<JsonObject> links, params string[] prefixes) =>
links.Where(l => Value(l, "mediaType") is { } type && prefixes.Any(p => type.StartsWith(p, StringComparison.OrdinalIgnoreCase)) && IsWeb(Value(l, "href")))
.Select(l => new MediaVariant
{
Url = Value(l, "href"),
MediaType = Value(l, "mediaType"),
Width = Int(l, "width"),
Height = Int(l, "height"),
Fps = Double(l, "fps"),
Size = Long(l, "size"),
Streams = Objects(l["attachment"])
.Where(p => Value(p, "name") == "ffprobe_codec_type")
.Select(p => Value(p, "value"))
.Where(v => v is "audio" or "video")
.Distinct()
.ToList()
})
.Take(MaxVariants)
.ToList();
static string Named(JsonNode node) => node switch
{
JsonValue value when value.TryGetValue<string>(out var text) => Text(text, 200),
JsonObject named => Text(Value(named, "name") ?? Value(named, "identifier"), 200),
_ => default
};
static DateTime? Moment(string value) =>
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) ? parsed.UtcDateTime : (DateTime?)null;
static bool IsWeb(string url) =>
Uri.TryCreate(url, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp);
static int? Int(JsonObject node, string property) =>
node?[property] is JsonValue value && value.TryGetValue<double>(out var number) && number is >= 0 and < int.MaxValue ? (int)number : (int?)null;
static long? Long(JsonObject node, string property) =>
node?[property] is JsonValue value && value.TryGetValue<double>(out var number) && number is >= 0 and < long.MaxValue ? (long)number : (long?)null;
static double? Double(JsonObject node, string property) =>
node?[property] is JsonValue value && value.TryGetValue<double>(out var number) && double.IsFinite(number) ? number : (double?)null;
static bool? Bool(JsonObject node, string property) =>
node?[property] is JsonValue value && value.TryGetValue<bool>(out var flag) ? flag : (bool?)null;
[GeneratedRegex(@"^[\w+\-.@]+$")]
private static partial Regex Shortcode();
[GeneratedRegex("<[^>]*>")]
private static partial Regex Tags();
[GeneratedRegex(@"\s+")]
private static partial Regex Spaces();
}
}
@@ -123,12 +123,13 @@ namespace PrivaPub.Federation.Outbox
await _breaker.Succeeded(job.Host, token);
return JobOutcome.Done;
}
if (response.StatusCode == HttpStatusCode.TooManyRequests)
if (response.StatusCode == HttpStatusCode.TooManyRequests
|| response.StatusCode == HttpStatusCode.ServiceUnavailable && response.Headers.RetryAfter != default)
{
var retryAfter = response.Headers.RetryAfter?.Delta ?? (response.Headers.RetryAfter?.Date - DateTimeOffset.UtcNow);
return retryAfter > TimeSpan.Zero
? JobOutcome.Defer(DateTime.UtcNow + Min(retryAfter.Value, TimeSpan.FromHours(6)), "429")
: JobOutcome.Retry("429");
? JobOutcome.Defer(DateTime.UtcNow + Min(retryAfter.Value, TimeSpan.FromHours(6)), $"{status}")
: JobOutcome.Retry($"{status}");
}
if (status is >= 400 and < 500 && status != 408)
{
@@ -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);
}
}
}
+14 -7
View File
@@ -32,8 +32,10 @@ namespace PrivaPub.Federation.Outbox
public async Task<IReadOnlyList<string>> Audience(LocalActor author, PostEntity post, CancellationToken token)
{
if (post.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo || post.IsLocalOnly)
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)
@@ -58,14 +60,19 @@ namespace PrivaPub.Federation.Outbox
inboxes.Add(parentAuthor.InboxURL);
}
if (!string.IsNullOrEmpty(post.GroupId) && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted)
{
var group = await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
if (group is { IsFederated: true })
inboxes.AddRange(await _delivery.FollowerInboxes(group, token));
return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
}
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)
@@ -37,18 +37,24 @@ namespace PrivaPub.Federation.Rendering
["discoverable"] = "toot:discoverable",
["indexable"] = "toot:indexable",
["blurhash"] = "toot:blurhash",
["votersCount"] = "toot:votersCount",
["Emoji"] = "toot:Emoji",
["litepub"] = "http://litepub.social/ns#",
["EmojiReact"] = "litepub:EmojiReact",
["focalPoint"] = new JsonObject { ["@container"] = "@list", ["@id"] = "toot:focalPoint" },
["schema"] = "http://schema.org#",
["PropertyValue"] = "schema:PropertyValue",
["value"] = "schema:value",
["webfinger"] = "https://purl.archive.org/socialweb/webfinger#webfinger"
["webfinger"] = "https://purl.archive.org/socialweb/webfinger#webfinger",
["lemmy"] = "https://join-lemmy.org/ns#",
["postingRestrictedToMods"] = "lemmy:postingRestrictedToMods"
});
public static string Html(string markdown) =>
string.IsNullOrEmpty(markdown) ? string.Empty : Markdown.ToHtml(markdown, Pipeline).Trim();
public static string Timestamp(DateTime value) =>
DateTime.SpecifyKind(value, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture);
DateTime.SpecifyKind(value, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
public static string Day(DateTime value) =>
DateTime.SpecifyKind(value, DateTimeKind.Utc).Date.ToString("yyyy-MM-ddT00:00:00Z", CultureInfo.InvariantCulture);
@@ -76,6 +82,8 @@ namespace PrivaPub.Federation.Rendering
["outbox"] = actor.Outbox,
["followers"] = actor.Followers,
["following"] = actor.Following,
["featured"] = actor.Featured,
["featuredTags"] = actor.FeaturedTags,
["published"] = Day(actor.Published),
["manuallyApprovesFollowers"] = actor.ManuallyApprovesFollowers,
["discoverable"] = actor.Discoverable,
@@ -95,6 +103,11 @@ namespace PrivaPub.Federation.Rendering
["value"] = FieldValue(field.Value)
}).ToArray())
};
if (actor is { Kind: LocalActorKind.Group, IsCircle: false })
{
document["attributedTo"] = actor.Wardens;
document["postingRestrictedToMods"] = actor.PostingRestrictedToModerators;
}
if (!string.IsNullOrEmpty(actor.PictureURL))
document["icon"] = new JsonObject { ["type"] = "Image", ["url"] = actor.PictureURL };
if (!string.IsNullOrEmpty(actor.ThumbnailURL))
@@ -107,17 +120,25 @@ namespace PrivaPub.Federation.Rendering
var mentions = post.Mentions.Select(m => (JsonNode)m.ActorURI).ToArray();
var (to, cc) = post.Visibility switch
{
PostVisibility.Circle when group != default => (new JsonArray(group.Uri, group.Flock), new JsonArray(mentions)),
PostVisibility.Unlisted => (new JsonArray(author.Followers), new JsonArray(mentions.Prepend(Public).ToArray())),
PostVisibility.FollowersOnly => (new JsonArray(author.Followers), new JsonArray(mentions)),
PostVisibility.Direct => (new JsonArray(mentions), new JsonArray()),
_ => (new JsonArray(Public), new JsonArray(mentions.Prepend(author.Followers).ToArray()))
};
if (group != default)
if (group is { IsCircle: false })
cc.Add(group.Uri);
var note = NoteBody(post, author, to, cc, inReplyTo);
if (group != default)
note["audience"] = group.Uri;
else if (!string.IsNullOrEmpty(post.AudienceURI))
note["audience"] = post.AudienceURI;
if (group is { IsCircle: false } && string.IsNullOrEmpty(inReplyTo))
{
note["type"] = "Page";
note["name"] = post.Title ?? Headline(post);
}
return note;
}
@@ -139,6 +160,30 @@ namespace PrivaPub.Federation.Rendering
return note;
}
static void AddPoll(JsonObject note, PostPoll poll)
{
note["type"] = "Question";
note[poll.Multiple ? "anyOf" : "oneOf"] = new JsonArray(poll.Options.Select(option => (JsonNode)new JsonObject
{
["type"] = "Note",
["name"] = option.Title,
["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = option.Votes }
}).ToArray());
if (poll.ExpiresAt is { } ends)
note["endTime"] = Timestamp(ends);
if (poll.ClosedAt is { } closed)
note["closed"] = Timestamp(closed);
if (poll.VotersCount is { } voters)
note["votersCount"] = voters;
}
static string Headline(PostEntity post)
{
var text = System.Text.RegularExpressions.Regex.Replace(post.ContentHtml ?? post.Text ?? string.Empty, "<[^>]+>", " ");
text = WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(text, "\\s+", " ")).Trim();
return text.Length <= 100 ? text : text[..97] + "...";
}
static JsonObject NoteBody(PostEntity post, LocalActor author, JsonArray to, JsonArray cc, string inReplyTo)
{
var content = post.ContentHtml ?? Html(post.Text);
@@ -168,6 +213,30 @@ namespace PrivaPub.Federation.Rendering
};
if (!string.IsNullOrEmpty(post.Language))
note["contentMap"] = new JsonObject { [post.Language] = content };
if (post.Poll is { } poll)
AddPoll(note, poll);
var attachments = post.Media.Where(m => !string.IsNullOrEmpty(m.URL)).Select(m =>
{
var attachment = new JsonObject
{
["type"] = "Document",
["mediaType"] = m.ContentType,
["url"] = m.URL,
["name"] = m.Description
};
if (!string.IsNullOrEmpty(m.Blurhash))
attachment["blurhash"] = m.Blurhash;
if (m.Focus is { Length: 2 })
attachment["focalPoint"] = new JsonArray(m.Focus[0], m.Focus[1]);
if (m.Width.HasValue && m.Height.HasValue)
{
attachment["width"] = m.Width;
attachment["height"] = m.Height;
}
return (JsonNode)attachment;
}).ToArray();
if (attachments.Length > 0)
note["attachment"] = new JsonArray(attachments);
if (!string.IsNullOrEmpty(post.Title))
note["name"] = post.Title;
var summary = post.SpoilerText ?? (post.HasContentWarning ? post.Title ?? ContentWarning : default);
@@ -0,0 +1,40 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.User;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Federation.Signing
{
public interface ISignedFetchAuthorizer
{
Task<ForeignAvatar> Requester(HttpRequest request, CancellationToken token);
}
public class SignedFetchAuthorizer : ISignedFetchAuthorizer
{
readonly IRemoteActorService _remoteActors;
public SignedFetchAuthorizer(IRemoteActorService remoteActors)
{
_remoteActors = remoteActors;
}
public async Task<ForeignAvatar> Requester(HttpRequest request, CancellationToken token)
{
var parameters = HttpSignatures.Parse(request.Headers["Signature"].ToString());
if (parameters == default || HttpSignatures.CheckRequest(request, parameters, body: default) != default)
return default;
var signingString = HttpSignatures.SigningString(request, parameters);
var actor = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: false, token);
if (actor != default && HttpSignatures.Verify(actor.PublicKey, signingString, parameters.Signature))
return actor;
actor = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token);
return actor != default && HttpSignatures.Verify(actor.PublicKey, signingString, parameters.Signature) ? actor : default;
}
public static bool MayReadCircle(GroupEntity circle, ForeignAvatar requester) =>
requester != default && circle.Members.Any(m => m.IsForeign
&& (m.AvatarId == requester.ActorURI || requester.AvatarType == AvatarType.Application && Origin.Same(m.AvatarId, requester.ActorURI)));
}
}
+27
View File
@@ -13,6 +13,8 @@ namespace PrivaPub.Infrastructure.Data
{
public static class Indexes
{
public static readonly TimeSpan TombstoneLifetime = TimeSpan.FromDays(90);
public static async Task Create(CancellationToken token = default)
{
await Unique<Post>(p => p.ObjectURI, Builders<Post>.Filter.Type(p => p.ObjectURI, BsonType.String), token);
@@ -21,6 +23,7 @@ namespace PrivaPub.Infrastructure.Data
await Plain<Post>(token, p => p.ActorURI);
await Plain<Post>(token, p => p.ConversationId, p => p.ID);
await Plain<Post>(token, p => p.InReplyToURI);
await DB.Default.Index<Post>().Key(p => p.Geo, KeyType.Geo2DSphere).CreateAsync(token);
await Unique<DmPost>(p => p.ObjectURI, Builders<DmPost>.Filter.Type(p => p.ObjectURI, BsonType.String), token);
await Plain<DmPost>(token, p => p.GroupId, p => p.ID);
@@ -87,8 +90,32 @@ namespace PrivaPub.Infrastructure.Data
.Key(m => m.Timeline, KeyType.Ascending)
.Option(o => o.Unique = true)
.CreateAsync(token);
foreach (var pair in new (Func<Task>, string)[]
{
(() => DB.Default.Index<Block>().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.TargetActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "block"),
(() => DB.Default.Index<Mute>().Key(m => m.AvatarId, KeyType.Ascending).Key(m => m.TargetActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "mute"),
(() => DB.Default.Index<AccountDomainBlock>().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.Domain, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "domain block"),
(() => DB.Default.Index<Bookmark>().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "bookmark"),
(() => DB.Default.Index<Pin>().Key(p => p.AvatarId, KeyType.Ascending).Key(p => p.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "pin")
})
await pair.Item1();
await Plain<Block>(token, b => b.TargetActorURI);
await Plain<Mute>(token, m => m.TargetActorURI);
await Plain<Report>(token, r => r.IsResolved, r => r.ID);
await Unique<DomainBlock>(b => b.Domain, Builders<DomainBlock>.Filter.Type(b => b.Domain, BsonType.String), token);
await Unique<RemoteInstance>(i => i.Host, Builders<RemoteInstance>.Filter.Type(i => i.Host, BsonType.String), token);
await Unique<ObjectRecord>(r => r.ObjectURI, Builders<ObjectRecord>.Filter.Type(r => r.ObjectURI, BsonType.String), token);
await Plain<ObjectRecord>(token, r => r.PostId);
await Unique<DeletedObject>(d => d.ObjectURI, Builders<DeletedObject>.Filter.Type(d => d.ObjectURI, BsonType.String), token);
await DB.Default.Index<DeletedObject>().Key(d => d.DeletedAt, KeyType.Ascending).Option(o => o.ExpireAfter = TombstoneLifetime).CreateAsync(token);
await DB.Default.Index<Downvote>().Key(d => d.PostId, KeyType.Ascending).Key(d => d.ActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token);
await Plain<Downvote>(token, d => d.ActivityURI);
await DB.Default.Index<PollVote>().Key(v => v.PostId, KeyType.Ascending).Key(v => v.ActorURI, KeyType.Ascending).Key(v => v.Choice, KeyType.Ascending)
.Option(o => o.Unique = true).CreateAsync(token);
await DB.Default.Index<PollVote>().Key(v => v.PostId, KeyType.Ascending).Key(v => v.VoterAccountId, KeyType.Ascending).CreateAsync(token);
await DB.Default.Index<Reaction>().Key(r => r.PostId, KeyType.Ascending).Key(r => r.ActorURI, KeyType.Ascending).Key(r => r.Emoji, KeyType.Ascending)
.Option(o => o.Unique = true).CreateAsync(token);
await Plain<Reaction>(token, r => r.ActivityURI);
}
static async Task Unique<T>(System.Linq.Expressions.Expression<Func<T, object>> key, FilterDefinition<T> partial,
@@ -0,0 +1,26 @@
using MongoDB.Entities;
using PrivaPub.Models.Post;
namespace PrivaPub.Infrastructure.Data.Migrations
{
public class _006_unedited_remote_posts_lose_year_one : IMigration
{
public async Task UpgradeAsync()
{
var posts = await DB.Default.Find<Post>()
.Match(p => p.EditedAt == DateTime.MinValue || p.UpdateDate == DateTime.MinValue || p.Revisions.Any(r => r.EditedAt == DateTime.MinValue))
.ExecuteAsync();
foreach (var post in posts)
{
foreach (var revision in post.Revisions.Where(r => r.EditedAt == DateTime.MinValue))
revision.EditedAt = post.CreationDate;
await DB.Default.Update<Post>().MatchID(post.ID)
.Modify(p => p.EditedAt, post.EditedAt == DateTime.MinValue ? null : post.EditedAt)
.Modify(p => p.UpdateDate, post.UpdateDate == DateTime.MinValue ? post.CreationDate : post.UpdateDate)
.Modify(p => p.Revisions, post.Revisions)
.ExecuteAsync();
}
}
}
}
@@ -0,0 +1,23 @@
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Infrastructure.Ids;
using PrivaPub.Models.User;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Infrastructure.Data.Migrations
{
public class _007_personas_publish_a_spread_day : IMigration
{
public async Task UpgradeAsync()
{
var missing = new BsonDocument(nameof(Avatar.PublishedOn), new BsonDocument("$exists", false));
foreach (var avatar in await DB.Default.Collection<Avatar>().Find(missing).ToListAsync())
await DB.Default.Update<Avatar>().MatchID(avatar.ID).Modify(a => a.PublishedOn, PrivacyIds.PublishedDay(avatar.CreatedAt)).ExecuteAsync();
foreach (var group in await DB.Default.Collection<GroupEntity>().Find(missing).ToListAsync())
await DB.Default.Update<GroupEntity>().MatchID(group.ID).Modify(g => g.PublishedOn, PrivacyIds.PublishedDay(group.CreationDate)).ExecuteAsync();
}
}
}
+54 -5
View File
@@ -21,7 +21,9 @@ namespace PrivaPub.Infrastructure.Http
{
bool IsAllowed(Uri target);
Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token);
bool FailedTemporarily(string url);
Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token);
Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token);
}
public class FederationHttp : IFederationHttp
@@ -106,7 +108,8 @@ namespace PrivaPub.Infrastructure.Http
}
if (!response.IsSuccessStatusCode)
return Refuse(negativeKey, url, $"status {(int)response.StatusCode}");
return Refuse(negativeKey, url, $"status {(int)response.StatusCode}",
transient: (int)response.StatusCode is >= 500 or 429 or 408);
var mediaType = response.Content.Headers.ContentType?.MediaType;
if (mediaType == default || !JsonMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase))
@@ -124,14 +127,60 @@ namespace PrivaPub.Infrastructure.Http
}
catch (OperationCanceledException) when (!token.IsCancellationRequested)
{
return Refuse(negativeKey, url, "a timeout");
return Refuse(negativeKey, url, "a timeout", transient: true);
}
catch (Exception ex) when (ex is HttpRequestException or JsonException or BlockedDestinationException)
catch (HttpRequestException ex)
{
return Refuse(negativeKey, url, ex.Message, transient: true);
}
catch (Exception ex) when (ex is JsonException or BlockedDestinationException)
{
return Refuse(negativeKey, url, ex.Message);
}
}
public bool FailedTemporarily(string url) =>
Uri.TryCreate(url, UriKind.Absolute, out var target) && _cache.TryGetValue(NegativeKey(target), out bool transient) && transient;
public async Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
return default;
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
timeout.CancelAfter(TimeSpan.FromSeconds(60));
try
{
for (var hop = 0; hop <= MaxRedirects; hop++)
{
using var request = new HttpRequestMessage(HttpMethod.Get, target);
request.Headers.Accept.ParseAdd("image/*, video/*, audio/*");
using var response = await _httpClientFactory.CreateClient(ClientName).SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
if (IsRedirect(response.StatusCode))
{
var location = response.Headers.Location;
var next = location == default ? default : location.IsAbsoluteUri ? location : new Uri(target, location);
if (!IsAllowed(next))
return default;
target = next;
continue;
}
var mediaType = response.Content.Headers.ContentType?.MediaType?.ToLowerInvariant();
if (!response.IsSuccessStatusCode || mediaType == default
|| !(mediaType.StartsWith("image/") || mediaType.StartsWith("video/") || mediaType.StartsWith("audio/"))
|| mediaType.Contains("svg") || response.Content.Headers.ContentLength > maxBytes)
return default;
var bytes = await ReadBounded(response.Content, (int)Math.Min(maxBytes, int.MaxValue), timeout.Token);
return bytes == default ? default : (bytes, mediaType);
}
return default;
}
catch (Exception ex) when (ex is HttpRequestException or BlockedDestinationException or OperationCanceledException && !token.IsCancellationRequested)
{
_logger.LogInformation("Media {Url} refused: {Reason}", url, ex.Message);
return default;
}
}
public async Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token)
{
if (!IsAllowed(request.RequestUri))
@@ -160,9 +209,9 @@ namespace PrivaPub.Infrastructure.Http
static string NegativeKey(Uri target) => "federation-http:refused:" + target.AbsoluteUri;
FetchedJson Refuse(string negativeKey, string url, string reason)
FetchedJson Refuse(string negativeKey, string url, string reason, bool transient = false)
{
_cache.Set(negativeKey, true, NegativeCacheLifetime);
_cache.Set(negativeKey, transient, NegativeCacheLifetime);
_logger.LogInformation("GET {Url} refused: {Reason}", url, reason);
return default;
}
@@ -4,5 +4,7 @@ namespace PrivaPub.Infrastructure.Http
{
public bool AllowPrivateNetworks { get; set; }
public bool AllowPlainHttp { get; set; }
public bool SecureMode { get; set; }
public bool AcceptAnyCertificate { get; set; }
}
}
@@ -1,4 +1,5 @@
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
namespace PrivaPub.Infrastructure.Http
@@ -12,6 +13,9 @@ namespace PrivaPub.Infrastructure.Http
{
public static SocketsHttpHandler Create(FederationOptions options) => new()
{
SslOptions = options.AcceptAnyCertificate
? new SslClientAuthenticationOptions { RemoteCertificateValidationCallback = (_, _, _, _) => true }
: new SslClientAuthenticationOptions(),
AllowAutoRedirect = false,
UseProxy = false,
UseCookies = false,
+12
View File
@@ -1,12 +1,24 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using MongoDB.Bson;
namespace PrivaPub.Infrastructure.Ids
{
public static class PrivacyIds
{
public static string ForDay(DateTime when) => At(DateTime.SpecifyKind(when, DateTimeKind.Utc).Date);
public static DateTime PublishedDay(DateTime created) =>
DateTime.SpecifyKind(created, DateTimeKind.Utc).Date.AddDays(-RandomNumberGenerator.GetInt32(0, PublishedSpreadDays));
const int PublishedSpreadDays = 14;
public static string Arrived(DateTime published) =>
DateTime.SpecifyKind(published, DateTimeKind.Utc) > DateTime.UtcNow - LiveWindow ? ObjectId.GenerateNewId().ToString() : At(published);
static readonly TimeSpan LiveWindow = TimeSpan.FromHours(1);
public static string At(DateTime when)
{
var seconds = Math.Clamp(new DateTimeOffset(DateTime.SpecifyKind(when, DateTimeKind.Utc)).ToUnixTimeSeconds(), 0,
@@ -15,10 +15,13 @@ using PrivaPub.Services.ClientToServer.Private;
using PrivaPub.Services.ClientToServer.Public;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Signing;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Moderation;
using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Domain.Content;
using PrivaPub.Domain.Relationships;
using PrivaPub.Domain.Social;
using PrivaPub.Domain.Statuses;
using PrivaPub.Domain.Timelines;
@@ -64,6 +67,8 @@ namespace PrivaPub.Middleware
.AddSingleton<IRemoteActorService, RemoteActorService>()
.AddSingleton<IDeliveryService, DeliveryService>()
.AddSingleton<IOutboxPublisher, OutboxPublisher>()
.AddSingleton<IGroupDistributor, GroupDistributor>()
.AddSingleton<ISignedFetchAuthorizer, SignedFetchAuthorizer>()
.AddSingleton<IFanout, Fanout>()
.AddSingleton<IInboxReceiver, InboxReceiver>()
.AddSingleton<IActivityHandler, FollowHandler>()
@@ -71,12 +76,22 @@ namespace PrivaPub.Middleware
.AddSingleton<IActivityHandler, RejectHandler>()
.AddSingleton<IActivityHandler, UndoHandler>()
.AddSingleton<IActivityHandler, LikeHandler>()
.AddSingleton<IActivityHandler, DislikeHandler>()
.AddSingleton<IActivityHandler, EmojiReactHandler>()
.AddSingleton<IReactions, Reactions>()
.AddSingleton<IActivityHandler, JoinHandler>()
.AddSingleton<IActivityHandler, AnnounceHandler>()
.AddSingleton<IActivityHandler, FlagHandler>()
.AddSingleton<IActivityHandler, CreateHandler>()
.AddSingleton<IActivityHandler, DeleteHandler>()
.AddSingleton<IActivityHandler, UpdateHandler>()
.AddSingleton<IJobHandler, InboxProcessor>()
.AddSingleton<IRemotePosts, RemotePosts>()
.AddSingleton<IObjectRecords, ObjectRecords>()
.AddSingleton<IPollService, PollService>()
.AddSingleton<IJobHandler, PollRefreshJob>()
.AddSingleton<IJobHandler, PollCloseJob>()
.AddSingleton<IJobHandler, InstanceDescriber>()
.AddSingleton<IJobHandler, AncestorsJobHandler>()
.AddSingleton<IJobQueue, JobQueue>()
.AddSingleton<IHostCircuitBreaker, HostCircuitBreaker>()
@@ -138,6 +153,8 @@ namespace PrivaPub.Middleware
.AddTransient<IGroupUsersService, GroupUsersService>()
.AddTransient<IPostsService, PostsService>()
.AddTransient<IStatusService, StatusService>()
.AddTransient<IRelationshipService, RelationshipService>()
.AddTransient<IReportService, ReportService>()
.AddTransient<IFollowService, FollowService>()
.AddTransient<ITimelineService, TimelineService>()
.AddSingleton<AppConfigurationService>()
@@ -0,0 +1,10 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Federation
{
public class DeletedObject : Entity
{
public string ObjectURI { get; set; }
public DateTime DeletedAt { get; set; } = DateTime.UtcNow;//expires after Indexes.TombstoneLifetime
}
}
@@ -0,0 +1,50 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Federation
{
public class ObjectRecord : Entity
{
public string ObjectURI { get; set; }
public string PostId { get; set; }
public string ObjectType { get; set; }
public string Host { get; set; }
public string Raw { get; set; }//the object exactly as it reached us, unless it was larger than MaxRawBytes
public string RawHash { get; set; }//sha-256 of the raw bytes, hex
public int RawBytes { get; set; }
public bool RawTruncated { get; set; }
public string ActivityContext { get; set; }//the delivering activity's @context, which an embedded object does not repeat
public ObjectPath Path { get; set; }
public bool Refetched { get; set; }//delivered, but read again from its origin because it came embedded from another one
public string ActivityId { get; set; }//the activity that brought it, or that caused the fetch
public string ActivityType { get; set; }
public string ActivityActorURI { get; set; }
public string Inbox { get; set; }//"shared" or "personal" when delivered
public string SignatureScheme { get; set; }
public string KeyId { get; set; }
public string Algorithm { get; set; }
public List<string> SignedHeaders { get; set; } = new();
public DateTime ReceivedAt { get; set; } = DateTime.UtcNow;
public DateTime? Published { get; set; }
public DateTime? Updated { get; set; }
public List<ObjectRevision> Revisions { get; set; } = new();
}
public class ObjectRevision
{
public string Raw { get; set; }
public string RawHash { get; set; }
public bool RawTruncated { get; set; }
public string ActivityId { get; set; }
public DateTime? Updated { get; set; }
public DateTime ReceivedAt { get; set; } = DateTime.UtcNow;
}
public enum ObjectPath
{
Delivered,
Fetched
}
}
+11 -1
View File
@@ -20,6 +20,8 @@ namespace PrivaPub.Models.Group
public string PublicKey { get; set; }
public GroupKind Kind { get; set; }
public PostingPolicy PostingPolicy { get; set; }
public List<string> Rules { get; set; } = new();
public bool IsDiscoverable { get; set; } = true;
public bool ManuallyApprovesMembers { get; set; }
public string OwnerAvatarId { get; set; }
@@ -29,10 +31,11 @@ namespace PrivaPub.Models.Group
public string HashedInvitationPassword { get; set; }
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
public DateTime PublishedOn { get; set; } = PrivacyIds.PublishedDay(DateTime.UtcNow);
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? DeletionAt { get; set; }
public override object GenerateNewID() => PrivacyIds.ForDay(DateTime.UtcNow);
public override object GenerateNewID() => PrivacyIds.ForDay(PublishedOn);
}
public class GroupMember
@@ -49,6 +52,13 @@ namespace PrivaPub.Models.Group
Community
}
public enum PostingPolicy
{
Followers,
Anyone,
Moderators
}
public enum GroupRole
{
Member,
+4 -1
View File
@@ -22,7 +22,10 @@ namespace PrivaPub.Models.Jobs
{
Deliver,
ProcessInbox,
FetchAncestors
FetchAncestors,
DescribeInstance,
PollRefresh,
PollClose
}
public enum JobState
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more