Compare commits

..
11 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 1a62e8a567 CLAUDE.md describes the code after P0
Build / Build (push) Successful in 47s
Deploy / privapub.thepra.dev (push) Successful in 51s
The repo map gains Infrastructure/ and Objects/, the federation invariants
are the ones the code now enforces instead of a list of known holes, and
Commands, Data, Testing and Deploy cover the test project, the startup
order (warm maps, migrate, index), the admin CLI and the deploy checks.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

+3
View File
@@ -14,3 +14,6 @@ jobs:
- name: Build - name: Build
run: dotnet build PrivaPub.sln -c Release run: dotnet build PrivaPub.sln -c Release
- name: Test
run: dotnet test PrivaPub.sln -c Release --no-build
+19
View File
@@ -12,6 +12,8 @@ env:
BACKUPS: /var/backups/privapub.thepra.dev BACKUPS: /var/backups/privapub.thepra.dev
LOCAL_URL: http://127.0.0.1:6970 LOCAL_URL: http://127.0.0.1:6970
PUBLIC_URL: https://privapub.thepra.dev PUBLIC_URL: https://privapub.thepra.dev
MONGO_URI: mongodb://127.0.0.1:27022
MONGO_DB: PrivaPub
jobs: jobs:
site: site:
@@ -26,6 +28,9 @@ jobs:
echo "BUILD_REF=${GITHUB_REF_NAME:-master}" >> "$GITHUB_ENV" echo "BUILD_REF=${GITHUB_REF_NAME:-master}" >> "$GITHUB_ENV"
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV" echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
- name: Test
run: dotnet test PrivaPub.sln -c Release
- name: Publish - name: Publish
run: | run: |
rm -rf "$GITHUB_WORKSPACE/publish" rm -rf "$GITHUB_WORKSPACE/publish"
@@ -52,6 +57,13 @@ jobs:
echo "SNAPSHOT=$BACKUPS/site-$STAMP" >> "$GITHUB_ENV" echo "SNAPSHOT=$BACKUPS/site-$STAMP" >> "$GITHUB_ENV"
ls -1dt "$BACKUPS"/site-* 2>/dev/null | tail -n +4 | xargs -r rm -rf || true ls -1dt "$BACKUPS"/site-* 2>/dev/null | tail -n +4 | xargs -r rm -rf || true
- name: Dump the database
run: |
DUMP="$BACKUPS/mongo-$(date +%Y%m%d-%H%M%S).archive.gz"
mongodump --quiet --uri "$MONGO_URI" --db "$MONGO_DB" --gzip --archive="$DUMP"
echo "::notice::database dumped to $DUMP ($(du -h "$DUMP" | cut -f1))"
ls -1t "$BACKUPS"/mongo-*.archive.gz 2>/dev/null | tail -n +8 | xargs -r rm -f || true
- name: Stop, sync, start - name: Stop, sync, start
run: | run: |
sudo systemctl stop "$UNIT" sudo systemctl stop "$UNIT"
@@ -85,4 +97,11 @@ jobs:
[ "$code" = "200" ] || { echo "::error::the instance actor answered $code"; exit 1; } [ "$code" = "200" ] || { echo "::error::the instance actor answered $code"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/.well-known/nodeinfo") code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/.well-known/nodeinfo")
[ "$code" = "200" ] || { echo "::error::nodeinfo answered $code"; exit 1; } [ "$code" = "200" ] || { echo "::error::nodeinfo answered $code"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/swagger/index.html")
[ "$code" = "404" ] || { echo "::error::swagger answered $code in production"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' --data '{"junk":' "$PUBLIC_URL/human-centipede")
[ "$code" = "400" ] || { echo "::error::a junk inbox POST answered $code"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' \
--data '{"type":"Follow","actor":"https://example.org/users/x","object":"'"$PUBLIC_URL"'/peasants/privapub"}' "$PUBLIC_URL/human-centipede")
[ "$code" = "401" ] || { echo "::error::an unsigned inbox POST answered $code"; exit 1; }
echo "::notice::serving $served" echo "::notice::serving $served"
+233
View File
@@ -0,0 +1,233 @@
# CLAUDE.md
Guidance for working in this repository. `docs/ROADMAP.md` holds the owner's decisions and the phased plan to full
ActivityPub interop; read it before changing anything federation-, privacy- or API-shaped.
## What this is
**PrivaPub** (repo name SocialPub) is a self-hosted ActivityPub server in C#, live at https://privapub.thepra.dev.
It is meant as a Pleroma-like microblogging server for privacy-minded people and small communities, federating with
Mastodon, GoToSocial, Pleroma/Akkoma, Misskey and Lemmy.
Its defining idea: **one private login owns several public personas.**
- **`RootUser` is the private login.** Username, password, optional email (used only for recovery), policies.
- **`Avatar` is a public persona.** Each one is an ActivityPub actor with its own RSA keys at `/peasants/{username}`.
Root and avatar are linked only through `RootToAvatar`.
- **Personas must stay unlinkable** to other users and servers; only the instance admin can see the link.
- **`Group` is an ActivityPub Group actor.** It has members, an invitation code and an optional password. Groups are
becoming two kinds: a public **community** (FEP-1b12, Lemmy-compatible) and a private, invitation-only **circle**.
- **`DmGroup` is a direct-message conversation.**
- **`privapub` is the instance actor** (type Application). It signs fetches no persona should be tied to.
Privacy features in the model:
- location-ranged posts (`Post.Location`, `RangeKm`), to become local-only and never federated;
- contacts a persona can share (`Avatar.SharedPersonalContacts`);
- private notes (`RootUserNote`, `PersonalNote`).
The intended client is the Mastodon client API (Tusky, Elk, Phanpy, Ivory), where each avatar logs in as its own
account. The private `/clientapi` covers what Mastodon can't express. `thepra/decePubClient`
(https://decepub.thepra.dev) is the owner's own Pleroma-FE-like PWA. It still shows mock data and is not wired to the
server.
## Route names are deliberate
The odd names are the owner's and part of the project's character. **Never "fix" them to conventional ones.** A new
actor-scoped route gets a name in the same spirit, agreed with the owner, and a name is frozen once it has federated.
| Thing | Route |
|---|---|
| Actor | `/peasants/{name}` (`/users/{name}` 301-redirects here) |
| Inbox | `/peasants/{name}/mouth` |
| Outbox | `/peasants/{name}/anus` |
| Shared inbox | `/human-centipede` (also `/peasants/{name}/human-centipede`) |
| Token refresh | `/clientapi/user/sniff/again` |
These are agreed for the roadmap and replace the conventional names still in the code today:
| Thing | Route | Replaces |
|---|---|---|
| Followers | `/groupies` | `/followers` |
| Following | `/stalking` | `/following` |
| Notes | `/scribbles/{id}` | `/posts/{id}` |
| Activities | `/grunts/{id}` | `/activities/{id}` |
| Replies, likes, shares | `/gossip`, `/drool`, `/echoes` | |
| Featured | `/trophies` | |
| Featured tags | `/tattoos` | |
| Group members | `/flock` | |
| Group moderators | `/wardens` | |
| DM context | `/whispers/{id}` | |
Routes other software looks up by name stay conventional:
- `/.well-known/*` and `/nodeinfo/*`;
- `/api/v1/*` and `/oauth/*`;
- `/@{name}` pages;
- `/media/*`.
## Repo map
```
PrivaPub.sln
PrivaPub/ ASP.NET Core Web API, net10.0
Program.cs host, Mongo init (GUIDs Standard), pipeline, /build.json
Middleware/SocialPubConfigurations.cs every DI registration (auth, federation, services, swagger, CORS)
Controllers/ClientToServer/ /clientapi/*: RootUser (signup/login/invitations/recovery), PrivateAvatar,
Group, Post (posts + DMs), Admin, Data
Infrastructure/
Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out
Data/ Indexes (created at start), EntityMaps.Warm, Migrations/_NNN_*.cs
Cli/ AdminCommands (`PrivaPub admin promote|demote <root>`)
RateLimiting.cs accounts (per client address) and inbox (per sending origin) policies
Federation/
Controllers/ PeasantsController (actor, outbox, followers, following, posts, inboxes),
WellKnownController (webfinger, nodeinfo), UsersController (redirect)
Actors/ LocalActorService (LocalActor, Keys, ReservedName), RemoteActorService
(authoritative fetch, key verification, WebFinger), ActorDocument (parser)
Objects/ Origin (same-origin rules), ContentSanitizer (HtmlSanitizer, Mastodon allowlist)
Signing/ HttpSignatures (draft-cavage sign/verify)
Inbox/ InboxService (Follow/Undo/Create/Delete/Update)
Outbox/ DeliveryService + DeliveryWorker
Rendering/ ActivityPubRenderer (JsonObject builders)
Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, …
Models/ Mongo entities: User/, Group/, Post/, Federation/, AppConfiguration
StaticServices/ DbEntities (Find<T> accessors), AuthTokenManager (JWT), PasswordHasher
Data/InitDb.cs first-run seeding (languages)
PrivaPub.ClientModels/ DTOs + validation resources shared with clients
PrivaPub.Tests/ xUnit v3; Support/ has a fake two-origin peer and a throwaway-database fixture
deploy/ nginx vhost, systemd units (privapub, privapub-mongod), max/setup.sh
.gitea/workflows/ build.yml (push) · deploy.yml (tag v*)
docs/ROADMAP.md decisions + phased plan
```
The roadmap moves code towards `Infrastructure/`, `Federation/`, `Domain/`, `Api/{ClientApi,Mastodon}` and `Web/`,
one pure-move commit at a time.
## Commands
```bash
dotnet build PrivaPub.sln -c Release
dotnet test PrivaPub.sln # unit tests; integration tests skip
PRIVAPUB_TEST_MONGOD=1 dotnet test PrivaPub.sln # all of them, against mongod on 127.0.0.1:27017
# (PRIVAPUB_TEST_MONGO overrides; a fresh database per run, dropped after)
cd PrivaPub && ASPNETCORE_ENVIRONMENT=Development \
Kestrel__Endpoints__Http__Url=http://127.0.0.1:6970 Kestrel__Endpoints__Http__Protocols=Http1AndHttp2 \
AppConfiguration__BackendBaseAddress=http://127.0.0.1:6970 MongoSettings__Database=PrivaPubTest \
dotnet run # needs a local mongod on 27017; swagger at /swagger
```
Development config (`appsettings.Development.json`) binds HTTPS 7195 with HTTP/2 only; the overrides above make it
curl-able. Outbound fetches only go to https DNS names resolving to public addresses; a test network (Pasture) sets
`Federation__AllowPrivateNetworks=true` and `Federation__AllowPlainHttp=true`, which startup refuses in Production.
Promoting an admin on the box (signing up as "admin" grants nothing):
```bash
cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin promote <root>
```
## Federation invariants
1. **Every outbound request goes through `IFederationHttp`.** Its handler resolves the name itself and connects only
to public addresses; redirects are followed by hand (three at most, each re-checked); bodies are capped at 1 MB;
only JSON media types are read; a refused URL is not asked again for five minutes. Never create another
`HttpClient` for federation.
2. **Every fetch is signed by the instance actor** (`privapub`), never by a persona; deliveries are signed by the acting
avatar or group. Both are draft-cavage rsa-sha256 over `(request-target) host date` (+ `digest` on bodies).
3. **A remote document is believed only from its own address.** `RemoteActorService.FetchObject` requires the
document's `id` to be the URL it was served from (a same-origin alias is followed once). A key is accepted only if
the actor lists it, its `owner` is the actor and it shares the actor's origin.
4. **Inbound inboxes verify everything before acting:**
- the signature covers `(request-target)`, `host`, `digest` and `date` or `(created)`;
- the Digest matches the body and the date is at most an hour old and fifteen minutes ahead;
- the signature verifies against the key owner's key, and the activity's `actor` is the key owner;
- the activity's `id`, and any object it creates, updates or deletes, is on the actor's origin; a cross-origin
object is refetched from its own origin.
5. **Status codes:**
- bad or missing signature: **401**;
- malformed or forged body: **400**;
- accepted: **202**;
- over the rate limit: **429**;
- **never 500** from `/peasants` or an inbox. Peers retry or "double-knock" based on these codes.
6. **Actor documents:**
- served as `application/activity+json`;
- `publicKey` is an SPKI PEM at `{actor}#main-key`, with `owner` equal to the actor's id;
- the shared inbox is advertised in `endpoints.sharedInbox`.
7. **Remote HTML is sanitized before it is stored** (`ContentSanitizer`); `Post.ContentHtml` is what is shown,
`ContentFormat` says what `Text` holds. Remote names are plain text.
8. **Circles never federate.** A circle's actor, collections, WebFinger and inbox answer 404, and its posts are
`IsLocalOnly`. Only communities are Group actors.
9. **A DM joins a conversation only by `DmGroup.ParticipantsKey`**, the exact set of its participants; a remote
`context` decides nothing.
10. **Durable delivery:** activities go out through the delivery queue, never inline in a request.
## Privacy invariants
- **No root id in federation output, NodeInfo or logs, no IP next to an identity in logs, and no `ex.Message` to a
client** ("Something went wrong." instead).
- **Never derive a public name from the root username.** Invitation sign-up takes `AvatarUserName` and refuses one
equal to the login.
- **One username space:** personas, groups and the instance reserve their name in `ReservedName` (unique index) before
they are saved; `LocalActorService.TryReserveUserName` is the only way to claim one.
- **Per-avatar state stays per avatar:** blocks, mutes, notifications, follows. Nothing may relate sibling avatars.
- **Location-ranged posts never federate.**
## Data
- MongoDB through **MongoDB.Entities 25.1**, instance API: `DB.Default.Find<T>()`, `.SaveAsync(e)`, `.Update<T>()`,
`.DeleteAsync<T>()`, `.CountAsync<T>()`. `DbEntities` wraps the finds.
- **Pass no cancellation token to `DeleteAsync` outside a transaction;** v25 throws if you do.
- **Collection name = class name, so never rename an entity class.**
- `Entity.ID` is a 24-character lowercase hex string; `GenerateNewID()` returns `object`, so cast it.
- New fields must be additive: a deploy rollback restores the binary, not the database.
- **Startup order:** `EntityMaps.Warm()` (every entity's class map, one at a time; two mapped at once throw "An item
with the same key has already been added" and stay broken), then `MigrateAsync` (`Infrastructure/Data/Migrations`,
`_NNN_` order, each runs once), then `Indexes.Create()`. A new entity needs nothing; a new unique index needs a
dedupe migration before it.
- Production runs its **own mongod** on 127.0.0.1:27022 (unit `privapub-mongod`, no auth, data in
`/var/lib/privapub/mongo`). The box's shared mongod needs credentials nobody here has.
## Code style
- Tabs, block-scoped namespaces, Allman braces.
- New services use `readonly _camel` fields and constructor injection. Older services use PascalCase fields; leave
them.
- Services return `WebResult` (`PrivaPub.ClientModels/WebResult.cs`): `result.Invalidate(localizer[...], status)`.
Controllers turn it into a status code.
- Localised strings go through `IStringLocalizer<GenericRes>`.
- Every `Display`/`ErrorMessage` resource key must exist in `FieldsNameResource`/`ErrorsResource`, including the
`Designer.cs`, which the CLI build doesn't regenerate. A missing key throws at validation time.
- ActivityPub output is built with `System.Text.Json.Nodes` in `ActivityPubRenderer`, not typed models. Inbound
documents are read through `InboxService.Id`/`Value`, which handle string, object and array.
- Libraries chosen for the roadmap: HtmlSanitizer, Markdig (`DisableHtml`), NSign (RFC 9421 inbound), OpenIddict +
OpenIddict.MongoDb, NetVips, Blurhash.Core, FFMpegCore. No ImageSharp (licence key enforced), no MassTransit.
## Testing
`PrivaPub.Tests` (xUnit v3). Unit tests need nothing; tests marked `Category=Integration` need a mongod and skip
without `PRIVAPUB_TEST_MONGOD=1`. CI runs the unit tests only (the box's mongods are production).
- `Support/Peer` is an in-process HTTP server answering on two origins (`127.0.0.1` and `localhost`), so origin rules
can be tested; `Support/RemoteActor` signs real deliveries with its own key.
- Inbox scenarios go through `InboxService.Receive` with a signed request, not through the private handlers.
Beyond the tests, verify by building, running locally, and exercising:
- the client API (sign up, create an avatar, a group, a post);
- the ActivityPub endpoints with curl and `Accept: application/activity+json`.
Interop is checked against real servers: Fediverse Pasture with podman on the workstation, verify.funfedi.dev, and
the owner's GoToSocial at social.arasaka.software. **Ask before acting from the owner's GoToSocial account.**
## Deploy
- **CI/CD:** push to `master` runs `build.yml` (build + tests) on the instance-wide `build` runner. A `v*` tag runs
`deploy.yml`: tests, self-contained linux-x64 publish, snapshot and `mongodump` to `/var/backups/privapub.thepra.dev`,
stop → rsync → start, a `127.0.0.1:6970/build.json` health loop with rollback, then public checks (actor, NodeInfo,
Swagger 404, inbox junk 400, unsigned 401).
- **The box:** Max (`nuvola.xyz`). Unit `privapub` runs as www-data from `/var/www/privapub.thepra.dev` with
`ASPNETCORE_ENVIRONMENT=Production`.
- **One-time root setup:** `deploy/max/setup.sh`, run through `../arasaka.software/tools/max/run.sh`.
- **Config:** `appsettings.Production.json` is committed and deployed, **secrets included, by the owner's convention**
(the same as arasaka.software). A hand edit on the box is lost at the next deploy.
- **Secrets drift:** `AppConfigurationService` copies `AppConfiguration` into Mongo on first boot and reads the stored
copy afterwards, so changing those keys needs a Mongo update as well.
- **Logs:** `journalctl -u privapub`, plus the `logs` database on the private mongod.
@@ -24,6 +24,7 @@ namespace PrivaPub.ClientModels.Group
public string Description { get; set; } public string Description { get; set; }
public string InvitationPassword { get; set; } public string InvitationPassword { get; set; }
public bool IsCommunity { get; set; }
public bool IsDiscoverable { get; set; } = true; public bool IsDiscoverable { get; set; } = true;
public bool ManuallyApprovesMembers { get; set; } public bool ManuallyApprovesMembers { get; set; }
} }
+1
View File
@@ -8,6 +8,7 @@ namespace PrivaPub.ClientModels.Group
public string Description { get; set; } public string Description { get; set; }
public string Url { get; set; } public string Url { get; set; }
public string Handle { get; set; } public string Handle { get; set; }
public bool IsCommunity { get; set; }
public bool IsDiscoverable { get; set; } public bool IsDiscoverable { get; set; }
public bool ManuallyApprovesMembers { get; set; } public bool ManuallyApprovesMembers { get; set; }
public bool IsOwner { get; set; } public bool IsOwner { get; set; }
@@ -10,5 +10,12 @@ namespace PrivaPub.ClientModels
StringLength(Constants.GroupInvitationLength, MinimumLength = Constants.GroupInvitationLength, StringLength(Constants.GroupInvitationLength, MinimumLength = Constants.GroupInvitationLength,
ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource))] ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource))]
public string InvitationCode { get; set; } public string InvitationCode { get; set; }
[StringLength(32, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
RegularExpression("^[a-z0-9_]+$", ErrorMessageResourceName = "EmptySpacesNotAllowed", ErrorMessageResourceType = typeof(ErrorsResource))]
public string AvatarUserName { get; set; }
[StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string AvatarName { get; set; }
} }
} }
+1
View File
@@ -11,6 +11,7 @@ namespace PrivaPub.ClientModels.Post
public string AnsweringToPostId { get; set; } public string AnsweringToPostId { get; set; }
public string Title { get; set; } public string Title { get; set; }
public string Text { get; set; } public string Text { get; set; }
public string ContentHtml { get; set; }
public bool HasContentWarning { get; set; } public bool HasContentWarning { get; set; }
public bool IsFederatedCopy { get; set; } public bool IsFederatedCopy { get; set; }
public DateTime CreationDate { get; set; } public DateTime CreationDate { get; set; }
@@ -0,0 +1,51 @@
using PrivaPub.Federation.Objects;
namespace PrivaPub.Tests.Federation
{
public class ContentSanitizerTests
{
[Theory]
[InlineData("<script>alert(1)</script><p>hi</p>", "<p>hi</p>")]
[InlineData("<p onclick=\"alert(1)\">hi</p>", "<p>hi</p>")]
[InlineData("<img src=x onerror=alert(1)>", "")]
[InlineData("<a href=\"javascript:alert(1)\">x</a>", "<a rel=\"nofollow noopener noreferrer\" target=\"_blank\">x</a>")]
[InlineData("<a href=\"data:text/html,<script>alert(1)</script>\">x</a>", "<a rel=\"nofollow noopener noreferrer\" target=\"_blank\">x</a>")]
[InlineData("<a href=\"/relative\">x</a>", "<a rel=\"nofollow noopener noreferrer\" target=\"_blank\">x</a>")]
[InlineData("<iframe src=\"https://evil.example\"></iframe>text", "text")]
[InlineData("<style>body{display:none}</style><p>x</p>", "<p>x</p>")]
[InlineData("<svg><script>alert(1)</script></svg>", "")]
[InlineData("<p style=\"position:fixed\">x</p>", "<p>x</p>")]
[InlineData("<form action=\"https://evil.example\"><input name=p></form>ok", "ok")]
[InlineData("<p>a<!-- comment -->b</p>", "<p>ab</p>")]
[InlineData("<div><p>kept</p></div>", "<p>kept</p>")]
[InlineData("<h2>Title</h2>", "<p><strong>Title</strong></p>")]
[InlineData("<span class=\"evil big\">x</span>", "<span>x</span>")]
public void Removes_what_is_not_allowed(string input, string expected) =>
Assert.Equal(expected, ContentSanitizer.Html(input));
[Fact]
public void Keeps_mastodon_mention_and_hashtag_markup()
{
var html = "<p><span class=\"h-card\" translate=\"no\"><a href=\"https://m.example/@alice\" class=\"u-url mention\">@<span>alice</span></a></span> "
+ "<a href=\"https://m.example/tags/fedi\" class=\"mention hashtag\" rel=\"tag\">#<span>fedi</span></a> "
+ "<a href=\"https://example.org/a-long-link\"><span class=\"invisible\">https://</span><span class=\"ellipsis\">example.org/a-long</span></a></p>";
var sanitized = ContentSanitizer.Html(html);
Assert.Contains("class=\"h-card\"", sanitized);
Assert.Contains("class=\"u-url mention\"", sanitized);
Assert.Contains("class=\"mention hashtag\"", sanitized);
Assert.Contains("class=\"invisible\"", sanitized);
Assert.Contains("class=\"ellipsis\"", sanitized);
Assert.Contains("href=\"https://m.example/@alice\"", sanitized);
Assert.DoesNotContain("rel=\"tag\"", sanitized);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void Empty_input_is_empty(string input) =>
Assert.Equal(string.Empty, ContentSanitizer.Html(input));
}
}
@@ -0,0 +1,135 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Signing;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace PrivaPub.Tests.Federation
{
public class HttpSignaturesTests
{
const string Host = "privapub.test";
const string KeyId = "https://mastodon.example/users/alice#main-key";
static readonly DateTimeOffset Now = new(2026, 10, 1, 12, 0, 0, TimeSpan.Zero);
static readonly byte[] Body = Encoding.UTF8.GetBytes("""{"type":"Follow","actor":"https://mastodon.example/users/alice"}""");
readonly RSA _key = RSA.Create(2048);
string PublicKey => _key.ExportSubjectPublicKeyInfoPem();
DefaultHttpContext MastodonRequest(string signedTarget = "/peasants/bob/mouth", DateTimeOffset? signedAt = default,
string headers = "(request-target) host date digest content-type", byte[] signedBody = default, string extra = "")
{
var date = (signedAt ?? Now).ToString("r", CultureInfo.InvariantCulture);
var digest = HttpSignatures.Digest(signedBody ?? Body);
var values = new Dictionary<string, string>
{
["(request-target)"] = $"post {signedTarget}",
["host"] = Host,
["date"] = date,
["digest"] = digest,
["content-type"] = "application/activity+json"
};
var signingString = string.Join("\n", headers.Split(' ').Select(h => $"{h}: {values[h]}"));
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
var context = new DefaultHttpContext();
context.Request.Method = "POST";
context.Request.Host = new HostString(Host);
context.Request.Path = "/peasants/bob/mouth";
context.Features.Get<IHttpRequestFeature>().RawTarget = "/peasants/bob/mouth";
context.Request.Headers["Date"] = date;
context.Request.Headers["Digest"] = digest;
context.Request.Headers["Content-Type"] = "application/activity+json";
context.Request.Headers["Signature"] = $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"{headers}\",signature=\"{signature}\"{extra}";
return context;
}
string Check(HttpContext context, byte[] body = default)
{
var parameters = HttpSignatures.Parse(context.Request.Headers["Signature"].ToString());
var problem = HttpSignatures.CheckRequest(context.Request, parameters, body ?? Body, Now);
if (problem != default)
return problem;
return HttpSignatures.Verify(PublicKey, HttpSignatures.SigningString(context.Request, parameters), parameters.Signature)
? default
: "does not verify";
}
[Fact]
public void Accepts_a_mastodon_style_delivery() =>
Assert.Null(Check(MastodonRequest()));
[Fact]
public void Refuses_a_tampered_body() =>
Assert.Equal("the digest does not match the body", Check(MastodonRequest(), Encoding.UTF8.GetBytes("""{"type":"Delete"}""")));
[Fact]
public void Refuses_a_signature_for_another_inbox() =>
Assert.Equal("does not verify", Check(MastodonRequest(signedTarget: "/peasants/carol/mouth")));
[Fact]
public void Refuses_a_signature_older_than_an_hour() =>
Assert.Equal("the Date header is outside the allowed window", Check(MastodonRequest(signedAt: Now.AddMinutes(-61))));
[Fact]
public void Accepts_a_clock_slightly_ahead() =>
Assert.Null(Check(MastodonRequest(signedAt: Now.AddMinutes(10))));
[Fact]
public void Refuses_a_clock_far_ahead() =>
Assert.Equal("the Date header is outside the allowed window", Check(MastodonRequest(signedAt: Now.AddMinutes(20))));
[Fact]
public void Refuses_a_signature_without_the_request_target() =>
Assert.Equal("(request-target) is not signed", Check(MastodonRequest(headers: "host date digest")));
[Fact]
public void Refuses_a_signature_without_the_host() =>
Assert.Equal("host is not signed", Check(MastodonRequest(headers: "(request-target) date digest")));
[Fact]
public void Refuses_a_body_whose_digest_is_not_signed() =>
Assert.Equal("the digest is not signed", Check(MastodonRequest(headers: "(request-target) host date")));
[Fact]
public void Refuses_an_expired_signature() =>
Assert.Equal("the signature has expired",
Check(MastodonRequest(extra: $",expires=\"{Now.AddMinutes(-20).ToUnixTimeSeconds()}\"")));
[Fact]
public void Signs_with_the_raw_request_target()
{
var context = MastodonRequest(signedTarget: "/peasants/b%6Fb/mouth");
context.Features.Get<IHttpRequestFeature>().RawTarget = "/peasants/b%6Fb/mouth";
Assert.Null(Check(context));
}
[Fact]
public void Verifies_its_own_outbound_signature()
{
var (privateKey, publicKey) = Keys.NewKeyPair();
var signer = new LocalActor { UserName = "bob", BaseAddress = "https://privapub.test", PrivateKeyPem = privateKey };
using var outbound = new HttpRequestMessage(HttpMethod.Post, "https://mastodon.example/users/alice/inbox");
HttpSignatures.Sign(outbound, signer, Body);
var context = new DefaultHttpContext();
context.Request.Method = "POST";
context.Request.Host = new HostString("mastodon.example");
context.Request.Path = "/users/alice/inbox";
context.Features.Get<IHttpRequestFeature>().RawTarget = "/users/alice/inbox";
foreach (var header in outbound.Headers)
context.Request.Headers[header.Key] = string.Join(", ", header.Value);
var parameters = HttpSignatures.Parse(context.Request.Headers["Signature"].ToString());
Assert.Equal("https://privapub.test/peasants/bob#main-key", parameters.KeyId);
Assert.Null(HttpSignatures.CheckRequest(context.Request, parameters, Body));
Assert.True(HttpSignatures.Verify(publicKey, HttpSignatures.SigningString(context.Request, parameters), parameters.Signature));
}
}
}
@@ -0,0 +1,205 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Outbox;
using PrivaPub.Models;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class InboxScenarioTests : IAsyncLifetime
{
const string Host = "privapub.test";
const string Base = "https://" + Host;
Peer _peer;
LocalActorService _local;
InboxService _inbox;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_peer = await Peer.Start();
var cache = new MemoryCache(new MemoryCacheOptions());
_local = new LocalActorService(new DbEntities(), new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = Base }));
var remote = new RemoteActorService(Peer.Http(cache), _local, cache, new DbEntities());
_inbox = new InboxService(new DbEntities(), _local, remote, new DeliveryService(new DbEntities()), NullLogger<InboxService>.Instance);
}
public async ValueTask DisposeAsync()
{
if (_peer != default)
await _peer.DisposeAsync();
}
async Task<LocalActor> LocalAvatar(string name)
{
var (privateKey, publicKey) = Keys.NewKeyPair();
var avatar = new Avatar { UserName = $"{name}{Guid.NewGuid():N}"[..20], PrivateKey = privateKey, PublicKey = publicKey };
await DB.Default.SaveAsync(avatar, TestContext.Current.CancellationToken);
return _local.FromAvatar(avatar);
}
static JsonObject DirectCreate(RemoteActor author, string to, string context = default, string objectOrigin = default, string attributedTo = default)
{
var id = $"{objectOrigin ?? Origin(author.Id)}/notes/{Guid.NewGuid():N}";
var note = new JsonObject
{
["id"] = id,
["type"] = "Note",
["attributedTo"] = attributedTo ?? author.Id,
["content"] = "<p>psst</p>",
["to"] = new JsonArray(to),
["cc"] = new JsonArray()
};
if (context != default)
note["context"] = context;
return new JsonObject
{
["id"] = $"{Origin(author.Id)}/activities/{Guid.NewGuid():N}",
["type"] = "Create",
["actor"] = author.Id,
["to"] = new JsonArray(to),
["object"] = note
};
}
static string Origin(string uri) => new Uri(uri).GetLeftPart(UriPartial.Authority);
[Fact]
public async Task A_context_cannot_pull_a_stranger_into_an_existing_conversation()
{
var token = TestContext.Current.CancellationToken;
var alice = await LocalAvatar("alice");
var bob = new RemoteActor(_peer, "bob");
var mallory = new RemoteActor(_peer, "mallory");
var context = $"{_peer.A}/contexts/{Guid.NewGuid():N}";
var first = await _inbox.Receive(bob.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri, context)), alice, token);
var injected = await _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, context)), alice, token);
Assert.Equal(202, first.StatusCode);
Assert.Equal(202, injected.StatusCode);
var bobDm = await DB.Default.Find<DmPost>().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token);
var malloryDm = await DB.Default.Find<DmPost>().Match(p => p.ActorURI == mallory.Id).ExecuteSingleAsync(token);
Assert.NotEqual(bobDm.GroupId, malloryDm.GroupId);
var bobConversation = await DB.Default.Find<DmGroup>().OneAsync(bobDm.GroupId, token);
Assert.DoesNotContain(bobConversation.Members, m => m.AvatarId == mallory.Id);
}
[Fact]
public async Task Replies_between_the_same_people_land_in_the_same_conversation()
{
var token = TestContext.Current.CancellationToken;
var alice = await LocalAvatar("alice");
var bob = new RemoteActor(_peer, "bob");
await _inbox.Receive(bob.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri)), alice, token);
await _inbox.Receive(bob.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri)), alice, token);
var dms = await DB.Default.Find<DmPost>().Match(p => p.ActorURI == bob.Id).ExecuteAsync(token);
Assert.Equal(2, dms.Count);
Assert.Single(dms.Select(d => d.GroupId).Distinct());
}
[Fact]
public async Task An_activity_id_on_another_origin_is_refused()
{
var token = TestContext.Current.CancellationToken;
var alice = await LocalAvatar("alice");
var mallory = new RemoteActor(_peer, "mallory");
var create = DirectCreate(mallory, alice.Uri);
create["id"] = $"{_peer.B}/activities/{Guid.NewGuid():N}";
var result = await _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", create), alice, token);
Assert.Equal(400, result.StatusCode);
}
[Fact]
public async Task A_note_put_in_someone_elses_mouth_is_refused()
{
var token = TestContext.Current.CancellationToken;
var alice = await LocalAvatar("alice");
var mallory = new RemoteActor(_peer, "mallory");
var victim = new RemoteActor(_peer, "victim");
var result = await _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth",
DirectCreate(mallory, alice.Uri, attributedTo: victim.Id)), alice, token);
Assert.Equal(400, result.StatusCode);
Assert.False(await DB.Default.Find<DmPost>().Match(p => p.ActorURI == victim.Id).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_cross_origin_object_is_fetched_from_its_origin_before_it_is_believed()
{
var token = TestContext.Current.CancellationToken;
var alice = await LocalAvatar("alice");
var mallory = new RemoteActor(_peer, "mallory");
var result = await _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth",
DirectCreate(mallory, alice.Uri, objectOrigin: _peer.B)), alice, token);
Assert.Equal(202, result.StatusCode);
Assert.False(await DB.Default.Find<DmPost>().Match(p => p.ActorURI == mallory.Id).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_bad_signature_is_a_401()
{
var token = TestContext.Current.CancellationToken;
var alice = await LocalAvatar("alice");
var mallory = new RemoteActor(_peer, "mallory");
var request = mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri));
request.Headers["Signature"] = request.Headers["Signature"].ToString().Replace("signature=\"", "signature=\"AAAA");
Assert.Equal(401, (await _inbox.Receive(request, alice, token)).StatusCode);
}
[Fact]
public async Task Junk_is_a_400_never_a_500()
{
var token = TestContext.Current.CancellationToken;
var alice = await LocalAvatar("alice");
var mallory = new RemoteActor(_peer, "mallory");
foreach (var junk in new JsonNode[] { new JsonArray(1, 2), JsonValue.Create("x"), new JsonObject { ["type"] = "Create" } })
Assert.Equal(400, (await _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", junk), alice, token)).StatusCode);
}
[Fact]
public async Task A_circle_is_not_a_federated_actor()
{
var token = TestContext.Current.CancellationToken;
var (privateKey, publicKey) = Keys.NewKeyPair();
var circle = new GroupEntity { UserName = $"circle{Guid.NewGuid():N}"[..20], PrivateKey = privateKey, PublicKey = publicKey };
await DB.Default.SaveAsync(circle, token);
var bob = new RemoteActor(_peer, "bob");
var actor = _local.FromGroup(circle);
var follow = new JsonObject
{
["id"] = $"{bob.Id}/follows/{Guid.NewGuid():N}",
["type"] = "Follow",
["actor"] = bob.Id,
["object"] = actor.Uri
};
Assert.False(actor.IsFederated);
Assert.Equal(404, (await _inbox.Receive(bob.Post(Host, "/human-centipede", follow), default, token)).StatusCode);
}
}
}
@@ -0,0 +1,244 @@
using Microsoft.Extensions.Caching.Memory;
using PrivaPub.Federation.Actors;
using PrivaPub.Models;
using PrivaPub.StaticServices;
using PrivaPub.Tests.Support;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class RemoteActorServiceTests : IAsyncLifetime
{
Peer _peer;
RemoteActorService _service;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_peer = await Peer.Start();
var cache = new MemoryCache(new MemoryCacheOptions());
var local = new LocalActorService(new DbEntities(),
new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = "https://privapub.test" }));
_service = new RemoteActorService(Peer.Http(cache), local, cache, new DbEntities());
}
public async ValueTask DisposeAsync()
{
if (_peer != default)
await _peer.DisposeAsync();
}
static string Actor(string id, string keyId = default, string owner = default, string pem = default, string type = "Person") =>
new JsonObject
{
["id"] = id,
["type"] = type,
["preferredUsername"] = id[(id.LastIndexOf('/') + 1)..],
["inbox"] = id + "/inbox",
["publicKey"] = new JsonObject
{
["id"] = keyId ?? id + "#main-key",
["owner"] = owner ?? id,
["publicKeyPem"] = pem ?? Keys.NewKeyPair().PublicKeyPem
}
}.ToJsonString();
string Unique(string name) => $"/users/{name}{Guid.NewGuid():N}";
[Fact]
public async Task Accepts_an_actor_whose_document_names_its_own_address()
{
var path = Unique("alice");
_peer.Serve(path, Actor("{A}" + path));
var actor = await _service.GetActorByKeyId($"{_peer.A}{path}#main-key", refresh: false, TestContext.Current.CancellationToken);
Assert.NotNull(actor);
Assert.Equal(_peer.A + path, actor.ActorURI);
Assert.Equal($"{_peer.A}{path}#main-key", actor.PublicKeyId);
}
[Fact]
public async Task Refuses_a_document_that_claims_another_origin()
{
var victim = Unique("bob");
var mallory = Unique("mallory");
_peer.Serve(mallory, Actor("{B}" + victim));
var actor = await _service.GetActor(_peer.A + mallory, refresh: false, TestContext.Current.CancellationToken);
var byKey = await _service.GetActorByKeyId($"{_peer.B}{victim}#main-key", refresh: false, TestContext.Current.CancellationToken);
Assert.Null(actor);
Assert.Null(byKey);
}
[Fact]
public async Task Refuses_a_key_owned_by_someone_else()
{
var eve = Unique("eve");
_peer.Serve(eve, Actor("{A}" + eve, owner: "{A}/users/alice"));
Assert.Null(await _service.GetActorByKeyId($"{_peer.A}{eve}#main-key", refresh: false, TestContext.Current.CancellationToken));
}
[Fact]
public async Task Refuses_a_key_document_whose_owner_is_on_another_origin()
{
var alice = Unique("alice");
var key = $"/keys/{Guid.NewGuid():N}";
_peer.Serve(alice, Actor("{B}" + alice));
_peer.Serve(key, new JsonObject { ["id"] = "{A}" + key, ["owner"] = "{B}" + alice, ["publicKeyPem"] = Keys.NewKeyPair().PublicKeyPem }.ToJsonString());
Assert.Null(await _service.GetActorByKeyId(_peer.A + key, refresh: false, TestContext.Current.CancellationToken));
}
[Fact]
public async Task Refuses_a_key_the_actor_does_not_list()
{
var alice = Unique("alice");
_peer.Serve(alice, Actor("{A}" + alice));
Assert.Null(await _service.GetActorByKeyId($"{_peer.A}{alice}#other-key", refresh: false, TestContext.Current.CancellationToken));
}
[Fact]
public async Task Follows_a_same_origin_alias_to_the_actor_it_names()
{
var alice = Unique("alice");
var alias = $"/@alias{Guid.NewGuid():N}";
_peer.Serve(alice, Actor("{A}" + alice));
_peer.Serve(alias, Actor("{A}" + alice));
var actor = await _service.GetActor(_peer.A + alias, refresh: false, TestContext.Current.CancellationToken);
Assert.NotNull(actor);
Assert.Equal(_peer.A + alice, actor.ActorURI);
}
[Fact]
public async Task Resolves_a_gotosocial_style_key_address()
{
var gts = Unique("gts");
var keyPath = gts + "/main-key";
var pem = Keys.NewKeyPair().PublicKeyPem;
_peer.Serve(gts, Actor("{A}" + gts, keyId: "{A}" + keyPath, pem: pem));
_peer.Serve(keyPath, Actor("{A}" + gts, keyId: "{A}" + keyPath, pem: pem));
var actor = await _service.GetActorByKeyId(_peer.A + keyPath, refresh: false, TestContext.Current.CancellationToken);
Assert.NotNull(actor);
Assert.Equal(_peer.A + gts, actor.ActorURI);
Assert.Equal(pem, actor.PublicKey);
}
[Fact]
public async Task A_rotated_key_replaces_the_stored_one_but_only_once_per_interval()
{
var alice = Unique("alice");
var keyId = $"{_peer.A}{alice}#main-key";
var first = Keys.NewKeyPair().PublicKeyPem;
var second = Keys.NewKeyPair().PublicKeyPem;
var third = Keys.NewKeyPair().PublicKeyPem;
_peer.Serve(alice, Actor("{A}" + alice, pem: first));
var token = TestContext.Current.CancellationToken;
Assert.Equal(first, (await _service.GetActorByKeyId(keyId, refresh: false, token)).PublicKey);
_peer.Serve(alice, Actor("{A}" + alice, pem: second));
Assert.Equal(first, (await _service.GetActorByKeyId(keyId, refresh: true, token)).PublicKey);
var fresh = new RemoteActorService(Peer.Http(), new LocalActorService(new DbEntities(),
new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = "https://privapub.test" })),
new MemoryCache(new MemoryCacheOptions()), new DbEntities());
Assert.Equal(second, (await fresh.GetActorByKeyId(keyId, refresh: true, token)).PublicKey);
_peer.Serve(alice, Actor("{A}" + alice, pem: third));
Assert.Equal(second, (await fresh.GetActorByKeyId(keyId, refresh: true, token)).PublicKey);
Assert.Single(await new DbEntities().ForeignAvatars.Match(a => a.ActorURI == _peer.A + alice).ExecuteAsync(token));
}
[Fact]
public async Task Signs_every_fetch()
{
var alice = Unique("alice");
_peer.Serve(alice, Actor("{A}" + alice));
await _service.GetActor(_peer.A + alice, refresh: false, TestContext.Current.CancellationToken);
var request = Assert.Single(_peer.Requests, r => r.Path == alice);
Assert.Contains("keyId=\"https://privapub.test/peasants/privapub#main-key\"", request.Signature);
}
}
public class ActorDocumentTests
{
[Fact]
public void Reads_a_mastodon_actor()
{
var document = ActorDocument.Parse(JsonDocument.Parse("""
{
"@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"],
"id": "https://mastodon.example/users/alice",
"type": "Person",
"preferredUsername": "alice",
"name": "Alice",
"inbox": "https://mastodon.example/users/alice/inbox",
"outbox": "https://mastodon.example/users/alice/outbox",
"url": "https://mastodon.example/@alice",
"discoverable": false,
"endpoints": { "sharedInbox": "https://mastodon.example/inbox" },
"icon": { "type": "Image", "url": "https://files.mastodon.example/a.png" },
"publicKey": {
"id": "https://mastodon.example/users/alice#main-key",
"owner": "https://mastodon.example/users/alice",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIB\n-----END PUBLIC KEY-----\n"
}
}
""").RootElement);
Assert.Equal("alice", document.PreferredUsername);
Assert.Equal("https://mastodon.example/inbox", document.SharedInbox);
Assert.Equal("https://files.mastodon.example/a.png", document.Icon);
Assert.False(document.Discoverable);
Assert.NotNull(document.Key("https://mastodon.example/users/alice#main-key"));
}
[Fact]
public void Keeps_only_keys_owned_by_the_actor_on_its_origin()
{
var document = ActorDocument.Parse(JsonDocument.Parse("""
{
"id": "https://a.example/users/alice",
"type": "Person",
"publicKey": [
{ "id": "https://a.example/users/alice#main-key", "owner": "https://a.example/users/alice", "publicKeyPem": "x" },
{ "id": "https://b.example/keys/1", "owner": "https://a.example/users/alice", "publicKeyPem": "y" },
{ "id": "https://a.example/users/alice#other", "owner": "https://a.example/users/bob", "publicKeyPem": "z" },
{ "id": "https://a.example/users/alice#unowned", "publicKeyPem": "w" }
]
}
""").RootElement);
Assert.Equal("https://a.example/users/alice#main-key", Assert.Single(document.Keys).Id);
}
[Fact]
public void Refuses_an_inbox_on_another_origin()
{
var document = ActorDocument.Parse(JsonDocument.Parse("""
{ "id": "https://a.example/users/alice", "type": "Person", "inbox": "https://b.example/inbox" }
""").RootElement);
Assert.Null(document.Inbox);
}
[Theory]
[InlineData("Note")]
[InlineData("Tombstone")]
[InlineData("Collection")]
public void Refuses_non_actor_types(string type) =>
Assert.Null(ActorDocument.Parse(JsonDocument.Parse($$"""{ "id": "https://a.example/x", "type": "{{type}}" }""").RootElement));
}
}
@@ -0,0 +1,102 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using PrivaPub.Infrastructure.Http;
namespace PrivaPub.Tests.Infrastructure
{
public sealed class FederationHttpTests : IAsyncLifetime
{
WebApplication _peer;
string _base;
public async ValueTask InitializeAsync()
{
var builder = WebApplication.CreateSlimBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
_peer = builder.Build();
_peer.MapGet("/actor", () => Results.Text("{\"id\":\"x\"}", "application/activity+json"));
_peer.MapGet("/html", () => Results.Text("<html></html>", "text/html"));
_peer.MapGet("/big", () => Results.Text("{\"a\":\"" + new string('a', FederationHttp.MaxResponseBytes) + "\"}", "application/activity+json"));
_peer.MapGet("/hop/{n:int}", (int n) => Results.Redirect(n == 0 ? "/actor" : $"/hop/{n - 1}"));
_peer.MapGet("/gone", () => Results.StatusCode(410));
await _peer.StartAsync();
_base = _peer.Urls.First();
}
public async ValueTask DisposeAsync() => await _peer.DisposeAsync();
static FederationHttp Client(bool allowTestNetwork = true)
{
var options = new FederationOptions { AllowPrivateNetworks = allowTestNetwork, AllowPlainHttp = allowTestNetwork };
var services = new ServiceCollection();
services.AddHttpClient(FederationHttp.ClientName)
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options));
var provider = services.BuildServiceProvider();
return new FederationHttp(provider.GetRequiredService<IHttpClientFactory>(), new MemoryCache(new MemoryCacheOptions()),
new StaticOptionsMonitor(options), NullLogger<FederationHttp>.Instance);
}
[Fact]
public async Task Reads_a_json_document()
{
using var fetched = await Client().GetJson($"{_base}/actor", "application/activity+json", default, TestContext.Current.CancellationToken);
Assert.NotNull(fetched);
Assert.Equal("x", fetched.Root.GetProperty("id").GetString());
}
[Fact]
public async Task Follows_up_to_three_redirects_and_reports_the_final_url()
{
using var fetched = await Client().GetJson($"{_base}/hop/2", "application/activity+json", default, TestContext.Current.CancellationToken);
Assert.NotNull(fetched);
Assert.Equal($"{_base}/actor", fetched.FinalUri.ToString());
}
[Fact]
public async Task Refuses_a_fourth_redirect() =>
Assert.Null(await Client().GetJson($"{_base}/hop/3", "application/activity+json", default, TestContext.Current.CancellationToken));
[Fact]
public async Task Refuses_html() =>
Assert.Null(await Client().GetJson($"{_base}/html", "application/activity+json", default, TestContext.Current.CancellationToken));
[Fact]
public async Task Refuses_a_body_over_the_limit() =>
Assert.Null(await Client().GetJson($"{_base}/big", "application/activity+json", default, TestContext.Current.CancellationToken));
[Fact]
public async Task Refuses_an_error_status() =>
Assert.Null(await Client().GetJson($"{_base}/gone", "application/activity+json", default, TestContext.Current.CancellationToken));
[Fact]
public async Task Refuses_a_private_network_in_production_mode() =>
Assert.Null(await Client(allowTestNetwork: false).GetJson($"{_base}/actor", "application/activity+json", default, TestContext.Current.CancellationToken));
[Theory]
[InlineData("https://mastodon.social/users/Gargron", true)]
[InlineData("http://mastodon.social/users/Gargron", false)]
[InlineData("https://user:pass@mastodon.social/", false)]
[InlineData("https://localhost/", false)]
[InlineData("https://printer.local/", false)]
[InlineData("https://metadata.google.internal/", false)]
[InlineData("https://127.0.0.1/", false)]
[InlineData("https://[::1]/", false)]
[InlineData("ftp://example.org/", false)]
public void IsAllowed_takes_https_dns_names_only(string url, bool allowed) =>
Assert.Equal(allowed, Client(allowTestNetwork: false).IsAllowed(new Uri(url)));
sealed class StaticOptionsMonitor : IOptionsMonitor<FederationOptions>
{
public StaticOptionsMonitor(FederationOptions value) => CurrentValue = value;
public FederationOptions CurrentValue { get; }
public FederationOptions Get(string name) => CurrentValue;
public IDisposable OnChange(Action<FederationOptions, string> listener) => default;
}
}
}
@@ -0,0 +1,62 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Infrastructure.Data;
using PrivaPub.Infrastructure.Data.Migrations;
using PrivaPub.Models;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using PrivaPub.Tests.Support;
namespace PrivaPub.Tests.Infrastructure
{
[Trait("Category", "Integration")]
public class IndexTests
{
[Fact]
public async Task Duplicates_are_removed_and_the_unique_indexes_then_hold()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var actorUri = $"https://r.example/users/{Guid.NewGuid():N}";
var localId = Guid.NewGuid().ToString("N")[..24];
await DB.Default.SaveAsync(new[]
{
new ForeignAvatar { ActorURI = actorUri, Name = "old", UpdatedAt = DateTime.UtcNow.AddDays(-2) },
new ForeignAvatar { ActorURI = actorUri, Name = "new", UpdatedAt = DateTime.UtcNow }
}, token);
await DB.Default.SaveAsync(new[]
{
new Follower { LocalActorId = localId, ActorURI = actorUri, IsAccepted = false },
new Follower { LocalActorId = localId, ActorURI = actorUri, IsAccepted = true }
}, token);
await new _001_deduplicate_before_unique_indexes().UpgradeAsync();
await Indexes.Create(token);
Assert.Equal("new", (await DB.Default.Find<ForeignAvatar>().Match(a => a.ActorURI == actorUri).ExecuteSingleAsync(token)).Name);
Assert.True((await DB.Default.Find<Follower>().Match(f => f.LocalActorId == localId).ExecuteSingleAsync(token)).IsAccepted);
var duplicate = await Assert.ThrowsAsync<MongoWriteException>(async () =>
await DB.Default.SaveAsync(new ForeignAvatar { ActorURI = actorUri }, token));
Assert.Equal(ServerErrorCategory.DuplicateKey, duplicate.WriteError.Category);
}
[Fact]
public async Task A_username_is_reserved_once_across_personas_and_groups()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
await Indexes.Create(token);
var local = new LocalActorService(new DbEntities(), new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = "https://privapub.test" }));
var name = $"n{Guid.NewGuid():N}"[..20];
Assert.True(await local.TryReserveUserName(name, LocalActorKind.Person, "a", token));
Assert.False(await local.TryReserveUserName(name.ToUpperInvariant(), LocalActorKind.Group, "b", token));
Assert.True(await local.IsUserNameTaken(name, token));
Assert.False(await local.TryReserveUserName("admin", LocalActorKind.Person, "c", token));
Assert.False(await local.TryReserveUserName(LocalActorService.InstanceUserName, LocalActorKind.Person, "c", token));
}
}
}
@@ -0,0 +1,86 @@
using System.Net;
using PrivaPub.Infrastructure.Http;
namespace PrivaPub.Tests.Infrastructure
{
public class IpRangeGuardTests
{
[Theory]
[InlineData("1.1.1.1")]
[InlineData("8.8.8.8")]
[InlineData("93.184.215.14")]
[InlineData("172.15.255.255")]
[InlineData("172.32.0.1")]
[InlineData("100.63.255.255")]
[InlineData("100.128.0.0")]
[InlineData("2a01:4f8::1")]
[InlineData("2606:4700:4700::1111")]
[InlineData("::ffff:1.1.1.1")]
public void Public_addresses_are_allowed(string address) =>
Assert.True(IpRangeGuard.IsPublic(IPAddress.Parse(address)));
[Theory]
[InlineData("0.0.0.0")]
[InlineData("10.1.2.3")]
[InlineData("100.64.0.1")]
[InlineData("100.127.255.255")]
[InlineData("127.0.0.1")]
[InlineData("127.255.255.254")]
[InlineData("169.254.169.254")]
[InlineData("172.16.0.1")]
[InlineData("172.31.255.255")]
[InlineData("192.0.0.170")]
[InlineData("192.0.2.1")]
[InlineData("192.88.99.1")]
[InlineData("192.168.1.1")]
[InlineData("198.18.0.1")]
[InlineData("198.51.100.7")]
[InlineData("203.0.113.9")]
[InlineData("224.0.0.251")]
[InlineData("239.255.255.250")]
[InlineData("240.0.0.1")]
[InlineData("255.255.255.255")]
[InlineData("::")]
[InlineData("::1")]
[InlineData("::127.0.0.1")]
[InlineData("::ffff:127.0.0.1")]
[InlineData("::ffff:10.0.0.1")]
[InlineData("::ffff:169.254.169.254")]
[InlineData("64:ff9b::7f00:1")]
[InlineData("64:ff9b::808:808")]
[InlineData("64:ff9b:1::1")]
[InlineData("100::1")]
[InlineData("2001::1")]
[InlineData("2001:0:4136:e378:8000:63bf:3fff:fdd2")]
[InlineData("2001:db8::1")]
[InlineData("2001:10::1")]
[InlineData("2002:7f00:1::1")]
[InlineData("2002:c0a8:101::1")]
[InlineData("3fff::1")]
[InlineData("fc00::1")]
[InlineData("fd12:3456:789a::1")]
[InlineData("fe80::1")]
[InlineData("fec0::1")]
[InlineData("ff02::1")]
public void Private_and_special_addresses_are_refused(string address) =>
Assert.False(IpRangeGuard.IsPublic(IPAddress.Parse(address)));
[Fact]
public async Task Connect_refuses_a_name_that_resolves_to_loopback()
{
await Assert.ThrowsAsync<BlockedDestinationException>(async () =>
await SafeHttpHandlerFactory.Connect(new DnsEndPoint("localhost", 443), allowPrivateNetworks: false, TestContext.Current.CancellationToken));
}
[Theory]
[InlineData("127.0.0.1")]
[InlineData("[::1]")]
[InlineData("169.254.169.254")]
public async Task Connect_refuses_a_private_literal(string host)
{
await Assert.ThrowsAsync<BlockedDestinationException>(async () =>
await SafeHttpHandlerFactory.Connect(new DnsEndPoint(host, 443), allowPrivateNetworks: false, TestContext.Current.CancellationToken));
}
}
}
@@ -0,0 +1,33 @@
using MongoDB.Entities;
using PrivaPub.Infrastructure.Data.Migrations;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
namespace PrivaPub.Tests.Infrastructure
{
[Trait("Category", "Integration")]
public class MigrationTests
{
[Fact]
public async Task Stored_remote_content_is_sanitized_and_local_content_rendered()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var remote = new Post { IsFederatedCopy = true, Text = "<p>hi<script>alert(1)</script></p>", ObjectURI = $"https://r.example/{Guid.NewGuid():N}" };
var local = new Post { Text = "**bold** <b>raw</b>", ObjectURI = $"https://privapub.test/{Guid.NewGuid():N}" };
await DB.Default.SaveAsync(new[] { remote, local }, token);
await new _002_sanitize_stored_content().UpgradeAsync();
var migratedRemote = await DB.Default.Find<Post>().OneAsync(remote.ID, token);
var migratedLocal = await DB.Default.Find<Post>().OneAsync(local.ID, token);
Assert.Equal("<p>hi</p>", migratedRemote.Text);
Assert.Equal("<p>hi</p>", migratedRemote.ContentHtml);
Assert.Equal(ContentFormat.Html, migratedRemote.ContentFormat);
Assert.Equal("**bold** <b>raw</b>", migratedLocal.Text);
Assert.Equal("<p><strong>bold</strong> &lt;b&gt;raw&lt;/b&gt;</p>", migratedLocal.ContentHtml);
Assert.Equal(ContentFormat.Markdown, migratedLocal.ContentFormat);
}
}
}
+29
View File
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PrivaPub\PrivaPub.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+43
View File
@@ -0,0 +1,43 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Infrastructure.Data;
[assembly: AssemblyFixture(typeof(PrivaPub.Tests.Support.MongoFixture))]
namespace PrivaPub.Tests.Support
{
public sealed class MongoFixture : IAsyncLifetime
{
public const string Skip = "set PRIVAPUB_TEST_MONGOD=1 (and optionally PRIVAPUB_TEST_MONGO) to run the tests that need a mongod";
public static bool Enabled => Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGOD") == "1";
public string Database { get; } = $"PrivaPubTests_{Guid.NewGuid():N}";
public async ValueTask InitializeAsync()
{
if (!Enabled)
return;
try
{
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
}
catch (BsonSerializationException)
{
}
var connection = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGO") ?? "mongodb://127.0.0.1:27017";
await DB.InitAsync(Database, MongoClientSettings.FromConnectionString(connection));
EntityMaps.Warm();
}
public async ValueTask DisposeAsync()
{
if (Enabled)
await DB.Default.Database().Client.DropDatabaseAsync(Database);
}
}
}
+79
View File
@@ -0,0 +1,79 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using PrivaPub.Infrastructure.Http;
using System.Collections.Concurrent;
namespace PrivaPub.Tests.Support
{
public sealed class Peer : IAsyncDisposable
{
readonly WebApplication _app;
readonly ConcurrentDictionary<string, string> _documents = new();
public int Port { get; }
public string A => $"http://127.0.0.1:{Port}";
public string B => $"http://localhost:{Port}";
public ConcurrentQueue<HttpRequestRecord> Requests { get; } = new();
Peer(WebApplication app, int port)
{
_app = app;
Port = port;
}
public static async Task<Peer> Start()
{
var builder = WebApplication.CreateSlimBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
var app = builder.Build();
Peer peer = default;
app.Run(async context =>
{
peer.Requests.Enqueue(new(context.Request.Method, context.Request.Path, context.Request.Headers["Signature"].ToString()));
var key = context.Request.Path.Value;
if (!peer._documents.TryGetValue(key, out var document))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
context.Response.ContentType = "application/activity+json";
await context.Response.WriteAsync(document.Replace("{A}", peer.A).Replace("{B}", peer.B));
});
await app.StartAsync();
peer = new Peer(app, new Uri(app.Urls.First()).Port);
return peer;
}
public void Serve(string path, string json) => _documents[path] = json;
public static FederationHttp Http(IMemoryCache cache = default)
{
var options = new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true };
var services = new ServiceCollection();
services.AddHttpClient(FederationHttp.ClientName)
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options));
return new FederationHttp(services.BuildServiceProvider().GetRequiredService<IHttpClientFactory>(),
cache ?? new MemoryCache(new MemoryCacheOptions()), new StaticOptions<FederationOptions>(options),
NullLogger<FederationHttp>.Instance);
}
public async ValueTask DisposeAsync() => await _app.DisposeAsync();
}
public sealed record HttpRequestRecord(string Method, string Path, string Signature);
public sealed class StaticOptions<T> : IOptionsMonitor<T>
{
public StaticOptions(T value) => CurrentValue = value;
public T CurrentValue { get; }
public T Get(string name) => CurrentValue;
public IDisposable OnChange(Action<T, string> listener) => default;
}
}
+65
View File
@@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using PrivaPub.Federation.Signing;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Support
{
public sealed class RemoteActor
{
readonly RSA _key = RSA.Create(2048);
public RemoteActor(Peer peer, string name, string origin = default)
{
Name = $"{name}{Guid.NewGuid():N}"[..20];
Id = $"{origin ?? peer.A}/users/{Name}";
peer.Serve($"/users/{Name}", Document().ToJsonString());
}
public string Name { get; }
public string Id { get; }
public string KeyId => Id + "#main-key";
public JsonObject Document() => new()
{
["id"] = Id,
["type"] = "Person",
["preferredUsername"] = Name,
["inbox"] = Id + "/inbox",
["followers"] = Id + "/followers",
["publicKey"] = new JsonObject
{
["id"] = KeyId,
["owner"] = Id,
["publicKeyPem"] = _key.ExportSubjectPublicKeyInfoPem()
}
};
public HttpRequest Post(string host, string path, JsonNode activity)
{
var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
var digest = HttpSignatures.Digest(body);
var signingString = $"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}";
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
var context = new DefaultHttpContext();
context.Request.Method = "POST";
context.Request.Host = new HostString(host);
context.Request.Path = path;
context.Features.Get<IHttpRequestFeature>().RawTarget = path;
context.Request.Headers["Date"] = date;
context.Request.Headers["Digest"] = digest;
context.Request.Headers["Content-Type"] = "application/activity+json";
context.Request.Headers["Signature"] = $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest\",signature=\"{signature}\"";
context.Request.Body = new MemoryStream(body);
context.Request.ContentLength = body.Length;
return context.Request;
}
}
}
+34
View File
@@ -7,20 +7,54 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PrivaPub", "PrivaPub\PrivaP
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PrivaPub.ClientModels", "PrivaPub.ClientModels\PrivaPub.ClientModels.csproj", "{5E63C68C-0E60-4418-A53A-FF4EF0007080}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PrivaPub.ClientModels", "PrivaPub.ClientModels\PrivaPub.ClientModels.csproj", "{5E63C68C-0E60-4418-A53A-FF4EF0007080}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PrivaPub.Tests", "PrivaPub.Tests\PrivaPub.Tests.csproj", "{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|Any CPU.Build.0 = Debug|Any CPU {EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|x64.ActiveCfg = Debug|Any CPU
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|x64.Build.0 = Debug|Any CPU
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|x86.ActiveCfg = Debug|Any CPU
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|x86.Build.0 = Debug|Any CPU
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|Any CPU.ActiveCfg = Release|Any CPU {EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|Any CPU.Build.0 = Release|Any CPU {EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|Any CPU.Build.0 = Release|Any CPU
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|x64.ActiveCfg = Release|Any CPU
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|x64.Build.0 = Release|Any CPU
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|x86.ActiveCfg = Release|Any CPU
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|x86.Build.0 = Release|Any CPU
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|Any CPU.Build.0 = Debug|Any CPU {5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|x64.ActiveCfg = Debug|Any CPU
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|x64.Build.0 = Debug|Any CPU
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|x86.ActiveCfg = Debug|Any CPU
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|x86.Build.0 = Debug|Any CPU
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|Any CPU.ActiveCfg = Release|Any CPU {5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|Any CPU.Build.0 = Release|Any CPU {5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|Any CPU.Build.0 = Release|Any CPU
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|x64.ActiveCfg = Release|Any CPU
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|x64.Build.0 = Release|Any CPU
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|x86.ActiveCfg = Release|Any CPU
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|x86.Build.0 = Release|Any CPU
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Debug|x64.ActiveCfg = Debug|Any CPU
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Debug|x64.Build.0 = Debug|Any CPU
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Debug|x86.ActiveCfg = Debug|Any CPU
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Debug|x86.Build.0 = Debug|Any CPU
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Release|Any CPU.Build.0 = Release|Any CPU
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Release|x64.ActiveCfg = Release|Any CPU
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Release|x64.Build.0 = Release|Any CPU
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Release|x86.ActiveCfg = Release|Any CPU
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -45,7 +45,7 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(RemoveUsers)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(RemoveUsers)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
@@ -73,7 +73,7 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(BanUsers)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(BanUsers)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
@@ -99,7 +99,7 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(UnbanUsers)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(UnbanUsers)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
@@ -122,7 +122,7 @@ namespace PrivaPub.Controllers.ClientToServer
// catch (Exception ex) // catch (Exception ex)
// { // {
// Logger.LogError(ex, $"{nameof(User)}.{nameof(GetUsers)}()"); // Logger.LogError(ex, $"{nameof(User)}.{nameof(GetUsers)}()");
// return BadRequest(result.Invalidate(ex.Message)); // return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
// } // }
//} //}
} }
@@ -48,7 +48,7 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(DataController)}.{nameof(Languages)}()"); Logger.LogError(ex, $"{nameof(DataController)}.{nameof(Languages)}()");
return BadRequest(result.Invalidate(ex.Message, exception: ex)); return BadRequest(result.Invalidate("Something went wrong.", exception: ex));
} }
} }
} }
@@ -52,7 +52,7 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"{nameof(PrivateAvatarController)}.{nameof(InsertAvatar)}()"); _logger.LogError(ex, $"{nameof(PrivateAvatarController)}.{nameof(InsertAvatar)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(_localizer["Something went wrong."]));
} }
} }
@@ -74,7 +74,7 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"{nameof(PrivateAvatarController)}.{nameof(UpdateAvatar)}()"); _logger.LogError(ex, $"{nameof(PrivateAvatarController)}.{nameof(UpdateAvatar)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(_localizer["Something went wrong."]));
} }
} }
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -10,6 +11,8 @@ using PrivaPub.ClientModels.Group;
using PrivaPub.ClientModels.User; using PrivaPub.ClientModels.User;
using PrivaPub.ClientModels.User.Avatar; using PrivaPub.ClientModels.User.Avatar;
using PrivaPub.Extensions; using PrivaPub.Extensions;
using PrivaPub.Federation.Actors;
using PrivaPub.Infrastructure;
using PrivaPub.Models; using PrivaPub.Models;
using PrivaPub.Models.User; using PrivaPub.Models.User;
using PrivaPub.Resources; using PrivaPub.Resources;
@@ -28,6 +31,7 @@ namespace PrivaPub.Controllers.ClientToServer
readonly IRootUsersService UsersService; readonly IRootUsersService UsersService;
readonly IGroupUsersService GroupUsersService; readonly IGroupUsersService GroupUsersService;
readonly IPrivateAvatarUsersService AvatarUsersService; readonly IPrivateAvatarUsersService AvatarUsersService;
readonly ILocalActorService LocalActors;
readonly AuthTokenManager AuthTokenManager; readonly AuthTokenManager AuthTokenManager;
readonly IOptionsMonitor<AppConfiguration> AppConfiguration; readonly IOptionsMonitor<AppConfiguration> AppConfiguration;
readonly ILogger<RootUserController> Logger; readonly ILogger<RootUserController> Logger;
@@ -36,6 +40,7 @@ namespace PrivaPub.Controllers.ClientToServer
public RootUserController(IRootUsersService usersService, public RootUserController(IRootUsersService usersService,
IGroupUsersService groupUsersService, IGroupUsersService groupUsersService,
IPrivateAvatarUsersService avatarUsersService, IPrivateAvatarUsersService avatarUsersService,
ILocalActorService localActors,
AuthTokenManager authTokenManager, AuthTokenManager authTokenManager,
IOptionsMonitor<AppConfiguration> appConfiguration, IOptionsMonitor<AppConfiguration> appConfiguration,
IStringLocalizer<GenericRes> localizer, IStringLocalizer<GenericRes> localizer,
@@ -44,6 +49,7 @@ namespace PrivaPub.Controllers.ClientToServer
UsersService = usersService; UsersService = usersService;
GroupUsersService = groupUsersService; GroupUsersService = groupUsersService;
AvatarUsersService = avatarUsersService; AvatarUsersService = avatarUsersService;
LocalActors = localActors;
AuthTokenManager = authTokenManager; AuthTokenManager = authTokenManager;
AppConfiguration = appConfiguration; AppConfiguration = appConfiguration;
Localizer = localizer; Localizer = localizer;
@@ -52,7 +58,7 @@ namespace PrivaPub.Controllers.ClientToServer
#region User endpoints #region User endpoints
[HttpPost, Route("/clientapi/user/signup"), Authorize(Policy = Policies.IsUser), AllowAnonymous] [HttpPost, Route("/clientapi/user/signup"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser), AllowAnonymous]
public async Task<IActionResult> SignUp(LoginForm signUpForm) public async Task<IActionResult> SignUp(LoginForm signUpForm)
{ {
if (User.Identity?.IsAuthenticated ?? false) return Redirect("/"); if (User.Identity?.IsAuthenticated ?? false) return Redirect("/");
@@ -67,18 +73,16 @@ namespace PrivaPub.Controllers.ClientToServer
(var user, var userSettings) = ((RootUser, ViewUserSettings))result.Data; (var user, var userSettings) = ((RootUser, ViewUserSettings))result.Data;
var jwtUser = AuthTokenManager.GenerateToken(user, userSettings); var jwtUser = AuthTokenManager.GenerateToken(user, userSettings);
Logger.LogInformation(
$"{nameof(SignUp)}();IP:[{HttpContext.Connection?.RemoteIpAddress}];\nUser-Agent:[{Request.Headers["User-Agent"]}];\nUserId:[{user.ID}]");
return Ok(jwtUser); return Ok(jwtUser);
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(SignUp)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(SignUp)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
[HttpPost, Route("/clientapi/user/login"), Authorize(Policy = Policies.IsUser), AllowAnonymous] [HttpPost, Route("/clientapi/user/login"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser), AllowAnonymous]
public async Task<IActionResult> Login(LoginForm loginForm) public async Task<IActionResult> Login(LoginForm loginForm)
{ {
if (User.Identity?.IsAuthenticated ?? false) return Redirect("/discussions"); if (User.Identity?.IsAuthenticated ?? false) return Redirect("/discussions");
@@ -94,19 +98,17 @@ namespace PrivaPub.Controllers.ClientToServer
var (user, userSettings) = var (user, userSettings) =
((RootUser, ViewUserSettings))result.Data; ((RootUser, ViewUserSettings))result.Data;
var jwtUser = AuthTokenManager.GenerateToken(user, userSettings); var jwtUser = AuthTokenManager.GenerateToken(user, userSettings);
Logger.LogInformation(
$"{nameof(Login)}();IP:[{HttpContext.Connection?.RemoteIpAddress}];\nUser-Agent:[{Request.Headers["User-Agent"]}];\nUserId:[{user.ID}]");
return Ok(jwtUser); return Ok(jwtUser);
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(Login)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(Login)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
[HttpPost, Route("/clientapi/user/invitation/signup"), AllowAnonymous] [HttpPost, Route("/clientapi/user/invitation/signup"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
public async Task<IActionResult> InvitationSignUp(InvitationLoginForm signUpForm, CancellationToken token) public async Task<IActionResult> InvitationSignUp(InvitationLoginForm signUpForm, CancellationToken token)
{ {
var result = new WebResult(); var result = new WebResult();
@@ -118,6 +120,11 @@ namespace PrivaPub.Controllers.ClientToServer
if (!invitation.IsValid) if (!invitation.IsValid)
return StatusCode(invitation.StatusCode, invitation); return StatusCode(invitation.StatusCode, invitation);
var avatarUserName = signUpForm.AvatarUserName?.Trim().ToLowerInvariant();
var personaProblem = await PersonaProblem(avatarUserName, signUpForm.UserName, token);
if (personaProblem != default)
return BadRequest(result.Invalidate(personaProblem));
result = await UsersService.SignUpAsync(signUpForm, signUpForm.InvitationCode); result = await UsersService.SignUpAsync(signUpForm, signUpForm.InvitationCode);
if (!result.IsValid) if (!result.IsValid)
return StatusCode(result.StatusCode, result); return StatusCode(result.StatusCode, result);
@@ -126,8 +133,8 @@ namespace PrivaPub.Controllers.ClientToServer
var avatar = await AvatarUsersService.InsertAvatar(new InsertAvatarForm var avatar = await AvatarUsersService.InsertAvatar(new InsertAvatarForm
{ {
RootId = user.ID, RootId = user.ID,
Name = user.UserName, Name = string.IsNullOrWhiteSpace(signUpForm.AvatarName) ? avatarUserName : signUpForm.AvatarName.Trim(),
UserName = user.UserName, UserName = avatarUserName,
Biography = string.Empty Biography = string.Empty
}); });
if (!avatar.IsValid) if (!avatar.IsValid)
@@ -142,18 +149,16 @@ namespace PrivaPub.Controllers.ClientToServer
if (!joined.IsValid) if (!joined.IsValid)
return StatusCode(joined.StatusCode, joined); return StatusCode(joined.StatusCode, joined);
Logger.LogInformation(
$"{nameof(InvitationSignUp)}();IP:[{HttpContext.Connection?.RemoteIpAddress}];\nUser-Agent:[{Request.Headers["User-Agent"]}];\nUserId:[{user.ID}]");
return Ok(AuthTokenManager.GenerateToken(user, userSettings)); return Ok(AuthTokenManager.GenerateToken(user, userSettings));
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(InvitationSignUp)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(InvitationSignUp)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
[HttpPost, Route("/clientapi/user/invitation/login"), AllowAnonymous] [HttpPost, Route("/clientapi/user/invitation/login"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
public async Task<IActionResult> InvitationLogin(InvitationLoginForm loginForm, CancellationToken token) public async Task<IActionResult> InvitationLogin(InvitationLoginForm loginForm, CancellationToken token)
{ {
var result = new WebResult(); var result = new WebResult();
@@ -171,14 +176,20 @@ namespace PrivaPub.Controllers.ClientToServer
var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data; var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data;
var avatars = await AvatarUsersService.GetRootAvatars(user.ID, token); var avatars = await AvatarUsersService.GetRootAvatars(user.ID, token);
var avatar = (avatars.Data as List<ViewAvatar>)?.FirstOrDefault(); var avatarUserName = loginForm.AvatarUserName?.Trim().ToLowerInvariant();
var avatar = string.IsNullOrEmpty(avatarUserName)
? (avatars.Data as List<ViewAvatar>)?.FirstOrDefault()
: (avatars.Data as List<ViewAvatar>)?.FirstOrDefault(a => a.UserName == avatarUserName);
if (avatar == default) if (avatar == default)
{ {
var personaProblem = await PersonaProblem(avatarUserName, user.UserName, token);
if (personaProblem != default)
return BadRequest(new WebResult().Invalidate(personaProblem));
var inserted = await AvatarUsersService.InsertAvatar(new InsertAvatarForm var inserted = await AvatarUsersService.InsertAvatar(new InsertAvatarForm
{ {
RootId = user.ID, RootId = user.ID,
Name = user.UserName, Name = string.IsNullOrWhiteSpace(loginForm.AvatarName) ? avatarUserName : loginForm.AvatarName.Trim(),
UserName = user.UserName, UserName = avatarUserName,
Biography = string.Empty Biography = string.Empty
}); });
if (!inserted.IsValid) if (!inserted.IsValid)
@@ -195,17 +206,26 @@ namespace PrivaPub.Controllers.ClientToServer
if (!joined.IsValid) if (!joined.IsValid)
return StatusCode(joined.StatusCode, joined); return StatusCode(joined.StatusCode, joined);
Logger.LogInformation(
$"{nameof(InvitationLogin)}();IP:[{HttpContext.Connection?.RemoteIpAddress}];\nUser-Agent:[{Request.Headers["User-Agent"]}];\nUserId:[{user.ID}]");
return Ok(AuthTokenManager.GenerateToken(user, userSettings)); return Ok(AuthTokenManager.GenerateToken(user, userSettings));
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(InvitationLogin)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(InvitationLogin)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
async Task<string> PersonaProblem(string avatarUserName, string rootUserName, CancellationToken token)
{
if (string.IsNullOrEmpty(avatarUserName))
return Localizer["Choose a username for your persona."];
if (string.Equals(avatarUserName, rootUserName?.Trim(), StringComparison.OrdinalIgnoreCase))
return Localizer["Your persona's username must differ from your login."];
if (await LocalActors.IsUserNameTaken(avatarUserName, token))
return Localizer["The username '{0}' is already take.", avatarUserName];
return default;
}
[HttpGet, Route("/clientapi/user/logout"), Authorize(Policy = Policies.IsUser)] [HttpGet, Route("/clientapi/user/logout"), Authorize(Policy = Policies.IsUser)]
public IActionResult Logout() public IActionResult Logout()
{ {
@@ -217,7 +237,7 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(Logout)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(Logout)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
@@ -238,7 +258,7 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(UpdateUser)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(UpdateUser)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
@@ -259,7 +279,7 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(UpdateUserSettings)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(UpdateUserSettings)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
@@ -280,7 +300,7 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(UpdatePassword)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(UpdatePassword)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
@@ -299,7 +319,7 @@ namespace PrivaPub.Controllers.ClientToServer
// catch (Exception ex) // catch (Exception ex)
// { // {
// Logger.LogError(ex, $"{nameof(User)}.{nameof(GetUser)}()"); // Logger.LogError(ex, $"{nameof(User)}.{nameof(GetUser)}()");
// return BadRequest(result.Invalidate(ex.Message)); // return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
// } // }
//} //}
@@ -317,11 +337,11 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(GetUserSettings)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(GetUserSettings)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
[HttpPost, Route("/clientapi/user/recover/password"), AllowAnonymous] [HttpPost, Route("/clientapi/user/recover/password"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
public async Task<IActionResult> RecoverPassword(PasswordRecoveryForm passwordRecoveryForm) public async Task<IActionResult> RecoverPassword(PasswordRecoveryForm passwordRecoveryForm)
{ {
var result = new WebResult(); var result = new WebResult();
@@ -341,11 +361,11 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(RecoverPassword)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(RecoverPassword)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
[HttpPost, Route("/clientapi/user/recover/valid"), AllowAnonymous] [HttpPost, Route("/clientapi/user/recover/valid"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
public async Task<IActionResult> IsValidRecoveryCode( public async Task<IActionResult> IsValidRecoveryCode(
[FromBody, [FromBody,
Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)), Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
@@ -364,11 +384,11 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(IsValidRecoveryCode)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(IsValidRecoveryCode)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
[HttpPost, Route("/clientapi/user/recover/update/password"), AllowAnonymous] [HttpPost, Route("/clientapi/user/recover/update/password"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
public async Task<IActionResult> ChangePassword(NewPasswordForm newPasswordForm) public async Task<IActionResult> ChangePassword(NewPasswordForm newPasswordForm)
{ {
var result = new WebResult(); var result = new WebResult();
@@ -385,7 +405,7 @@ namespace PrivaPub.Controllers.ClientToServer
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(User)}.{nameof(ChangePassword)}()"); Logger.LogError(ex, $"{nameof(User)}.{nameof(ChangePassword)}()");
return BadRequest(result.Invalidate(ex.Message)); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
@@ -406,7 +426,7 @@ namespace PrivaPub.Controllers.ClientToServer
// catch (Exception ex) // catch (Exception ex)
// { // {
// Logger.LogError(ex, $"{nameof(User)}.{nameof(RemoveSelf)}()"); // Logger.LogError(ex, $"{nameof(User)}.{nameof(RemoveSelf)}()");
// return BadRequest(result.Invalidate(ex.Message)); // return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
// } // }
//} //}
@@ -0,0 +1,78 @@
using System.Text.Json;
using PrivaPub.Federation.Objects;
namespace PrivaPub.Federation.Actors
{
public sealed record ActorKey(string Id, string Pem);
public sealed class ActorDocument
{
static readonly string[] ActorTypes = { "Person", "Service", "Application", "Group", "Organization" };
public string Id { get; init; }
public string Type { get; init; }
public string PreferredUsername { get; init; }
public string Name { get; init; }
public string Summary { get; init; }
public string Url { get; init; }
public string Inbox { get; init; }
public string Outbox { get; init; }
public string SharedInbox { get; init; }
public string Icon { get; init; }
public bool Discoverable { get; init; } = true;
public IReadOnlyList<ActorKey> Keys { get; init; } = Array.Empty<ActorKey>();
public ActorKey Key(string keyId) => Keys.FirstOrDefault(k => k.Id == keyId);
public static ActorDocument Parse(JsonElement root)
{
if (root.ValueKind != JsonValueKind.Object)
return default;
var id = RemoteActorService.Text(root, "id");
var type = RemoteActorService.Text(root, "type");
if (Origin.Of(id) == default || !ActorTypes.Contains(type))
return default;
var inbox = RemoteActorService.Text(root, "inbox");
return new ActorDocument
{
Id = id,
Type = type,
PreferredUsername = RemoteActorService.Text(root, "preferredUsername"),
Name = RemoteActorService.Text(root, "name"),
Summary = RemoteActorService.Text(root, "summary"),
Url = RemoteActorService.Text(root, "url") ?? id,
Inbox = Origin.Same(inbox, id) ? inbox : default,
Outbox = RemoteActorService.Text(root, "outbox"),
SharedInbox = root.TryGetProperty("endpoints", out var endpoints) ? RemoteActorService.Text(endpoints, "sharedInbox") : default,
Icon = root.TryGetProperty("icon", out var icon) ? RemoteActorService.Text(icon, "url") : default,
Discoverable = !root.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False,
Keys = ParseKeys(root, id)
};
}
static List<ActorKey> ParseKeys(JsonElement root, string actorId)
{
var keys = new List<ActorKey>();
if (!root.TryGetProperty("publicKey", out var publicKey))
return keys;
var candidates = publicKey.ValueKind switch
{
JsonValueKind.Object => new[] { publicKey },
JsonValueKind.Array => publicKey.EnumerateArray().Where(k => k.ValueKind == JsonValueKind.Object).ToArray(),
_ => Array.Empty<JsonElement>()
};
foreach (var key in candidates)
{
var keyId = RemoteActorService.Text(key, "id");
var owner = RemoteActorService.Text(key, "owner");
var pem = RemoteActorService.Text(key, "publicKeyPem");
if (string.IsNullOrEmpty(pem) || owner != actorId || !Origin.Same(keyId, actorId))
continue;
keys.Add(new ActorKey(keyId, pem));
}
return keys;
}
}
}
@@ -1,9 +1,11 @@
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using MongoDB.Driver;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Models; using PrivaPub.Models;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.User; using PrivaPub.Models.User;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
@@ -11,7 +13,7 @@ using System.Security.Cryptography;
using GroupEntity = PrivaPub.Models.Group.Group; using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Services.Federation namespace PrivaPub.Federation.Actors
{ {
public class LocalActor public class LocalActor
{ {
@@ -26,6 +28,7 @@ namespace PrivaPub.Services.Federation
public string PublicKeyPem { get; init; } public string PublicKeyPem { get; init; }
public bool Discoverable { get; init; } = true; public bool Discoverable { get; init; } = true;
public bool ManuallyApprovesFollowers { get; init; } public bool ManuallyApprovesFollowers { get; init; }
public bool IsFederated { get; init; } = true;
public DateTime Published { get; init; } public DateTime Published { get; init; }
public string BaseAddress { get; init; } public string BaseAddress { get; init; }
@@ -50,6 +53,7 @@ namespace PrivaPub.Services.Federation
Task<LocalActor> FindByUri(string actorUri, CancellationToken token); Task<LocalActor> FindByUri(string actorUri, CancellationToken token);
Task<LocalActor> GetInstanceActor(CancellationToken token); Task<LocalActor> GetInstanceActor(CancellationToken token);
Task<bool> IsUserNameTaken(string userName, CancellationToken token); Task<bool> IsUserNameTaken(string userName, CancellationToken token);
Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token);
LocalActor FromAvatar(Avatar avatar); LocalActor FromAvatar(Avatar avatar);
LocalActor FromGroup(GroupEntity group); LocalActor FromGroup(GroupEntity group);
} }
@@ -58,8 +62,15 @@ namespace PrivaPub.Services.Federation
{ {
public const string InstanceUserName = "privapub"; public const string InstanceUserName = "privapub";
static readonly HashSet<string> ReservedByInstance = new(StringComparer.Ordinal)
{
InstanceUserName, "admin", "administrator", "root", "system", "support", "help", "moderator", "mod",
"abuse", "postmaster", "webmaster", "hostmaster", "security", "noreply", "no_reply", "null", "undefined"
};
readonly DbEntities _dbEntities; readonly DbEntities _dbEntities;
readonly IOptionsMonitor<AppConfiguration> _appConfiguration; readonly IOptionsMonitor<AppConfiguration> _appConfiguration;
InstanceActor _instanceActor;
public LocalActorService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration) public LocalActorService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration)
{ {
@@ -115,13 +126,7 @@ namespace PrivaPub.Services.Federation
public async Task<LocalActor> GetInstanceActor(CancellationToken token) public async Task<LocalActor> GetInstanceActor(CancellationToken token)
{ {
var instance = await _dbEntities.InstanceActors.ExecuteFirstAsync(token); var instance = _instanceActor ??= await LoadInstanceActor(token);
if (instance == default)
{
var (privateKey, publicKey) = Keys.NewKeyPair();
instance = new InstanceActor { PrivateKey = privateKey, PublicKey = publicKey };
await DB.Default.SaveAsync(instance, token);
}
return new LocalActor return new LocalActor
{ {
@@ -138,14 +143,39 @@ namespace PrivaPub.Services.Federation
}; };
} }
async Task<InstanceActor> LoadInstanceActor(CancellationToken token)
{
var instance = await _dbEntities.InstanceActors.Sort(i => i.CreationDate, Order.Ascending).ExecuteFirstAsync(token);
if (instance != default)
return instance;
var (privateKey, publicKey) = Keys.NewKeyPair();
instance = new InstanceActor { PrivateKey = privateKey, PublicKey = publicKey };
await DB.Default.SaveAsync(instance, token);
return instance;
}
public async Task<bool> IsUserNameTaken(string userName, CancellationToken token) public async Task<bool> IsUserNameTaken(string userName, CancellationToken token)
{ {
userName = userName?.ToLowerInvariant(); userName = userName?.ToLowerInvariant();
if (string.IsNullOrEmpty(userName) || userName == InstanceUserName) if (string.IsNullOrEmpty(userName) || ReservedByInstance.Contains(userName))
return true; return true;
if (await _dbEntities.Avatars.Match(a => a.UserName == userName).ExecuteAnyAsync(token)) return await DB.Default.Find<ReservedName>().Match(r => r.Name == userName).ExecuteAnyAsync(token);
}
public async Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token)
{
userName = userName?.ToLowerInvariant();
if (string.IsNullOrEmpty(userName) || ReservedByInstance.Contains(userName))
return false;
try
{
await DB.Default.SaveAsync(new ReservedName { Name = userName, OwnerKind = kind, OwnerId = ownerId }, token);
return true; return true;
return await _dbEntities.Groups.Match(g => g.UserName == userName).ExecuteAnyAsync(token); }
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return false;
}
} }
public LocalActor FromAvatar(Avatar avatar) => new() public LocalActor FromAvatar(Avatar avatar) => new()
@@ -176,6 +206,7 @@ namespace PrivaPub.Services.Federation
PublicKeyPem = group.PublicKey, PublicKeyPem = group.PublicKey,
Discoverable = group.IsDiscoverable, Discoverable = group.IsDiscoverable,
ManuallyApprovesFollowers = group.ManuallyApprovesMembers, ManuallyApprovesFollowers = group.ManuallyApprovesMembers,
IsFederated = group.Kind == GroupKind.Community,
Published = group.CreationDate, Published = group.CreationDate,
BaseAddress = BaseAddress BaseAddress = BaseAddress
}; };
@@ -0,0 +1,197 @@
using MongoDB.Entities;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Signing;
using PrivaPub.Infrastructure.Http;
namespace PrivaPub.Federation.Actors
{
public interface IRemoteActorService
{
Task<FetchedJson> FetchObject(string uri, CancellationToken token);
Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token);
Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token);
Task<string> ResolveHandle(string handle, CancellationToken token);
}
public class RemoteActorService : IRemoteActorService
{
public const string ActivityJson = "application/activity+json";
const string Accept = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";
static readonly TimeSpan CacheLifetime = TimeSpan.FromDays(1);
static readonly TimeSpan RefetchInterval = TimeSpan.FromMinutes(5);
readonly IFederationHttp _http;
readonly ILocalActorService _localActors;
readonly IMemoryCache _cache;
readonly DbEntities _dbEntities;
public RemoteActorService(IFederationHttp http, ILocalActorService localActors, IMemoryCache cache, DbEntities dbEntities)
{
_http = http;
_localActors = localActors;
_cache = cache;
_dbEntities = dbEntities;
}
public async Task<FetchedJson> FetchObject(string uri, CancellationToken token)
{
var signer = await _localActors.GetInstanceActor(token);
var fetched = await Get(uri, signer, token);
if (fetched == default)
return default;
var id = Text(fetched.Root, "id");
if (Origin.IsDocumentAt(id, fetched.FinalUri))
return fetched;
var finalUri = fetched.FinalUri;
fetched.Dispose();
if (!Origin.Same(id, finalUri.AbsoluteUri))
return default;
var named = await Get(id, signer, token);
if (named != default && Origin.IsDocumentAt(Text(named.Root, "id"), named.FinalUri))
return named;
named?.Dispose();
return default;
}
public async Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token)
{
if (string.IsNullOrEmpty(actorUri))
return default;
actorUri = StripFragment(actorUri);
var cached = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token);
if (cached != default && !refresh && DateTime.UtcNow - cached.UpdatedAt < CacheLifetime)
return cached;
if (!MayFetch(actorUri))
return cached;
using var fetched = await FetchObject(actorUri, token);
var actor = fetched == default ? default : ActorDocument.Parse(fetched.Root);
if (actor == default)
return cached;
var key = cached == default ? default : actor.Key(cached.PublicKeyId);
return await Upsert(actor, key ?? actor.Keys.FirstOrDefault(), token);
}
public async Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token)
{
if (Origin.Of(keyId) == default)
return default;
var cached = await _dbEntities.ForeignAvatars.Match(a => a.PublicKeyId == keyId).ExecuteFirstAsync(token);
if (cached != default && !string.IsNullOrEmpty(cached.PublicKey) && !refresh)
return cached;
if (!MayFetch(keyId))
return cached;
using var fetched = await FetchObject(StripFragment(keyId), token);
if (fetched == default)
return cached;
var actor = ActorDocument.Parse(fetched.Root);
if (actor == default)
{
var owner = Text(fetched.Root, "owner");
if (Text(fetched.Root, "id") != keyId || !Origin.Same(owner, keyId))
return default;
using var ownerDocument = await FetchObject(owner, token);
actor = ownerDocument == default ? default : ActorDocument.Parse(ownerDocument.Root);
}
var key = actor?.Key(keyId);
if (key == default)
return default;
return await Upsert(actor, key, token);
}
public async Task<string> ResolveHandle(string handle, CancellationToken token)
{
var parts = handle?.TrimStart('@').Split('@');
if (parts is not { Length: 2 } || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1]))
return default;
var url = $"https://{parts[1]}/.well-known/webfinger?resource={Uri.EscapeDataString($"acct:{parts[0]}@{parts[1]}")}";
using var fetched = await _http.GetJson(url, "application/jrd+json, application/json", sign: default, token);
if (fetched == default)
return default;
var document = fetched.Document;
if (!document.RootElement.TryGetProperty("links", out var links) || links.ValueKind != JsonValueKind.Array)
return default;
foreach (var link in links.EnumerateArray())
{
var type = Text(link, "type") ?? string.Empty;
if (Text(link, "rel") == "self" && (type.Contains("activity+json") || type.Contains("ld+json")))
return Text(link, "href");
}
return default;
}
async Task<FetchedJson> Get(string uri, LocalActor signer, CancellationToken token) =>
await _http.GetJson(uri, Accept, request => HttpSignatures.Sign(request, signer, body: null), token);
bool MayFetch(string uri)
{
var key = "remote-actor:fetched:" + uri;
if (_cache.TryGetValue(key, out _))
return false;
_cache.Set(key, true, RefetchInterval);
return true;
}
static async Task<ForeignAvatar> Upsert(ActorDocument actor, ActorKey key, CancellationToken token)
{
var now = DateTime.UtcNow;
return await DB.Default.UpdateAndGet<ForeignAvatar>()
.Match(a => a.ActorURI == actor.Id)
.Modify(a => a.UserName, actor.PreferredUsername)
.Modify(a => a.Name, actor.Name)
.Modify(a => a.Biography, ContentSanitizer.Html(actor.Summary))
.Modify(a => a.Url, actor.Url)
.Modify(a => a.Domain, new Uri(actor.Id).Authority)
.Modify(a => a.InboxURL, actor.Inbox)
.Modify(a => a.OutboxURL, actor.Outbox)
.Modify(a => a.SharedInboxURL, actor.SharedInbox)
.Modify(a => a.PictureURL, actor.Icon)
.Modify(a => a.IsDiscoverable, actor.Discoverable)
.Modify(a => a.AvatarType, Enum.TryParse<AvatarType>(actor.Type, out var type) ? type : AvatarType.Person)
.Modify(a => a.PublicKeyId, key?.Id)
.Modify(a => a.PublicKey, key?.Pem)
.Modify(a => a.UpdatedAt, now)
.Modify(b => b.SetOnInsert(a => a.CreatedAt, now))
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
}
public static string StripFragment(string uri)
{
var hash = uri.IndexOf('#');
return hash < 0 ? uri : uri[..hash];
}
public static string Text(JsonElement element, string property)
{
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(property, out var value))
return default;
return value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Object => Text(value, "id") ?? Text(value, "href"),
JsonValueKind.Array => value.EnumerateArray().Select(v => v.ValueKind == JsonValueKind.String ? v.GetString() : Text(v, "id")).FirstOrDefault(v => v != null),
_ => default
};
}
}
}
@@ -1,16 +1,20 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using PrivaPub.Services.Federation;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post; using PostEntity = PrivaPub.Models.Post.Post;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Inbox;
using PrivaPub.Infrastructure;
namespace PrivaPub.Controllers.ServerToServer namespace PrivaPub.Federation.Controllers
{ {
[ApiController, Route("peasants")] [ApiController, Route("peasants")]
public class PeasantsController : ControllerBase public class PeasantsController : ControllerBase
@@ -36,23 +40,23 @@ namespace PrivaPub.Controllers.ServerToServer
public async Task<IActionResult> GetActor(string actor, CancellationToken token) public async Task<IActionResult> GetActor(string actor, CancellationToken token)
{ {
var local = await _localActors.FindByUserName(actor, token); var local = await _localActors.FindByUserName(actor, token);
return local == default ? NotFound() : Activity(ActivityPubRenderer.Actor(local)); return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.Actor(local));
} }
[HttpGet, Route("{actor}/anus")] [HttpGet, Route("{actor}/anus")]
public async Task<IActionResult> Outbox(string actor, CancellationToken token) public async Task<IActionResult> Outbox(string actor, CancellationToken token)
{ {
var local = await _localActors.FindByUserName(actor, token); var local = await _localActors.FindByUserName(actor, token);
if (local == default) if (local is not { IsFederated: true })
return NotFound(); return NotFound();
var total = local.Kind == LocalActorKind.Group var total = local.Kind == LocalActorKind.Group
? await DB.Default.CountAsync<PostEntity>(p => p.GroupId == local.Id, token) ? await DB.Default.CountAsync<PostEntity>(p => p.GroupId == local.Id && !p.IsLocalOnly, token)
: await DB.Default.CountAsync<PostEntity>(p => p.GroupUserId == local.Id && !p.IsFederatedCopy, token); : await DB.Default.CountAsync<PostEntity>(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.IsLocalOnly, token);
var latest = await (local.Kind == LocalActorKind.Group var latest = await (local.Kind == LocalActorKind.Group
? _dbEntities.Posts.Match(p => p.GroupId == local.Id) ? _dbEntities.Posts.Match(p => p.GroupId == local.Id && !p.IsLocalOnly)
: _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy)) : _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.IsLocalOnly))
.Sort(p => p.CreationDate, Order.Descending) .Sort(p => p.CreationDate, Order.Descending)
.Limit(OutboxSize) .Limit(OutboxSize)
.ExecuteAsync(token); .ExecuteAsync(token);
@@ -77,7 +81,7 @@ namespace PrivaPub.Controllers.ServerToServer
public async Task<IActionResult> Followers(string actor, CancellationToken token) public async Task<IActionResult> Followers(string actor, CancellationToken token)
{ {
var local = await _localActors.FindByUserName(actor, token); var local = await _localActors.FindByUserName(actor, token);
if (local == default) if (local is not { IsFederated: true })
return NotFound(); return NotFound();
var count = await DB.Default.CountAsync<Follower>( var count = await DB.Default.CountAsync<Follower>(
f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted, token); f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted, token);
@@ -88,7 +92,7 @@ namespace PrivaPub.Controllers.ServerToServer
public async Task<IActionResult> Following(string actor, CancellationToken token) public async Task<IActionResult> Following(string actor, CancellationToken token)
{ {
var local = await _localActors.FindByUserName(actor, token); var local = await _localActors.FindByUserName(actor, token);
return local == default ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, Enumerable.Empty<JsonNode>())); return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, Enumerable.Empty<JsonNode>()));
} }
[HttpGet, Route("{actor}/posts/{postId}")] [HttpGet, Route("{actor}/posts/{postId}")]
@@ -99,7 +103,7 @@ namespace PrivaPub.Controllers.ServerToServer
return NotFound(); return NotFound();
var post = await _dbEntities.Posts var post = await _dbEntities.Posts
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy) .Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.IsLocalOnly)
.ExecuteFirstAsync(token); .ExecuteFirstAsync(token);
if (post == default) if (post == default)
return NotFound(); return NotFound();
@@ -111,20 +115,20 @@ namespace PrivaPub.Controllers.ServerToServer
return Activity(note); return Activity(note);
} }
[HttpPost, Route("{actor}/mouth")] [HttpPost, Route("{actor}/mouth"), EnableRateLimiting(RateLimiting.Inbox)]
public async Task<IActionResult> Inbox(string actor, CancellationToken token) public async Task<IActionResult> Inbox(string actor, CancellationToken token)
{ {
var local = await _localActors.FindByUserName(actor, token); var local = await _localActors.FindByUserName(actor, token);
if (local == default) if (local is not { IsFederated: true })
return NotFound(); return NotFound();
return Answer(await _inbox.Receive(Request, local, token)); return Answer(await _inbox.Receive(Request, local, token));
} }
[HttpPost, Route("{actor}/human-centipede")] [HttpPost, Route("{actor}/human-centipede"), EnableRateLimiting(RateLimiting.Inbox)]
public async Task<IActionResult> ActorSharedInbox(string actor, CancellationToken token) => public async Task<IActionResult> ActorSharedInbox(string actor, CancellationToken token) =>
Answer(await _inbox.Receive(Request, default, token)); Answer(await _inbox.Receive(Request, default, token));
[HttpPost, Route("/human-centipede")] [HttpPost, Route("/human-centipede"), EnableRateLimiting(RateLimiting.Inbox)]
public async Task<IActionResult> SharedInbox(CancellationToken token) => public async Task<IActionResult> SharedInbox(CancellationToken token) =>
Answer(await _inbox.Receive(Request, default, token)); Answer(await _inbox.Receive(Request, default, token));
@@ -1,6 +1,6 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace PrivaPub.Controllers.ServerToServer namespace PrivaPub.Federation.Controllers
{ {
[ApiController, Route("users")] [ApiController, Route("users")]
public class UsersController : ControllerBase public class UsersController : ControllerBase
@@ -4,14 +4,14 @@ using MongoDB.Entities;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using PrivaPub.Models.User; using PrivaPub.Models.User;
using PrivaPub.Services.Federation;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post; using PostEntity = PrivaPub.Models.Post.Post;
using PrivaPub.Federation.Actors;
namespace PrivaPub.Controllers.ServerToServer namespace PrivaPub.Federation.Controllers
{ {
[ApiController] [ApiController]
public class WellKnownController : ControllerBase public class WellKnownController : ControllerBase
@@ -43,7 +43,7 @@ namespace PrivaPub.Controllers.ServerToServer
else else
actor = await _localActors.FindByUri(resource, token); actor = await _localActors.FindByUri(resource, token);
if (actor == default) if (actor is not { IsFederated: true })
return NotFound(); return NotFound();
var document = new JsonObject var document = new JsonObject
@@ -2,6 +2,7 @@ using MongoDB.Entities;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using PrivaPub.Models.Group; using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.User; using PrivaPub.Models.User;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
@@ -11,8 +12,13 @@ using System.Text.Json.Nodes;
using DmPostEntity = PrivaPub.Models.Post.DmPost; using DmPostEntity = PrivaPub.Models.Post.DmPost;
using GroupEntity = PrivaPub.Models.Group.Group; using GroupEntity = PrivaPub.Models.Group.Group;
using PostEntity = PrivaPub.Models.Post.Post; using PostEntity = PrivaPub.Models.Post.Post;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Signing;
using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
namespace PrivaPub.Services.Federation namespace PrivaPub.Federation.Inbox
{ {
public sealed record InboxResult(int StatusCode, string Error = default); public sealed record InboxResult(int StatusCode, string Error = default);
@@ -77,14 +83,13 @@ namespace PrivaPub.Services.Federation
if (requestProblem != default) if (requestProblem != default)
return new(StatusCodes.Status401Unauthorized, requestProblem); return new(StatusCodes.Status401Unauthorized, requestProblem);
var signAs = recipient ?? await _localActors.GetInstanceActor(token);
var signingString = HttpSignatures.SigningString(request, parameters); var signingString = HttpSignatures.SigningString(request, parameters);
var keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, signAs, refresh: false, token); var keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: false, token);
if (keyOwner == default && type == "Delete" && Id(activity["object"]) == actorUri) if (keyOwner == default && type == "Delete" && Id(activity["object"]) == actorUri)
return new(StatusCodes.Status202Accepted); return new(StatusCodes.Status202Accepted);
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature)) if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
{ {
keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, signAs, refresh: true, token); keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token);
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature)) if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
return new(StatusCodes.Status401Unauthorized, "the signature does not verify"); return new(StatusCodes.Status401Unauthorized, "the signature does not verify");
} }
@@ -92,15 +97,19 @@ namespace PrivaPub.Services.Federation
if (!string.Equals(keyOwner.ActorURI, actorUri, StringComparison.Ordinal)) if (!string.Equals(keyOwner.ActorURI, actorUri, StringComparison.Ordinal))
return new(StatusCodes.Status401Unauthorized, "the activity's actor is not the key's owner"); return new(StatusCodes.Status401Unauthorized, "the activity's actor is not the key's owner");
var activityId = Id(activity);
if (activityId != default && !Origin.Same(activityId, actorUri))
return new(StatusCodes.Status400BadRequest, "the activity's id is not on its actor's origin");
_logger.LogInformation("Inbox {Recipient}: {Type} from {Actor}", recipient?.Handle ?? "shared", type, actorUri); _logger.LogInformation("Inbox {Recipient}: {Type} from {Actor}", recipient?.Handle ?? "shared", type, actorUri);
return type switch return type switch
{ {
"Follow" => await Follow(activity, keyOwner, token), "Follow" => await Follow(activity, keyOwner, token),
"Undo" => await Undo(activity, keyOwner, token), "Undo" => await Undo(activity, keyOwner, token),
"Create" => await Create(activity, keyOwner, signAs, token), "Create" => await Create(activity, keyOwner, token),
"Delete" => await Delete(activity, keyOwner, token), "Delete" => await Delete(activity, keyOwner, token),
"Update" => await Update(activity, keyOwner, signAs, token), "Update" => await Update(activity, keyOwner, token),
_ => new(StatusCodes.Status202Accepted) _ => new(StatusCodes.Status202Accepted)
}; };
} }
@@ -108,7 +117,7 @@ namespace PrivaPub.Services.Federation
async Task<InboxResult> Follow(JsonNode follow, ForeignAvatar follower, CancellationToken token) async Task<InboxResult> Follow(JsonNode follow, ForeignAvatar follower, CancellationToken token)
{ {
var target = await _localActors.FindByUri(Id(follow["object"]), token); var target = await _localActors.FindByUri(Id(follow["object"]), token);
if (target == default || target.Kind == LocalActorKind.Application) if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application)
return new(StatusCodes.Status404NotFound, "no such local actor"); return new(StatusCodes.Status404NotFound, "no such local actor");
var existing = await _dbEntities.Followers var existing = await _dbEntities.Followers
@@ -143,6 +152,8 @@ namespace PrivaPub.Services.Federation
var innerType = inner is JsonObject ? Value(inner, "type") : default; var innerType = inner is JsonObject ? Value(inner, "type") : default;
var innerId = Id(inner); var innerId = Id(inner);
if (inner is JsonObject && Id(inner["actor"]) != actor.ActorURI)
return new(StatusCodes.Status400BadRequest, "an actor can only undo its own activities");
if (innerType is not (null or "Follow")) if (innerType is not (null or "Follow"))
return new(StatusCodes.Status202Accepted); return new(StatusCodes.Status202Accepted);
@@ -163,13 +174,13 @@ namespace PrivaPub.Services.Federation
return new(StatusCodes.Status202Accepted); return new(StatusCodes.Status202Accepted);
} }
async Task<InboxResult> Create(JsonNode create, ForeignAvatar author, LocalActor signAs, CancellationToken token) async Task<InboxResult> Create(JsonNode create, ForeignAvatar author, CancellationToken token)
{ {
var note = create["object"]; var note = create["object"];
if (note is JsonValue) if (note is not JsonObject || !Origin.Same(Id(note), author.ActorURI))
{ {
using var fetched = await _remoteActors.Fetch(Id(note), signAs, token); using var fetched = await _remoteActors.FetchObject(Id(note), token);
note = fetched == default ? default : JsonNode.Parse(fetched.RootElement.GetRawText()); note = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText());
} }
if (note is not JsonObject || Value(note, "type") is not ("Note" or "Article" or "Page" or "Question")) if (note is not JsonObject || Value(note, "type") is not ("Note" or "Article" or "Page" or "Question"))
return new(StatusCodes.Status202Accepted); return new(StatusCodes.Status202Accepted);
@@ -186,7 +197,7 @@ namespace PrivaPub.Services.Federation
foreach (var uri in addressed.Concat(new[] { Id(note["audience"]) }).Where(u => u != default).Distinct()) foreach (var uri in addressed.Concat(new[] { Id(note["audience"]) }).Where(u => u != default).Distinct())
{ {
var local = await _localActors.FindByUri(uri, token); var local = await _localActors.FindByUri(uri, token);
if (local != default && localTargets.All(l => l.Id != local.Id)) if (local is { IsFederated: true } && localTargets.All(l => l.Id != local.Id))
localTargets.Add(local); localTargets.Add(local);
} }
@@ -201,13 +212,16 @@ namespace PrivaPub.Services.Federation
if (await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token)) if (await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token))
return new(StatusCodes.Status202Accepted); return new(StatusCodes.Status202Accepted);
var html = ContentSanitizer.Html(Value(note, "content"));
var post = new PostEntity var post = new PostEntity
{ {
ObjectURI = objectUri, ObjectURI = objectUri,
ActorURI = author.ActorURI, ActorURI = author.ActorURI,
GroupId = group?.Id, GroupId = group?.Id,
Title = Value(note, "summary") ?? Value(note, "name"), Title = Value(note, "summary") ?? Value(note, "name"),
Text = Value(note, "content"), Text = html,
ContentHtml = html,
ContentFormat = ContentFormat.Html,
HasContentWarning = note["sensitive"] is JsonValue sensitive && sensitive.TryGetValue<bool>(out var s) && s, HasContentWarning = note["sensitive"] is JsonValue sensitive && sensitive.TryGetValue<bool>(out var s) && s,
AnsweringToPostId = await LocalPostId(inReplyTo, token) ?? inReplyTo, AnsweringToPostId = await LocalPostId(inReplyTo, token) ?? inReplyTo,
IsFederatedCopy = true, IsFederatedCopy = true,
@@ -229,15 +243,22 @@ namespace PrivaPub.Services.Federation
if (await _dbEntities.DmPosts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token)) if (await _dbEntities.DmPosts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token))
return new(StatusCodes.Status202Accepted); return new(StatusCodes.Status202Accepted);
var participants = addressed.Where(a => a != ActivityPubRenderer.Public).Append(author.ActorURI).ToList(); var participants = recipients.Select(r => r.Uri)
var dmGroup = await FindOrCreateDmGroup(participants, Value(note, "context") ?? Value(note, "conversation"), token); .Concat(addressed.Where(a => a != ActivityPubRenderer.Public && !a.EndsWith("/followers") && !a.EndsWith("/following")))
.Append(author.ActorURI)
.ToList();
var context = Value(note, "context") ?? Value(note, "conversation");
var dmGroup = await FindOrCreateDmGroup(participants, Origin.Same(context, author.ActorURI) ? context : default, token);
var dmHtml = ContentSanitizer.Html(Value(note, "content"));
var dm = new DmPostEntity var dm = new DmPostEntity
{ {
ObjectURI = objectUri, ObjectURI = objectUri,
ActorURI = author.ActorURI, ActorURI = author.ActorURI,
GroupId = dmGroup.ID, GroupId = dmGroup.ID,
Title = Value(note, "summary"), Title = Value(note, "summary"),
Text = Value(note, "content"), Text = dmHtml,
ContentHtml = dmHtml,
ContentFormat = ContentFormat.Html,
HasContentWarning = note["sensitive"] is JsonValue dmSensitive && dmSensitive.TryGetValue<bool>(out var ds) && ds, HasContentWarning = note["sensitive"] is JsonValue dmSensitive && dmSensitive.TryGetValue<bool>(out var ds) && ds,
AnsweringToPostId = inReplyTo, AnsweringToPostId = inReplyTo,
IsFederatedCopy = true, IsFederatedCopy = true,
@@ -251,7 +272,7 @@ namespace PrivaPub.Services.Federation
async Task<InboxResult> Delete(JsonNode delete, ForeignAvatar actor, CancellationToken token) async Task<InboxResult> Delete(JsonNode delete, ForeignAvatar actor, CancellationToken token)
{ {
var objectUri = Id(delete["object"]); var objectUri = Id(delete["object"]);
if (string.IsNullOrEmpty(objectUri)) if (!Origin.Same(objectUri, actor.ActorURI))
return new(StatusCodes.Status202Accepted); return new(StatusCodes.Status202Accepted);
if (objectUri == actor.ActorURI) if (objectUri == actor.ActorURI)
@@ -274,27 +295,29 @@ namespace PrivaPub.Services.Federation
return new(StatusCodes.Status202Accepted); return new(StatusCodes.Status202Accepted);
} }
async Task<InboxResult> Update(JsonNode update, ForeignAvatar actor, LocalActor signAs, CancellationToken token) async Task<InboxResult> Update(JsonNode update, ForeignAvatar actor, CancellationToken token)
{ {
var inner = update["object"]; var inner = update["object"];
if (Id(inner) == actor.ActorURI) if (Id(inner) == actor.ActorURI)
{ {
await _remoteActors.GetActor(actor.ActorURI, signAs, refresh: true, token); await _remoteActors.GetActor(actor.ActorURI, refresh: true, token);
return new(StatusCodes.Status202Accepted); return new(StatusCodes.Status202Accepted);
} }
if (inner is not JsonObject || Id(inner["attributedTo"]) != actor.ActorURI) if (inner is not JsonObject || Id(inner["attributedTo"]) != actor.ActorURI || !Origin.Same(Id(inner), actor.ActorURI))
return new(StatusCodes.Status202Accepted); return new(StatusCodes.Status202Accepted);
var objectUri = Id(inner); var objectUri = Id(inner);
var text = Value(inner, "content"); var text = ContentSanitizer.Html(Value(inner, "content"));
await DB.Default.Update<PostEntity>() await DB.Default.Update<PostEntity>()
.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI) .Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI)
.Modify(p => p.Text, text) .Modify(p => p.Text, text)
.Modify(p => p.ContentHtml, text)
.Modify(p => p.UpdateDate, DateTime.UtcNow) .Modify(p => p.UpdateDate, DateTime.UtcNow)
.ExecuteAsync(token); .ExecuteAsync(token);
await DB.Default.Update<DmPostEntity>() await DB.Default.Update<DmPostEntity>()
.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI) .Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI)
.Modify(p => p.Text, text) .Modify(p => p.Text, text)
.Modify(p => p.ContentHtml, text)
.Modify(p => p.UpdateDate, DateTime.UtcNow) .Modify(p => p.UpdateDate, DateTime.UtcNow)
.ExecuteAsync(token); .ExecuteAsync(token);
return new(StatusCodes.Status202Accepted); return new(StatusCodes.Status202Accepted);
@@ -330,27 +353,19 @@ namespace PrivaPub.Services.Federation
foreach (var uri in participantUris.Distinct(StringComparer.Ordinal)) foreach (var uri in participantUris.Distinct(StringComparer.Ordinal))
{ {
var local = await _localActors.FindByUri(uri, token); var local = await _localActors.FindByUri(uri, token);
members.Add(local != default && local.Kind == LocalActorKind.Person var member = local != default && local.Kind == LocalActorKind.Person
? new GroupMember { AvatarId = local.Id } ? new GroupMember { AvatarId = local.Id }
: new GroupMember { AvatarId = uri, IsForeign = true }); : new GroupMember { AvatarId = uri, IsForeign = true };
if (members.All(m => m.AvatarId != member.AvatarId || m.IsForeign != member.IsForeign))
members.Add(member);
} }
if (!string.IsNullOrEmpty(context)) var key = DmGroup.KeyOf(members);
{ var match = await _dbEntities.DmGroups.Match(g => g.ParticipantsKey == key && !g.DeletionAt.HasValue).ExecuteFirstAsync(token);
var byContext = await _dbEntities.DmGroups.Match(g => g.ConversationURI == context).ExecuteFirstAsync(token);
if (byContext != default)
return byContext;
}
var keys = members.Select(m => m.AvatarId).OrderBy(k => k, StringComparer.Ordinal).ToList();
var candidates = await _dbEntities.DmGroups
.Match(g => !g.DeletionAt.HasValue && g.Members.Count == keys.Count)
.ExecuteAsync(token);
var match = candidates.FirstOrDefault(g => g.Members.Select(m => m.AvatarId).OrderBy(k => k, StringComparer.Ordinal).SequenceEqual(keys));
if (match != default) if (match != default)
return match; return match;
var dmGroup = new DmGroup { Members = members, ConversationURI = context }; var dmGroup = new DmGroup { Members = members, ConversationURI = context, ParticipantsKey = key };
await DB.Default.SaveAsync(dmGroup, token); await DB.Default.SaveAsync(dmGroup, token);
return dmGroup; return dmGroup;
} }
@@ -0,0 +1,80 @@
using AngleSharp.Css.Dom;
using AngleSharp.Dom;
using Ganss.Xss;
using System.Text.RegularExpressions;
namespace PrivaPub.Federation.Objects
{
public static partial class ContentSanitizer
{
static readonly HtmlSanitizer Sanitizer = Build();
public static string Html(string html) =>
string.IsNullOrWhiteSpace(html) ? string.Empty : Sanitizer.Sanitize(html).Trim();
static HtmlSanitizer Build()
{
var sanitizer = new HtmlSanitizer(new HtmlSanitizerOptions
{
AllowedTags = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"p", "br", "span", "a", "abbr", "del", "s", "pre", "blockquote", "code", "b", "strong", "u", "i", "em",
"sub", "sup", "ul", "ol", "li", "ruby", "rt", "rp", "h1", "h2", "h3", "h4", "h5", "h6"
},
AllowedAttributes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"href", "rel", "class", "translate", "start", "reversed", "value", "title"
},
AllowedCssProperties = new HashSet<string>(),
AllowedAtRules = new HashSet<CssRuleType>(),
AllowedSchemes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"http", "https", "dat", "dweb", "ipfs", "ipns", "ssb", "gopher", "xmpp", "magnet", "gemini"
},
UriAttributes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "href" }
})
{
KeepChildNodes = true
};
foreach (var allowed in new[] { "mention", "hashtag", "ellipsis", "invisible" })
sanitizer.AllowedClasses.Add(allowed);
sanitizer.RemovingCssClass += (_, e) => e.Cancel = MicroformatClass().IsMatch(e.CssClass);
sanitizer.RemovingTag += (_, e) =>
{
if (e.Tag.LocalName is "script" or "style" or "template" or "iframe" or "object" or "embed" or "noscript" or "svg" or "math")
e.Tag.InnerHtml = string.Empty;
};
sanitizer.PostProcessNode += (_, e) =>
{
if (e.Node is not IElement element)
return;
switch (element.LocalName)
{
case "a":
if (!SchemePrefix().IsMatch(element.GetAttribute("href") ?? string.Empty))
element.RemoveAttribute("href");
element.SetAttribute("rel", "nofollow noopener noreferrer");
element.SetAttribute("target", "_blank");
break;
case "h1" or "h2" or "h3" or "h4" or "h5" or "h6":
var paragraph = e.Document.CreateElement("p");
var strong = e.Document.CreateElement("strong");
while (element.FirstChild != default)
strong.AppendChild(element.FirstChild);
paragraph.AppendChild(strong);
e.ReplacementNodes.Add(paragraph);
break;
}
};
return sanitizer;
}
[GeneratedRegex("^[a-z][a-z0-9+.-]*:", RegexOptions.IgnoreCase)]
private static partial Regex SchemePrefix();
[GeneratedRegex("^(h|p|u|dt|e)-[a-z0-9-]+$")]
private static partial Regex MicroformatClass();
}
}
+24
View File
@@ -0,0 +1,24 @@
namespace PrivaPub.Federation.Objects
{
public static class Origin
{
public static string Of(string uri) =>
Uri.TryCreate(uri, UriKind.Absolute, out var parsed) ? Of(parsed) : default;
public static string Of(Uri uri) =>
uri is { IsAbsoluteUri: true } && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp)
? $"{uri.Scheme}://{uri.IdnHost.ToLowerInvariant()}:{uri.Port}"
: default;
public static bool Same(string first, string second)
{
var origin = Of(first);
return origin != default && origin == Of(second);
}
public static bool IsDocumentAt(string id, Uri location) =>
Uri.TryCreate(id, UriKind.Absolute, out var parsed)
&& location is { IsAbsoluteUri: true }
&& Uri.Compare(parsed, location, UriComponents.HttpRequestUrl, UriFormat.UriEscaped, StringComparison.Ordinal) == 0;
}
}
@@ -7,8 +7,11 @@ using System.Net;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Text; using System.Text;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Signing;
using PrivaPub.Infrastructure.Http;
namespace PrivaPub.Services.Federation namespace PrivaPub.Federation.Outbox
{ {
public interface IDeliveryService public interface IDeliveryService
{ {
@@ -67,13 +70,13 @@ namespace PrivaPub.Services.Federation
static readonly TimeSpan Poll = TimeSpan.FromSeconds(3); static readonly TimeSpan Poll = TimeSpan.FromSeconds(3);
readonly IServiceProvider _services; readonly IServiceProvider _services;
readonly IHttpClientFactory _httpClientFactory; readonly IFederationHttp _http;
readonly ILogger<DeliveryWorker> _logger; readonly ILogger<DeliveryWorker> _logger;
public DeliveryWorker(IServiceProvider services, IHttpClientFactory httpClientFactory, ILogger<DeliveryWorker> logger) public DeliveryWorker(IServiceProvider services, IFederationHttp http, ILogger<DeliveryWorker> logger)
{ {
_services = services; _services = services;
_httpClientFactory = httpClientFactory; _http = http;
_logger = logger; _logger = logger;
} }
@@ -129,7 +132,7 @@ namespace PrivaPub.Services.Federation
delivery.Attempts++; delivery.Attempts++;
try try
{ {
if (!Uri.TryCreate(delivery.InboxURL, UriKind.Absolute, out var inbox) || !RemoteActorService.IsFetchable(inbox)) if (!Uri.TryCreate(delivery.InboxURL, UriKind.Absolute, out var inbox) || !_http.IsAllowed(inbox))
{ {
delivery.AbandonedAt = DateTime.UtcNow; delivery.AbandonedAt = DateTime.UtcNow;
delivery.LastError = "not a deliverable inbox"; delivery.LastError = "not a deliverable inbox";
@@ -144,7 +147,7 @@ namespace PrivaPub.Services.Federation
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(RemoteActorService.ActivityJson); request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(RemoteActorService.ActivityJson);
HttpSignatures.Sign(request, signer, body); HttpSignatures.Sign(request, signer, body);
using var response = await _httpClientFactory.CreateClient(RemoteActorService.HttpClientName).SendAsync(request, token); using var response = await _http.Send(request, token);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
delivery.DeliveredAt = DateTime.UtcNow; delivery.DeliveredAt = DateTime.UtcNow;
@@ -160,7 +163,7 @@ namespace PrivaPub.Services.Federation
return; return;
} }
} }
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !token.IsCancellationRequested) catch (Exception ex) when (ex is HttpRequestException or BlockedDestinationException or TaskCanceledException && !token.IsCancellationRequested)
{ {
delivery.LastError = ex.Message; delivery.LastError = ex.Message;
} }
@@ -7,8 +7,9 @@ using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post; using PostEntity = PrivaPub.Models.Post.Post;
using DmPostEntity = PrivaPub.Models.Post.DmPost; using DmPostEntity = PrivaPub.Models.Post.DmPost;
using PrivaPub.Federation.Actors;
namespace PrivaPub.Services.Federation namespace PrivaPub.Federation.Rendering
{ {
public static class ActivityPubRenderer public static class ActivityPubRenderer
{ {
@@ -1,15 +1,19 @@
using Microsoft.AspNetCore.Http.Features;
using System.Globalization; using System.Globalization;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using PrivaPub.Federation.Actors;
namespace PrivaPub.Services.Federation namespace PrivaPub.Federation.Signing
{ {
public sealed record SignatureParameters(string KeyId, string Algorithm, string[] Headers, byte[] Signature, public sealed record SignatureParameters(string KeyId, string Algorithm, string[] Headers, byte[] Signature,
string Created, string Expires); string Created, string Expires);
public static class HttpSignatures public static class HttpSignatures
{ {
static readonly TimeSpan AllowedClockSkew = TimeSpan.FromHours(12); public static readonly TimeSpan MaxAge = TimeSpan.FromHours(1);
public static readonly TimeSpan MaxClockSkew = TimeSpan.FromMinutes(15);
public static string Digest(byte[] body) => "SHA-256=" + Convert.ToBase64String(SHA256.HashData(body)); public static string Digest(byte[] body) => "SHA-256=" + Convert.ToBase64String(SHA256.HashData(body));
@@ -104,7 +108,7 @@ namespace PrivaPub.Services.Federation
switch (header) switch (header)
{ {
case "(request-target)": case "(request-target)":
lines.Add($"(request-target): {request.Method.ToLowerInvariant()} {request.PathBase}{request.Path}{request.QueryString}"); lines.Add($"(request-target): {request.Method.ToLowerInvariant()} {RequestTarget(request)}");
break; break;
case "(created)": case "(created)":
lines.Add($"(created): {parameters.Created}"); lines.Add($"(created): {parameters.Created}");
@@ -125,10 +129,17 @@ namespace PrivaPub.Services.Federation
return string.Join("\n", lines); return string.Join("\n", lines);
} }
public static string CheckRequest(HttpRequest request, SignatureParameters parameters, byte[] body) public static string CheckRequest(HttpRequest request, SignatureParameters parameters, byte[] body) =>
CheckRequest(request, parameters, body, DateTimeOffset.UtcNow);
public static string CheckRequest(HttpRequest request, SignatureParameters parameters, byte[] body, DateTimeOffset now)
{ {
if (parameters.Algorithm is not ("rsa-sha256" or "hs2019")) if (parameters.Algorithm is not ("rsa-sha256" or "hs2019"))
return $"unsupported signature algorithm '{parameters.Algorithm}'"; return $"unsupported signature algorithm '{parameters.Algorithm}'";
if (!parameters.Headers.Contains("(request-target)"))
return "(request-target) is not signed";
if (!parameters.Headers.Contains("host"))
return "host is not signed";
if (body is { Length: > 0 }) if (body is { Length: > 0 })
{ {
@@ -141,24 +152,48 @@ namespace PrivaPub.Services.Federation
return "the digest does not match the body"; return "the digest does not match the body";
} }
if (parameters.Headers.Contains("date")) var signsDate = parameters.Headers.Contains("date");
var signsCreated = parameters.Headers.Contains("(created)");
if (!signsDate && !signsCreated)
return "neither date nor (created) is signed";
if (signsDate)
{ {
if (!DateTimeOffset.TryParse(request.Headers["Date"].ToString(), CultureInfo.InvariantCulture, if (!DateTimeOffset.TryParse(request.Headers["Date"].ToString(), CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal, out var date)) DateTimeStyles.AssumeUniversal, out var date))
return "unreadable Date header"; return "unreadable Date header";
if ((DateTimeOffset.UtcNow - date).Duration() > AllowedClockSkew) if (!IsFresh(date, now))
return "the Date header is outside the allowed window"; return "the Date header is outside the allowed window";
} }
else if (!parameters.Headers.Contains("(created)"))
return "neither date nor (created) is signed";
if (!string.IsNullOrEmpty(parameters.Expires) && long.TryParse(parameters.Expires, out var expires) if (signsCreated)
&& DateTimeOffset.FromUnixTimeSeconds(expires) < DateTimeOffset.UtcNow - AllowedClockSkew) {
return "the signature has expired"; if (!long.TryParse(parameters.Created, NumberStyles.Integer, CultureInfo.InvariantCulture, out var created))
return "unreadable (created)";
if (!IsFresh(DateTimeOffset.FromUnixTimeSeconds(created), now))
return "(created) is outside the allowed window";
}
if (!string.IsNullOrEmpty(parameters.Expires) || parameters.Headers.Contains("(expires)"))
{
if (!long.TryParse(parameters.Expires, NumberStyles.Integer, CultureInfo.InvariantCulture, out var expires))
return "unreadable (expires)";
if (DateTimeOffset.FromUnixTimeSeconds(expires) < now - MaxClockSkew)
return "the signature has expired";
}
return default; return default;
} }
static bool IsFresh(DateTimeOffset signedAt, DateTimeOffset now) =>
signedAt >= now - MaxAge && signedAt <= now + MaxClockSkew;
static string RequestTarget(HttpRequest request)
{
var raw = request.HttpContext.Features.Get<IHttpRequestFeature>()?.RawTarget;
return string.IsNullOrEmpty(raw) || raw[0] != '/' ? $"{request.PathBase}{request.Path}{request.QueryString}" : raw;
}
public static bool Verify(string publicKeyPem, string signingString, byte[] signature) public static bool Verify(string publicKeyPem, string signingString, byte[] signature)
{ {
if (string.IsNullOrEmpty(publicKeyPem) || signingString == null) if (string.IsNullOrEmpty(publicKeyPem) || signingString == null)
@@ -0,0 +1,40 @@
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.Models.User;
namespace PrivaPub.Infrastructure.Cli
{
public static class AdminCommands
{
const string Usage = "usage: PrivaPub admin promote|demote <root username>";
public static async Task<int> Run(string[] args)
{
if (args is not [("promote" or "demote") and var verb, var userName])
{
Console.Error.WriteLine(Usage);
return 2;
}
userName = userName.ToLowerInvariant();
var user = await DB.Default.Find<RootUser>().Match(u => u.UserName == userName).ExecuteFirstAsync();
if (user == default)
{
Console.Error.WriteLine($"no root user '{userName}'");
return 1;
}
user.Policies.RemoveAll(p => p is Policies.IsAdmin or Policies.IsModerator);
if (verb == "promote")
user.Policies.AddRange(new[] { Policies.IsAdmin, Policies.IsModerator });
if (!user.Policies.Contains(Policies.IsUser))
user.Policies.Add(Policies.IsUser);
user.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(user);
Console.WriteLine($"{userName}: {string.Join(", ", user.Policies)}");
return 0;
}
}
}
@@ -0,0 +1,18 @@
using MongoDB.Entities;
namespace PrivaPub.Infrastructure.Data
{
public static class EntityMaps
{
public static void Warm()
{
var collection = typeof(DB).GetMethods()
.Single(m => m.Name == nameof(DB.Collection) && m.IsGenericMethodDefinition && m.GetParameters().Length == 0);
var entities = typeof(EntityMaps).Assembly.GetTypes()
.Where(t => t is { IsClass: true, IsAbstract: false, IsGenericTypeDefinition: false } && typeof(IEntity).IsAssignableFrom(t))
.OrderBy(t => t.FullName, StringComparer.Ordinal);
foreach (var entity in entities)
collection.MakeGenericMethod(entity).Invoke(DB.Default, null);
}
}
}
+72
View File
@@ -0,0 +1,72 @@
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
namespace PrivaPub.Infrastructure.Data
{
public static class Indexes
{
public static async Task Create(CancellationToken token = default)
{
await Unique<Post>(p => p.ObjectURI, Builders<Post>.Filter.Type(p => p.ObjectURI, BsonType.String), token);
await Plain<Post>(token, p => p.GroupUserId, p => p.ID);
await Plain<Post>(token, p => p.GroupId, p => p.ID);
await Plain<Post>(token, p => p.ActorURI);
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);
await Unique<ForeignAvatar>(a => a.ActorURI, Builders<ForeignAvatar>.Filter.Type(a => a.ActorURI, BsonType.String), token);
await Plain<ForeignAvatar>(token, a => a.PublicKeyId);
await DB.Default.Index<Follower>()
.Key(f => f.LocalActorId, KeyType.Ascending)
.Key(f => f.LocalActorKind, KeyType.Ascending)
.Key(f => f.ActorURI, KeyType.Ascending)
.Option(o => o.Unique = true)
.CreateAsync(token);
await Plain<Follower>(token, f => f.ActorURI);
await DB.Default.Index<RootToAvatar>()
.Key(r => r.RootId, KeyType.Ascending)
.Key(r => r.AvatarId, KeyType.Ascending)
.Option(o => o.Unique = true)
.CreateAsync(token);
await Plain<RootToAvatar>(token, r => r.AvatarId);
await Unique<ReservedName>(r => r.Name, Builders<ReservedName>.Filter.Type(r => r.Name, BsonType.String), token);
await Unique<RootUser>(u => u.UserName, Builders<RootUser>.Filter.Type(u => u.UserName, BsonType.String), token);
await Plain<Avatar>(token, a => a.UserName);
await Plain<Group>(token, g => g.UserName);
await Plain<Group>(token, g => g.InvitationCode);
await Plain<DmGroup>(token, g => g.ParticipantsKey);
await Plain<Delivery>(token, d => d.DeliveredAt, d => d.AbandonedAt, d => d.NextAttemptAt);
}
static async Task Unique<T>(System.Linq.Expressions.Expression<Func<T, object>> key, FilterDefinition<T> partial,
CancellationToken token) where T : IEntity =>
await DB.Default.Index<T>()
.Key(key, KeyType.Ascending)
.Option(o =>
{
o.Unique = true;
o.PartialFilterExpression = partial;
})
.CreateAsync(token);
static async Task Plain<T>(CancellationToken token, params System.Linq.Expressions.Expression<Func<T, object>>[] keys) where T : IEntity
{
var index = DB.Default.Index<T>();
foreach (var key in keys)
index.Key(key, KeyType.Ascending);
await index.CreateAsync(token);
}
}
}
@@ -0,0 +1,59 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
namespace PrivaPub.Infrastructure.Data.Migrations
{
public class _001_deduplicate_before_unique_indexes : IMigration
{
public async Task UpgradeAsync()
{
var foreignAvatars = await DB.Default.Find<ForeignAvatar>().Match(a => a.ActorURI != null).ExecuteAsync();
await DeleteAllBut(foreignAvatars.GroupBy(a => a.ActorURI).Select(g => g.OrderByDescending(a => a.UpdatedAt)), DeleteForeignAvatar);
var posts = await DB.Default.Find<Post>().Match(p => p.ObjectURI != null).ExecuteAsync();
await DeleteAllBut(posts.GroupBy(p => p.ObjectURI).Select(g => g.OrderBy(p => p.ID, StringComparer.Ordinal)), DeletePost);
var dmPosts = await DB.Default.Find<DmPost>().Match(p => p.ObjectURI != null).ExecuteAsync();
await DeleteAllBut(dmPosts.GroupBy(p => p.ObjectURI).Select(g => g.OrderBy(p => p.ID, StringComparer.Ordinal)), DeleteDmPost);
var followers = await DB.Default.Find<Follower>().ExecuteAsync();
await DeleteAllBut(followers.GroupBy(f => (f.LocalActorId, f.LocalActorKind, f.ActorURI))
.Select(g => g.OrderByDescending(f => f.IsAccepted).ThenBy(f => f.ID, StringComparer.Ordinal)), DeleteFollower);
var links = await DB.Default.Find<RootToAvatar>().ExecuteAsync();
await DeleteAllBut(links.GroupBy(r => (r.RootId, r.AvatarId)).Select(g => g.OrderBy(r => r.ID, StringComparer.Ordinal)), DeleteLink);
var taken = new HashSet<string>(StringComparer.Ordinal) { LocalActorService.InstanceUserName };
var reservations = new List<ReservedName>
{
new() { Name = LocalActorService.InstanceUserName, OwnerKind = LocalActorKind.Application }
};
foreach (var avatar in (await DB.Default.Find<Avatar>().ExecuteAsync()).OrderBy(a => a.ID, StringComparer.Ordinal))
if (!string.IsNullOrEmpty(avatar.UserName) && taken.Add(avatar.UserName.ToLowerInvariant()))
reservations.Add(new() { Name = avatar.UserName.ToLowerInvariant(), OwnerKind = LocalActorKind.Person, OwnerId = avatar.ID });
foreach (var group in (await DB.Default.Find<Group>().ExecuteAsync()).OrderBy(g => g.ID, StringComparer.Ordinal))
if (!string.IsNullOrEmpty(group.UserName) && taken.Add(group.UserName.ToLowerInvariant()))
reservations.Add(new() { Name = group.UserName.ToLowerInvariant(), OwnerKind = LocalActorKind.Group, OwnerId = group.ID });
await DB.Default.DeleteAsync<ReservedName>(_ => true);
await DB.Default.SaveAsync(reservations);
}
static async Task DeleteAllBut<T>(IEnumerable<IOrderedEnumerable<T>> groups, Func<T, Task> delete)
{
foreach (var group in groups)
foreach (var duplicate in group.Skip(1))
await delete(duplicate);
}
static Task DeleteForeignAvatar(ForeignAvatar a) => DB.Default.DeleteAsync<ForeignAvatar>(a.ID);
static Task DeletePost(Post p) => DB.Default.DeleteAsync<Post>(p.ID);
static Task DeleteDmPost(DmPost p) => DB.Default.DeleteAsync<DmPost>(p.ID);
static Task DeleteFollower(Follower f) => DB.Default.DeleteAsync<Follower>(f.ID);
static Task DeleteLink(RootToAvatar r) => DB.Default.DeleteAsync<RootToAvatar>(r.ID);
}
}
@@ -0,0 +1,40 @@
using MongoDB.Entities;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
namespace PrivaPub.Infrastructure.Data.Migrations
{
public class _002_sanitize_stored_content : IMigration
{
public async Task UpgradeAsync()
{
foreach (var post in await DB.Default.Find<Post>().ExecuteAsync())
{
var html = post.IsFederatedCopy ? ContentSanitizer.Html(post.Text) : ActivityPubRenderer.Html(post.Text);
await DB.Default.Update<Post>().MatchID(post.ID)
.Modify(p => p.Text, post.IsFederatedCopy ? html : post.Text)
.Modify(p => p.ContentHtml, html)
.Modify(p => p.ContentFormat, post.IsFederatedCopy ? ContentFormat.Html : ContentFormat.Markdown)
.ExecuteAsync();
}
foreach (var post in await DB.Default.Find<DmPost>().ExecuteAsync())
{
var html = post.IsFederatedCopy ? ContentSanitizer.Html(post.Text) : ActivityPubRenderer.Html(post.Text);
await DB.Default.Update<DmPost>().MatchID(post.ID)
.Modify(p => p.Text, post.IsFederatedCopy ? html : post.Text)
.Modify(p => p.ContentHtml, html)
.Modify(p => p.ContentFormat, post.IsFederatedCopy ? ContentFormat.Html : ContentFormat.Markdown)
.ExecuteAsync();
}
foreach (var avatar in await DB.Default.Find<ForeignAvatar>().ExecuteAsync())
await DB.Default.Update<ForeignAvatar>().MatchID(avatar.ID)
.Modify(a => a.Biography, ContentSanitizer.Html(avatar.Biography))
.ExecuteAsync();
}
}
}
@@ -0,0 +1,28 @@
using MongoDB.Entities;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
namespace PrivaPub.Infrastructure.Data.Migrations
{
public class _003_conversation_keys_and_circles : IMigration
{
public async Task UpgradeAsync()
{
foreach (var dmGroup in await DB.Default.Find<DmGroup>().ExecuteAsync())
await DB.Default.Update<DmGroup>().MatchID(dmGroup.ID)
.Modify(g => g.ParticipantsKey, DmGroup.KeyOf(dmGroup.Members))
.ExecuteAsync();
await DB.Default.Update<Group>()
.Match(_ => true)
.Modify(g => g.Kind, GroupKind.Circle)
.ExecuteAsync();
await DB.Default.Update<Post>()
.Match(p => p.GroupId != null && !p.IsFederatedCopy)
.Modify(p => p.IsLocalOnly, true)
.ExecuteAsync();
}
}
}
@@ -0,0 +1,164 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using System.Net;
using System.Text.Json;
namespace PrivaPub.Infrastructure.Http
{
public sealed class FetchedJson : IDisposable
{
public Uri FinalUri { get; init; }
public JsonDocument Document { get; init; }
public JsonElement Root => Document.RootElement;
public void Dispose() => Document?.Dispose();
}
public interface IFederationHttp
{
bool IsAllowed(Uri target);
Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token);
Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token);
}
public class FederationHttp : IFederationHttp
{
public const string ClientName = "Federation";
public const int MaxResponseBytes = 1024 * 1024;
public static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(15);
const int MaxRedirects = 3;
static readonly TimeSpan NegativeCacheLifetime = TimeSpan.FromMinutes(5);
static readonly string[] JsonMediaTypes =
{
"application/activity+json",
"application/ld+json",
"application/jrd+json",
"application/json"
};
readonly IHttpClientFactory _httpClientFactory;
readonly IMemoryCache _cache;
readonly IOptionsMonitor<FederationOptions> _options;
readonly ILogger<FederationHttp> _logger;
public FederationHttp(IHttpClientFactory httpClientFactory, IMemoryCache cache, IOptionsMonitor<FederationOptions> options,
ILogger<FederationHttp> logger)
{
_httpClientFactory = httpClientFactory;
_cache = cache;
_options = options;
_logger = logger;
}
public bool IsAllowed(Uri target)
{
if (target is not { IsAbsoluteUri: true } || !string.IsNullOrEmpty(target.UserInfo))
return false;
var options = _options.CurrentValue;
if (target.Scheme != Uri.UriSchemeHttps && !(options.AllowPlainHttp && target.Scheme == Uri.UriSchemeHttp))
return false;
if (options.AllowPrivateNetworks)
return true;
return target.HostNameType == UriHostNameType.Dns
&& target.Host.Contains('.')
&& !target.Host.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase)
&& !target.Host.EndsWith(".local", StringComparison.OrdinalIgnoreCase)
&& !target.Host.EndsWith(".internal", StringComparison.OrdinalIgnoreCase);
}
public async Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
return default;
var negativeKey = NegativeKey(target);
if (_cache.TryGetValue(negativeKey, out _))
return default;
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
timeout.CancelAfter(RequestTimeout);
try
{
for (var hop = 0; hop <= MaxRedirects; hop++)
{
using var request = new HttpRequestMessage(HttpMethod.Get, target);
request.Headers.Accept.ParseAdd(accept);
sign?.Invoke(request);
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 Refuse(negativeKey, url, "a redirect to a disallowed location");
target = next;
continue;
}
if (!response.IsSuccessStatusCode)
return Refuse(negativeKey, url, $"status {(int)response.StatusCode}");
var mediaType = response.Content.Headers.ContentType?.MediaType;
if (mediaType == default || !JsonMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase))
return Refuse(negativeKey, url, $"content type '{mediaType}'");
if (response.Content.Headers.ContentLength > MaxResponseBytes)
return Refuse(negativeKey, url, "a body over the size limit");
var body = await ReadBounded(response.Content, MaxResponseBytes, timeout.Token);
if (body == default)
return Refuse(negativeKey, url, "a body over the size limit");
return new FetchedJson { FinalUri = target, Document = JsonDocument.Parse(body) };
}
return Refuse(negativeKey, url, "too many redirects");
}
catch (OperationCanceledException) when (!token.IsCancellationRequested)
{
return Refuse(negativeKey, url, "a timeout");
}
catch (Exception ex) when (ex is HttpRequestException or JsonException or BlockedDestinationException)
{
return Refuse(negativeKey, url, ex.Message);
}
}
public async Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token)
{
if (!IsAllowed(request.RequestUri))
throw new BlockedDestinationException(request.RequestUri?.Host);
return await _httpClientFactory.CreateClient(ClientName).SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token);
}
public static async Task<byte[]> ReadBounded(HttpContent content, int limit, CancellationToken token)
{
await using var stream = await content.ReadAsStreamAsync(token);
using var buffer = new MemoryStream();
var chunk = new byte[16 * 1024];
int read;
while ((read = await stream.ReadAsync(chunk, token)) > 0)
{
if (buffer.Length + read > limit)
return default;
buffer.Write(chunk, 0, read);
}
return buffer.ToArray();
}
static bool IsRedirect(HttpStatusCode status) =>
status is HttpStatusCode.MovedPermanently or HttpStatusCode.Found or HttpStatusCode.SeeOther
or HttpStatusCode.TemporaryRedirect or HttpStatusCode.PermanentRedirect;
static string NegativeKey(Uri target) => "federation-http:refused:" + target.AbsoluteUri;
FetchedJson Refuse(string negativeKey, string url, string reason)
{
_cache.Set(negativeKey, true, NegativeCacheLifetime);
_logger.LogInformation("GET {Url} refused: {Reason}", url, reason);
return default;
}
}
}
@@ -0,0 +1,8 @@
namespace PrivaPub.Infrastructure.Http
{
public class FederationOptions
{
public bool AllowPrivateNetworks { get; set; }
public bool AllowPlainHttp { get; set; }
}
}
@@ -0,0 +1,55 @@
using System.Net;
using System.Net.Sockets;
namespace PrivaPub.Infrastructure.Http
{
public static class IpRangeGuard
{
static readonly IPNetwork[] BlockedV4 =
{
IPNetwork.Parse("0.0.0.0/8"),
IPNetwork.Parse("10.0.0.0/8"),
IPNetwork.Parse("100.64.0.0/10"),
IPNetwork.Parse("127.0.0.0/8"),
IPNetwork.Parse("169.254.0.0/16"),
IPNetwork.Parse("172.16.0.0/12"),
IPNetwork.Parse("192.0.0.0/24"),
IPNetwork.Parse("192.0.2.0/24"),
IPNetwork.Parse("192.88.99.0/24"),
IPNetwork.Parse("192.168.0.0/16"),
IPNetwork.Parse("198.18.0.0/15"),
IPNetwork.Parse("198.51.100.0/24"),
IPNetwork.Parse("203.0.113.0/24"),
IPNetwork.Parse("224.0.0.0/4"),
IPNetwork.Parse("240.0.0.0/4")
};
static readonly IPNetwork GlobalUnicastV6 = IPNetwork.Parse("2000::/3");
static readonly IPNetwork[] BlockedV6 =
{
IPNetwork.Parse("2001::/32"),//Teredo
IPNetwork.Parse("2001:2::/48"),
IPNetwork.Parse("2001:10::/28"),
IPNetwork.Parse("2001:20::/28"),
IPNetwork.Parse("2001:db8::/32"),
IPNetwork.Parse("2002::/16"),//6to4
IPNetwork.Parse("3fff::/20")
};
public static bool IsPublic(IPAddress address)
{
if (address == default)
return false;
if (address.IsIPv4MappedToIPv6)
address = address.MapToIPv4();
return address.AddressFamily switch
{
AddressFamily.InterNetwork => !BlockedV4.Any(range => range.Contains(address)),
AddressFamily.InterNetworkV6 => GlobalUnicastV6.Contains(address) && !BlockedV6.Any(range => range.Contains(address)),
_ => false
};
}
}
}
@@ -0,0 +1,58 @@
using System.Net;
using System.Net.Sockets;
namespace PrivaPub.Infrastructure.Http
{
public sealed class BlockedDestinationException : Exception
{
public BlockedDestinationException(string host) : base($"'{host}' does not resolve to a public address") { }
}
public static class SafeHttpHandlerFactory
{
public static SocketsHttpHandler Create(FederationOptions options) => new()
{
AllowAutoRedirect = false,
UseProxy = false,
UseCookies = false,
AutomaticDecompression = DecompressionMethods.All,
ConnectTimeout = TimeSpan.FromSeconds(10),
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
MaxResponseHeadersLength = 64,
ConnectCallback = (context, token) => Connect(context.DnsEndPoint, options.AllowPrivateNetworks, token)
};
public static async ValueTask<Stream> Connect(DnsEndPoint endPoint, bool allowPrivateNetworks, CancellationToken token)
{
var addresses = await Resolve(endPoint.Host, token);
if (addresses.Length == 0 || !allowPrivateNetworks && !addresses.All(IpRangeGuard.IsPublic))
throw new BlockedDestinationException(endPoint.Host);
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
try
{
await socket.ConnectAsync(addresses, endPoint.Port, token);
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
throw;
}
}
static async Task<IPAddress[]> Resolve(string host, CancellationToken token)
{
if (IPAddress.TryParse(host.Trim('[', ']'), out var literal))
return new[] { literal };
try
{
return await Dns.GetHostAddressesAsync(host, token);
}
catch (SocketException)
{
return Array.Empty<IPAddress>();
}
}
}
}
+39
View File
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.RateLimiting;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Signing;
using System.Threading.RateLimiting;
namespace PrivaPub.Infrastructure
{
public static class RateLimiting
{
public const string Accounts = "accounts";
public const string Inbox = "inbox";
public static IServiceCollection PrivaPubRateLimiting(this IServiceCollection service) =>
service.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy(Accounts, context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
options.AddPolicy(Inbox, context => RateLimitPartition.GetTokenBucketLimiter(
SenderOrigin(context.Request) ?? "unsigned:" + context.Connection.RemoteIpAddress,
_ => new TokenBucketRateLimiterOptions
{
TokenLimit = 300,
TokensPerPeriod = 50,
ReplenishmentPeriod = TimeSpan.FromSeconds(10),
QueueLimit = 0
}));
});
static string SenderOrigin(HttpRequest request)
{
var signature = request.Headers["Signature"].ToString();
return string.IsNullOrEmpty(signature) ? default : Origin.Of(HttpSignatures.Parse(signature)?.KeyId);
}
}
}
+16 -7
View File
@@ -11,7 +11,11 @@ using System.Text.Json.Serialization;
using Microsoft.OpenApi; using Microsoft.OpenApi;
using PrivaPub.Services.ClientToServer.Private; using PrivaPub.Services.ClientToServer.Private;
using PrivaPub.Services.ClientToServer.Public; using PrivaPub.Services.ClientToServer.Public;
using PrivaPub.Services.Federation; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Inbox;
using PrivaPub.Infrastructure.Http;
using Microsoft.Extensions.Options;
namespace PrivaPub.Middleware namespace PrivaPub.Middleware
{ {
@@ -30,14 +34,19 @@ namespace PrivaPub.Middleware
//.AddHostedService<GroupsCleanerWorker>() //.AddHostedService<GroupsCleanerWorker>()
//.AddHostedService<PoliciesCleanerWorker>(); //.AddHostedService<PoliciesCleanerWorker>();
} }
public static IServiceCollection PrivaPubFederationConfiguration(this IServiceCollection service) public static IServiceCollection PrivaPubFederationConfiguration(this IServiceCollection service, IConfiguration configuration)
{ {
service.AddHttpClient(RemoteActorService.HttpClientName, client => service.Configure<FederationOptions>(configuration.GetSection("Federation"));
{ service.AddHttpClient(FederationHttp.ClientName, (provider, client) =>
client.Timeout = TimeSpan.FromSeconds(20); {
client.DefaultRequestHeaders.UserAgent.ParseAdd($"PrivaPub/{BuildInfo.Ref}"); var baseAddress = provider.GetRequiredService<IOptionsMonitor<AppConfiguration>>().CurrentValue.BackendBaseAddress?.TrimEnd('/');
}); client.Timeout = FederationHttp.RequestTimeout;
client.DefaultRequestHeaders.UserAgent.ParseAdd($"PrivaPub/{BuildInfo.Ref} (+{baseAddress}/)");
})
.ConfigurePrimaryHttpMessageHandler(provider =>
SafeHttpHandlerFactory.Create(provider.GetRequiredService<IOptions<FederationOptions>>().Value));
return service return service
.AddSingleton<IFederationHttp, FederationHttp>()
.AddSingleton<ILocalActorService, LocalActorService>() .AddSingleton<ILocalActorService, LocalActorService>()
.AddSingleton<IRemoteActorService, RemoteActorService>() .AddSingleton<IRemoteActorService, RemoteActorService>()
.AddSingleton<IDeliveryService, DeliveryService>() .AddSingleton<IDeliveryService, DeliveryService>()
+8
View File
@@ -1,5 +1,8 @@
using MongoDB.Entities; using MongoDB.Entities;
using System.Security.Cryptography;
using System.Text;
namespace PrivaPub.Models.Group namespace PrivaPub.Models.Group
{ {
public class DmGroup : Entity public class DmGroup : Entity
@@ -7,9 +10,14 @@ namespace PrivaPub.Models.Group
public string Description { get; set; } public string Description { get; set; }
public List<GroupMember> Members { get; set; } = new(); public List<GroupMember> Members { get; set; } = new();
public string ConversationURI { get; set; }//context of the federated thread public string ConversationURI { get; set; }//context of the federated thread
public string ParticipantsKey { get; set; }
public DateTime CreationDate { get; set; } = DateTime.UtcNow; public DateTime CreationDate { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? DeletionAt { get; set; } public DateTime? DeletionAt { get; set; }
public static string KeyOf(IEnumerable<GroupMember> members) =>
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("\n",
members.Select(m => (m.IsForeign ? "remote:" : "local:") + m.AvatarId).Distinct().Order(StringComparer.Ordinal)))));
} }
} }
+7
View File
@@ -17,6 +17,7 @@ namespace PrivaPub.Models.Group
public string PrivateKey { get; set; } public string PrivateKey { get; set; }
public string PublicKey { get; set; } public string PublicKey { get; set; }
public GroupKind Kind { get; set; }
public bool IsDiscoverable { get; set; } = true; public bool IsDiscoverable { get; set; } = true;
public bool ManuallyApprovesMembers { get; set; } public bool ManuallyApprovesMembers { get; set; }
public string OwnerAvatarId { get; set; } public string OwnerAvatarId { get; set; }
@@ -38,6 +39,12 @@ namespace PrivaPub.Models.Group
public DateTime JoinedAt { get; set; } = DateTime.UtcNow; public DateTime JoinedAt { get; set; } = DateTime.UtcNow;
} }
public enum GroupKind
{
Circle,
Community
}
public enum GroupRole public enum GroupRole
{ {
Member, Member,
+8
View File
@@ -0,0 +1,8 @@
namespace PrivaPub.Models.Post
{
public enum ContentFormat
{
Markdown,
Html
}
}
+2
View File
@@ -9,6 +9,8 @@ namespace PrivaPub.Models.Post
public string GroupId { get; set; } public string GroupId { get; set; }
public string Title { get; set; } public string Title { get; set; }
public string Text { get; set; } public string Text { get; set; }
public string ContentHtml { get; set; }
public ContentFormat ContentFormat { get; set; }
public List<PostMedia> Media { get; set; } = new(); public List<PostMedia> Media { get; set; } = new();
public List<float> Location { get; set; } = new(); public List<float> Location { get; set; } = new();
public bool HasContentWarning { get; set; } = false; public bool HasContentWarning { get; set; } = false;
+3
View File
@@ -9,10 +9,13 @@ namespace PrivaPub.Models.Post
public string GroupId { get; set; } public string GroupId { get; set; }
public string Title { get; set; } public string Title { get; set; }
public string Text { get; set; } public string Text { get; set; }
public string ContentHtml { get; set; }
public ContentFormat ContentFormat { get; set; }
public List<PostMedia> Media { get; set; } = new(); public List<PostMedia> Media { get; set; } = new();
public List<float> Location { get; set; } = new(); public List<float> Location { get; set; } = new();
public float RangeKm { get; set; } = 5.0f; public float RangeKm { get; set; } = 5.0f;
public bool HasContentWarning { get; set; } = false; public bool HasContentWarning { get; set; } = false;
public bool IsLocalOnly { get; set; }
public bool IsFederatedCopy { get; set; } public bool IsFederatedCopy { get; set; }
public string ObjectURI { get; set; }//the Note's id public string ObjectURI { get; set; }//the Note's id
-12
View File
@@ -1,12 +0,0 @@
namespace PrivaPub.Models.Post
{
public class PostBoost
{
public string PostId { get; set; }
public string GroupUserId { get; set; }
public bool IsFederatedCopy { get; set; }
public DateTime CreationDate { get; set; }
}
}
+14
View File
@@ -0,0 +1,14 @@
using MongoDB.Entities;
using PrivaPub.Models.Federation;
namespace PrivaPub.Models.User
{
public class ReservedName : Entity
{
public string Name { get; set; }
public LocalActorKind OwnerKind { get; set; }
public string OwnerId { get; set; }
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
}
}
+1
View File
@@ -7,6 +7,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="HtmlSanitizer" Version="9.2.1039" />
<PackageReference Include="MailKit" Version="4.18.0" /> <PackageReference Include="MailKit" Version="4.18.0" />
<PackageReference Include="Markdig" Version="1.4.0" /> <PackageReference Include="Markdig" Version="1.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
+25 -3
View File
@@ -11,6 +11,10 @@ using Serilog;
using PrivaPub.Data; using PrivaPub.Data;
using PrivaPub.Extensions; using PrivaPub.Extensions;
using PrivaPub.Infrastructure;
using PrivaPub.Infrastructure.Cli;
using PrivaPub.Infrastructure.Data;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Middleware; using PrivaPub.Middleware;
using PrivaPub.Models; using PrivaPub.Models;
using PrivaPub.Services; using PrivaPub.Services;
@@ -47,8 +51,9 @@ try
.PrivaPubOptimizationConfiguration() .PrivaPubOptimizationConfiguration()
.PrivaPubDataBaseConfiguration() .PrivaPubDataBaseConfiguration()
.PrivaPubServicesConfiguration() .PrivaPubServicesConfiguration()
.PrivaPubFederationConfiguration() .PrivaPubFederationConfiguration(builder.Configuration)
.PrivaPubCORSConfiguration() .PrivaPubCORSConfiguration()
.PrivaPubRateLimiting()
.PrivaPubMiddlewareConfiguration(); .PrivaPubMiddlewareConfiguration();
} }
catch (Exception ex) catch (Exception ex)
@@ -57,11 +62,24 @@ try
throw; throw;
} }
var federationOptions = builder.Configuration.GetSection("Federation").Get<FederationOptions>() ?? new();
if (builder.Environment.IsProduction() && (federationOptions.AllowPrivateNetworks || federationOptions.AllowPlainHttp))
throw new InvalidOperationException("Federation:AllowPrivateNetworks and Federation:AllowPlainHttp are for test networks and must stay off in Production.");
try try
{ {
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard)); BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
var mongoSettings = builder.Configuration.GetSection(nameof(MongoSettings)).Get<MongoSettings>(); var mongoSettings = builder.Configuration.GetSection(nameof(MongoSettings)).Get<MongoSettings>();
await DB.InitAsync(mongoSettings.Database, MongoClientSettings.FromConnectionString(mongoSettings.ConnectionString)); await DB.InitAsync(mongoSettings.Database, MongoClientSettings.FromConnectionString(mongoSettings.ConnectionString));
EntityMaps.Warm();
await DB.Default.MigrateAsync<Program>();
await Indexes.Create();
if (args is ["admin", ..])
{
Environment.ExitCode = await AdminCommands.Run(args[1..]);
return;
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -92,8 +110,11 @@ try
}); });
} }
app.UseSwagger(); if (app.Environment.IsDevelopment())
app.UseSwaggerUI(); {
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseCors("DefaultCORS"); app.UseCors("DefaultCORS");
@@ -103,6 +124,7 @@ try
app.UseRequestLocalization(await localizationService.Get()); app.UseRequestLocalization(await localizationService.Get());
app.UseRouting(); app.UseRouting();
app.UseRateLimiter();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
-12
View File
@@ -1,12 +0,0 @@
namespace PrivaPub.Services
{
public class ActivityPubClient : HttpClient
{
//readonly HttpClient client;
//public ActivityPubClient(HttpClient client)
//{
// this.client = client;
//}
}
}
@@ -5,10 +5,11 @@ using MongoDB.Entities;
using PrivaPub.ClientModels; using PrivaPub.ClientModels;
using PrivaPub.ClientModels.User; using PrivaPub.ClientModels.User;
using PrivaPub.ClientModels.User.Avatar; using PrivaPub.ClientModels.User.Avatar;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User; using PrivaPub.Models.User;
using PrivaPub.Resources; using PrivaPub.Resources;
using PrivaPub.Services.Federation;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
using PrivaPub.Federation.Actors;
namespace PrivaPub.Services.ClientToServer.Private namespace PrivaPub.Services.ClientToServer.Private
{ {
@@ -64,6 +65,9 @@ namespace PrivaPub.Services.ClientToServer.Private
PublicKey = publicKey, PublicKey = publicKey,
Domain = _localActors.BaseAddress Domain = _localActors.BaseAddress
}; };
newAvatar.ID = (string)newAvatar.GenerateNewID();
if (!await _localActors.TryReserveUserName(userName, LocalActorKind.Person, newAvatar.ID, default))
return result.Invalidate(_localizer["The username '{0}' is already take.", userName]);
if (form.Settings is { IsDefault: false }) if (form.Settings is { IsDefault: false })
newAvatar.Settings = ToSettings(form.Settings); newAvatar.Settings = ToSettings(form.Settings);
@@ -81,7 +85,7 @@ namespace PrivaPub.Services.ClientToServer.Private
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"{nameof(PrivateAvatarUsersService)}.{nameof(InsertAvatar)}"); _logger.LogError(ex, $"{nameof(PrivateAvatarUsersService)}.{nameof(InsertAvatar)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex); return result.Invalidate(_localizer["Something went wrong."], exception: ex);
} }
} }
@@ -118,7 +122,7 @@ namespace PrivaPub.Services.ClientToServer.Private
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"{nameof(PrivateAvatarUsersService)}.{nameof(UpdateAvatar)}"); _logger.LogError(ex, $"{nameof(PrivateAvatarUsersService)}.{nameof(UpdateAvatar)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex); return result.Invalidate(_localizer["Something went wrong."], exception: ex);
} }
} }
@@ -139,7 +143,7 @@ namespace PrivaPub.Services.ClientToServer.Private
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"{nameof(PrivateAvatarUsersService)}.{nameof(GetRootAvatars)}"); _logger.LogError(ex, $"{nameof(PrivateAvatarUsersService)}.{nameof(GetRootAvatars)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex); return result.Invalidate(_localizer["Something went wrong."], exception: ex);
} }
} }
@@ -1,191 +0,0 @@
using MongoDB.Entities;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Net.Http.Headers;
using System.Text.Json;
namespace PrivaPub.Services.Federation
{
public interface IRemoteActorService
{
Task<JsonDocument> Fetch(string uri, LocalActor signAs, CancellationToken token);
Task<ForeignAvatar> GetActor(string actorUri, LocalActor signAs, bool refresh, CancellationToken token);
Task<ForeignAvatar> GetActorByKeyId(string keyId, LocalActor signAs, bool refresh, CancellationToken token);
Task<string> ResolveHandle(string handle, CancellationToken token);
}
public class RemoteActorService : IRemoteActorService
{
public const string HttpClientName = "ActivityPub";
public const string ActivityJson = "application/activity+json";
const string Accept = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";
static readonly TimeSpan CacheLifetime = TimeSpan.FromDays(1);
readonly IHttpClientFactory _httpClientFactory;
readonly DbEntities _dbEntities;
readonly ILogger<RemoteActorService> _logger;
public RemoteActorService(IHttpClientFactory httpClientFactory, DbEntities dbEntities, ILogger<RemoteActorService> logger)
{
_httpClientFactory = httpClientFactory;
_dbEntities = dbEntities;
_logger = logger;
}
public async Task<JsonDocument> Fetch(string uri, LocalActor signAs, CancellationToken token)
{
if (!Uri.TryCreate(uri, UriKind.Absolute, out var target) || !IsFetchable(target))
return default;
using var request = new HttpRequestMessage(HttpMethod.Get, target);
request.Headers.Accept.ParseAdd(Accept);
if (signAs != default)
HttpSignatures.Sign(request, signAs, body: null);
using var response = await _httpClientFactory.CreateClient(HttpClientName).SendAsync(request, token);
if (!response.IsSuccessStatusCode)
{
_logger.LogInformation("GET {Uri} answered {Status}", uri, (int)response.StatusCode);
return default;
}
await using var stream = await response.Content.ReadAsStreamAsync(token);
return await JsonDocument.ParseAsync(stream, cancellationToken: token);
}
public async Task<ForeignAvatar> GetActor(string actorUri, LocalActor signAs, bool refresh, CancellationToken token)
{
if (string.IsNullOrEmpty(actorUri))
return default;
actorUri = StripFragment(actorUri);
var cached = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token);
if (cached != default && !refresh && DateTime.UtcNow - cached.UpdatedAt < CacheLifetime)
return cached;
using var document = await Fetch(actorUri, signAs, token);
if (document == default)
return cached;
return await Upsert(document.RootElement, cached, token);
}
public async Task<ForeignAvatar> GetActorByKeyId(string keyId, LocalActor signAs, bool refresh, CancellationToken token)
{
if (string.IsNullOrEmpty(keyId))
return default;
if (!refresh)
{
var cached = await _dbEntities.ForeignAvatars.Match(a => a.PublicKeyId == keyId).ExecuteFirstAsync(token);
if (cached != default && !string.IsNullOrEmpty(cached.PublicKey))
return cached;
}
using var document = await Fetch(StripFragment(keyId), signAs, token);
if (document == default)
return default;
var root = document.RootElement;
if (root.TryGetProperty("publicKey", out _))
{
var actorUri = Text(root, "id");
var existing = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token);
return await Upsert(root, existing, token);
}
var owner = Text(root, "owner");
return owner == default ? default : await GetActor(owner, signAs, refresh: true, token);
}
public async Task<string> ResolveHandle(string handle, CancellationToken token)
{
var parts = handle?.TrimStart('@').Split('@');
if (parts is not { Length: 2 } || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1]))
return default;
var url = $"https://{parts[1]}/.well-known/webfinger?resource={Uri.EscapeDataString($"acct:{parts[0]}@{parts[1]}")}";
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsFetchable(target))
return default;
using var request = new HttpRequestMessage(HttpMethod.Get, target);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/jrd+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var response = await _httpClientFactory.CreateClient(HttpClientName).SendAsync(request, token);
if (!response.IsSuccessStatusCode)
return default;
await using var stream = await response.Content.ReadAsStreamAsync(token);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: token);
if (!document.RootElement.TryGetProperty("links", out var links) || links.ValueKind != JsonValueKind.Array)
return default;
foreach (var link in links.EnumerateArray())
{
var type = Text(link, "type") ?? string.Empty;
if (Text(link, "rel") == "self" && (type.Contains("activity+json") || type.Contains("ld+json")))
return Text(link, "href");
}
return default;
}
async Task<ForeignAvatar> Upsert(JsonElement actor, ForeignAvatar existing, CancellationToken token)
{
var actorUri = Text(actor, "id");
if (string.IsNullOrEmpty(actorUri))
return existing;
var avatar = existing ?? new ForeignAvatar { ActorURI = actorUri, CreatedAt = DateTime.UtcNow };
avatar.ActorURI = actorUri;
avatar.UserName = Text(actor, "preferredUsername");
avatar.Name = Text(actor, "name");
avatar.Biography = Text(actor, "summary");
avatar.Url = Text(actor, "url") ?? actorUri;
avatar.Domain = new Uri(actorUri).Authority;
avatar.InboxURL = Text(actor, "inbox");
avatar.OutboxURL = Text(actor, "outbox");
avatar.IsDiscoverable = !actor.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False;
avatar.AvatarType = Enum.TryParse<AvatarType>(Text(actor, "type"), out var type) ? type : AvatarType.Person;
if (actor.TryGetProperty("endpoints", out var endpoints) && endpoints.ValueKind == JsonValueKind.Object)
avatar.SharedInboxURL = Text(endpoints, "sharedInbox");
if (actor.TryGetProperty("publicKey", out var publicKey) && publicKey.ValueKind == JsonValueKind.Object)
{
avatar.PublicKeyId = Text(publicKey, "id");
avatar.PublicKey = Text(publicKey, "publicKeyPem");
}
if (actor.TryGetProperty("icon", out var icon) && icon.ValueKind == JsonValueKind.Object)
avatar.PictureURL = Text(icon, "url");
avatar.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(avatar, token);
return avatar;
}
public static bool IsFetchable(Uri target) =>
target.Scheme == Uri.UriSchemeHttps
&& target.HostNameType == UriHostNameType.Dns
&& !target.IsLoopback
&& !target.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
&& target.Host.Contains('.');
public static string StripFragment(string uri)
{
var hash = uri.IndexOf('#');
return hash < 0 ? uri : uri[..hash];
}
public static string Text(JsonElement element, string property)
{
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(property, out var value))
return default;
return value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Object => Text(value, "id") ?? Text(value, "href"),
JsonValueKind.Array => value.EnumerateArray().Select(v => v.ValueKind == JsonValueKind.String ? v.GetString() : Text(v, "id")).FirstOrDefault(v => v != null),
_ => default
};
}
}
}
+15 -8
View File
@@ -7,10 +7,12 @@ using PrivaPub.ClientModels.Group;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using PrivaPub.Models.Group; using PrivaPub.Models.Group;
using PrivaPub.Resources; using PrivaPub.Resources;
using PrivaPub.Services.Federation;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
using GroupEntity = PrivaPub.Models.Group.Group; using GroupEntity = PrivaPub.Models.Group.Group;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Outbox;
namespace PrivaPub.Services namespace PrivaPub.Services
{ {
@@ -67,7 +69,7 @@ namespace PrivaPub.Services
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(GetGroup)}"); _logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(GetGroup)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex); return result.Invalidate(_localizer["Something went wrong."], exception: ex);
} }
} }
@@ -88,7 +90,7 @@ namespace PrivaPub.Services
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(GetGroups)}"); _logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(GetGroups)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex); return result.Invalidate(_localizer["Something went wrong."], exception: ex);
} }
} }
@@ -113,6 +115,7 @@ namespace PrivaPub.Services
Domain = _localActors.BaseAddress, Domain = _localActors.BaseAddress,
PrivateKey = privateKey, PrivateKey = privateKey,
PublicKey = publicKey, PublicKey = publicKey,
Kind = form.IsCommunity ? GroupKind.Community : GroupKind.Circle,
IsDiscoverable = form.IsDiscoverable, IsDiscoverable = form.IsDiscoverable,
ManuallyApprovesMembers = form.ManuallyApprovesMembers, ManuallyApprovesMembers = form.ManuallyApprovesMembers,
OwnerAvatarId = form.AvatarId, OwnerAvatarId = form.AvatarId,
@@ -120,6 +123,9 @@ namespace PrivaPub.Services
HashedInvitationPassword = string.IsNullOrEmpty(form.InvitationPassword) ? default : _passwordHasher.Hash(form.InvitationPassword), HashedInvitationPassword = string.IsNullOrEmpty(form.InvitationPassword) ? default : _passwordHasher.Hash(form.InvitationPassword),
Members = new() { new GroupMember { AvatarId = form.AvatarId, Role = GroupRole.Owner } } Members = new() { new GroupMember { AvatarId = form.AvatarId, Role = GroupRole.Owner } }
}; };
group.ID = (string)group.GenerateNewID();
if (!await _localActors.TryReserveUserName(userName, LocalActorKind.Group, group.ID, token))
return result.Invalidate(_localizer["The username '{0}' is already take.", userName]);
var actor = _localActors.FromGroup(group); var actor = _localActors.FromGroup(group);
group.Url = actor.Uri; group.Url = actor.Uri;
group.InboxURL = actor.Inbox; group.InboxURL = actor.Inbox;
@@ -132,7 +138,7 @@ namespace PrivaPub.Services
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(InsertGroup)}"); _logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(InsertGroup)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex); return result.Invalidate(_localizer["Something went wrong."], exception: ex);
} }
} }
@@ -168,7 +174,7 @@ namespace PrivaPub.Services
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(UpdateGroup)}"); _logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(UpdateGroup)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex); return result.Invalidate(_localizer["Something went wrong."], exception: ex);
} }
} }
@@ -208,7 +214,7 @@ namespace PrivaPub.Services
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(JoinGroup)}"); _logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(JoinGroup)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex); return result.Invalidate(_localizer["Something went wrong."], exception: ex);
} }
} }
@@ -234,7 +240,7 @@ namespace PrivaPub.Services
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(LeaveGroup)}"); _logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(LeaveGroup)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex); return result.Invalidate(_localizer["Something went wrong."], exception: ex);
} }
} }
@@ -276,7 +282,7 @@ namespace PrivaPub.Services
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(ApproveMember)}"); _logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(ApproveMember)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex); return result.Invalidate(_localizer["Something went wrong."], exception: ex);
} }
} }
@@ -324,6 +330,7 @@ namespace PrivaPub.Services
Description = group.Description, Description = group.Description,
Url = actor.Uri, Url = actor.Uri,
Handle = actor.Handle, Handle = actor.Handle,
IsCommunity = group.Kind == GroupKind.Community,
IsDiscoverable = group.IsDiscoverable, IsDiscoverable = group.IsDiscoverable,
ManuallyApprovesMembers = group.ManuallyApprovesMembers, ManuallyApprovesMembers = group.ManuallyApprovesMembers,
IsOwner = group.OwnerAvatarId == avatarId, IsOwner = group.OwnerAvatarId == avatarId,
+36 -11
View File
@@ -6,14 +6,17 @@ using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Post; using PrivaPub.ClientModels.Post;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using PrivaPub.Models.Group; using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Resources; using PrivaPub.Resources;
using PrivaPub.Services.Federation;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using DmPostEntity = PrivaPub.Models.Post.DmPost; using DmPostEntity = PrivaPub.Models.Post.DmPost;
using PostEntity = PrivaPub.Models.Post.Post; using PostEntity = PrivaPub.Models.Post.Post;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Outbox;
namespace PrivaPub.Services namespace PrivaPub.Services
{ {
@@ -62,6 +65,7 @@ namespace PrivaPub.Services
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
LocalActor group = default; LocalActor group = default;
var isLocalOnly = false;
if (!string.IsNullOrEmpty(form.GroupId)) if (!string.IsNullOrEmpty(form.GroupId))
{ {
var groupEntity = await _dbEntities.Groups.MatchID(form.GroupId).ExecuteFirstAsync(token); var groupEntity = await _dbEntities.Groups.MatchID(form.GroupId).ExecuteFirstAsync(token);
@@ -69,6 +73,7 @@ namespace PrivaPub.Services
|| !groupEntity.Members.Any(m => !m.IsForeign && m.AvatarId == form.AvatarId)) || !groupEntity.Members.Any(m => !m.IsForeign && m.AvatarId == form.AvatarId))
return result.Invalidate(_localizer["Group not found."], StatusCodes.Status404NotFound); return result.Invalidate(_localizer["Group not found."], StatusCodes.Status404NotFound);
group = _localActors.FromGroup(groupEntity); group = _localActors.FromGroup(groupEntity);
isLocalOnly = !group.IsFederated;
} }
var post = new PostEntity var post = new PostEntity
@@ -77,14 +82,23 @@ namespace PrivaPub.Services
GroupId = group?.Id, GroupId = group?.Id,
Title = form.Title, Title = form.Title,
Text = form.Text, Text = form.Text,
ContentHtml = ActivityPubRenderer.Html(form.Text),
ContentFormat = ContentFormat.Markdown,
HasContentWarning = form.HasContentWarning, HasContentWarning = form.HasContentWarning,
AnsweringToPostId = form.AnsweringToPostId, AnsweringToPostId = form.AnsweringToPostId,
IsLocalOnly = isLocalOnly,
ActorURI = author.Uri ActorURI = author.Uri
}; };
post.ID = (string)post.GenerateNewID(); post.ID = (string)post.GenerateNewID();
post.ObjectURI = author.PostUri(post.ID); post.ObjectURI = author.PostUri(post.ID);
await DB.Default.SaveAsync(post, token); await DB.Default.SaveAsync(post, token);
if (isLocalOnly)
{
result.Data = ToView(post);
return result;
}
var inReplyTo = await ReplyTarget(form.AnsweringToPostId, token); var inReplyTo = await ReplyTarget(form.AnsweringToPostId, token);
var note = ActivityPubRenderer.Note(post, author, group, inReplyTo); var note = ActivityPubRenderer.Note(post, author, group, inReplyTo);
var create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}"); var create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}");
@@ -98,7 +112,7 @@ namespace PrivaPub.Services
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(InsertPost)}"); _logger.LogError(ex, $"{nameof(PostsService)}.{nameof(InsertPost)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex); return result.Invalidate(_localizer["Something went wrong."], exception: ex);
} }
} }
@@ -118,6 +132,8 @@ namespace PrivaPub.Services
return result.Invalidate(_localizer["Post not found."], StatusCodes.Status404NotFound); return result.Invalidate(_localizer["Post not found."], StatusCodes.Status404NotFound);
await DB.Default.DeleteAsync<PostEntity>(post.ID); await DB.Default.DeleteAsync<PostEntity>(post.ID);
if (post.IsLocalOnly)
return result;
var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}", var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
new JsonArray(ActivityPubRenderer.Public), new JsonArray(author.Followers)); new JsonArray(ActivityPubRenderer.Public), new JsonArray(author.Followers));
@@ -134,7 +150,7 @@ namespace PrivaPub.Services
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(DeletePost)}"); _logger.LogError(ex, $"{nameof(PostsService)}.{nameof(DeletePost)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex); return result.Invalidate(_localizer["Something went wrong."], exception: ex);
} }
} }
@@ -167,7 +183,7 @@ namespace PrivaPub.Services
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(GetPosts)}"); _logger.LogError(ex, $"{nameof(PostsService)}.{nameof(GetPosts)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex); return result.Invalidate(_localizer["Something went wrong."], exception: ex);
} }
} }
@@ -200,10 +216,15 @@ namespace PrivaPub.Services
if (members.All(m => m.AvatarId != member.AvatarId)) if (members.All(m => m.AvatarId != member.AvatarId))
members.Add(member); members.Add(member);
} }
dmGroup = new DmGroup { Members = members }; var key = DmGroup.KeyOf(members);
dmGroup.ID = (string)dmGroup.GenerateNewID(); dmGroup = await _dbEntities.DmGroups.Match(g => g.ParticipantsKey == key && !g.DeletionAt.HasValue).ExecuteFirstAsync(token);
dmGroup.ConversationURI = $"{author.Uri}/conversations/{dmGroup.ID}"; if (dmGroup == default)
await DB.Default.SaveAsync(dmGroup, token); {
dmGroup = new DmGroup { Members = members, ParticipantsKey = key };
dmGroup.ID = (string)dmGroup.GenerateNewID();
dmGroup.ConversationURI = $"{author.Uri}/conversations/{dmGroup.ID}";
await DB.Default.SaveAsync(dmGroup, token);
}
} }
var dm = new DmPostEntity var dm = new DmPostEntity
@@ -211,6 +232,8 @@ namespace PrivaPub.Services
GroupUserId = author.Id, GroupUserId = author.Id,
GroupId = dmGroup.ID, GroupId = dmGroup.ID,
Text = form.Text, Text = form.Text,
ContentHtml = ActivityPubRenderer.Html(form.Text),
ContentFormat = ContentFormat.Markdown,
HasContentWarning = form.HasContentWarning, HasContentWarning = form.HasContentWarning,
ActorURI = author.Uri ActorURI = author.Uri
}; };
@@ -249,7 +272,7 @@ namespace PrivaPub.Services
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(InsertDm)}"); _logger.LogError(ex, $"{nameof(PostsService)}.{nameof(InsertDm)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex); return result.Invalidate(_localizer["Something went wrong."], exception: ex);
} }
} }
@@ -294,7 +317,7 @@ namespace PrivaPub.Services
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(GetDms)}"); _logger.LogError(ex, $"{nameof(PostsService)}.{nameof(GetDms)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex); return result.Invalidate(_localizer["Something went wrong."], exception: ex);
} }
} }
@@ -310,7 +333,7 @@ namespace PrivaPub.Services
var actorUri = await _remoteActors.ResolveHandle(handle, token); var actorUri = await _remoteActors.ResolveHandle(handle, token);
if (actorUri == default) if (actorUri == default)
return default; return default;
var foreign = await _remoteActors.GetActor(actorUri, await _localActors.GetInstanceActor(token), refresh: false, token); var foreign = await _remoteActors.GetActor(actorUri, refresh: false, token);
return foreign == default ? default : new GroupMember { AvatarId = foreign.ActorURI, IsForeign = true }; return foreign == default ? default : new GroupMember { AvatarId = foreign.ActorURI, IsForeign = true };
} }
@@ -343,6 +366,7 @@ namespace PrivaPub.Services
AnsweringToPostId = post.AnsweringToPostId, AnsweringToPostId = post.AnsweringToPostId,
Title = post.Title, Title = post.Title,
Text = post.Text, Text = post.Text,
ContentHtml = post.ContentHtml,
HasContentWarning = post.HasContentWarning, HasContentWarning = post.HasContentWarning,
IsFederatedCopy = post.IsFederatedCopy, IsFederatedCopy = post.IsFederatedCopy,
CreationDate = post.CreationDate CreationDate = post.CreationDate
@@ -358,6 +382,7 @@ namespace PrivaPub.Services
AnsweringToPostId = post.AnsweringToPostId, AnsweringToPostId = post.AnsweringToPostId,
Title = post.Title, Title = post.Title,
Text = post.Text, Text = post.Text,
ContentHtml = post.ContentHtml,
HasContentWarning = post.HasContentWarning, HasContentWarning = post.HasContentWarning,
IsFederatedCopy = post.IsFederatedCopy, IsFederatedCopy = post.IsFederatedCopy,
CreationDate = post.CreationDate CreationDate = post.CreationDate
+18 -25
View File
@@ -64,13 +64,6 @@ namespace PrivaPub.Services
UserName = signUpForm.UserName, UserName = signUpForm.UserName,
HashedPassword = signUpPasswordHashed HashedPassword = signUpPasswordHashed
}; };
if (signUpForm.UserName == "admin")
{
newUser.Policies.Clear();
newUser.Policies.Add(Policies.IsAdmin);
newUser.Policies.Add(Policies.IsUser);
newUser.Policies.Add(Policies.IsModerator);
}
var cultureLanguage = CultureInfo.CurrentCulture.TwoLetterISOLanguageName; var cultureLanguage = CultureInfo.CurrentCulture.TwoLetterISOLanguageName;
var language = await DbEntities.Languages.Match(l => l.International2Code == cultureLanguage).ExecuteFirstAsync(); var language = await DbEntities.Languages.Match(l => l.International2Code == cultureLanguage).ExecuteFirstAsync();
@@ -111,7 +104,7 @@ namespace PrivaPub.Services
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(SignUpAsync)}()"); Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(SignUpAsync)}()");
return result.Invalidate(ex.Message, exception: ex); return result.Invalidate(Localizer["Something went wrong."], exception: ex);
} }
} }
@@ -165,7 +158,7 @@ namespace PrivaPub.Services
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(LoginAsync)}()"); Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(LoginAsync)}()");
return result.Invalidate(ex.Message, exception: ex); return result.Invalidate(Localizer["Something went wrong."], exception: ex);
} }
} }
@@ -192,7 +185,7 @@ namespace PrivaPub.Services
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UpdateUserAsync)}()"); Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UpdateUserAsync)}()");
return result.Invalidate(ex.Message, exception: ex); return result.Invalidate(Localizer["Something went wrong."], exception: ex);
} }
} }
@@ -230,7 +223,7 @@ namespace PrivaPub.Services
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UpdateUserSettingsAsync)}()"); Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UpdateUserSettingsAsync)}()");
return result.Invalidate(ex.Message, exception: ex); return result.Invalidate(Localizer["Something went wrong."], exception: ex);
} }
} }
@@ -259,7 +252,7 @@ namespace PrivaPub.Services
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UpdateUserPasswordAsync)}()"); Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UpdateUserPasswordAsync)}()");
return result.Invalidate(ex.Message, exception: ex); return result.Invalidate(Localizer["Something went wrong."], exception: ex);
} }
} }
@@ -291,7 +284,7 @@ namespace PrivaPub.Services
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(RemoveUserAsync)}()"); Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(RemoveUserAsync)}()");
return result.Invalidate(ex.Message, exception: ex); return result.Invalidate(Localizer["Something went wrong."], exception: ex);
} }
} }
@@ -310,7 +303,7 @@ namespace PrivaPub.Services
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(BanUserAsync)}()"); Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(BanUserAsync)}()");
return result.Invalidate(ex.Message, exception: ex); return result.Invalidate(Localizer["Something went wrong."], exception: ex);
} }
} }
@@ -329,7 +322,7 @@ namespace PrivaPub.Services
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UnbanUserAsync)}()"); Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UnbanUserAsync)}()");
return result.Invalidate(ex.Message, exception: ex); return result.Invalidate(Localizer["Something went wrong."], exception: ex);
} }
} }
@@ -357,7 +350,7 @@ namespace PrivaPub.Services
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(GetUserSettingsAsync)}()"); Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(GetUserSettingsAsync)}()");
return result.Invalidate(ex.Message, exception: ex); return result.Invalidate(Localizer["Something went wrong."], exception: ex);
} }
} }
@@ -448,30 +441,30 @@ namespace PrivaPub.Services
{ {
var toParsed = await smtpClient.VerifyAsync(user.Email); var toParsed = await smtpClient.VerifyAsync(user.Email);
if (toParsed == null) if (toParsed == null)
return result.Invalidate($"Invalid email of {user.Email}.", (int)SmtpStatusCode.MailboxUnavailable); return result.Invalidate(Localizer["Invalid email."], (int)SmtpStatusCode.MailboxUnavailable);
} }
catch (OperationCanceledException ex) catch (OperationCanceledException ex)
{ {
Logger.LogWarning( Logger.LogWarning(
$"SMTP operation canceled exception at email verification of {user.Email}. Exception=[{ex.Message}]"); "SMTP operation canceled at email verification: {Error}", ex.Message);
} }
catch (SmtpCommandException ex) catch (SmtpCommandException ex)
{ {
Logger.LogWarning( Logger.LogWarning(
$"SMTP command exception at email verification of {user.Email}. Exception=[{ex.Message}]"); "SMTP command exception at email verification: {Error}", ex.Message);
} }
catch (SmtpProtocolException ex) catch (SmtpProtocolException ex)
{ {
Logger.LogWarning( Logger.LogWarning(
$"SMTP protocol exception at email verification of {user.Email}. Exception=[{ex.Message}]"); "SMTP protocol exception at email verification: {Error}", ex.Message);
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogWarning($"General exception at email verification of {user.Email}. Exception=[{ex.Message}]"); Logger.LogWarning("Exception at email verification: {Error}", ex.Message);
} }
var message = new MimeMessage(); var message = new MimeMessage();
message.From.Add(new MailboxAddress(Localizer["Eugene - collAnon support"], AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername)); message.From.Add(new MailboxAddress("PrivaPub", AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername));
message.To.Add(MailboxAddress.Parse(user.Email)); message.To.Add(MailboxAddress.Parse(user.Email));
message.Subject = Localizer["PrivaPub - Password recovery link"]; message.Subject = Localizer["PrivaPub - Password recovery link"];
message.Body = new TextPart("plain") message.Body = new TextPart("plain")
@@ -501,7 +494,7 @@ Eugene from collAnon, following is the password recovery link:
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(SetupAndSendRecoveryEmail)}()"); Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(SetupAndSendRecoveryEmail)}()");
return result.Invalidate(ex.Message, exception: ex); return result.Invalidate(Localizer["Something went wrong."], exception: ex);
} }
} }
@@ -519,7 +512,7 @@ Eugene from collAnon, following is the password recovery link:
} }
catch (Exception ex) catch (Exception ex)
{ {
return result.Invalidate(ex.Message, exception: ex); return result.Invalidate(Localizer["Something went wrong."], exception: ex);
} }
} }
@@ -555,7 +548,7 @@ Eugene from collAnon, following is the password recovery link:
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(ChangePassword)}()"); Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(ChangePassword)}()");
return result.Invalidate(ex.Message, exception: ex); return result.Invalidate(Localizer["Something went wrong."], exception: ex);
} }
} }
+12 -1
View File
@@ -1,2 +1,13 @@
# SocialPub # PrivaPub
A self-hosted ActivityPub server in C# (.NET 10, MongoDB) where one private login owns several unlinkable public
personas. It federates with Mastodon, GoToSocial, Pleroma/Akkoma, Misskey and Lemmy.
- Live: https://privapub.thepra.dev
- Client: [decePubClient](https://git.thepra.dev/thepra/decePubClient), https://decepub.thepra.dev
- Architecture, conventions and deploy: [CLAUDE.md](CLAUDE.md)
- Decisions and the plan to full ActivityPub interop: [docs/ROADMAP.md](docs/ROADMAP.md)
```bash
dotnet build PrivaPub.sln -c Release
```
+461
View File
@@ -0,0 +1,461 @@
# PrivaPub roadmap
Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federation gap audit of commit `075c222`, research on .NET ActivityPub libraries, and the owner's decisions. Each phase ends in a tagged deploy plus verification; tick phases off here as they land.
## Status
- [ ] P0 Security baseline
- [ ] P1.1 Infrastructure and content model
- [ ] P1.2 Social graph and timelines
- [ ] P2 Mastodon client API
- [ ] P3 Social features
- [ ] P4 Groups and privacy features
- [ ] P5 FEPs and polish
## Intent
It is a self-hosted, Pleroma-like microblogging server for privacy-minded individuals and small communities. It
federates with Mastodon, Pleroma/Akkoma, Misskey and GoToSocial (`AvatarServer` enum). Its distinguishing idea is
**one private login (RootUser) owning several public personas (Avatars), each a separate actor with its own keys**.
The rename SocialPub → PrivaPub happened in the commit that added the private avatar service.
Its privacy additions on top of the usual fediverse features:
- **Location-ranged posts:** `Post.Location` and `RangeKm` (5 km default), currently unused.
- **Contacts a persona chooses to share:** `Avatar.SharedPersonalContacts`, plus a root contact book (`ContactItem`).
- **Private notes:** about other accounts (`RootUserNote`) and on one's own avatar (`PersonalNote`).
- **Invitation-gated groups and DM groups.**
- **Signup without email:** email is only for password recovery.
The client (decePubClient) is a near copy of **Pleroma-FE plus Pleroma's admin panel**: a visibility picker, a
subject line, Plain/HTML/Markdown content types, media with alt text, boosts and likes, threads, mutes and blocks, data
import/export, and admin sections for users, reports, emoji, MRF policies and uploads. Today it shows mock data and
calls nothing.
Leftovers of the collAnon template, not intent:
- the discussion and confrontation resources;
- collAnon's invitation flow;
- QR scanning;
- the "collAnon support" mail sender.
The finishing pass reinterpreted the owner's never-written `Group` and `DmGroup` as an ActivityPub Group actor with an
invitation code and a direct-message conversation. The owner confirmed it on 2026-10-01, with groups split into communities
and circles (see Owner decisions).
**Status at HEAD:**
| Status | Features |
|---|---|
| Works | Accounts and JWT, several avatars, per-avatar actors and keys, posts, replies, CW, delete, DMs, groups and invitations, WebFinger, NodeInfo, signed inbox for Follow/Undo/Create/Delete/Update, delivery queue |
| Missing | Following remote accounts (no outgoing Follow), home/local/federated timelines, notifications, likes, boosts, visibility other than public, media, polls, edits by the author, profile Update federation, mutes, blocks, reports (Flag), Move, custom emoji, the location and contact features, admin and moderation, and a client that talks to the server |
**Persona separation is breached in three places today:**
- invitation signup names the avatar after the root username;
- logs pair root IDs with IP addresses;
- NodeInfo counts avatars as users.
## Where it stood
**Works:**
- WebFinger, including `acct:` for groups
- NodeInfo 2.0
- Person, Group and Application actors with SPKI keys
- Outbound cavage signatures (every fetch is signed, so authorized-fetch servers work)
- Inbound cavage verification, with key refetch and a key-owner check
- Shared inbox, durable delivery with backoff
- Follow with auto-Accept or manual approval for groups, and Undo
- Inbound public, group and DM Creates
- Delete and Update restricted to the author
- DMs both ways, with Mention tags
**Critical (security):**
- **S1, actor/key cache poisoning:** `RemoteActorService.Upsert` and `GetActorByKeyId` accept any document under its
own `id`, with no origin, `publicKey.id` or `owner` checks. Any remote actor can be impersonated.
- **S2:** no check that an object's `id` host matches its actor's.
- **S3, SSRF:** only string checks. Names resolving to private addresses, redirects and rebinding all get through, and
a request can trigger it before authentication.
- **S4:** unbounded fetch size, and 500s on unexpected content.
- **S5:** remote HTML is stored raw, with no format flag.
- **S6:** loose signature freshness.
- **S8:** DM conversation injection.
- **S9:** "private" groups federate as Public.
- **S14:** the "admin" username grants admin, and Swagger is open in production.
**Blockers:**
- **O1:** no outgoing Follow, so no home timeline.
- **O2, O3:** replies and mentions aren't delivered to their targets.
- **I1:** inbound replies and mentions are stored but invisible, and there are no notifications.
- **G1:** groups Announce the object URI instead of the activity, so Lemmy and other FEP-1b12 software see empty
communities.
- **K7:** no media, timelines, likes or boosts for local users.
**Important:**
- **Data:** no indexes or unique constraints, upsert races (K1-K4).
- **Inbox:** processing runs synchronously with no idempotency store (I9, I10).
- **Delivery:** one serial worker, and a single poisoned row can stall the queue (L1-L3).
- **Objects:**
- Edits and profile updates don't federate (O7).
- No 410 Tombstones (A7).
- Content warning without a title (O5).
- Titles are lost on Mastodon (O6).
- Visibility modes are missing (O4).
- **Interactions:** likes, boosts and reports are dropped (I2, I3).
- **Media:** inbound attachments are dropped, and there's no media proxy (I6, S13).
- **Actor profile:**
- Actor `url` serves JSON to browsers (A1).
- No `attachment` fields, Move or `alsoKnownAs` (A2, A3).
- No locked or undiscoverable accounts (A4).
- **Signatures:** inbound RFC 9421 and Content-Digest (H1, H2).
## Owner decisions (2026-10-01)
| Question | Decision |
|---|---|
| Client interface | **Mastodon client API**, so Tusky, Elk, Phanpy, Ivory and the official apps work. Each avatar is its own Mastodon account: at OAuth authorize, the logged-in RootUser picks the avatar the token is for. PrivaPub-only features (avatars, groups, contacts, range posts) stay on `/clientapi`. Moving decePubClient onto the Mastodon API is out of scope. |
| Personas | **Unlinkable to other users and servers.** The admin can still see the link in the database. No root IDs next to IPs in logs, NodeInfo counts nothing that links personas, blocks, mutes and notifications are per avatar, and invitation signup no longer names the avatar after the root username. |
| Location-ranged posts | **Local only, never federated.** Shown to local users within the radius, with coordinates rounded on storage. |
| Groups | **Per group, two kinds.** A *community* federates per FEP-1b12, Lemmy-compatible: it Announces the activity, uses `audience`, and accepts posts from non-followers. A *circle* is invitation-only: posts are addressed to the members collection, and objects are served only to signed requests from members. |
## Libraries (researched; no maintained .NET ActivityPub library exists, so Letterbook and Iceshrimp.NET both wrote their own)
| Area | Choice |
|---|---|
| AS2 / ActivityPub model | Own thin layer on `System.Text.Json.Nodes`: inbound helpers for single-or-array values, id-or-object, `type` arrays and the three Public forms; outbound builders with Mastodon's `@context`. No JSON-LD processing; fetch from the origin instead of verifying LD signatures. |
| HTTP signatures | Keep own draft-cavage (sign and verify). Add **NSign 1.2.5** (`NSign.AspNetCore`) to verify inbound RFC 9421 and Content-Digest. Always answer a bad signature with 401, never 500. |
| HTML | **HtmlSanitizer 9.x** with Mastodon's allowlist (tags `p br span a del pre code em strong b i u ul ol li blockquote`, attributes `href rel class`, microformat classes, a scheme allowlist, forced `rel=nofollow noopener noreferrer`) |
| Markdown | **Markdig 1.4**: `DisableHtml`, autolinks, custom inline parsers for `@user@domain` and `#tag` emitting Mastodon's h-card and hashtag markup |
| Media | **NetVips** (+NetVips.Native) for images: autorotate, strip EXIF/GPS, thumbnails. **Blurhash.Core**, **FFMpegCore** for video. Own `IMediaStore` (local disk now). Not ImageSharp 4, whose license key is enforced at build. |
| OAuth | **OpenIddict 7.7 + OpenIddict.MongoDb**: authorization code with PKCE, client_credentials, `oob` redirect, dynamic apps from `POST /api/v1/apps`, reference tokens |
| Jobs | Own Mongo job collection for both inbox processing and delivery: `FindOneAndUpdate` leases, a `Channel` wake-up, per-host circuit breaker, Mastodon's backoff (`n⁴+15+jitter`, 16 tries). No MassTransit (commercial from v9, no Mongo transport). |
| Tests | New xUnit project with fixture JSON captured from Mastodon, GoToSocial, Misskey, Lemmy and Akkoma; interop checked with Fediverse Pasture, verify.funfedi.dev and activitypub.academy |
**FEPs, now:** FEP-f1d5 (NodeInfo 2.0 and 2.1), FEP-67ff (FEDERATION.md), FEP-8fcf, FEP-5feb `indexable`,
FEP-7628 Move, FEP-2c59, FEP-044f quotes (parse, plus `interactionPolicy`), FEP-1b12 groups.
**FEPs, later:** FEP-521a, FEP-8b32, FEP-e232, relays (FEP-ae0c).
**FEPs, skipped:** FEP-c390, FEP-ef61, FEP-fb2a, FEP-844e.
## Route names (deliberate; part of the project's character)
The odd names are the owner's and stay. Every new actor-scoped route follows the same theme. The names marked *new*
were agreed on 2026-10-01.
| Thing | Route | Status |
|---|---|---|
| Actor | `/peasants/{name}` | kept (`/users/{name}` 301-redirects here) |
| Inbox | `/peasants/{name}/mouth` | kept |
| Outbox | `/peasants/{name}/anus` | kept |
| Shared inbox | `/human-centipede` (and `/peasants/{name}/human-centipede`) | kept |
| Token refresh (client API) | `/clientapi/user/sniff/again` | kept |
| Followers | `/peasants/{name}/groupies` | *new*, replaces `/followers` in P1 |
| Following | `/peasants/{name}/stalking` | *new*, replaces `/following` in P1 |
| Notes (objects) | `/peasants/{name}/scribbles/{id}` | *new*, replaces `/posts/{id}` in P1 |
| Activities | `/peasants/{name}/grunts/{id}` | *new*, replaces `/activities/{id}` in P1 |
| Replies, likes, shares collections | `/scribbles/{id}/gossip`, `/drool`, `/echoes` | *new* |
| Featured (pins) | `/peasants/{name}/trophies` | *new* |
| Featured tags | `/peasants/{name}/tattoos` | *new* |
| Group members | `/peasants/{group}/flock` | *new* |
| Group moderators | `/peasants/{group}/wardens` | *new* |
| DM conversation context | `/peasants/{name}/whispers/{id}` | *new*, replaces `/conversations/{id}` |
Routes outside the actor namespace stay conventional, because other software looks for them by name:
- `/.well-known/*` and `/nodeinfo/*`;
- the Mastodon API (`/api/v1/*`, `/oauth/*`);
- the human pages `/@{name}` and `/@{name}/{id}`;
- `/media/*`.
Once a P1 deploy has federated a name, it is frozen.
## Target structure (moved incrementally; entity class names and URI shapes never change)
```
PrivaPub/
Infrastructure/ Http/ (IpRangeGuard, SafeHttpHandlerFactory, FederationHttp, BoundedReader), Jobs/ (JobQueue, JobWorker,
Backoff, HostCircuitBreaker), Ids/ (PrivacyIdGenerator, PostIds), Data/ (Indexes, Migrations/_NNN_*),
RateLimiting, ErrorHandling
Federation/ Signing/ (DraftCavage ← Services/Federation/HttpSignatures.cs, SignedFetchAuthorizer, Rfc9421Verifier)
Actors/ (LocalActorService, Keys, RemoteActorService, WebFingerClient, ActorParser)
Objects/ (Origin, ObjectFetcher, NoteParser, Addressing, ContentSanitizer)
Inbox/ (InboxReceiver, InboxProcessor, Handlers/{Follow,Accept,Reject,Undo,Create,Update,Delete,Like,Announce,Flag,Move}Handler)
Outbox/ (AudienceResolver, OutboxPublisher, GroupDistributor) Rendering/ (ApContext, Actor/Note/Activity/CollectionRenderer)
Controllers/ (Peasants, WellKnown, NodeInfo, Users)
Domain/ AvatarContext, Statuses/StatusService (← Services/PostsService.cs), Timelines/FanoutService, Notifications/,
Relationships/, Groups/GroupService (← GroupUsersService.cs), Content/ (MarkdownRenderer, PlainTextRenderer,
MentionTagParsers), Privacy/VisibilityPolicy (single CanSee), Media/ (P3), Geo/ (P4)
Api/ ClientApi/Controllers (← Controllers/ClientToServer, routes unchanged), Mastodon/{Controllers,Entities,Mappers,Auth,Infrastructure}
Web/Pages/ Profile, Status (P1); OAuth/Login, ChooseAvatar, Oob (P2)
```
The first refactor commit is a pure move with namespaces only. Logic changes follow in separate commits.
## Phases (each one ends in a tagged deploy plus verification)
### P0 Security baseline
- **S1, actor verification:** `RemoteActorService`.
- `GetActor` accepts a document only if its `id` equals the final URL, on the same origin.
- `GetActorByKeyId` resolves the owner, then the strict actor, and requires `publicKey.id == keyId` and
`owner == id`.
- Upserts become atomic on a unique `ActorURI`.
- Key refetch is limited to once per 5 minutes.
- **S3 and S4, safe outbound HTTP:**
- A `SocketsHttpHandler.ConnectCallback` resolves DNS and rejects loopback, private, link-local, CGNAT and ULA
addresses, plus NAT64, 6to4, Teredo and IPv4-mapped forms. It then connects to the vetted IP.
- Redirects are manual (at most 3, each re-checked).
- Responses are capped at 1 MB with a content-type check and a 15 s timeout, with a negative cache for failing URLs.
- Exceptions map to ProblemDetails: 400 or 401, never 500.
- **S2, origin checks:** `Origin.Same`. An activity's or object's `id` must share the actor's origin, and
cross-origin embedded objects are refetched.
- **S5, sanitising:** `ContentSanitizer` with HtmlSanitizer. `Post.ContentHtml` and `ContentFormat` are added, and
migration `_002` sanitises stored remote content.
- **S6, signature freshness:** `(request-target)` and `host` are required, plus `date` or `(created)` and `digest`.
The window is −1 h to +15 min. `(expires)` is enforced, and the raw request target is used.
- **S8, DM injection:** a DM joins a conversation by `context` only if the author is already a member; otherwise it
is matched on `DmGroup.ParticipantsKey`.
- **S9, groups:** a `Group.Kind` field is added. Existing groups become Circles (migration `_003`), and Circle posts
stay local-only until P4.
- **S14:**
- Delete the `"admin"` signup branch and add the CLI `admin promote`.
- Serve Swagger only in Development.
- Return generic error messages (no `ex.Message`).
- **Privacy:**
- Drop the IP, User-Agent and root-id log lines in `RootUserController`.
- Invitation signup takes its own `AvatarUserName`.
- All fetches are signed by the instance actor.
- **Indexes and races:** `Infrastructure/Data/Indexes.cs`, with migration `_001` deduplicating first.
- Unique: `ObjectURI`, `ForeignAvatar.ActorURI`, the Follower triple, RootToAvatar, and `ReservedName` (one
username space across avatars and groups).
- Plain: `PublicKeyId`, `(GroupUserId, _id)`, `(GroupId, _id)`.
- **Rate limiting** on login, signup, invitation and inbox (per key host).
- **Cleanup:** delete `Services/ActivityPubClient.cs` and `Models/Post/PostBoost.cs`.
- **New `PrivaPub.Tests`** (xUnit v3): IpRangeGuard table, signature fixtures (a real Mastodon request plus tampering
cases), key-poisoning, origin rules, XSS corpus, DM injection.
- **CI:** `build.yml` runs the tests. `deploy.yml` runs the tests and a `mongodump` before swapping.
### P1 Interop foundations (two deploys)
**P1.1, infrastructure and content model:**
- **Mongo job queue** (`Job`, `RemoteInstance`):
- Leases via `UpdateAndGet`, `Channel` wake-up, a reaper for expired leases.
- Workers: delivery 8 (at most 2 per host), inbox 2.
- Backoff `n⁴+15+jitter`, 16 tries. Non-transient errors go straight to Dead.
- Per-host circuit breaker. Dedupe on `activity.id` and `activityId|inbox`.
- TTL on finished jobs.
- Migration `_004` moves pending `Delivery` rows into jobs.
- **Async inbox:** `InboxReceiver` verifies and enqueues, then answers 202 (a duplicate also gets 202).
`InboxProcessor` dispatches to the handlers split out of `InboxService`.
- **Merge DmPost into Post** (migration `_005`; ids kept; `Visibility=Direct`, `ConversationId`). Post gains:
- `Visibility {Public, Unlisted, FollowersOnly, Direct, Circle, LocalGeo}`
- `AuthorAccountId`, `To`, `Cc`, `ActivityURI`, `Url`, `ContextURI`
- `InReplyToURI`, `InReplyToAccountId`, `ReblogOfPostId`
- `SpoilerText`, `Language`, `Mentions[]`, `Tags[]`, `Media[]`
- counters, `Revisions[]`, `DeletedAt`
`/clientapi/dm/*` moves onto Post, and every read goes through `VisibilityPolicy.CanSee`.
- **Parsing inbound content:**
- `NoteParser` handles Note, Article, Page and Question. It reads `content`, then `contentMap`, then
`_misskey_content`; also `summary`, `name`, `sensitive`, the `tag` array, `attachment` (as remote media),
`inReplyTo`, `context`, `audience`, `updated`, and the `quote` fields (stored as a URI).
- `Addressing` classifies Public, Unlisted, FollowersOnly or Direct. Followers-only is detected by the actor's
stored `followers` URL exactly, not by a path suffix.
- **Rendering local text:**
- `MarkdownRenderer` (Markdig) and `PlainTextRenderer` (for Mastodon API input).
- Mentions resolved through WebFinger become an h-card plus a `Mention` tag; hashtags become a `Hashtag` tag.
- **Renderers:**
- `ApContext`: Mastodon's `@context` (toot, schema, `indexable`, `blurhash`, `focalPoint`, `featured`,
`alsoKnownAs`, `movedTo`, `webfinger`).
- Actor and note `url` point at HTML pages; fields become `attachment` PropertyValue; the `webfinger` property is
added; `published` is truncated to the day.
- A title becomes `name` and is also prepended to the content as bold text.
- A CW without a title gets a localised "Content warning" summary.
- Routes are renamed to the themed set (`/groupies`, `/stalking`, `/scribbles/{id}`, `/grunts/{id}`,
`/whispers/{id}`), activity URIs resolve, and the outbox gets a `first` page.
- **Web:**
- Razor pages `/@{user}` and `/@{user}/{id}`, with content negotiation; `/peasants/{u}` with `text/html`
redirects there.
- WebFinger `profile-page` points at the HTML page.
- NodeInfo 2.1 (avatars counted as users, `openRegistrations` from config) and `FEDERATION.md`.
- **Moderation:** `DomainBlock` (Silence, Suspend, RejectMedia), applied in the receiver, the fetcher and delivery.
- **IDs:** register `PrivacyIdGenerator` for Avatar and Group (day-truncated timestamp plus random bytes). Remote post
ids are ObjectIds generated from their `published` time.
**P1.2, social graph and timelines:**
- **Outgoing Follow** (`Following` entity, plus Accept, Reject and outbound Undo). A local target is handled in-process.
- **`AudienceResolver` and `OutboxPublisher`:**
| Visibility | to | cc |
|---|---|---|
| Public | Public | followers + mentions |
| Unlisted | followers | Public + mentions |
| Followers-only | followers | mentions |
| Direct | mentions | (none) |
Replies are also delivered to the parent's author, and group posts to the group. Mentions are delivered to personal
inboxes.
- **Inbound Create:**
- The interested set is followers of the author, plus addressed or mentioned local avatars, plus the local parent's
author, plus group members. If nobody local is interested, the post is dropped.
- Store the post (idempotent on ObjectURI), then fan out to the **`TimelineEntry`** collection
(`{AvatarId, PostId, AuthorAccountId, ReblogOfPostId}`, unique `(AvatarId, PostId)`, paged by PostId).
- Mastodon's reply, mute and reblog rules apply at write time.
- Mention notifications are written.
- **`Notification`** entity: `{AvatarId, Type, FromAccountId, PostId, DedupeKey}`, unique on `DedupeKey`.
- **Edits:** outbound `Update{Note}` to the same To/Cc, with revisions kept. Profile edits send `Update{Person}`.
- **Deletes:** soft delete, a Delete sent to the stored audience, and the object then answers **410** with a
Tombstone (a deleted actor answers 410 too).
- **Inbound Like and Announce:** a `Favourite` entity, reblog rows with the original refetched, Undo of both,
counters and notifications.
- **Threads:** a `FetchAncestors` backfill job, depth at most 10.
- **Test endpoints:** `/clientapi/timeline/home`, `/clientapi/follow` and `/clientapi/notifications`, usable before P2.
### P2 Mastodon client API
- **OAuth:** OpenIddict 7.7 + OpenIddict.MongoDb.
- Storage is `IMongoDatabase` registered as `DB.Default.Database()`.
- Endpoints: `/oauth/authorize`, `/oauth/token`, `/oauth/revoke` and `/.well-known/oauth-authorization-server`.
- Flows: authorization code (PKCE optional) and client_credentials.
- Scopes: `read`, `write`, `follow`, `push` and the granular ones.
- Tokens are non-expiring reference tokens. The signing and encryption keys are persistent, in `/etc/privapub/oidc-*.pem`.
- The `oob` code page is supported, as is `force_login`.
- **Authorize UX:** log in with the root password (a short-lived cookie limited to `/oauth`), choose an avatar, consent.
- **The token's `sub` is the avatar id, and no root claim is ever in the principal.** A banned or deleted root
invalidates its tokens.
- `POST /api/v1/apps` creates OpenIddict applications dynamically and prunes unused ones.
- **Plumbing:**
- `MastodonJson`: snake_case, explicit nulls and empty arrays (Tusky breaks on missing fields).
- A binder that merges Rails-style query, form and JSON parameters.
- `{"error"}` responses.
- `Link` paging on `max_id`, `since_id` and `min_id`; CORS exposes `Link`.
- `Idempotency-Key` honoured on posting statuses.
- **Endpoints:**
- **Instance:** v1 and v2, advertising version `4.2.0 (compatible; PrivaPub)`.
- **Accounts:** `verify_credentials`, `update_credentials`, `:id`, `:id/statuses`, `lookup`, `relationships`,
`search`, followers and following, follow and unfollow, `follow_requests`.
- **Statuses:** CRUD, edit, `context`, `history`, `source`, favourite and reblog plus their undos (outbound Like and
Announce), `reblogged_by`, `favourited_by`.
- **Timelines and the rest:** home, public, tag; notifications; markers; conversations; `/api/v2/search` with
`resolve`.
- **Stubs:** `custom_emojis`, filters, lists, announcements, trends, suggestions, `followed_tags`, preferences.
- **Mapping:**
- Avatar, ForeignAvatar and Group all map to Account (`group: true` for groups). `acct` uses a WebFinger-verified
handle; `created_at` is truncated to the day; avatar and header always have a placeholder URL.
- Post maps to Status. Reblogs wrap the original. Per-viewer flags are loaded in batches.
- LocalGeo never appears in the Mastodon API.
- **New Avatar settings:** `IsLocked`, `IsDiscoverable`, `IsIndexable`, `IsBot`, `HideCollections`,
`DefaultVisibility`, `DefaultSensitive`, `DefaultLanguage`.
### P3 Social features
- **Media pipeline:** `MediaAttachment` entity plus `/api/v1/media` and `/api/v2/media`.
- NetVips: autorotate, strip all metadata, 4096 px cap, 640 px thumbnail.
- Blurhash.Core.
- FFMpegCore: remux only, with `-map_metadata -1`.
- Stored in `/var/lib/privapub/media`, outside the web root that deploys replace. nginx serves `/media/` with
`nosniff` and a strict CSP, and allows larger uploads only on the media endpoints.
- A job deletes unattached uploads.
- **Remote media proxy:** `/media/proxy/{hmac}/{url}`, going through the safe HTTP handler, with a ~5 GB disk LRU. The
API and avatars use proxied URLs, so clients never contact remote hosts.
- **Outbound attachments:** `mediaType`, alt text, `blurhash`, `focalPoint` and dimensions. Profile avatar and header
images are sent as `Update{Person}`.
- **Bookmarks and pins:** pins are served as the `featured` collection at `/peasants/{name}/trophies`, with featured
tags at `/tattoos`.
- **Blocks and mutes:** per avatar. **Block is not federated**: it sends Reject or Undo Follow instead and drops the
blocked actor's traffic. Domain blocks per account as well.
- **Reports:** inbound Flag becomes a `Report`. Outbound Flag is **sent by the instance actor**, so the reporting
persona isn't revealed. Moderator endpoints on `/clientapi`.
- **Locked accounts:** the follow-request flow.
### P4 Groups and privacy features
- **Group model:** `Group.Kind`, `PostingPolicy`, `Rules`; `GroupMember.State`; new collections for
moderators (`/wardens`), members (`/flock`) and featured (`/trophies`).
- **Communities (FEP-1b12):**
- `GroupDistributor` announces the full activity (Create, Update, Delete, Like or Undo) with `audience` to the
group's followers. New posts additionally get `Announce(object)`, so Mastodon shows them.
- Top-level posts render as `Page` with a title; comments as `Note`.
- Posting from non-followers is allowed according to `PostingPolicy`.
- Inbound Announces from remote groups are refetched from their origin.
- A Mastodon client posts into a group by mentioning `@group@host`.
- **Circles:**
- The actor is undiscoverable, with manual approval and invitation-only joining.
- Posts are addressed `to: [members, group]` and delivered to members' **personal** inboxes, never with Announce.
- `SignedFetchAuthorizer` serves circle objects only to a signed request from a member, or from the instance actor
of a member's server. Anything else gets 404.
- `FEDERATION.md` documents the Mastodon limitation: Mastodon members' replies reach only whoever they mention.
- **Local-only location posts:**
- `Post.Location` becomes a GeoJSON point rounded to 2 decimals (about 1 km), with `RangeKm` clamped to 1–50 and a
`2dsphere` index. Visibility is LocalGeo.
- `/clientapi/post/nearby` runs `$geoNear` filtered by each post's own radius. The viewer's position is never stored.
- The audience is always empty; ActivityPub endpoints and the Mastodon API never expose these posts.
- **Persona hardening:** optionally re-key existing avatar ids; an optional `SecureMode` (signed GETs required); no
suggestions or directories that could relate sibling avatars.
### P5 FEPs and polish (ongoing)
- **FEP-8fcf:** followers synchronisation.
- **FEP-5feb:** honour remote `indexable` in search.
- **FEP-7628:** Move in both directions, plus editing `alsoKnownAs`.
- **FEP-044f:** quotes and `interactionPolicy`.
- **RFC 9421:** inbound verification with NSign, plus Content-Digest.
- **Mastodon API extras:** polls, the streaming WebSocket (nginx Upgrade headers), Web Push (VAPID), and grouped
notifications v2, after which the advertised version moves to 4.3.
- **Backfill:** an author's outbox after following them.
- **Later:** FEP-521a, FEP-8b32, FEP-e232, relays.
### Cut or deferred (deliberately)
- **Cut:**
- Link-preview cards: an SSRF and privacy risk.
- Translation, trends, directory and lists (filters stay as stubs).
- Scheduled posts.
- The Mastodon admin API: moderation stays on `/clientapi`.
- `POST /api/v1/accounts` registration: avatars are created through `/clientapi`.
- Local custom emoji.
- S3 storage.
- JSON-LD and LD-signature processing.
- Outbound RFC 9421.
- **Deferred:** video transcoding (remux only for now).
- **Out of scope:** moving decePubClient onto the Mastodon API.
## Verification (per phase)
- **Unit tests** (`PrivaPub.Tests`) on every phase. Fixture JSON is captured per peer into
`Fixtures/{mastodon,gotosocial,misskey,akkoma,lemmy}/`. Renderers are checked against golden JSON. Inbound
scenarios run as integration tests on a throwaway mongod.
- **Interop:** **Fediverse Pasture** runs on the workstation with podman: Mastodon, GoToSocial, Misskey/Sharkey,
Akkoma, plus Lemmy for P4. `tools/pasture/docker-compose.privapub.yml` runs PrivaPub with `AllowPrivateNetworks`;
startup asserts that flag is false in Production. The matrix, per peer:
- follow both ways;
- public, unlisted, followers-only and direct posts both ways;
- a reply landing in the remote thread;
- a mention producing a notification;
- edit, and delete answering 410;
- like and boost counters;
- CW and title rendering on Mastodon;
- Pasture's odd-payload inputs always answered 202 or 4xx, never 500.
Also run verify.funfedi.dev against `/peasants/privapub` and one avatar.
- **P0:** `curl` the inbox with junk and get 400/401. Resolving `127.0.0.1.nip.io` or a host with an `::1` record is
refused. Swagger answers 404 in production.
- **P1:** 500 deliveries queued for a dead host don't delay deliveries to live hosts.
- **P2:** Tusky, Elk, Phanpy and Ivory each log in with two avatars of one root as separate accounts. Home paginates
both ways; post, reply, DM, follow-by-search, notifications, CW, and edit or delete-and-redraft all work.
`tools/smoke/mastodon-api.sh` runs after every deploy. A test asserts that no response for avatar A contains avatar
B's id.
- **P3:**
- `exiftool` shows no GPS data on an uploaded original.
- Elk renders the blurhash.
- Phanpy's network tab shows no requests to remote hosts.
- Like and boost round-trip with Mastodon and GtS, and a block cuts the follow both ways.
- A Flag reaches the Mastodon admin UI.
- **P4:**
- Lemmy follows a community; posts and comments flow both ways; votes show; moderator deletes propagate.
- A Mastodon member of a circle gets a limited post. An unsigned or non-member GET answers 404.
- Two local avatars, 3 km and 8 km away, see a 5 km post correctly, and the post creates no delivery jobs.
- **Every deploy:** `/build.json` reports the commit. `privapub.thepra.dev/peasants/privapub` and NodeInfo answer.
## Risks and sequencing
- Unique indexes fail on existing duplicates, so the dedupe migration runs first. Run a `mongodump` before every
tagged deploy from P0 on.
- Merge DmPost into Post and register `PrivacyIdGenerator` **before P2** exposes ids to clients.
- The publish grows by about 20 MB (OpenIddict, NetVips.Native, FFMpegCore). Keep `deploy.yml`'s asserted file list
current, and add ffmpeg in `deploy/max/setup.sh`.
- Pasture's private-network and plain-HTTP allowances stay out of `appsettings.Production.json`, and startup asserts
they're off.
- Scale: this is several weeks of work. Each phase is a separate tagged release, and I report after each one.