Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6727e1817a | ||
|
|
c42229f78c | ||
|
|
8156c1927c | ||
|
|
088c54ddf3 | ||
|
|
6fccb6fe35 | ||
|
|
3b3f1f6b93 | ||
|
|
62189bf11d | ||
|
|
fb4949b511 | ||
|
|
d4b1788a4f | ||
|
|
587997dd67 | ||
|
|
6fc2d001b2 | ||
|
|
4eda3da75d | ||
|
|
163f7e4e2b | ||
|
|
44a5895b0c | ||
|
|
3d7d406834 | ||
|
|
bdc8be4508 | ||
|
|
9ad96f560d | ||
|
|
bc5f2beaf7 | ||
|
|
fba57318fa | ||
|
|
12bb75809f | ||
|
|
37b12c5fee | ||
|
|
830385ea2b | ||
|
|
dc63fa57b8 | ||
|
|
5252b8f320 | ||
|
|
a4ced664cf | ||
|
|
8fecf7de2d | ||
|
|
3ca29603ed | ||
|
|
8e59145826 | ||
|
|
e4f9d0b61e | ||
|
|
65938bb2a0 | ||
|
|
bb680e8cb8 | ||
|
|
52d201eee9 | ||
|
|
402f9e0d75 | ||
|
|
8492f24064 | ||
|
|
6f240828cf | ||
|
|
5ecf2d9629 | ||
|
|
f105d1f7ea | ||
|
|
f66c280b0b | ||
|
|
10ff4b3d2a | ||
|
|
bebb4d6370 | ||
|
|
cb34ab88f7 | ||
|
|
af2c61292b | ||
|
|
a3a66b6db2 | ||
|
|
5d5f1d6909 | ||
|
|
9872b78677 | ||
|
|
b478049303 | ||
|
|
b66f0acf4e | ||
|
|
679463eb3f | ||
|
|
02928caac1 | ||
|
|
e708f1ad2e | ||
|
|
0310bbe0f9 | ||
|
|
69ab8e50eb | ||
|
|
3df3c333bf | ||
|
|
99b5e2dbf3 | ||
|
|
d06f684f0d | ||
|
|
b39eadffeb | ||
|
|
9d0b5fac3e | ||
|
|
9ab87b2779 | ||
|
|
c47b6e5533 | ||
|
|
e0682fa868 | ||
|
|
bd18de35a6 | ||
|
|
bf37223849 | ||
|
|
93e13e5563 | ||
|
|
b45321f28d | ||
|
|
b218768904 | ||
|
|
53158b4cf4 | ||
|
|
12b51a211a | ||
|
|
a584134858 | ||
|
|
0aea550c6c | ||
|
|
5721bfd796 | ||
|
|
1c0dfeb9a4 | ||
|
|
f6fc0c535c | ||
|
|
1c7d1ece9c | ||
|
|
5f150b7a59 | ||
|
|
7b0377c85f | ||
|
|
f3e8cce2ed | ||
|
|
eeb873b816 | ||
|
|
f1e743c331 | ||
|
|
1265611f9e | ||
|
|
f38ac73615 | ||
|
|
a85ebd30c0 | ||
|
|
bd5901d15e | ||
|
|
6b08da458a | ||
|
|
26346cde30 | ||
|
|
47d6e22988 | ||
|
|
0646de22bd | ||
|
|
2b42377b3f | ||
|
|
8ae265c8f8 | ||
|
|
9f0b25e92b | ||
|
|
3866558d1d | ||
|
|
fb90558dec | ||
|
|
5860b71223 | ||
|
|
c65a44ba88 | ||
|
|
761cfc2cce | ||
|
|
2d293a6148 | ||
|
|
7f6837ccb1 | ||
|
|
206506f5e9 | ||
|
|
a5d493a8c9 | ||
|
|
e485f7bd47 | ||
|
|
7eb7c017a5 | ||
|
|
01808fa644 | ||
|
|
7e37f56db6 | ||
|
|
8686275b69 | ||
|
|
01e5aa4ddd | ||
|
|
c5a69d240a | ||
|
|
26dac40720 | ||
|
|
34c0f696af | ||
|
|
4a9a3d3235 | ||
|
|
17922f4dfd | ||
|
|
ea5e607779 | ||
|
|
b146e182c6 | ||
|
|
0695c08ac3 | ||
|
|
bbeeda7268 | ||
|
|
325f7605f2 | ||
|
|
1d12dff033 | ||
|
|
aecbf620a8 | ||
|
|
9268968fd4 | ||
|
|
b274ce1268 | ||
|
|
40217f3261 | ||
|
|
d61eac0a01 | ||
|
|
b2570eab38 | ||
|
|
71ca134599 | ||
|
|
d4226a289c | ||
|
|
83d4148de1 | ||
|
|
5f72435bfb | ||
|
|
7bfd7ce285 | ||
|
|
864dae4469 | ||
|
|
cafd2e6db3 | ||
|
|
73c362de96 | ||
|
|
5b3e456e09 | ||
|
|
b710493701 | ||
|
|
ce473f2741 | ||
|
|
02a05d7a9a | ||
|
|
0614bdcf18 | ||
|
|
4b6d261739 | ||
|
|
f70ec59359 | ||
|
|
ed08f80f05 | ||
|
|
2873344690 | ||
|
|
d6131af289 | ||
|
|
6b55b5c35c | ||
|
|
d405269526 | ||
|
|
08acdc0503 | ||
|
|
b9286c2c6e | ||
|
|
128ff89426 | ||
|
|
436f7da464 | ||
|
|
5d7edc4a4c | ||
|
|
eb7552e8f3 | ||
|
|
31d1d21d1e | ||
|
|
d9fb5c582f | ||
|
|
2cd75176a4 | ||
|
|
c7e8760ffe | ||
|
|
ad8d353f9a | ||
|
|
e2f61ede56 | ||
|
|
8f25bf056d | ||
|
|
123ba39520 | ||
|
|
f6dbf71964 | ||
|
|
8c2eba6cbb | ||
|
|
fcd35f5043 | ||
|
|
5f56681c01 | ||
|
|
cd5eb25948 | ||
|
|
ab526ed291 | ||
|
|
aa7e43a61e | ||
|
|
e29da1b47a | ||
|
|
d4e9acdb79 | ||
|
|
5f31dbd521 | ||
|
|
e0f2eb7da7 | ||
|
|
d0a13d9cc2 | ||
|
|
4f5df743ae | ||
|
|
c8a305ae92 | ||
|
|
fc5bb9511f | ||
|
|
31d614efd4 | ||
|
|
2645dea26f | ||
|
|
2cfea7b60c | ||
|
|
c5e4934ba6 | ||
|
|
c301f0c498 | ||
|
|
6ef7e2891a | ||
|
|
cee85309b8 | ||
|
|
fc15f6356d | ||
|
|
bc8ce63707 | ||
|
|
90a7e38ce9 | ||
|
|
d37999970c | ||
|
|
e247001bdb | ||
|
|
ac7cbd136b | ||
|
|
4fa53f63bd | ||
|
|
15cd034b29 | ||
|
|
125a49a1a0 | ||
|
|
5e34517e73 | ||
|
|
85fdff606d | ||
|
|
00b2685cf4 | ||
|
|
7c6fe80f1b | ||
|
|
e6729c77e7 | ||
|
|
f9658a8e7a | ||
|
|
6f1ab0073c | ||
|
|
001fdac3be | ||
|
|
fdcf5176bc | ||
|
|
7be6749a23 | ||
|
|
81de470f64 | ||
|
|
b691c6766d | ||
|
|
fc111d8c46 | ||
|
|
dcd024100a | ||
|
|
bb9bd71391 | ||
|
|
4c94254502 | ||
|
|
829eae3ccf | ||
|
|
8f4d6cbdf9 | ||
|
|
31542a181e |
No files matched your search
@@ -14,6 +14,3 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: dotnet build PrivaPub.sln -c Release
|
||||
|
||||
- name: Test
|
||||
run: dotnet test PrivaPub.sln -c Release --no-build
|
||||
+43
-10
@@ -12,8 +12,7 @@ env:
|
||||
BACKUPS: /var/backups/privapub.thepra.dev
|
||||
LOCAL_URL: http://127.0.0.1:6970
|
||||
PUBLIC_URL: https://privapub.thepra.dev
|
||||
MONGO_URI: mongodb://127.0.0.1:27022
|
||||
MONGO_DB: PrivaPub
|
||||
SERVER_BACKUPS: /var/lib/privapub/backups
|
||||
|
||||
jobs:
|
||||
site:
|
||||
@@ -28,9 +27,6 @@ jobs:
|
||||
echo "BUILD_REF=${GITHUB_REF_NAME:-master}" >> "$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
|
||||
run: |
|
||||
rm -rf "$GITHUB_WORKSPACE/publish"
|
||||
@@ -57,12 +53,24 @@ jobs:
|
||||
echo "SNAPSHOT=$BACKUPS/site-$STAMP" >> "$GITHUB_ENV"
|
||||
ls -1dt "$BACKUPS"/site-* 2>/dev/null | tail -n +4 | xargs -r rm -rf || true
|
||||
|
||||
- name: Dump the database
|
||||
# The server's own backup (PrivaPub admin backup, owner decision 2026-10-07) of the database as the live build left it:
|
||||
# the new build's command runs before its migrations. The media are listed, not linked (the runner may not link
|
||||
# www-data's files; the nightly backups hold them). Never the statistics salt, which must not outlive its day (owner
|
||||
# rule), nor the configuration's copy with its secrets. No deploy while a restore waits for its boot.
|
||||
- name: Back up 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
|
||||
[ ! -e "$SERVER_BACKUPS/restore.json" ] || { echo "::error::a restore is pending or running ($SERVER_BACKUPS/restore.json): deploy after it"; exit 1; }
|
||||
out=$(cd "$GITHUB_WORKSPACE/publish" && ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin backup --kind pre-deploy --db-only)
|
||||
echo "$out"
|
||||
id=$(echo "$out" | grep -oE '^[0-9]{8}-[0-9]{6}-pre-deploy' | tail -1)
|
||||
[ -d "$SERVER_BACKUPS/$id" ] || { echo "::error::the backup was not made"; exit 1; }
|
||||
(cd "$GITHUB_WORKSPACE/publish" && ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin backup verify "$id")
|
||||
if [ -e "$SERVER_BACKUPS/$id/db/InteractionSalt.jsonl.gz" ]; then
|
||||
echo "::error::the backup holds the statistics salt"
|
||||
exit 1
|
||||
fi
|
||||
echo "BACKUP_ID=$id" >> "$GITHUB_ENV"
|
||||
echo "::notice::database backed up as $id"
|
||||
|
||||
- name: Stop, sync, start
|
||||
run: |
|
||||
@@ -95,6 +103,11 @@ jobs:
|
||||
[ "$served" = "$BUILD_COMMIT" ] || { echo "::error::served build is '$served', expected '$BUILD_COMMIT'"; exit 1; }
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$PUBLIC_URL/peasants/privapub")
|
||||
[ "$code" = "200" ] || { echo "::error::the instance actor answered $code"; exit 1; }
|
||||
# SecureMode (owner decision 2026-10-04): an unsigned reader gets 401 from a persona, a browser the public page
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$PUBLIC_URL/peasants/thepra")
|
||||
[ "$code" = "401" ] || { echo "::error::an unsigned GET of a persona answered $code, not 401: SecureMode is off"; exit 1; }
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: text/html' "$PUBLIC_URL/peasants/thepra")
|
||||
[ "$code" = "302" ] || { echo "::error::a browser asking for a persona got $code, not a redirect"; exit 1; }
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/.well-known/nodeinfo")
|
||||
[ "$code" = "200" ] || { echo "::error::nodeinfo answered $code"; exit 1; }
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/swagger/index.html")
|
||||
@@ -105,4 +118,24 @@ jobs:
|
||||
--data '{"type":"Follow","actor":"https://example.org/users/x","object":"'"$PUBLIC_URL"'/peasants/privapub"}' "$PUBLIC_URL/human-centipede")
|
||||
[ "$code" = "401" ] || { echo "::error::an unsigned inbox POST answered $code"; exit 1; }
|
||||
tools/smoke/mastodon-api.sh "$PUBLIC_URL"
|
||||
open=$(curl -fsS "$PUBLIC_URL/nodeinfo/2.1" | python3 -c "import json,sys; print(json.load(sys.stdin)['openRegistrations'])")
|
||||
enabled=$(curl -fsS "$PUBLIC_URL/api/v2/instance" | python3 -c "import json,sys; print(json.load(sys.stdin)['registrations']['enabled'])")
|
||||
[ "$open" = "$enabled" ] || { echo "::error::NodeInfo says registrations are $open, the instance API $enabled"; exit 1; }
|
||||
curl -fsS "$PUBLIC_URL/stargazing" | grep -q 'The crawler is <strong>on</strong>' \
|
||||
|| { echo "::error::/stargazing does not say the crawler is on"; exit 1; }
|
||||
echo "::notice::serving $served"
|
||||
|
||||
# The deploy no longer signs in (owner decision 2026-10-17): it kept a login of its own, deploy-smoke, whose @thepra
|
||||
# is the administrator's now. Its checks are the anonymous ones above.
|
||||
|
||||
# The server fetches DB-IP Lite itself (GeoUpdater) about 30 s after it starts and then daily; the deploy only checks.
|
||||
- name: Geolocation databases are current
|
||||
run: |
|
||||
for kind in city asn; do
|
||||
db="/var/lib/privapub/geo/dbip-$kind-lite.mmdb"
|
||||
for i in $(seq 1 60); do [ -s "$db" ] && break; sleep 10; done
|
||||
[ -s "$db" ] || { echo "::error::$db is missing: the server has not fetched DB-IP Lite"; exit 1; }
|
||||
days=$(( ($(date +%s) - $(stat -c %Y "$db")) / 86400 ))
|
||||
[ "$days" -le 40 ] || { echo "::error::$db was installed $days days ago"; exit 1; }
|
||||
done
|
||||
curl -fsS "$PUBLIC_URL/stargazing" | grep -o 'DB-IP Lite [0-9-]*' | head -1 | sed 's/^/::notice::locating with /'
|
||||
+10
@@ -257,6 +257,7 @@ Generated_Code/
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
!PrivaPub/Infrastructure/Backup/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
@@ -401,3 +402,12 @@ FodyWeavers.xsd
|
||||
|
||||
PrivaPub/media-store/
|
||||
PrivaPub/media-store-proxy/
|
||||
PrivaPub/media-store-trash/
|
||||
PrivaPub/media-store-incoming/
|
||||
PrivaPub/media-store-backups/
|
||||
tools/pasture/.publish/
|
||||
tools/pasture/.flood/
|
||||
.claude/worktrees/
|
||||
tools/pasture/.ca/
|
||||
tools/pasture/.state/
|
||||
tools/pasture/out/
|
||||
@@ -1,7 +1,9 @@
|
||||
# CLAUDE.md
|
||||
# CLAUDE.md
|
||||
|
||||
Guidance for working in this repository. `docs/ROADMAP.md` holds the owner's decisions and the phased plan to full
|
||||
ActivityPub interop; read it before changing anything federation-, privacy- or API-shaped.
|
||||
ActivityPub interop; read it before changing anything federation-, privacy- or API-shaped. `docs/INTEROP.md` is the
|
||||
per-platform evidence behind phases P5 to P8: what each peer sends, what it expects, and what PrivaPub still drops.
|
||||
Check the platform's section there before writing a parser or a renderer, and add to it whatever you learn.
|
||||
|
||||
## What this is
|
||||
|
||||
@@ -19,6 +21,13 @@ Its defining idea: **one private login owns several public personas.**
|
||||
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.
|
||||
- **`privapub_reports` is the reporter** (type Service, `LocalActorKind.Reporter`). It carries reports to Lemmy, which
|
||||
takes none from an Application, and to PieFed and Mbin, and names nobody.
|
||||
- **`privapub_feeds` is the reader of feeds** (type Service, `LocalActorKind.Reader`). When a persona follows a feed
|
||||
(Lemmy's multi-community, PieFed's feed), it follows the feed's communities in the server's name
|
||||
(`Domain/Social/FeedFollows.cs`, owner decision 2026-10-07); its `Following` rows carry `FollowerKind`, and nobody's
|
||||
home gets what it brings in. All three are server actors (`LocalActor.IsServerActor`): never followed, mentioned or
|
||||
shown as accounts.
|
||||
|
||||
Privacy features in the model:
|
||||
- location-ranged posts (`Post.Location`, `RangeKm`), to become local-only and never federated;
|
||||
@@ -27,8 +36,8 @@ Privacy features in the model:
|
||||
|
||||
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.
|
||||
(https://decepub.thepra.dev) is the owner's own Pleroma-FE-like PWA. It signs in once on `/clientapi` and exchanges that
|
||||
JWT for each persona's Mastodon token (`PersonaExchange`), then uses both APIs.
|
||||
|
||||
## Route names are deliberate
|
||||
|
||||
@@ -46,6 +55,7 @@ actor-scoped route gets a name in the same spirit, agreed with the owner, and a
|
||||
| Notes | `/peasants/{name}/scribbles/{id}` |
|
||||
| Activities | `/peasants/{name}/grunts/{id}` (`create-{postId}` resolves) |
|
||||
| DM context | `/peasants/{name}/whispers/{id}` |
|
||||
| Quote permission (FEP-044f `QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{id}` |
|
||||
| Token refresh | `/clientapi/user/sniff/again` |
|
||||
|
||||
Agreed for later phases: `/gossip`, `/drool`, `/echoes` (replies, likes, shares), `/trophies` (featured), `/tattoos`
|
||||
@@ -69,7 +79,7 @@ PrivaPub/ ASP.NET Core Web API, net10.0
|
||||
Infrastructure/
|
||||
Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out
|
||||
Jobs/ JobQueue (leases), JobWorker, Backoff, HostCircuitBreaker
|
||||
Ids/ PrivacyIds (day-only ids for personas and groups, published-time ids for remote posts)
|
||||
Ids/ PrivacyIds (day-only ids for personas and groups, arrival-ordered ids for remote posts)
|
||||
Data/ Indexes (created at start), EntityMaps.Warm, Migrations/_NNN_*.cs
|
||||
Cli/ AdminCommands (`PrivaPub admin promote|demote <root>`)
|
||||
RateLimiting.cs accounts (per client address) and inbox (per sending origin) policies
|
||||
@@ -80,9 +90,14 @@ PrivaPub/ ASP.NET Core Web API, net10.0
|
||||
(authoritative fetch, key verification, WebFinger), ActorDocument (parser)
|
||||
Objects/ Origin, ActivityJson, NoteParser, Addressing, ContentSanitizer
|
||||
Moderation/ DomainBlocks (suspend / silence / reject media)
|
||||
Signing/ HttpSignatures (draft-cavage sign/verify)
|
||||
Signing/ HttpSignatures (draft-cavage sign/verify), MessageSignatures (RFC 9421 verify),
|
||||
RequestSignature (whichever a request carries), IntegrityProofs (FEP-8b32
|
||||
eddsa-jcs-2022 by a persona's Ed25519 key, FEP-521a Multikeys), Jcs (RFC 8785)
|
||||
Inbox/ InboxReceiver (verify, queue, 202) → InboxProcessor (job) → Handlers/{Follow,Accept,Reject,
|
||||
Undo,Create,Update,Delete,Like,Announce}; RemotePosts (build, fetch parents, FetchAncestors)
|
||||
Undo,Create,Update,Delete,Like,Announce}; RemotePosts (build, fetch parents, FetchAncestors);
|
||||
RemoteReplies (FetchReplies: a thread's `context`, else `replies` two levels down);
|
||||
Forwarded (what a thread's server passes on, believed as far as the origin vouches);
|
||||
ReplyRelay (third-party replies to a persona's posts passed on to its followers)
|
||||
Outbox/ OutboxPublisher (who a post goes to), DeliveryService (queues jobs) + DeliveryJobHandler
|
||||
Rendering/ ActivityPubRenderer (Mastodon @context, actors, notes, collections)
|
||||
Domain/
|
||||
@@ -92,7 +107,9 @@ PrivaPub/ ASP.NET Core Web API, net10.0
|
||||
Privacy/ VisibilityPolicy (IsPublic expression, CanSee)
|
||||
Relationships/ RelationshipService (blocks, mutes, account domain blocks; Hidden), ReportService
|
||||
Media/ MediaService (libvips, ffmpeg remux, blurhash), MediaProxy, MediaJanitor
|
||||
Domain/Statuses/ StatusService: publish, edit, remove, favourite, reblog, for a persona (both client APIs use it)
|
||||
Domain/Statuses/ StatusService: publish, edit, remove, favourite, reblog, for a persona (both client APIs use it);
|
||||
InteractionApprovals: GoToSocial's canReply/canLike/canAnnounce, judged, asked and answered;
|
||||
Participations: a persona's Join and Leave of a remote event, and the organiser's answer
|
||||
Api/Mastodon/
|
||||
Auth/ OpenIddict setup (keys in Mongo), MastodonScopes, TokenController, OAuthPruner
|
||||
Infrastructure/ MastodonController (avatar context, scopes, errors, Link), MastodonParams, MastodonJson, Page
|
||||
@@ -105,7 +122,6 @@ PrivaPub/ ASP.NET Core Web API, net10.0
|
||||
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
|
||||
@@ -118,9 +134,6 @@ one pure-move commit at a time.
|
||||
|
||||
```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 \
|
||||
@@ -129,31 +142,65 @@ cd PrivaPub && ASPNETCORE_ENVIRONMENT=Development \
|
||||
|
||||
Development config (`appsettings.Development.json`) binds HTTPS 7195 with HTTP/2 only; the overrides above make it
|
||||
curl-able. Outbound fetches only go to https DNS names resolving to public addresses; a test network (Pasture) sets
|
||||
`Federation__AllowPrivateNetworks=true` and `Federation__AllowPlainHttp=true`, which startup refuses in Production.
|
||||
`Federation__AllowPrivateNetworks=true`, `Federation__AllowPlainHttp=true` and `Federation__AcceptAnyCertificate=true`,
|
||||
which startup refuses in Production.
|
||||
|
||||
Promoting an admin on the box (signing up as "admin" grants nothing):
|
||||
Sign-up is closed in production (invitations only). The server's first login is made from the client instead, while no
|
||||
administrator exists (owner decision 2026-10-16, first come, first served):
|
||||
- `GET /clientapi/user/setup` says whether setup waits (`ViewServerSetup.IsPending`), and `POST` takes a `SetupForm`
|
||||
(login, password, first persona). That login is made the administrator, with its persona.
|
||||
- `ServerSetup` (`Models/ServerSetup.cs`) closes setup for good: one document with the fixed id `setup`. The first request
|
||||
to insert it wins, so two setups at once make one administrator.
|
||||
- It is never given back once it names an administrator, whoever is demoted or deleted later.
|
||||
- `admin promote` and `create-root --admin` seal it too, and migration `_018` seals a server that already had an
|
||||
administrator.
|
||||
- It is never in a backup and never dropped by a restore.
|
||||
|
||||
The admin CLI runs with every service built but nothing started (no Kestrel, no hosted services), so it can run next to
|
||||
the live service. It is the way back when setup is closed and no administrator can sign in; promoting is how an admin is
|
||||
made (signing up as "admin" grants nothing):
|
||||
|
||||
```bash
|
||||
cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin promote <root>
|
||||
cd /var/www/privapub.thepra.dev
|
||||
echo '<password>' | ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin create-root <login> --admin # password on stdin
|
||||
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin promote|demote <root>
|
||||
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin move-persona <persona> <root> # its link moves, its Mastodon tokens end
|
||||
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin delete-root <root> # as the client deletes a login: its personas go too
|
||||
```
|
||||
|
||||
The deploy runs them as `build-runner`, which owns the published files, reads `appsettings.Production.json` through
|
||||
group www-data and reaches the private mongod; `sudo -u www-data` works too.
|
||||
|
||||
## 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.
|
||||
`HttpClient` for federation. The only other outbound traffic is SMTP and `GeoUpdater`'s monthly DB-IP Lite download
|
||||
(its own `geo` client, a fixed HTTPS host, size-capped, the file checked before it is swapped in).
|
||||
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).
|
||||
avatar or group. Both are draft-cavage rsa-sha256 over `(request-target) host date` (+ `digest` on bodies). Inbound,
|
||||
an RFC 9421 signature (`Signature-Input`) is verified too, its target against our public address. A persona's own
|
||||
activity going to a relay also carries a FEP-8b32 proof by its Ed25519 key (`Avatar.SigningKey`, one per persona,
|
||||
never shared), and nothing else does: Mitra refuses a proof by a key it has not read. A forwarded activity is taken
|
||||
as it came only when its proof verifies with a Multikey of its actor's.
|
||||
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 signature verifies against the key owner's key, and the activity's `actor` is the key owner, or else it was
|
||||
forwarded (`Forwarded`: a thread's server passing on what happens in it, as Mastodon and Friendica do). A forwarded
|
||||
activity proves nothing about its author: answered 202, a Create or Update is taken as its object reads at the
|
||||
actor's origin now, a Delete of a public or unlisted copy once that origin answers 404 or 410
|
||||
(`RemoteActorService.IsGone`), anything else let go. Forwarded copies have their own dedupe key, so a copy that
|
||||
failed never hides the author's own delivery;
|
||||
- an activity is queued once per id, but an id that comes back carrying another type, actor or object is a second
|
||||
activity, not a copy (Friendica's ids are `uniqid()`, which two of its processes can share), and is queued apart;
|
||||
- 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.
|
||||
object is refetched from its own origin. One attributed to another account of the actor's server (Mobilizon's
|
||||
organiser and the group's event) is taken as that server has it, and deleted once it answers 404 or 410.
|
||||
5. **Status codes:**
|
||||
- bad or missing signature: **401**;
|
||||
- malformed or forged body: **400**;
|
||||
@@ -168,55 +215,210 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
|
||||
`ContentFormat` says what `Text` holds. Remote names are plain text.
|
||||
8. **Circles federate to members only.** A circle is an undiscoverable Group actor that takes follow requests (the owner
|
||||
approves); its posts are addressed to the circle and its `/flock`, delivered to members' personal inboxes, never
|
||||
announced, and served only to a signed request from a member or a member's instance actor
|
||||
(`SignedFetchAuthorizer`), 404 otherwise. Circles never appear in search, lookups, mentions or profile pages.
|
||||
announced. Each member's copy also names that member in `cc` (`OutboxPublisher.Naming`, owner decision
|
||||
2026-10-04), because Mastodon and GoToSocial keep a post only when it names one of their accounts; Create, every
|
||||
Update and the Delete all go through `OutboxPublisher.Publish` for that. A reply to a circle post stays in the circle,
|
||||
and a circle post never asks a non-member for a quote. Circles never appear in search, lookups, mentions or profile
|
||||
pages.
|
||||
**A post that is not public is served only to a signed request from someone it was for** (`SignedFetchAuthorizer.MayRead`):
|
||||
a follower or an addressed account for followers-only, an addressed account for a DM, a member for a circle, or the
|
||||
instance actor of a server where one of them lives; 404 to anyone else, 410 to them once it is deleted. A circle
|
||||
refetch names the requesting member, or the members on the requesting server. A DM's `context`
|
||||
(`/peasants/{name}/whispers/{id}`) lists the conversation's posts for its participants. Mastodon deletes its copy
|
||||
when a refetch answers 404, which is why this matters.
|
||||
**Communities** are FEP-1b12 groups: `GroupDistributor` announces the whole activity (plus the object for new posts,
|
||||
for Mastodon), top-level posts are `Page`s with a `name`, posting follows `Group.PostingPolicy`.
|
||||
Located posts (`LocalGeo`) are the only local-only posts.
|
||||
Located posts (`LocalGeo`) are the only local-only posts. The Mastodon API looks past them everywhere, homes
|
||||
included (the fan-out gives them no `TimelineEntry`, not even their author's): they are read through
|
||||
`/clientapi/post/nearby` and deleted through `/clientapi/post/delete`.
|
||||
9. **A DM joins a conversation only by `DmGroup.ParticipantsKey`**, the exact set of its participants; a remote
|
||||
`context` decides nothing. DMs are `Post`s with `Visibility = Direct` and a `ConversationId` (`DmPost` is legacy).
|
||||
`DmGroup.LastPostId` pages `/api/v1/conversations`, and `ConversationState` keeps each persona's read and removed
|
||||
marks (`Domain/Statuses/ConversationStates.cs`; writing in a conversation reads it).
|
||||
10. **Nothing slow happens inside a request.** Deliveries and inbox processing are `Job`s (`Infrastructure/Jobs`):
|
||||
leased, retried on Mastodon's curve, at most two per host, paused per host by `RemoteInstance`. The inbox answers
|
||||
202 once it has verified and queued; a handler must be idempotent (unique `ObjectURI`, job `DedupeKey`).
|
||||
11. **Every "may anyone see this" goes through `VisibilityPolicy.IsPublic`;** a persona-specific read uses `CanSee`.
|
||||
12. **Remote content is stored only when someone here asked for it:** a persona follows the author, is addressed or
|
||||
202 once it has verified and queued; a handler must be idempotent (unique `ObjectURI`, job `DedupeKey`). A lease
|
||||
(2 minutes, stamped with its own owner) is renewed every third of its length while the handler runs, so a long job
|
||||
runs once; a lease found taken cancels the handler, and `Finish` only counts for the lease it was given.
|
||||
11. **Every number about posts and users goes through `Domain/Privacy/Counted`** (owner decision 2026-10-04: one
|
||||
answer everywhere): a persona's `statuses_count`, its outbox `totalItems`, NodeInfo `localPosts` and the instance
|
||||
`status_count` all count Mastodon's way (not deleted, not a DM, boosts included, circle and located posts too);
|
||||
users exclude personas of banned or deleted roots; `replies_count` counts only public and unlisted replies; a
|
||||
remote account that deletes itself takes its likes, votes, reactions, boosts and replies out of every count
|
||||
(`GoneActors`). Follower and following counts are public, their members never are (`hide_collections` is always
|
||||
true).
|
||||
12. **Every "may anyone see this" goes through `VisibilityPolicy.IsPublic`;** a persona-specific read uses `CanSee`, and
|
||||
any other read of stored posts filters by `IsShown`. All three hide deleted posts and the posts of a remote account
|
||||
that deleted itself (`Post.AuthorGone`: kept, hidden everywhere, owner decision).
|
||||
13. **Remote content is stored only when someone here asked for it:** a persona follows the author, is addressed or
|
||||
mentioned, it replies to a local post, or it is addressed to a community the author follows; a public parent is
|
||||
fetched as context. Followers-only is detected by the author's stored `followers` URL.
|
||||
13. **Home timelines are written, not computed:** every stored or created post goes through `Fanout.Distribute`, and
|
||||
14. **Home timelines are written, not computed:** every stored or created post goes through `Fanout.Distribute`, and
|
||||
every delete removes its `TimelineEntry` rows. Local deletes are soft (content cleared, 410 Tombstone).
|
||||
15. **What PrivaPub passes on is exactly what it received** (owner decision 2026-10-05). A public or unlisted reply from
|
||||
another server to a persona's public, unlisted or followers-only post, and its Update and Delete, go to the
|
||||
persona's followers as the activity arrived (`Arrival.Raw`, `Federation/Inbox/ReplyRelay.cs`), signed by the
|
||||
persona for the transport only: never to the replier's own server, never for a local-only or group post. Only an
|
||||
activity its own actor delivered carries `Raw`, so a forwarded one is never passed on again.
|
||||
16. **An `Accept` or `Reject` is routed by what it answers:** our Follow (`FollowService`, any `-again-N` resend), an
|
||||
interaction request (`InteractionApprovals`, with the author's authorization read back from its origin) or a
|
||||
persona's `Join` (`Participations`, by its `/grunts/join-<id>` id), each only from the origin of what it answers.
|
||||
17. **A server's software is for display, with two exceptions** (owner decisions 2026-10-05 and 2026-10-06): a direct
|
||||
message to one account on a server whose NodeInfo names Lemmy before 1.0 or Mbin goes as a `ChatMessage`
|
||||
(`StatusService.TakesOnlyChatMessages`), as one to an account that writes to us that way does; and a report of a
|
||||
post in a community on a server whose NodeInfo is in `ReportService.ServiceReportTakers` leaves in Lemmy's shape,
|
||||
from the reporter, one `Flag` per post. A server joins that set only once the pasture shows it keeps such a report
|
||||
with its reason. Nothing else may branch on `RemoteInstance.Software`.
|
||||
|
||||
## Mastodon client API invariants
|
||||
|
||||
1. **A token is one persona.** Its subject is the avatar id; the root id lives only in the fifteen-minute `/oauth`
|
||||
cookie used while choosing the persona, and never in a token, an authorization or a response.
|
||||
- **The first-party client exchanges the root JWT for one persona's token** (RFC 8693 on `/oauth/token`,
|
||||
`subject_token_type` `urn:ietf:params:oauth:token-type:jwt`, `avatar_id`; `Api/Mastodon/Auth/PersonaExchange.cs`).
|
||||
Only the seeded public application (`ClientApi:FirstPartyClientId`, default `decepub`) holds the grant.
|
||||
`RootJwtSubjectToken` validates the JWT through `RootJwt`, the same rules as JwtBearer (signature, lifetime, ban,
|
||||
deletion, session stamp). The issued token is the same kind as the authorization code flow's, so revocation and
|
||||
`RootSessions` cover it.
|
||||
2. `/api/*` authenticates with OpenIddict validation, everything else with the old JWT (`PrivaPub` policy scheme).
|
||||
Every `/api` request re-checks that the persona's root is neither banned nor deleted (`MastodonController`).
|
||||
3. Read parameters through `Params` (query, form and JSON merged Rails-style), never MVC binding. A value type read
|
||||
from a conditional must say `(int?)null`, not `default`: that bug once made every list one item long.
|
||||
4. Answer with `Json(...)` (snake_case, explicit nulls) or `Error(status, message)`; page lists with `Page` and `Link`.
|
||||
5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade.
|
||||
6. Advertise `4.2.0 (compatible; PrivaPub)` until grouped notifications exist.
|
||||
5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade. Any `/api`
|
||||
error that leaves without a body (a challenge, a 404 from routing, a 429) gets Mastodon's `{"error": ...}` from
|
||||
`UseMastodonErrorBodies`.
|
||||
6. Advertise `4.2.0 (compatible; PrivaPub)` until streaming and Web Push exist too; `api_versions.mastodon = 7` stays,
|
||||
because clients show quotes from it, so everything a 4.3+ client calls because of it must answer: grouped
|
||||
notifications (`/api/v2/notifications`, its unread count, groups, accounts and dismiss) and the notification policy.
|
||||
The policy is real (`Domain/Social/NotificationPolicies.cs`, applied in `Notifications.Add`): accepted by default,
|
||||
it filters (into a `NotificationRequest` per account, out of every list and count unless `include_filtered`) or drops
|
||||
what strangers, accounts that do not follow the persona (or only for three days), accounts newer than 30 days,
|
||||
unasked private mentions and silenced accounts send; accepting a request lets that account in for good
|
||||
(`NotificationPermission`).
|
||||
7. **What a Mastodon `Status` cannot say goes in `Status.privapub`** (`PrivaPubStatus`): object type, title, excerpt,
|
||||
cover, the author's source, link, video, audio and event details, a remote post's own place (Pixelfed's `location`,
|
||||
never federated again), up/down votes and the viewer's own downvote (`votes.downvoted`, set through PrivaPub's
|
||||
`POST /api/v1/statuses/:id/downvote` and `/undownvote`; a favourite is the upvote), and its community's flairs
|
||||
(`ObjectShapes.Flairs`, both dialects). Every media URL in it goes
|
||||
through the proxy; only page links (`link.url`, an event's online link) point at the remote site, because following
|
||||
one is the reader's choice.
|
||||
|
||||
## Media invariants
|
||||
|
||||
1. **No upload keeps its metadata.** Images are re-encoded by libvips with `keep=none`; audio and video are remuxed with
|
||||
`-map_metadata -1`. `MediaProcessingTests` checks EXIF and XMP are gone.
|
||||
2. Files live under `Media:Root` (`/var/lib/privapub/media`), never in the published directory; the proxy cache is the
|
||||
sibling `media-proxy`, which `/media/files` does not serve.
|
||||
3. **A client never contacts a remote server for media:** every remote URL the API returns goes through
|
||||
`-map_metadata -1`.
|
||||
2. **An image is checked before it is decoded.**
|
||||
- **Loaders:** only libvips' JPEG, PNG, GIF, WebP and HEIF loaders ever run on an upload (`MediaService`'s static
|
||||
constructor blocks every other loader), so an SVG, PDF or TIFF claiming another type never loads.
|
||||
- **Size:** the header alone tells width × height (× frames for a GIF), refused above `Media:MaxPixels` (40 MP) or
|
||||
`Media:MaxFrames`.
|
||||
- **Then:** the image is shrunk on load to `MaxImageSide`, turned by its orientation, and its colours brought into
|
||||
sRGB (`thumbnail`).
|
||||
- **GIFs:** an animated GIF becomes a looping silent H.264 mp4 typed `gifv`, as on Mastodon (ffmpeg with libx264;
|
||||
`PostMedia.Kind` says so), and a still GIF is an image.
|
||||
- **Types:** HEIC and HEIF are not accepted, since the bundled libvips has no HEVC decoder.
|
||||
- **Load:** processing runs `Media:Concurrency` at a time, and uploads have their own rate limit (`uploads`, per
|
||||
credential).
|
||||
3. **Audio and video are processed off the request when the client allows it.**
|
||||
- **v2:** `POST /api/v2/media` stores them as sent in `media-incoming` (beside the root, never served) and answers
|
||||
202 with no url, while a `ProcessMedia` job (one at a time, its lease renewed) does the work. Until then
|
||||
`GET /api/v1/media/:id` answers 206, and 422 with the reason if it failed. Media still processing can't be posted.
|
||||
- **v1:** processes before answering.
|
||||
- **ffmpeg** reads only the stored file: `-protocol_whitelist file`, and the input format is forced from the probe.
|
||||
Data and subtitle tracks are dropped (`-dn -sn`, which iPhone MOVs need).
|
||||
- **Remux or transcode:** a video browsers play as it is (H.264, VP8, VP9 or AV1, within `Media:MaxVideoPixels` and
|
||||
`MaxFrameRate`) is remuxed; anything else is transcoded to H.264 that fits, as Mastodon does.
|
||||
- **Limits:** longer than `Media:MaxSeconds` is refused.
|
||||
- **Saving:** nothing is saved until everything succeeded; outputs move into place rather than being read into
|
||||
memory, and every temporary file goes. FLAC is served as `audio/flac`, and the unit runs with `PrivateTmp`.
|
||||
4. Files live under `Media:Root` (`/var/lib/privapub/media`), never in the published directory; the proxy cache is the
|
||||
sibling `media-proxy` and the trash the sibling `media-trash`, neither of which `/media/files` serves.
|
||||
5. **A file lives exactly as long as something holds it.** Every upload is a `MediaAttachment` row, profile pictures
|
||||
too (`Kind` avatar or header, `ProfileOfAvatarId`). Deleting a post, an edit leaving media out, a replaced picture,
|
||||
a dropped scheduled post, a removed root, and an upload never posted for a day each trash theirs
|
||||
(`IMediaService.Trash`):
|
||||
- the row gets `TrashedAt` in one conditional update, so a row attached meanwhile is left alone;
|
||||
- its files move into `media-trash` at once, so `/media/files` stops serving them;
|
||||
- `MediaJanitor` deletes them a day later (`TrashGrace`).
|
||||
|
||||
What a login's media take (every persona's, trashed ones no longer) is `Counted.MediaOfRoot`, shown to the login at
|
||||
`/clientapi/user/storage`; `Media:QuotaBytesPerRoot` (0, none, by default) refuses an upload over it with 422; the
|
||||
administrator's statistics overview has the totals, the proxy cache and the trash.
|
||||
|
||||
Nothing is deleted because it looks unused. `PrivaPub admin media audit [--fix]` compares disk and database: with
|
||||
`--fix` (as www-data) it gives pictures shown from before their rows a row, and trashes media of deleted posts or
|
||||
personas, rows whose files are missing, and files nothing holds.
|
||||
6. **A client never contacts a remote server for media:** every remote URL the API returns goes through
|
||||
`IMediaProxy.Wrap`, an HMAC-signed `/media/proxy/` URL fetched by `IFederationHttp.GetMedia`.
|
||||
7. **The proxy serves three ways:**
|
||||
- **Cached:** a file already cached is served from disk, ranges included. It is opened before the answer, so a trim
|
||||
that deletes it meanwhile breaks nothing.
|
||||
- **Downloaded:** a request without a `Range` is downloaded whole, up to `Media:MaxProxiedBytes`, then cached:
|
||||
- once for everyone asking for it at the same time;
|
||||
- streamed into a `.part` file beside its place and renamed there, never held in memory;
|
||||
- with its host in its `.type` sidecar, so that blocking a server can purge it.
|
||||
- **Streamed:** a ranged request, or anything too big to cache, is streamed from the origin with the range passed on,
|
||||
and never cached. That is how remote video plays. A file found too big is remembered for an hour, so it isn't
|
||||
fetched twice; one that failed is remembered for five minutes.
|
||||
|
||||
Also:
|
||||
- The cache's size is counted as it grows and trimmed as soon as it passes `ProxyCacheBytes`; the janitor
|
||||
recounts hourly.
|
||||
- Browsers may cache only a success.
|
||||
- Nothing of a suspended server, or of one whose media are rejected, is proxied (avatars, emoji, covers and link
|
||||
cards included, since they all go through it), and blocking one purges its cache.
|
||||
- The `proxy` rate limit counts per client address.
|
||||
|
||||
nginx has a `/media/proxy/` location with `proxy_buffering off` and a 600 s read timeout for those streams.
|
||||
8. **A focal point is two finite numbers** within -1..1 (`FocalPoint.Parse`); anything else is ignored. A stored NaN made
|
||||
every status, timeline and Note holding its post fail to serialise; migration `_016` removed the ones stored before.
|
||||
9. **Remote video and audio become one playable attachment** in the Mastodon API (`MastodonMapper.Playable`): the best MP4
|
||||
up to 720p that carries both sound and picture, including PeerTube's fragmented files inside an HLS entry. HLS
|
||||
playlists themselves are not rewritten.
|
||||
|
||||
## 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).
|
||||
- **Sign-in and recovery never tell which logins or addresses exist** (owner decision 2026-10-04): every failed sign-in
|
||||
gets "That username and password do not match." after the same hashing (`RootUsersService.Decoy`, a constant-time
|
||||
comparison), and only a correct password learns of a ban. Every recovery request answers the same sentence and
|
||||
queues a `SendRecovery` job, found or not; `RecoveryJob` sends the email and keeps only a SHA-256 of the code, for an
|
||||
hour. A recovered password ends every session of the root (`RootSessions`: a new `SessionStamp`, which every `/clientapi`
|
||||
JWT carries, and OpenIddict revocation for each persona).
|
||||
- **Deleting a root deletes everything public it had** (`RootRemoval`, from the admin route or `/clientapi/user/delete`
|
||||
with the password): its sessions end, each persona and each group it owns sends `Delete{Actor}` to followers, members
|
||||
and the accounts it follows, the personas' posts are emptied, and `/peasants/{name}`, its inbox and WebFinger answer
|
||||
410 (`LocalActorService.Gone`) while the names stay reserved. The root keeps only a `deleted-{id}` name.
|
||||
- **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.
|
||||
- **Usernames are `Constants.UserNameRegex`** (`^[a-z0-9_]+$`): the intersection of what Mastodon and Misskey accept. A
|
||||
name outside it creates a persona nobody on those servers can reach.
|
||||
- **An actor's `published` is `PublishedOn`**, a random whole day up to two weeks before creation, so personas made the
|
||||
same day do not share a date. New persona and group ids carry that same day (`GenerateNewID`), because local clients
|
||||
see ids. Never emit `CreatedAt`.
|
||||
- **Per-avatar state stays per avatar:** blocks, mutes, notifications, follows. Nothing may relate sibling avatars.
|
||||
- **Blocks never federate,** and reports leave as `Flag` from the instance actor.
|
||||
- **Blocks federate** (owner decision, 2026-10-01): a block is sent as `Block` from the blocking avatar, an unblock as
|
||||
`Undo{Block}`. Reports still leave as `Flag` from the server's own actors (the instance actor, or the reporter for
|
||||
communities on Lemmy, PieFed and Mbin), never from the reporting avatar.
|
||||
- **What PrivaPub reveals is the owner's call.** Previews, blocks, website authorship, views, bridging and reactions were
|
||||
decided in `docs/ROADMAP.md` ("Owner decisions on what PrivaPub reveals"). Anything new that tells another server
|
||||
something about an avatar gets the same treatment: ask, then record it there.
|
||||
- **Location-ranged posts never federate.**
|
||||
- **Statistics name servers, never people** (owner decisions on statistics, 2026-10-03). Every interaction is recorded
|
||||
through `IInteractionLedger` (`Infrastructure/Statistics`) as an `InteractionEvent` (90 days), but an event never
|
||||
holds a root, persona or group id, an activity id, an inbox URL, a remote actor URI or a sender IP:
|
||||
- Distinct remote accounts are counted with `ActorHash`, an HMAC keyed by that day's `InteractionSalt`, which the
|
||||
day's rollup deletes.
|
||||
- `LocalKind` survives `Interactions.Sanitize` only on public and unlisted traffic, so a circle can show neither by
|
||||
presence nor by absence, and no reason code names one.
|
||||
- Traffic a reader causes (the media proxy, lookups, the client API, fetches of our own documents) is only counted
|
||||
per day (`Count`, `CountServer`), never logged per event.
|
||||
- A host claimed by an unverified sender is kept only if it is already a `RemoteInstance`.
|
||||
- `Record` never blocks and never throws: it writes to a bounded channel, and a full channel drops and counts.
|
||||
|
||||
## Data
|
||||
|
||||
@@ -226,12 +428,154 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
|
||||
- **Collection name = class name, so never rename an entity class.**
|
||||
- `Entity.ID` is a 24-character lowercase hex string; `GenerateNewID()` returns `object`, so cast it.
|
||||
- New fields must be additive: a deploy rollback restores the binary, not the database.
|
||||
- **Every stored remote object has an `ObjectRecord`** (raw JSON up to 256 KB plus its hash; delivered or fetched; the
|
||||
activity, inbox, key and signed headers; received time; up to 10 later revisions). Write it right after the post's
|
||||
save: `CreateHandler` (delivered) and `RemotePosts.StoreContext` (fetched) do, and `UpdateHandler` appends a
|
||||
revision. Delivery details reach them through `Arrival.Current`, which `InboxProcessor` sets for the handler's
|
||||
duration. A fetched record has no signature, key or `@context` of the activity that caused the fetch (its activity
|
||||
fields name that trigger), and every record stores its `Extensions` and `ContextNamespaces` for statistics. The raw form lives outside `Post` so timelines never load it. Read through
|
||||
`/api/privapub/v1/statuses/:id/provenance` and `/api/privapub/v1/instances/:host` (or `?host[]=`, up to 40; this
|
||||
server answers too).
|
||||
- **A server's place leaves only through `PublicGeo.Project`:** the city, coordinates (0.1°) and network it was located
|
||||
to, whatever its size (there is no user threshold), and only the CDN's name for a CDN-fronted one. This server's own
|
||||
place is its host's address located once a day (`SelfLocation`), or `Statistics:Geo:Self` when the owner sets it.
|
||||
- **CDNs are found three ways, best first** (`Infrastructure/Geo/CdnCatalog.cs`): the ranges a CDN publishes
|
||||
(`CdnUpdater` downloads them daily into `CdnRangeSet`, keeping the old set when a download or parse fails), the CDN's
|
||||
fingerprint in the responses PrivaPub already gets (`EdgeHintsHandler` on the federation client, memory only), and
|
||||
a network that carries nothing but a CDN. Never list an ASN shared with plain hosting (AWS, Google, Microsoft,
|
||||
DataPacket). A server's `Geo` records the CDN, its domain and how it was found.
|
||||
- **Servers through time** (`Domain/Statistics/ServerPlaces.cs`, from the weekly `RemoteInstanceSnapshot`s):
|
||||
`/api/privapub/v1/instances/:host/history`, a CDN-fronted server's `geo.before_cdn`, and the CDNs grouped by their
|
||||
own domain (`/api/privapub/v1/cdns`, `/cdns/:domain` with weekly joined/left).
|
||||
- **Remote objects are parsed for every shape in `ObjectShapes`:** `url`/`icon`/`image` as a value, an object or an
|
||||
array; Markdown `content`; missing `mediaType`s inferred; thumbnails from any of their five places; and the typed
|
||||
`Link`, `Video`, `Audio` and `Event` details. A Mastodon API card is built from those, never by fetching the linked page
|
||||
(that fetch is the owner's decision 1, for P6).
|
||||
- **An `Update` is an edit only when its `updated` is newer than ours**, or for a first edit no older and with the text
|
||||
actually changed, since GoToSocial's whole-second timestamps make a quick edit carry `updated == published` (`RemoteEdits.IsEdit`; plain and community-wrapped
|
||||
Updates both go through `RemoteEdits.Apply`, and deletes through `RemoteDeletes.Remove`). Otherwise it refreshes
|
||||
the poll, video, audio and event details and nothing else, and leaves no revision: Mastodon and Misskey refresh poll
|
||||
counts with bare Updates.
|
||||
- **A poll vote is a `Note` with a `name`, an `inReplyTo` that is a poll we hold, and no content.** `CreateHandler`
|
||||
hands it to `PollService.Receive` before anything else, so it never becomes a reply. Our votes on other servers' polls
|
||||
go only to the poll's author, without `published` (PieFed counts a vote only then). A `closed` in the future is the
|
||||
poll's end, not its closing: Akkoma sends an open poll's end only there (`ObjectShapes`).
|
||||
- **Link previews follow owner decision 1** (`Domain/Content/LinkPreviews.cs`): only public posts, queued on arrival with
|
||||
0–60 s of jitter, one cached `LinkPreview` per address for the whole server, never fetched when someone reads.
|
||||
`LinkPreviews.Wanted` is called where posts are saved (`CreateHandler`, `StoreContext`, `StatusService.Publish`).
|
||||
- **Quote states come from `QuoteService.Resolve`:**
|
||||
- with a stamp: accepted only if `Verified` (fetched, on the quoted author's origin, naming both posts exactly);
|
||||
- a FEP-044f `quote` without a stamp: pending;
|
||||
- older keys only: accepted when the quoted post is public.
|
||||
|
||||
`QuotesCount` moves with the accepted state, never with the raw key. When a persona quotes, `QuotePermission` decides:
|
||||
asking first for posts that state a policy, quoting at once for posts that state none, refusing otherwise. Only `quote`
|
||||
of a post that asks for consent puts `quote` in our JSON; older-key quotes leave it out, as Sharkey learned they must.
|
||||
- **Our quote policy is `ActivityPubRenderer.QuotableBy`:** the post's `LocalQuotePolicy`, falling back to the persona's
|
||||
`Settings.QuotePolicy` (default `public`, owner decision), and always `nobody` for anything but public and unlisted. The
|
||||
same rule writes `canQuote`, answers `QuoteRequest`s (`QuoteService.ReceiveRequest`) and fills `quote_approval`, so they
|
||||
cannot disagree. A persona quoting another persona gets a parrot-licence too, so other servers see an approved quote.
|
||||
- **A server is described on arrival, never on read.** The first record from a host enqueues `DescribeInstance` (its
|
||||
NodeInfo, at most once a week, into `RemoteInstance`), so opening the details view tells nobody anything. A
|
||||
description that failed frees its week, so the server's next arrival (an hour on at the soonest) asks again.
|
||||
- **Post ids are the timeline order, so they follow arrival, not `published`.** `PrivacyIds.Arrived` gives a remote post
|
||||
published within the last hour (or in the future) a fresh `ObjectId`, which sorts after every post already stored,
|
||||
and only backfill keeps a `published`-derived id. With `published` ids a reply arriving in the same second could sort
|
||||
under the post it answers, and a late arrival landed behind a client's `since_id` and was never seen. `created_at`
|
||||
comes from `CreationDate`, never from the id.
|
||||
- **The same ordering problem exists on the other side, so `published` carries milliseconds**
|
||||
(`ActivityPubRenderer.Timestamp`). GoToSocial (ULIDs) and Mastodon (Snowflakes) derive a remote status's id from
|
||||
`published` at millisecond resolution. With whole seconds, two of our posts from the same second sorted at random
|
||||
there.
|
||||
- **Startup order:** `EntityMaps.Warm()` (every entity's class map, one at a time; two mapped at once throw "An item
|
||||
with the same key has already been added" and stay broken), then `MigrateAsync` (`Infrastructure/Data/Migrations`,
|
||||
`_NNN_` order, each runs once), then `Indexes.Create()`. A new entity needs nothing; a new unique index needs a
|
||||
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.
|
||||
`/var/lib/privapub/mongo`). The box's shared mongod needs credentials nobody here has. It is a one-member replica set
|
||||
(`--replSet rs0`, a 990 MB oplog; owner decision 2026-10-07), so a backup reads every collection at one instant;
|
||||
`setup.sh` converts it once (PrivaPub stopped, mongod restarted, `rs.initiate`), and every connection string says
|
||||
`directConnection=true`, which also works against a standalone. The pasture's mongo is one too.
|
||||
- **Backups** (`Infrastructure/Backup/`; owner decisions 2026-10-07: the whole server, plain files protected by their
|
||||
permissions, run from the CLI, nightly and from the administrator's page). Each backup is a directory
|
||||
`<yyyyMMdd-HHmmss>-<kind>` under `Backups:Root` (`/var/lib/privapub/backups`, www-data 2770, files 0640), written as
|
||||
`.partial` and renamed once whole:
|
||||
- `manifest.json` (`ArchiveManifest`): host, build, newest migration, whether it was read at one instant, each
|
||||
collection's count, size, sha256 and indexes, what was left out and why, and the media list;
|
||||
- `db/<collection>.jsonl.gz`: each document as one line of canonical Extended JSON, read raw, so every BSON type comes
|
||||
back byte for byte; on a replica set every collection is read in one snapshot session
|
||||
(`minSnapshotHistoryWindowInSeconds` is raised to an hour only while it reads);
|
||||
- `media/`: hard links to the files of untrashed `MediaAttachment` rows (no disk spent; a deleted file stays until the
|
||||
backup rotates out), copies where a link can't be made. `--db-only` lists them instead.
|
||||
|
||||
**Never in a backup** (`ServerBackup.Excluded`): `InteractionSalt` (owner rule), `Job` and `Delivery` (work in flight),
|
||||
`EmailRecovery`, `openiddict.tokens` and `.authorizations` (sessions), `MaintenanceLock`, `ServerSetup` (a restore
|
||||
never opens setup again), and `AppConfiguration` (its SMTP password; it is made again from appsettings at boot). One
|
||||
backup or restore runs at a time
|
||||
(`MaintenanceLock`, a heartbeat document; a holder silent for 2 minutes is taken over), and the media janitor purges
|
||||
nothing meanwhile. `BackupScheduler` backs up at `Backups:NightlyAt` (03:30 UTC), or at once when it missed the night;
|
||||
rotation keeps 7 daily and 4 weekly nightly backups, 3 pre-deploy, 3 pre-restore, and manual and uploaded ones until
|
||||
deleted. CLI: `PrivaPub admin backup [--kind manual|pre-deploy] [--db-only]`, `admin backups`, `admin backup verify
|
||||
<id>`; these run before migrations, so the deploy's backup is of the database as the live build left it.
|
||||
- **Restores** (`ServerRestore`, `ProtectiveMerge`; owner decision 2026-10-07). Asking (`PrivaPub admin restore <id>`, or
|
||||
the administrator's page) checks the backup (same host, a format and newest migration this build reads, every hash)
|
||||
and writes `restore.json` in the backups' root; the running service sees it within seconds (`RestoreWatcher`) and
|
||||
stops, and systemd starts it again. `MaintenanceGate` (in `Program`, right after the build, before migrations, indexes
|
||||
and hosted services) carries it out:
|
||||
1. a pre-restore backup P, taken once (a retry reuses it, never a half-restored database);
|
||||
2. every collection the backup holds dropped and imported raw with its indexes, every other one dropped, except what a
|
||||
backup never holds, which stays as it is;
|
||||
3. media files the live directory lacks linked back from the backup (or moved from the trash); a restore deletes no file;
|
||||
4. **the protective merge from P: a restore never undoes a protective act.** Followers and follows are P's; blocks,
|
||||
mutes, domain blocks, being blocked, reserved names, `DeletedObject`, reports, filters and OAuth applications are
|
||||
the union, P's row winning; roots, personas, groups, posts and remote accounts deleted since are deleted again, with
|
||||
what their deletion takes away; P's moderation of a persona and a root's password, e-mail, ban and policies stay;
|
||||
roots, personas and groups made since become tombstones (deleted, names kept), local posts made since answer 410,
|
||||
media made or trashed since go to the trash;
|
||||
5. every session ends (a new `SessionStamp` for each root, persona tokens deleted), circuits close, and a
|
||||
`RestoreRecord` (never in a backup) tells what happened (`admin restore --status`).
|
||||
|
||||
Each attempt redoes everything. Refused before anything changed, it is abandoned and recorded, and the server boots as
|
||||
it was; failed midway, the process exits 1 and the next start tries again; after 3 failures it exits 75, which the
|
||||
unit's `RestartPreventExitStatus` leaves down for someone to look. While `restore.json` exists, commands exit 75 (but
|
||||
`admin restore --status`) and the deploy refuses to run. For `RestoreRecord.FollowersGrace` (14 days) after a restore,
|
||||
FEP-8fcf rests: no `Collection-Synchronization` header goes out, and a follow only the remote remembers is adopted,
|
||||
not undone.
|
||||
- **The administrator's page** (`BackupController`, `/clientapi/admin/backups`; owner decision 2026-10-07, which approved
|
||||
these endpoints in production): the list (with what runs, the restore waiting, the last restore's report, logins
|
||||
deleted since each backup), back up now (202; the page polls), delete, and:
|
||||
- **download** for the password: a ticket good for ten minutes in the link's path (`/download/{ticket}`, anonymous, so a
|
||||
browser saves it to disk), the backup as one tar (`BackupTar`: its exact length known first, written from the
|
||||
files, `Range` honoured);
|
||||
- **upload** in pieces of at most 32 MB (`TransferStore`): each `PUT ?offset=` must start where the upload stands, else
|
||||
409 with what was received, so a broken upload resumes; `finish` reads it into a backup (`kind` uploaded), refusing
|
||||
anything but plain files under one backup's folder, a path leaving it, or more than the disk holds;
|
||||
- **restore** for the password and the server's host typed out.
|
||||
|
||||
Every call checks the administrator against the database, not only the token. nginx streams downloads for an hour and
|
||||
takes upload pieces unbuffered (`deploy/nginx`, applied by `setup.sh`).
|
||||
- **A persona's archive** (`Domain/Portability/`, `PersonaArchiveController` at `/clientapi/persona/{avatarId}/archive`;
|
||||
owner decision 2026-10-07), for the persona's own root only, never a banned one. State per persona in `PersonaArchive`
|
||||
(never in a backup), files in `<backups>/.personas/<avatarId>/`, a week.
|
||||
- **Export** (`ExportArchiveJob`): Mastodon's account-archive layout, so other servers' importers read it: `actor.json`
|
||||
(its public key only), `outbox.json` (its own posts as Creates, addressed as delivered, but circles' and located
|
||||
posts; its boosts as Announces), `media_attachments/files/` (what the attachments' urls name), avatar and header,
|
||||
`likes.json`, `bookmarks.json` and Mastodon's CSV files; then `privapub/`: filters, followed hashtags, the
|
||||
notification policy, pins, scheduled and located posts, `archive.json`. Nothing of its root, its siblings, its keys,
|
||||
anyone's token. Downloaded through a ten-minute ticket in the link's path.
|
||||
- **Import** (`ImportArchiveJob`, the parts the root picks, with progress, stoppable): uploaded in pieces like a backup,
|
||||
read through `SafeArchive`, which refuses links, paths leaving it, names twice, too many or too large files, a file
|
||||
compressed over 100:1, and streams the outbox an item at a time (none over 1 MB). Only the archive actor's own
|
||||
Creates become posts; boosts, direct and circle posts are counted. **Imported posts are delivered to no one**, put in
|
||||
no home and notify nobody (an exception next to located posts), yet show on the profile, the outbox, hashtags and
|
||||
search. Back home (same actor) a post keeps its id and address, and one already here, even deleted, or tombstoned,
|
||||
stays as it is; from another actor it gets an id of its date, its address here and `ImportedFromURI` (unique per
|
||||
persona), so importing twice changes nothing. Replies and self-quotes inside the archive point at their copies; polls
|
||||
come closed without votes; mentions stay links; HTML is sanitized; media go through `MediaService` and the quota.
|
||||
The other parts go through the existing services: follows asked again (never on a blocked server), blocks, mutes,
|
||||
blocked servers, lists (members only if followed), bookmarks (by signed fetch), filters, followed hashtags, the
|
||||
notification policy, pins (no `Add` delivered), the profile (announced as Settings would). Located and scheduled
|
||||
posts (PrivaPub's archives) and likes (each tells its author) only when asked. Followers are never imported.
|
||||
|
||||
## Code style
|
||||
|
||||
@@ -243,37 +587,340 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
|
||||
- Localised strings go through `IStringLocalizer<GenericRes>`.
|
||||
- Every `Display`/`ErrorMessage` resource key must exist in `FieldsNameResource`/`ErrorsResource`, including the
|
||||
`Designer.cs`, which the CLI build doesn't regenerate. A missing key throws at validation time.
|
||||
- **`cond ? value : default` with a value-type branch is the type's default, not null:** `false`, `0`, or year one
|
||||
stored as an edit date on every remote post. Write `(T?)null`. It has shipped four times (`MastodonParams.Bool/Int`,
|
||||
`NoteParser.Int`, `NoteParser.Time`).
|
||||
- ActivityPub output is built with `System.Text.Json.Nodes` in `ActivityPubRenderer`, not typed models. Inbound
|
||||
documents are read through `InboxService.Id`/`Value`, which handle string, object and array.
|
||||
documents are read through `ActivityJson.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
|
||||
## Verifying
|
||||
|
||||
`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:
|
||||
There is no test project (owner decision 2026-10-17: `PrivaPub.Tests` was removed, with the CI and deploy steps that
|
||||
ran it). Don't add one back unless asked. Verify by building, running locally, and exercising:
|
||||
- the client API (sign up, create an avatar, a group, a post);
|
||||
- the ActivityPub endpoints with curl and `Accept: application/activity+json`.
|
||||
|
||||
Interop is checked against real servers: Fediverse Pasture with podman on the workstation, verify.funfedi.dev, and
|
||||
the owner's GoToSocial at social.arasaka.software. **Ask before acting from the owner's GoToSocial account.**
|
||||
Interop is checked against real servers, starting with the workstation's own pasture:
|
||||
|
||||
```bash
|
||||
DOTNET=~/.dotnet/dotnet tools/pasture/run.sh up [gts mastodon ...] # podman: PrivaPub + the named peers (default gts) + Mongo, behind Caddy
|
||||
tools/pasture/interop.sh [gts mastodon ...] # each peer's scenario, then what PrivaPub's statistics saw of it
|
||||
tools/pasture/run.sh down # removes every pasture container and volume
|
||||
```
|
||||
|
||||
- **A change to what PrivaPub sends** (the `@context`, an actor's properties, an activity's shape, a delivery's headers)
|
||||
is run against every peer's scenario before it is committed, not only the peers it was written for. One reader
|
||||
stricter than the rest fails on it alone: the 2026-10-06 `proof` term passed every peer but Smithereen, whose JSON-LD
|
||||
1.0 reader refused every document carrying it.
|
||||
- **Layout:** `lib/pasture.sh` (network, Caddy, Mongo, PrivaPub), `peers/<name>.sh` (`<name>_up`, plus `peers/shared.sh`
|
||||
for the Postgres and Redis several peers share), `lib/interop.sh` (`ok`, `ko`, `xf` for a check expected to fail until
|
||||
a later phase, `privapub_token <persona>`, `stats_check <host> <software>`), `scenarios/<name>.sh`. Each peer talks to
|
||||
its own PrivaPub persona under one root, so OAuth's persona choice is exercised too. Images are pinned.
|
||||
- Caddy's CA lives in the `pasture-caddy-data` volume and is copied to `tools/pasture/.ca/root.crt` (and `bundle.pem`
|
||||
with the system roots) for peers that must trust it instead of skipping verification.
|
||||
|
||||
- **All sites on one podman network, one Caddy in front.** `privapub.test`, `gts.test`, `mastodon.test` and the other
|
||||
peers are network aliases of the Caddy container, which serves them all with its internal CA (`tls internal`).
|
||||
PrivaPub accepts any certificate (`appsettings.Pasture.json`) and GoToSocial is told to skip verification
|
||||
(`GTS_HTTP_CLIENT_TLS_INSECURE_SKIP_VERIFY`); Mastodon trusts the copied CA through `SSL_CERT_FILE`. Peers fetch over
|
||||
https only, so plain http between them is not an option.
|
||||
- From the workstation, PrivaPub's API is `http://127.0.0.1:6971`. A peer is reached as `https://<name>.test:6443` with
|
||||
`curl -k --resolve <name>.test:6443:127.0.0.1`, because sign-in cookies are bound to the host name.
|
||||
- **GoToSocial (0.22.1):**
|
||||
- Its cached home timeline can stop taking new posts after its first read, its owner's own included, while a
|
||||
`min_id` query shows them all. So a delivery is checked by looking the object up by URI with `resolve=false`
|
||||
(`on_gts`), which answers from GoToSocial's database and never fetches from us. A home-timeline check there proves
|
||||
nothing, in either direction. A deleted status still turns up in that search as a "deleted status" stub, so a
|
||||
delete is checked as a 404 on `/api/v1/statuses/{id}`.
|
||||
- It creates its accounts locked, so the scenario approves alice's request through `/api/v1/follow_requests`, which
|
||||
also checks our pending (`requested`) state and the manual Accept.
|
||||
- 64 checks: discovery and follows both ways; posts and CW; a reply and its notification; likes and boosts both
|
||||
ways; DMs both ways and off public timelines; polls both ways; quote policy; link cards; edits and deletes both
|
||||
ways; communities and circles; locked personas; interaction policies (a reply and a like asked for and approved
|
||||
through `/api/v1/interaction_requests`, a boost refused); unfollow; block and unblock; statistics.
|
||||
- It files a circle post as a direct message and shows it only to the accounts it mentions, so like a DM it is
|
||||
checked in the member's `/api/v1/conversations`, never by URI.
|
||||
- **Mastodon (4.7.3):** web and sidekiq on the shared Postgres and Redis, `ALLOWED_PRIVATE_ADDRESSES` for the network.
|
||||
Its token comes from `rails runner` (no password grant). Without Elasticsearch its status search finds nothing, so
|
||||
deliveries are checked through `/api/v1/accounts/:id/statuses` of the sender as Mastodon knows them, or
|
||||
`Status.exists?` through `rails runner`. Its actors are numbered (`/ap/users/<id>`), so look URIs up rather than
|
||||
build them. Circle posts and a followers-only post survive its signed refetch (`ActivityPub::FetchRemoteStatusService`
|
||||
through `rails runner`). Its block of a persona, and the unblock that ends the run, are checked through our
|
||||
`blocked_by`; the run starts with that unblock too, since Mastodon rejects every follow from an account it blocks.
|
||||
- **Misskey (2026.10.0):** one container on the shared Postgres and Redis. A new Misskey federates with nobody
|
||||
(`federation: none`) until `admin/update-meta` says `all`, which `misskey_up` does. Its API is `POST /api/<endpoint>`
|
||||
with the token as `i` (`mk` in the scenario); `users/relation` answers a list, `users/notes` leaves replies out unless
|
||||
asked, and a user has one reaction per note. Never verify a delivery with `ap/show`: it fetches. 35 checks.
|
||||
- **Sharkey (2025.4.7):** `peers/sharkey.sh` is the Misskey peer under another name, image, database, Redis db and
|
||||
home (`/sharkey/.config`); `scenarios/sharkey.sh` runs Misskey's scenario with `MISSKEY_NAME=sharkey`, then follows
|
||||
again (Misskey's ends unfollowed and blocked) and checks edits both ways and the FEP-e232 quote tag. 40 checks.
|
||||
- **Akkoma (3.20.1):** no official image, so `images/akkoma` installs the OTP release (pinned by checksum; the "stable"
|
||||
zip moves, and a moved one fails the build) and `akkoma_up` builds it once. Its HTTP clients read the CA bundles
|
||||
shipped in the release (CAStore, certifi), so the entrypoint appends Caddy's CA there. `pleroma_ctl` passes its
|
||||
arguments on unquoted: no value may contain a space. Its Linkify never takes `@user@host.test` for a mention, so the
|
||||
scenario addresses alice with Pleroma's `to[]`; its API never reports a remote blocker as `blocked_by`, so the block is
|
||||
read from its `user_relationships`. `akkoma_token <user>` signs one of its users in. 45 checks.
|
||||
- **Lemmy (1.0.0-beta.2):** the backend alone on the shared Postgres, its admin made by `setup` in the generated
|
||||
`config.hjson`. It trusts Caddy's CA through `SSL_CERT_FILE` and reaches the network through
|
||||
`DANGER_FEDERATION_ALLOW_LOCAL_IP=1`; 0.19 cannot join (its rustls trusts only its bundled roots). Its API is
|
||||
`/api/v4/<path>` with a bearer token (`lm` in the scenario), and `sort` values are lowercase. It logs no refused
|
||||
activity at `warn` (`LEMMY_LOG` sets `RUST_LOG`); the reason is in the 400's body. It answers our community's echo of
|
||||
its own activity and every bare `Announce{object}` 400 by design, and the echo is still needed (see
|
||||
`docs/INTEROP.md`, Lemmy). A new Lemmy never sends what it queued for a server before its send worker for that
|
||||
server started, so the scenario waits for that worker (`lm_worker`) before its first follow, and it sends what it
|
||||
queued every 30 seconds, so a vote or a moderator's act takes up to a minute. Its lock, unlock and unban need a
|
||||
`reason`, or it refuses them and federates nothing. 29 checks, among them a moderator's lock, ban and removal.
|
||||
- **PieFed (1.7.17):** dockurr's image of the release, its web app (with PieFed's own cron, `CRON=true`, for its send
|
||||
queue) and a Celery worker on the shared Postgres and Redis (dbs 10 and 11), sharing the `pasture-piefed-media`
|
||||
volume. httpx trusts only certifi's bundle, so the pasture's is mounted over it. `flask init-db` reads its admin
|
||||
(pfuser) from stdin and drops every table it finds, so it runs once, after the web app's migrations ("Starting
|
||||
Gunicorn"). Its API is Lemmy's v3 under `/api/alpha` with a JWT (`pf` in the scenario); `resolve_object` answers a
|
||||
view (`community.community.id`). It sends a community's announces to the Application at a peer's root, assuming
|
||||
`/inbox` when there is none, and keeps serving a thread its moderator removed. `scenarios/piefed.sh`, 29 checks:
|
||||
communities both ways, threads with titles, comments, votes up and down both ways, a community poll and alice's vote,
|
||||
private messages both ways, a moderator's lock, unlock and removal, the unfollow, statistics.
|
||||
- **Mbin (1.10.1):** its own image (FrankenPHP serving plain HTTP behind Caddy, `SERVER_NAME=:80`) and a messenger
|
||||
worker (`mbin_worker`, consuming again whenever it stops: once, the shared Postgres restarted under it and Mbin sent
|
||||
nothing for a day), on the shared Postgres and Redis (db 12) with a RabbitMQ of its own (its transports carry AMQP options; it
|
||||
runs as its own user on its own volume, or it cannot read the `.erlang.cookie` it wrote as root). The pasture's bundle
|
||||
is mounted over the system one; its API's rate limits (two threads every six minutes) are raised by a copy of its
|
||||
`rate_limiter.yaml`. Its admin mbuser is made by its console; `peers/mbin_token.py` gets mbuser's OAuth token through
|
||||
the authorization-code flow (login form, consent), since a client-credentials client acts as a bot that may not vote.
|
||||
Mbin names what it makes during a request after the request's host, so every call says `Host: mbin.test`, never
|
||||
the workstation's port. Its access token lasts an hour, and the scenario gets a new one when it has expired. Its API
|
||||
never starts a conversation with an account elsewhere, so mbuser answers in the thread alice began; Mbin addresses
|
||||
that answer to alice's profile page. `scenarios/mbin.sh`, 26 checks: magazines both ways, threads with titles, a Note
|
||||
to a magazine as a microblog post, comments both ways, favourites and upvotes both ways, private messages both ways,
|
||||
a moderator's lock, unlock and removal, the unfollow, statistics.
|
||||
- **NodeBB (4.16.1):** the official image on the pasture's Mongo (database `nodebb`), set up once by its automated
|
||||
setup (`SETUP` with `NODEBB_*` variables, admin nbuser) into the `pasture-nodebb-config` volume, trusting the CA
|
||||
through `NODE_EXTRA_CA_CERTS`; its image runs `npm install` at every start. Its API (`/api/v3`) takes a bearer token
|
||||
that `nodebb_settle` writes where NodeBB keeps its tokens. A remote account's uid there is its actor's URL, but its
|
||||
API follows one only when named by its handle; a chat room is made, then written in. `scenarios/nodebb.sh`, 23
|
||||
checks: a category followed and its topic as a titled thread, replies both ways, nbuser following alice, votes both
|
||||
ways, an edit and a deletion, a chat both ways, the unfollow, statistics.
|
||||
- **Lemmy 0.19 (0.19.20), as `lemmy19.test`:** what most of the threadiverse runs. Its release trusts only the roots its
|
||||
rustls bundles, so `images/lemmy19` builds the tag with reqwest's `rustls-tls-native-roots` added (about ten minutes
|
||||
the first time) and the pasture's bundle is mounted as the system's. Its config names the database as `uri` (1.0:
|
||||
`connection`) and its API is v3 (`/api/v3`, `sort=New`, `resolve_object` answering views). It takes private messages
|
||||
only as `ChatMessage`, which PrivaPub sends it, a first message too (invariant 17). `scenarios/lemmy19.sh`, 30
|
||||
checks.
|
||||
- **Threads (`scenarios/threads.sh`, needs mastodon):** mastouser follows alice, carol answers alice's public post,
|
||||
and opening the thread on Mastodon finds carol's reply through alice's `replies` (the scenario makes Mastodon's copy
|
||||
look ten minutes old: Mastodon reads a thread's replies only five minutes after it first holds the post). 5 checks.
|
||||
- **Followers synchronisation (`scenarios/followsync.sh`, needs mastodon):** sent: PrivaPub forgets mastouser's follow
|
||||
of alice (mongo), alice's followers-only post naming mastouser makes Mastodon read her roll-call and drop it; then
|
||||
Mastodon forgets a follow (rails) and alice's next followers-only post makes it send the `Undo` PrivaPub applies.
|
||||
Received: alice and bob follow mastouser; Mastodon forgets alice's follow and mastouser's followers-only post makes
|
||||
PrivaPub end it, then PrivaPub forgets it and the next makes PrivaPub undo it there. Follows are checked in Mastodon's
|
||||
database: its API caches relationships for a day. 15 checks.
|
||||
- **Pins (`scenarios/pins.sh`, needs mastodon):** a Mastodon account pins and unpins while alice follows it, alice pins
|
||||
and unpins while it follows her, a fresh account's earlier pin shows once PrivaPub resolves it, and following it brings
|
||||
its earlier posts (its outbox). 9 checks.
|
||||
- **Vernissage (1.43.0):** its image on SQLite in the `pasture-vernissage` volume, its queues on the shared Redis's
|
||||
database 0 (it federates nothing without them; every other database is taken), its seeded admin (admin / admin)
|
||||
logged in by the scenario. `scenarios/vernissage.sh`, 19 checks.
|
||||
- **BookWyrm (0.9.3):** its image, migrated, on the shared Postgres (database `bookwyrm`) and Redis (databases 14
|
||||
and 15), gunicorn and one Celery worker (federation runs there). It has no client API: bwuser (named
|
||||
`bwuser@bookwyrm.test`, as its signup names users), a book and every status come from its Django shell
|
||||
(`bookwyrm_shell`, `-v 0`). `scenarios/bookwyrm.sh`, 20 checks.
|
||||
- **Ghost (6.67) with its ActivityPub service (1.2.14):** Ghost and the service on the shared MySQL (databases `ghost`
|
||||
and `activitypub`, the service's migrations run first), routed by Caddy as Ghost's own proxy does. The owner comes
|
||||
from Ghost's setup API (a password that repeats the site's name is refused), staff device verification is off, and
|
||||
Ghost starts again once so it sets up its publishing webhooks. Calls to the service say `Host: ghost.test`, or it
|
||||
cannot find the site's keys. `scenarios/ghost.sh`, 22 checks.
|
||||
- **Owncast (0.3.0):** its image, its data in the `pasture-owncast` volume, federation turned on through its admin
|
||||
API (`owncast_admin`, its default admin / abc123) with the server's address and the actor's name.
|
||||
`scenarios/owncast.sh`, 13 checks.
|
||||
- **WriteFreely (0.17.2):** built from its release tarball with `openssl` beside it (`images/writefreely`, it makes
|
||||
each blog's keys with the command), on SQLite in the `pasture-writefreely` volume, which the scenario reads from the
|
||||
host. wfuser and its blog come from its CLI; its API takes a login. `scenarios/writefreely.sh`, 13 checks.
|
||||
- **snac2 (2.95):** built from its tag (`images/snac2`, the project publishes no image), its data in the
|
||||
`pasture-snac` volume from `snac init` and `snac adduser`; the password comes from `snac resetpwd` and the token from
|
||||
its login form and a code grant. Its API lags behind what it has taken in, so `scenarios/snac.sh` reads its files
|
||||
(`snac_stored`, `snac_in_timeline`). 27 checks.
|
||||
- **Mitra (5.9.1):** its image on the shared Postgres (database `mitra`), as `mitra.test`, with the pasture's bundle as
|
||||
the system's roots; mitrauser comes from its CLI and its token from a password grant. Its search resolves an account
|
||||
elsewhere only without `type`. `scenarios/mitra.sh`, 28 checks.
|
||||
- **Relays (`peers/relay.sh` Activity-Relay 2.0.9 as `relay.test`, on the shared Redis's database 13;
|
||||
`peers/aoderelay.sh` aode-relay 0.3.129 as `aoderelay.test`):** `appsettings.Pasture.json` names both in
|
||||
`Federation:Relays`, so PrivaPub subscribes a minute after it starts. `scenarios/relay.sh` has Mastodon subscribe to
|
||||
each in turn, checks that a post of an account nobody here follows reaches the federated timeline (forwarded by one,
|
||||
announced by the other), that alice's public post goes to the relays (and through both to Mastodon: Activity-Relay's
|
||||
forward on its FEP-8b32 proof, after the scenario makes Mastodon read alice's keys anew) and nothing less public, and
|
||||
has Mastodon leave again, so the town sees no relayed posts. 16 checks.
|
||||
- **Castopod (1.15.5, `peers/castopod.sh`):** the official image on the shared MySQL (database `castopod`, file cache,
|
||||
`CP_DISABLE_HTTPS`), its database and superadmin (admin@castopod.test) from `spark`, its REST API switched on in the
|
||||
`.env` the image rewrites at each start, `spark fediverse:broadcast` looping in the container. The podcast `@pod` is
|
||||
made and published through its admin pages (`cp_web`, CSRF on each form). `scenarios/castopod.sh`, 15 checks; the
|
||||
episode's sound is made with ffmpeg on the workstation.
|
||||
- **Ktistec (3.13.0, `peers/ktistec.sh`):** built from its tag by `images/ktistec` (its own Dockerfile, pinned: a static
|
||||
Crystal server, its assets with node), SQLite in the `pasture-ktistec` volume, the pasture bundle mounted as
|
||||
`/etc/ssl/cert.pem`. The server's name and ktuser come from its setup API (every field required, `summary` too);
|
||||
`ktistec_token` signs in on `/sessions`. `scenarios/ktistec.sh`, 27 checks: its Mastodon API where it has the route,
|
||||
its outbox API (`kn`) for follows, polls (`poll-duration` is seconds from now), edits, deletions and quotes, and a
|
||||
copy of its SQLite database (`kt_sql`) for what it holds.
|
||||
- **Forte (26.9.10, `peers/forte.sh`):** built from its tag by `images/forte` (composer on Hubzilla's PHP image, an nginx
|
||||
routing through `index.php?req=`), on the shared MySQL (database `forte`), its `.htconfig.php` written to
|
||||
`.state/forte`, a cron sidecar (`src/Daemon/Run.php Cron`). The pasture bundle is mounted over `library/cacert.pem`,
|
||||
the bundle its curl uses. ftuser (administrator) and ftfriend are made through its PHP (`forte_eval`: `Account::create`,
|
||||
`Channel::create` with the role `social` and `autoperms`, or every connection waits). Connecting from the command line
|
||||
runs the notifier itself (`ft_connect`). `scenarios/forte.sh`.
|
||||
- **Hubzilla (11.4.1, `peers/hubzilla.sh`):** the hlhd image (php-fpm and nginx on 8080) on the shared MySQL
|
||||
(database `hubzilla`, its schema loaded from the image), `.htconfig.php` written to `.state/hubzilla` and mounted, a
|
||||
cron sidecar running `Zotlabs/Daemon/Master.php Cron` each minute. Its addons `pubcrawl` (ActivityPub) and `statistics`
|
||||
(NodeInfo) are installed with `util/addons`; hzuser (the hub's administrator) and hzfriend are made through its own PHP
|
||||
(`hz_eval`), as accounts written the way its registration queue would and channels with the role `public` (the older
|
||||
role names grant connections nothing), the ActivityPub app installed as each channel. Its API (`api/z/1.0`) posts and
|
||||
edits with HTTP Basic (email and password); liking and dropping go through its web pages signed in (`hz_web`).
|
||||
`scenarios/hubzilla.sh`, 20 checks and one known gap (G-0010, its unfollow).
|
||||
- **Smithereen (1.0.3):** its image on the shared MySQL (database `smithereen`, its schema from the image's commit),
|
||||
with imgproxy and a file server behind Caddy as `smithereen.test` (`/i` and `/s`), trusting the CA through a JDK
|
||||
store with it added (`JAVA_TOOL_OPTIONS`). MySQL takes its stored functions only with
|
||||
`log_bin_trust_function_creators`, and a first start that failed there leaves the database without triggers (drop it
|
||||
and start again). Accounts come from its signup form (opened, with a short description, which its NodeInfo needs) and
|
||||
are renamed in its database; its API is VKontakte's (`/api/method/<name>?v=1.0`), with a password grant for a local
|
||||
application the peer script inserts. `scenarios/smithereen.sh`, 40 checks, walls both ways (alice's profile is
|
||||
told again first, so Smithereen reads her `sm:wall`; `servers.features & 1` says it took PrivaPub for a server with
|
||||
walls). Its scenario texts avoid apostrophes: `p_home_has` puts them inside a Python string.
|
||||
- **Load (`load.sh`, needs the `flood` peer):** `flood` (`flood/flood.cs`, published once into `.flood`) answers as
|
||||
twenty fake servers and sends signed activities at a set rate; `load.sh --rate=N --seconds=N` measures the answers,
|
||||
the queue's wait and processing times, its drain, and a persona's home timeline meanwhile, and keeps each run in
|
||||
`out/load/`. `docs/LOAD.md` has the method and the runs.
|
||||
- **Account moves (`scenarios/moves.sh`, needs gts):** two fresh GoToSocial accounts made by its admin CLI; alice follows
|
||||
the old one, the new one names it as an alias (`/api/v1/accounts/alias`), the old one moves (`/api/v1/accounts/move`),
|
||||
and PrivaPub shows it `moved` and moves alice's follow to the new one, which (locked by GoToSocial) approves it.
|
||||
8 checks.
|
||||
- **Hollo (0.9.19):** Fedify's microblog server on the shared Postgres, set up through its web form (which
|
||||
checks `Origin` against `Host`, so the request names `hollo.test` without the port). It needs a 44-character
|
||||
`SECRET_KEY`, a media directory and a `themeColor`; statuses and votes go as JSON. Town only, no scenario.
|
||||
- **PeerTube (8.3.1):** the official image on the shared Postgres and Redis (db 4), configured through `PEERTUBE_*`
|
||||
variables, trusting Caddy's CA through `NODE_EXTRA_CA_CERTS`; `peertube_settle` turns transcoding off (a test video
|
||||
is served as uploaded) and keeps root's token in `.state/peertube.token`. It wants a bare `Host: peertube.test`.
|
||||
Its list of comments held for review (`/api/v1/users/me/videos/comments?isHeldForReview=true`) answers 500 in 8.3.1;
|
||||
the video's owner sees them in the video's threads (`heldForReview`). `scenarios/peertube.sh`, 28 checks.
|
||||
- **Pleroma (2.10.2):** `images/pleroma` installs the OTP release, pinned by checksum, as Akkoma's does; it also needs
|
||||
libvips, and `instance gen` asks about deduplicating uploads. Its federation runs on hackney, which trusts only
|
||||
certifi's compiled-in roots, so the entrypoint points `:pleroma, :http, adapter` at the system CA bundle. It answers
|
||||
an inbox POST at once and verifies in a worker (`oban_jobs` shows what it refused and why). Town only, no scenario.
|
||||
- **Iceshrimp.NET (2026.1.2-beta):** on the shared Postgres with AuthorizedFetch on and open registrations, trusting
|
||||
Caddy's CA through `SSL_CERT_FILE`. Accounts come from its own `/api/iceshrimp/auth/register` (its login cuts the
|
||||
connection short for a name it does not know), Mastodon API tokens from its OAuth form, which prints the out-of-band
|
||||
code in the page. Its `jobs` table shows what it queued for whom. Town only, no scenario.
|
||||
- **Pixelfed (0.14.4):** serversideup's FrankenPHP image on the shared Postgres (database pixelfed) and Redis (dbs 5 and
|
||||
6), with Horizon and the scheduler as sidecars of the same image sharing its storage volume, and Caddy's CA mounted
|
||||
as its system bundle. `pixelfed_settle` makes `caption` nullable (its PostgreSQL migration never runs, so every remote
|
||||
boost failed), creates Passport's keys and restarts the web server so FrankenPHP reads them, numbers the OAuth
|
||||
clients from a million (Passport refuses a token whose user id equals its client's id), and imports its cities.
|
||||
Tokens are personal access tokens made through tinker (`App\Models\User`). Every top-level post needs a picture.
|
||||
`scenarios/pixelfed.sh`, 26 checks; the town's driver and `specs/pixelfed-pair.json`.
|
||||
- **WordPress (7.1.2, ActivityPub plugin 9.3.1):** the official image on a shared MySQL 8.4 (`shared_mysql_up`, ready
|
||||
only when it answers over TCP: its first start runs a server without networking), installed and configured by wp-cli,
|
||||
its CA bundle (`wp-includes/certificates/ca-bundle.crt`) given Caddy's root, and WP-Cron run every five seconds by a
|
||||
sidecar (`DISABLE_WP_CRON`), since the plugin federates from it. Its automatic updates are off
|
||||
(`AUTOMATIC_UPDATER_DISABLED`): the sidecar ran one (6 to 7.1.2) whose new CA bundle dropped Caddy's root, and
|
||||
WordPress reached nobody after it. Authors are actors; the REST API takes application passwords.
|
||||
`scenarios/wordpress.sh`, 17 checks.
|
||||
- **Friendica (2026.05):** the official image on the shared MySQL (database friendica) and Redis (db 7, for its cache,
|
||||
locks and sessions), installed by its autoinstall, with the image's worker daemon (`cron.sh`) as a sidecar sharing
|
||||
its files. `friendica_settle` repairs what the install leaves: it names the system user (uid 0), or Friendica never
|
||||
makes the system account that signs its fetches; it declares the daemon running (`worker_daemon_mode` in
|
||||
`key-value`), or the web container, which cannot see the sidecar's pid, never wakes it and everything waits for its
|
||||
five-minute cron; it makes the log file and turns logging on. `friendica_user` saves each account through the API
|
||||
with `locked=0` (a number: "true" reads as 0), which makes it a "soapbox" page that takes followers without asking and
|
||||
follows nobody back, and reads its outbox once: a Follow that reaches a user Friendica has not cached yet makes it
|
||||
fetch that user from itself, signed, and checking the signature recurses for about five minutes. Its API takes HTTP
|
||||
Basic (`nick:password`); an edit through it never federates, so the scenario edits in the web editor.
|
||||
`scenarios/friendica.sh`, 25 checks; the town's driver and `specs/friendica-pair.json`.
|
||||
- **Mobilizon (5.2.4):** kaihuri's image of the release on a PostGIS of its own (`shared_postgis_up`: it needs the
|
||||
extension), with the pasture's bundle mounted over certifi's and castore's CA files (hackney trusts only those) and
|
||||
the system store, and geocoding pointed at a closed local port. `mobilizon_ctl users.new` makes mzuser (it splits
|
||||
its arguments on spaces); the API is GraphQL (`/api`), signed in by the `login` mutation. An event made through it
|
||||
without options has its comments closed. `scenarios/mobilizon.sh`, 27 checks: a group and its events (dates, place),
|
||||
alice joining an event (Mobilizon's participant row and its Accept) and leaving it, comments both ways, the
|
||||
organiser's edit, closed comments and deletes, a group post, the unfollow.
|
||||
- **Gancio (1.28.2):** cisti's image on sqlite in the `pasture-gancio-data` volume, whose `config.json` is written before
|
||||
the first start (without it Gancio waits in its setup wizard), trusting Caddy's CA through `NODE_EXTRA_CA_CERTS`.
|
||||
`gancio users create` makes gcadmin; `gancio settings set enable_resources true` keeps fediverse replies. One
|
||||
Application actor, `relay`, publishes the agenda. Its API takes a token from an OAuth password grant
|
||||
(`/oauth/login`, client_id `self`); an event is a multipart POST (`--form-string` for text starting with `<`).
|
||||
`scenarios/gancio.sh`, 17 checks.
|
||||
- **Funkwhale (2.0.11):** the API (gunicorn), a Celery worker with its beat and the front's nginx, sharing the
|
||||
`pasture-funkwhale-data` volume, on the shared Postgres (database funkwhale) and Redis (dbs 8 and 9); Python's requests
|
||||
trusts the pasture's bundle through `REQUESTS_CA_BUNDLE`. `funkwhale-manage fw users create` makes fwuser and an OAuth
|
||||
access token is made in its Django shell. Its API is `/api/v1` and `/api/v2` (uploads); a track is a fresh tone made
|
||||
by ffmpeg in its container, since it skips a file it already has. `scenarios/funkwhale.sh`, 16 checks.
|
||||
- **SecureMode:** `PRIVAPUB_ENV="Federation__SecureMode=true" run.sh up …`, as production runs. A check of what an
|
||||
unsigned reader sees uses `unserved` and `gone_unsigned` (`lib/interop.sh`), which expect 401 when SecureMode is on and
|
||||
404 or 410 when it is off.
|
||||
- **Crawler:** `PRIVAPUB_ENV="Statistics__Crawler__Enabled=true Statistics__Crawler__Seeds__0=mastodon.test"
|
||||
run.sh up mastodon`, then `interop.sh crawler`. `PRIVAPUB_ENV` passes any setting to the PrivaPub container.
|
||||
- `run.sh up` replaces every container, Mongo included, so each run starts clean. To keep the data, republish into
|
||||
`tools/pasture/.publish` and `podman restart pasture-privapub`; that is how a migration is tried on dirty data.
|
||||
- `run.sh add <peer>` starts more peers next to a running pasture; `run.sh down` removes every pasture container with
|
||||
its volumes (`--purge` drops Caddy's CA too); `run.sh stats` shows each container's memory.
|
||||
- **The network looks public** (`PASTURE_SUBNET`, default `11.42.0.0/24`), so peers that refuse private addresses with no
|
||||
switch (Pixelfed, Mbin, WordPress, Discourse, Fedify) federate. Mastodon then needs `TRUSTED_PROXY_IP` for Caddy, or it
|
||||
answers every request with an IP spoofing error.
|
||||
- **`PASTURE_PORT`** moves PrivaPub's workstation port off 6971 when something else holds it; `lib/interop.sh` follows.
|
||||
- Every `ok`/`ko`/`xf` is also a line of `out/scenarios.jsonl`, which the town's report shows.
|
||||
|
||||
#### The town (`tools/pasture/town.sh`, `tools/pasture/town/`)
|
||||
|
||||
A fake community seeded across every running peer, then checked for coherence: what each server holds (read from its
|
||||
database, never by making it fetch), what each account sees through its API, counts, threads, edits, deletes, follows
|
||||
and the privacy rows. Python 3 standard library only, tabs.
|
||||
|
||||
```bash
|
||||
tools/pasture/town.sh selftest [peer...] # each driver against its own server; run after changing a driver or a pin
|
||||
tools/pasture/town.sh seed village # specs/village.json: 23 accounts on seven servers, about 11 minutes
|
||||
tools/pasture/town.sh check village # out/<run>/results.json; --deadline=0 re-sweeps an old run at once
|
||||
tools/pasture/town.sh report village # out/<run>/report.html, publishable (fake data, no tokens)
|
||||
tools/pasture/town.sh report village hollo-pair iceshrimp-pair pleroma-pair pixelfed-pair friendica-pair # one matrix
|
||||
tools/pasture/town.sh backlog --write # docs/INTEROP-BACKLOG.md
|
||||
```
|
||||
|
||||
- **Drivers** (`dialects/`): one class per platform on a dialect base (`mastodon_api`, `misskey_api`, `lemmy_api`,
|
||||
`privapub`). `stored()` reads the database (shared Postgres through `psql`, the shared MySQL through `mysql_json`,
|
||||
GoToSocial's sqlite copied out, Mongo);
|
||||
`seen()` asks the API as one account. A driver that cannot do something raises `Unsupported`, and the planner never
|
||||
asks for it (`gen.CAPS`).
|
||||
- **The plan** (`gen.py`) is a pure function of the spec and its seed; each run keeps its own `plan.json`, `ledger.jsonl`
|
||||
(what happened, one fact per line) and `results.json`. `seed --resume` redoes only failed steps.
|
||||
- **Cells** are `feature|origin|observer|direction` (`in`, `out`, `via`, `control`, `local`). A known gap is a
|
||||
`gaps.json` entry: matching failures are `xfail`, matching passes `xpass` (close the gap).
|
||||
- **What the peers do on purpose**, so the checker expects it: Misskey and Sharkey drop a reply whose parent they cannot
|
||||
fetch; Lemmy keeps only community content and private messages; servers send edits and deletes to the post's own
|
||||
audience only; a Misskey heart reaction arrives as a Like.
|
||||
- Peers' own limits are lifted for the town: GoToSocial `GTS_ADVANCED_RATE_LIMIT_REQUESTS=0`, Lemmy's
|
||||
`local_site_rate_limit`. Mastodon reserves usernames that contain `mastodon`.
|
||||
- decePubClient is served as `https://decepub.test` (`peers/decepub.sh`) once published with the Pasture environment
|
||||
(in decePubClient: `dotnet publish decePubClient.csproj -c Release -p:WasmApplicationEnvironmentName=Pasture -o publish`),
|
||||
its API then `https://privapub.test`.
|
||||
|
||||
After the pasture, verify.funfedi.dev and the owner's GoToSocial at social.arasaka.software. **Ask before acting from
|
||||
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`,
|
||||
- **CI/CD:** push to `master` runs `build.yml` (a build) on the instance-wide `build` runner. A `v*` tag runs
|
||||
`deploy.yml`: a self-contained linux-x64 publish, a snapshot of the site to `/var/backups/privapub.thepra.dev`,
|
||||
the server's own pre-deploy backup (`PrivaPub admin backup --kind pre-deploy --db-only`, run from the new build before
|
||||
its migrations, verified, and checked to hold no statistics salt; no deploy while `restore.json` waits),
|
||||
stop → rsync → start, a `127.0.0.1:6970/build.json` health loop with rollback, then public checks (actor, NodeInfo,
|
||||
Swagger 404, inbox junk 400, unsigned 401) and `tools/smoke/mastodon-api.sh` (app registration, client credentials,
|
||||
discovery, instance, public timeline; pass a persona token as a second argument to check the signed-in side).
|
||||
discovery, instance, public timeline). Then it checks that what production should be running is running:
|
||||
- NodeInfo and the instance API agree on registrations (closed, invitations only);
|
||||
- `/stargazing` says the crawler is on;
|
||||
- nothing signs in (owner decision 2026-10-17): the deploy's own login, `deploy-smoke`, gave its @thepra to the
|
||||
administrator and was deleted, and the signed-in check went with it;
|
||||
- the DB-IP Lite databases, which the server fetches itself (`GeoUpdater`), exist and are at most 40 days old.
|
||||
- **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`.
|
||||
- **One-time root setup:** `deploy/max/setup.sh`, run through `../arasaka.software/tools/max/run.sh` (directories, the
|
||||
runner's sudoers line, the units, nginx and the certificate). Nothing that runs later needs it again.
|
||||
- **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
|
||||
|
||||
+370
-25
@@ -8,16 +8,100 @@ PrivaPub is an ActivityPub server written in C#. This document follows
|
||||
- [ActivityPub](https://www.w3.org/TR/activitypub/) (server-to-server)
|
||||
- [WebFinger](https://webfinger.net/)
|
||||
- [HTTP Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures), `rsa-sha256` / `hs2019` with RSA keys
|
||||
(`hs2019` hashed with SHA-256, or SHA-512 as some sign it; a `SHA-256=` or `SHA-512=` digest)
|
||||
- [HTTP Message Signatures](https://www.rfc-editor.org/rfc/rfc9421) (RFC 9421) and Content-Digest (RFC 9530), verified on
|
||||
deliveries and signed fetches: `rsa-v1_5-sha256` and `rsa-pss-sha512` with RSA keys
|
||||
- [NodeInfo](https://nodeinfo.diaspora.software/) 2.0 and 2.1
|
||||
- Object integrity proofs ([FEP-8b32](https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.md),
|
||||
`eddsa-jcs-2022`, JSON canonicalised by [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785)) by Ed25519 keys published
|
||||
as Multikeys ([FEP-521a](https://codeberg.org/fediverse/fep/src/branch/main/fep/521a/fep-521a.md)), on what personas
|
||||
send to relays and verified on what is forwarded
|
||||
|
||||
## Tested against
|
||||
|
||||
End to end, in a private network on the workstation (`tools/pasture/`), each peer driven through its own client API
|
||||
and every run starting clean, with signed fetches required (as privapub.thepra.dev runs):
|
||||
- **GoToSocial 0.22.1**
|
||||
- **Mastodon 4.7.3**
|
||||
- **Misskey 2026.10**
|
||||
- **Sharkey 2025.4.7**
|
||||
- **Akkoma 3.20.1**
|
||||
- **Lemmy 1.0.0-beta.2**, and **Lemmy 0.19.20** (built with the platform's roots, so it trusts the pasture's CA)
|
||||
- **PeerTube 8.3.1**
|
||||
- **Pixelfed 0.14.4**
|
||||
- **WordPress 7.1.2 with ActivityPub 9.3.1**
|
||||
- **Friendica 2026.05**
|
||||
- **Mobilizon 5.2.4**
|
||||
- **Gancio 1.28.2**
|
||||
- **Funkwhale 2.0.11**
|
||||
- **PieFed 1.7.17**
|
||||
- **Mbin 1.10.1**
|
||||
- **NodeBB 4.16.1**
|
||||
- **Smithereen 1.0.3**
|
||||
- **Mitra 5.9.1**
|
||||
- **snac2 2.95**
|
||||
- **WriteFreely 0.17.2**
|
||||
- **Owncast 0.3.0**
|
||||
- **Ghost 6.67** with its ActivityPub service 1.2.14
|
||||
- **BookWyrm 0.9.3**
|
||||
- **Vernissage 1.43.0**
|
||||
- **Hubzilla 11.4.1** with its pubcrawl addon
|
||||
- **Forte 26.9.10** (portable identities: actors served through `/.well-known/apgateway/did:key:…`)
|
||||
- **Castopod 1.15.5** (podcasts as actors, their episodes with the sound)
|
||||
- **Ktistec 3.13.0**
|
||||
- **Activity-Relay 2.0.9** and **aode-relay 0.3.129**, as relays PrivaPub reads from
|
||||
- in the town only (a seeded community checked server by server): **Hollo 0.9.19**, **Iceshrimp.NET 2026.1.2-beta**,
|
||||
**Pleroma 2.10.2**
|
||||
|
||||
Checked both ways, where the peer has the feature:
|
||||
- follows, locked accounts included;
|
||||
- every visibility, content warnings, replies and their notifications;
|
||||
- likes, boosts and emoji reactions with their undos;
|
||||
- direct messages;
|
||||
- polls;
|
||||
- quotes;
|
||||
- images with alt text;
|
||||
- edits with history and deletes;
|
||||
- communities and circles;
|
||||
- blocks and unfollows.
|
||||
|
||||
`docs/INTEROP.md` has each peer's evidence and what is still expected to fail. The platforms not yet in the pasture are
|
||||
covered by unit tests written in their documents' shape.
|
||||
|
||||
## Supported FEPs
|
||||
|
||||
- [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md)
|
||||
- [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md)
|
||||
- [FEP-2c59: Discovery of a WebFinger address from an ActivityPub actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2c59/fep-2c59.md)
|
||||
- [FEP-2c59: Discovery of a WebFinger address from an ActivityPub actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2c59/fep-2c59.md):
|
||||
every actor names its handle, and a peer's `webfinger` on another domain than its actor's host (Mastodon's
|
||||
`LOCAL_DOMAIN`) is the handle shown once that domain's WebFinger points back to the actor; asked again on a rename
|
||||
- [FEP-d556: Server-Level Actor Discovery Using WebFinger](https://codeberg.org/fediverse/fep/src/branch/main/fep/d556/fep-d556.md)
|
||||
(WebFinger for the server's origin links its instance actor as `…#Service`) and
|
||||
[FEP-2677: Identifying the Application Actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2677/fep-2677.md)
|
||||
(`/.well-known/nodeinfo` links it as `…#Application`)
|
||||
- [FEP-844e: Capability discovery](https://codeberg.org/fediverse/fep/src/branch/main/fep/844e/fep-844e.md): the instance
|
||||
actor's `implements`, and every other actor's `generator`, name RFC 9421 (verified with RSA keys)
|
||||
- [FEP-1b12: Group federation](https://codeberg.org/fediverse/fep/src/branch/main/fep/1b12/fep-1b12.md) (communities; see "Groups")
|
||||
- [FEP-044f: Consent-respecting quote posts](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md)
|
||||
(`QuoteAuthorization` at `/parrot-licences/{id}`; checked with Mastodon both ways)
|
||||
- [FEP-9967: Polls](https://codeberg.org/fediverse/fep/src/branch/main/fep/9967/fep-9967.md)
|
||||
- [FEP-c0e0: Emoji reactions](https://codeberg.org/fediverse/fep/src/branch/main/fep/c0e0/fep-c0e0.md) (`EmojiReact`, and Misskey's
|
||||
`Like` with content)
|
||||
- [FEP-5feb: Search indexing consent](https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md) (`indexable`)
|
||||
- [FEP-521a: Representing actor's public keys](https://codeberg.org/fediverse/fep/src/branch/main/fep/521a/fep-521a.md) and
|
||||
[FEP-8b32: Object Integrity Proofs](https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.md) (see
|
||||
"Integrity proofs")
|
||||
- [FEP-171b: Conversation Containers](https://codeberg.org/fediverse/fep/src/branch/main/fep/171b/fep-171b.md), received:
|
||||
a thread owner's `Add` of someone's `Create`, `Update` or `Delete` to the thread's context is taken as that activity
|
||||
passed on by the owner (on its FEP-8b32 proof, or as its origin has it)
|
||||
- [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md)
|
||||
(sent and honoured; see "Followers synchronisation")
|
||||
- [FEP-5624: Per-object reply control policies](https://codeberg.org/fediverse/fep/src/branch/main/fep/5624/fep-5624.md):
|
||||
read (see "Replies its author approves")
|
||||
|
||||
Planned: FEP-1b12 (communities), FEP-8fcf (followers synchronisation), FEP-5feb (`indexable`), FEP-7628 (Move),
|
||||
FEP-044f (quotes).
|
||||
Planned (see `docs/ROADMAP.md`, phases P7 and P8, and the per-platform notes in `docs/INTEROP.md`): FEP-9098 (custom
|
||||
emoji), FEP-7888 and FEP-f228 (threads), FEP-7628 (Move), FEP-8967 (link
|
||||
attachments), FEP-ae0c (relays).
|
||||
|
||||
## Actors
|
||||
|
||||
@@ -33,15 +117,28 @@ actor document, a collection, NodeInfo or a delivery relates two avatars of the
|
||||
| Followers / following | `/peasants/{name}/groupies`, `/peasants/{name}/stalking` |
|
||||
| Objects | `/peasants/{name}/scribbles/{id}` |
|
||||
| Activities | `/peasants/{name}/grunts/{id}` |
|
||||
| Quote permissions (`QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{id}` |
|
||||
| Direct-message context | `/peasants/{name}/whispers/{id}` |
|
||||
| A persona's wall (FEP-400e) | `/peasants/{name}/graffiti` |
|
||||
| Followers on the asking server (FEP-8fcf) | `/peasants/{name}/groupies/roll-call` |
|
||||
| Profile and post pages | `/@{name}`, `/@{name}/{id}` |
|
||||
|
||||
The names are the project's own and are stable; resolve actors through WebFinger, not by guessing a path.
|
||||
|
||||
- The key is `{actor}#main-key`, RSA 2048, served as SPKI PEM with `owner` set to the actor.
|
||||
- `published` on an actor is truncated to the day. `indexable` is `false`.
|
||||
- `published` on an actor is a whole day, chosen at random up to two weeks before the account was made, so two
|
||||
personas made on the same day do not share a date. `indexable` is `false` unless the persona turns it on, and
|
||||
`discoverable` is `true` unless it turns that off (the deploy's own persona, @thepra, is undiscoverable).
|
||||
- The instance actor is `/peasants/privapub` (type `Application`). It signs every fetch PrivaPub makes, so no avatar's key
|
||||
is used to read another server's content.
|
||||
is used to read another server's content. It also answers at the server's root (`/`) for a request that asks for
|
||||
ActivityPub, as Lemmy's site actor does: PieFed sends a community's announces to the inbox of the Application at a
|
||||
peer's root, and to `/inbox` when there is none.
|
||||
- The reporter is `/peasants/privapub_reports` (type `Service`, "Reports from <host>"), one for the whole server with
|
||||
its own key. It sends the reports Lemmy, PieFed and Mbin take in Lemmy's shape (see **Reports** below) and does
|
||||
nothing else: nobody follows or mentions it, the Mastodon API has no account for it, and it has no page.
|
||||
- The reader of feeds is `/peasants/privapub_feeds` (type `Service`, "Feeds read on <host>"), one for the whole server
|
||||
with its own key. When a persona here follows a feed, the reader follows each community in it, and stops following one
|
||||
no feed read here holds any more; no community and no feed learns which persona reads it. It does nothing else.
|
||||
|
||||
## Groups
|
||||
|
||||
@@ -49,16 +146,38 @@ A group is either a **community** or a **circle**.
|
||||
|
||||
- A **community** follows [FEP-1b12](https://codeberg.org/fediverse/fep/src/branch/main/fep/1b12/fep-1b12.md). Posts
|
||||
addressed to it (in `to`, `cc` or `audience`) are accepted according to its posting policy (followers, anyone, or
|
||||
moderators only) and the group `Announce`s the whole activity, with `audience` set, to its followers. A new post is
|
||||
moderators only) and the group `Announce`s the whole activity, with `audience` set, to its followers and to the
|
||||
author's own server, whether anyone there follows it or not (Lemmy keeps its user's post pending until then). A new post is
|
||||
also announced as an object so Mastodon shows it. Updates and deletes of community content are announced too. A
|
||||
top-level post is a `Page` with a `name`. Members are counted at `/flock`; moderators are listed at `/wardens`, which
|
||||
the actor's `attributedTo` points to, with `postingRestrictedToMods` as Lemmy expects. A mention of a community posts into it.
|
||||
- A **circle** is private. Its actor is not discoverable and every follow is a request. Its posts are addressed to the
|
||||
circle and its members collection, delivered to each member's own inbox and never announced. They are served only
|
||||
to a signed request from a member, or from the instance actor of a member's server; anyone else gets 404. A
|
||||
Mastodon member's replies reach only the people they mention.
|
||||
circle and its members collection, delivered to each member's own inbox and never announced. Each member's copy also
|
||||
names that member in `cc`, so servers that keep only posts naming one of their accounts (Mastodon, GoToSocial) keep
|
||||
it; it names no other member. The posts are served only to a signed request from a member, or from the instance actor
|
||||
of a member's server, and that copy names the member (or the members on that server); anyone else gets 404. A
|
||||
Mastodon member's replies reach only the people they mention. Its owner lets a request in with an `Accept` of the
|
||||
Follow, and declines one or takes a member from elsewhere out with a `Reject` of it, as Mastodon removes a follower.
|
||||
- Announces from **remote** groups (Lemmy communities) are followed through to the activity: the object is fetched
|
||||
from its own origin, never taken from the announce.
|
||||
from its own origin, never taken from the announce. They are taken from a group someone here follows, and also when
|
||||
they relay a vote on one of our posts in a thread of that group (Lemmy sends a vote to the community alone). A post
|
||||
keeps the group its `audience` names, however it arrived.
|
||||
- A post in a remote group followed here that the group's own server delivers itself, as its author's `Create` with the
|
||||
group as its `audience` (Mbin sends a magazine's threads that way, with no announce), is kept as if announced: the
|
||||
group's server speaks for posts in it. The same from another server is not.
|
||||
- A remote community's **moderation** reaches the posts it holds. A removal (`Announce{Delete}`) is believed at once
|
||||
from a community on the post's own server, which speaks for it (PieFed keeps serving a thread its moderator removed),
|
||||
and from a community elsewhere once the post's origin answers it gone. A lock sent as it is (`Lock`, Mbin) is taken
|
||||
from the post's own server only. A lock (`Announce{Lock}`, or `commentsEnabled: false` on the post) refuses replies,
|
||||
ours included, until `Undo{Lock}`. A ban of a persona (`Announce{Block}` with the community as `target`) shows as
|
||||
`blocked_by` on the community and refuses the persona's posts and replies there until the `Undo`. A post moved to
|
||||
another community (PieFed's `Announce{Move{object: post, origin, target}}`, relayed by the community it leaves) goes
|
||||
there with its thread; a move into a community this server hosts is not taken.
|
||||
- **Feeds** (Lemmy 1.0's multi-communities and PieFed's feeds, `type: Feed`) are read as accounts whose following is
|
||||
their communities: the feed's `following` collection, read once a day with its counts, and each community in it read in
|
||||
turn. A persona following a feed is followed through the server (owner decision 2026-10-07): the reader of feeds
|
||||
follows its communities, the feed itself is sent nothing, and the persona reads the feed's threads in a timeline of
|
||||
its own (`GET /api/v1/timelines/feed/:id`; the feeds it reads are `GET /api/v1/feeds`), not in its home.
|
||||
|
||||
## Activities
|
||||
|
||||
@@ -67,32 +186,202 @@ Received:
|
||||
| Activity | Effect |
|
||||
|---|---|
|
||||
| `Follow` | follows an avatar or community; `Accept` is sent unless the community approves members by hand |
|
||||
| `Accept{Follow}`, `Reject{Follow}` | completes or ends a follow an avatar requested |
|
||||
| `Accept{Follow}`, `Reject{Follow}` | completes or ends a follow an avatar requested; once a follow holds, the account's latest public posts are read from its outbox's first page (twenty at most, once a day), so its profile is not empty; homes get only what arrives from then on |
|
||||
| `Accept{Join}`, `Reject{Join}` | from the event's server: a persona's participation in the event is accepted or refused |
|
||||
| `Undo{Follow, Like, Announce}` | reverses it |
|
||||
| `Create{Note, Article, Page, Question, …}` | stored when a local avatar follows the author, is addressed or mentioned, when it replies to a local post, or when it is addressed to a community the author follows; a public parent is fetched to complete the thread |
|
||||
| `Create{Note, Article, Page, Question, Video, Audio, Event, ChatMessage, …}` | stored when a local avatar follows the author, is addressed or mentioned, when it replies to a local post, or when it is addressed to a community the author follows (a persona is addressed by its actor id or by its profile page, as Mbin addresses its private messages); a public parent is fetched to complete the thread |
|
||||
| `Update{Note}` | replaces the content; the previous version is kept |
|
||||
| `Update{Person}` | refetches the actor |
|
||||
| `Like` | counted and notified, on posts the liker could see |
|
||||
| `Dislike` | counted as a downvote (Lemmy, PieFed, Mbin, Friendica); `Undo` takes it back |
|
||||
| `EmojiReact`, `Like` with an emoji `content` | an emoji reaction (FEP-c0e0; Pleroma, Akkoma, Iceshrimp.NET, Misskey, Sharkey), Unicode or a custom emoji from its `tag`; a `Like` whose content is ❤ stays a favourite; `Undo` takes it back |
|
||||
| `Warn` (Lemmy 1.0) | a community moderator's warning about a persona's post there becomes the persona's `moderation_warning` notification, believed from the community's own server or an account its moderators collection lists |
|
||||
| `Resolve{Flag}` (Lemmy 1.0) | a report we sent is marked resolved by the server holding what was reported (kept beside our own moderators' resolution) |
|
||||
| `Join` | answered with `Ignore`: PrivaPub hosts no events of its own yet (FEP-8a8e) |
|
||||
| `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin |
|
||||
| `Delete` | deletes the object, or the actor and its follows |
|
||||
| `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back, though a new post its server publishes under that id after the deletion (Gancio numbers events from the last one kept) is kept |
|
||||
| `Flag` | becomes a report for this server's moderators |
|
||||
| `Move` | an account moving: believed as Mastodon believes it, when the account sends it about itself and the new account, read again from its server, names it in `alsoKnownAs`. The old account then shows where it went (`moved`), and, as Mastodon does it (owner decision 2026-10-05), the personas following it follow the new one instead (a Follow to its server, an Undo to the old), in the same lists; a mute or a block of the old account carries over |
|
||||
| `Add`/`Remove` on the actor's `featured` | the account pins or unpins one of its own posts (fetched from its server when not held); inside a community's announce, the community features a post made in it. Its profile shows them first (`pinned=true`). The collection itself is read with the account's counts, at most once a day. On the account's wall (`sm:wall`), see "Walls". Any other target (a community's moderators) is dropped; one on the account's own server that PrivaPub does not know has its document read again first, at most hourly |
|
||||
| `Block` of a persona | the follows between them end, the blocker's posts and notifications are hidden from the persona, nothing of the persona's is addressed to the blocker, and the relationship says `blocked_by`; `Undo{Block}` lifts it |
|
||||
|
||||
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
|
||||
`Reject{Follow}`, `Like`, `Announce` and their `Undo`, `Flag`. A deleted post answers 410 with a `Tombstone`.
|
||||
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll votes, `EmojiReact` and its `Undo`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
|
||||
`Reject{Follow}`, `Like`, `Dislike`, `Announce` and their `Undo`, `Flag`, `Join` and `Leave` of a remote event, and the
|
||||
replies to a persona's posts passed on to its followers. A deleted post answers 410 with a `Tombstone`.
|
||||
|
||||
- **Votes.** A favourite is a `Like`, a downvote a `Dislike`; a persona has one vote a post, and a changed vote is sent
|
||||
as the new vote alone, as Lemmy sends one (an `Undo` of the old one beside it could arrive after it and take the new
|
||||
one away). `Undo` is sent only when the vote is taken back. A vote on a post in a remote community goes to the
|
||||
community, which counts it and passes it on, as Lemmy sends one, and to the post's author only when it lives on
|
||||
another server: one copy a server, since PieFed drops a second copy of an activity it has just seen.
|
||||
|
||||
- **Attachments** are `Document`s with `mediaType`, `name` (alt text), `blurhash`, `focalPoint`, `width` and `height`.
|
||||
Uploaded files have all metadata removed.
|
||||
- **Pinned posts** are the actor's `featured` collection (`/trophies`); `featuredTags` is `/tattoos`.
|
||||
- **Blocks are never sent.** A blocked account is sent `Reject{Follow}` if it followed, and is unfollowed.
|
||||
- **Reports** are sent as `Flag` by the instance actor, never by the reporting account.
|
||||
- **Threads** (owner decision 2026-10-06). A persona's public or unlisted post outside any group names its `replies`
|
||||
(`…/scribbles/{id}/replies`) and its conversation, `context` (FEP-7888): the root post's `…/scribbles/{id}/context`,
|
||||
which a reply inherits from its parent, ours or another server's. Both list only the public and unlisted posts
|
||||
PrivaPub holds, the context every one under the root (at most 500); followers-only, circle, direct and local-only posts
|
||||
name neither, and their collections answer 404. Mastodon completes a thread from them.
|
||||
- **Followers synchronisation** (FEP-8fcf, owner decision 2026-10-06). A persona's delivery addressed to its followers
|
||||
carries a signed `Collection-Synchronization` header: its `followers`, its roll-call (`…/groupies/roll-call`) and the
|
||||
digest (XOR of each id's SHA-256) of its accepted followers on the receiving server only. The roll-call answers a
|
||||
signed request with the persona's followers on the signer's server and nobody else's; unsigned, 401. A server whose
|
||||
view differs mends itself from it, Mastodon both ways (checked live). Mastodon's `Undo{Follow}` for a follow it never
|
||||
knew of has one id per account (`…#follows//undo`); when it comes again after a new follow, it ends that follow too.
|
||||
Honoured the other way: when a delivery's header digests the sender's followers here otherwise than the personas
|
||||
following it, PrivaPub reads the list it names (same origin, signed by the instance actor). A follow the list leaves out
|
||||
ends, but only when the list is the one the digest describes; a request it lists is taken as accepted; a persona it
|
||||
lists that follows nothing there sends `Undo{Follow}`, as Mastodon does.
|
||||
- **Walls** (FEP-400e, Smithereen's; owner decision 2026-10-06). A persona's actor names its `wall`
|
||||
(`…/graffiti`, `sm:wall`) and Smithereen's `privacySettings`: its followers may write on it (`wallPosting`), everyone
|
||||
may read it (`wallPostVisibility`). A public post that is not a reply, sent with the wall as its `target` by an account
|
||||
following the persona, is hosted: the persona is notified (as a mention), it reaches the persona's home and its
|
||||
followers' here, and the persona's followers' servers and the author's are told with `Add{Note}` (the wall as the
|
||||
target). Anything else written there is dropped. The persona may delete it (`DELETE /api/v1/statuses/:id`), which
|
||||
sends `Remove{Note}` with the wall as a plain id; Smithereen deletes the post then, as it does when an owner deletes a
|
||||
post on its wall. The wall lists the persona's public posts that start a thread and what was written on it, newest
|
||||
first. An account elsewhere that tells its followers here, with `Add{Note}` on its own wall, of a post written there
|
||||
has it shown to them as on its wall (`privapub.wall` on the status); a `Remove` there takes it away.
|
||||
- **Pinned posts** are the actor's `featured` collection (`/trophies`); `featuredTags` is `/tattoos`. A pin or an unpin
|
||||
is told to the post's audience as `Add` or `Remove` on `featured`, as Mastodon tells it.
|
||||
- **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it
|
||||
followed); an unblock sends `Undo{Block}`.
|
||||
- **Reports** are sent as `Flag`, never by the reporting account and never naming it.
|
||||
- To the reported account's server: from the instance actor, with the account and the posts as `object` and the
|
||||
persona's words in `content`.
|
||||
- To a community on a server whose NodeInfo names Lemmy, PieFed or Mbin (owner decision 2026-10-06, the second place
|
||||
PrivaPub decides by a server's software; PieFed and Mbin once the pasture showed each keeps such a report with its
|
||||
reason): one `Flag` per reported post or comment that was made in that community (its own
|
||||
`audience`, else its thread's), from the reporter, `to` the community, the post alone as `object`, the words (or,
|
||||
with none, the category) in `summary` and `content`, sent to the community's inbox. Lemmy refuses a Flag from an
|
||||
`Application`, with no `to` or with no reason; PieFed drops one from an `Application`; Mbin reads the reason from
|
||||
`summary` alone. Such a server gets no instance actor's Flag: an account alone, or a post outside its communities,
|
||||
is not reported there, since Lemmy takes neither (PieFed would take an account alone from the reporter; that is not
|
||||
sent yet).
|
||||
- **Direct messages** go out as a `Note` addressed to their recipients, except a message to one account elsewhere that
|
||||
writes to us as `ChatMessage`s (Pleroma's type), or whose server takes nothing else: Lemmy before 1.0 and Mbin, as
|
||||
their NodeInfo names them (owner decision 2026-10-05, the first place PrivaPub decides by a server's software). That
|
||||
message goes out as a `ChatMessage`, to the account alone, without a mention in its text.
|
||||
- **Replies are passed on** (inbox forwarding; owner decision 2026-10-05). A public or unlisted reply from another server
|
||||
to a persona's public, unlisted or followers-only post goes on to the persona's followers as its author's server sent
|
||||
and signed it, the way Mastodon forwards it: to every follower's server but the replier's own. Its `Update` and
|
||||
`Delete` follow the same way. Nothing is passed on for a local-only post, a group's post or a reply that is not public
|
||||
or unlisted. A server that receives it checks it as it checks any forwarded activity: Mastodon, Misskey and Sharkey
|
||||
keep it only when its author LD-signed it (Mastodon does; Misskey 2026.10 and Akkoma do not), others read the reply
|
||||
from its origin.
|
||||
- **Events** (owner decision 2026-10-05). A persona joins another server's event with a `Join` and leaves it with a
|
||||
`Leave`, both sent to the event's organiser only (`…/grunts/join-<id>`, `…/grunts/leave-<id>`). The organiser's
|
||||
server answers `Accept` (Mobilizon at once for an open event) or `Reject`, which the persona sees as
|
||||
`privapub.event.participation`: `pending`, `accepted` or `rejected`. An event that takes participants by invitation
|
||||
or on another site (Mobilizon's `joinMode` `invite` or `external`) is refused before anything is sent (422). Who
|
||||
takes part is public on the event's server.
|
||||
|
||||
A `Create`'s `Note` carries Mastodon's `content`, `contentMap`, `summary` and `sensitive`, plus `Mention` and `Hashtag`
|
||||
tags. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show
|
||||
tags.
|
||||
|
||||
**Polls** (FEP-9967) go out as a `Question` with `oneOf` or `anyOf`, each option's count in `replies.totalItems`,
|
||||
`endTime`, `votersCount`, and `closed` once it has ended. Incoming votes are `Create{Note{name, inReplyTo}}` with no
|
||||
content, one per choice, counted once per voter. Counts are refreshed with an `Update{Question}` at most every three
|
||||
minutes. Our own votes on other servers' polls are sent the same way to the poll's author only, without `published`.
|
||||
An incoming `Update` without a newer `updated` only refreshes counts and details; it is never recorded as an edit.
|
||||
A post's earlier versions in `formerRepresentations` (Pleroma, Akkoma) are its edit history, read as the post itself is:
|
||||
a post first met after its edits has them, and an edit that carries them brings the versions missed in between.
|
||||
|
||||
**Quotes** (FEP-044f, with the older keys):
|
||||
- **Received.** A quote is read from `quote`, `quoteUrl`, `quoteUri`, `_misskey_quote` or a FEP-e232 `Link` tag.
|
||||
- With a `quoteAuthorization`, the stamp is fetched and must be a `QuoteAuthorization` on the quoted author's origin,
|
||||
attributed to them, naming exactly the two posts.
|
||||
- A FEP-044f `quote` without a stamp is pending until an `Update` brings one.
|
||||
- A quote made with the older keys only, by software that asks nobody, is shown when the quoted post is public.
|
||||
- A `Delete` of a stamp revokes the quote.
|
||||
- **Sent.** Our accounts quote with every key, plus the `RE:` fallback.
|
||||
- When the quoted post states an `interactionPolicy.canQuote` that allows us, `quote` is included and a `QuoteRequest`
|
||||
(with the quoting post as `instrument`) goes to the quoted author. The quote stays pending until their `Accept` brings a
|
||||
stamp we can verify; the post is then updated with `quoteAuthorization`.
|
||||
- Posts that state no policy are quoted the older way, without `quote`.
|
||||
- Quoting posts are also delivered to the quoted author.
|
||||
- **Quoting our accounts.** Our public and unlisted posts state `interactionPolicy.canQuote`. By default anyone may quote,
|
||||
automatically (as on Mastodon); an account can choose followers only or nobody, and so can each post.
|
||||
- A `QuoteRequest` is answered with `Accept{result}` naming a stamp at `/peasants/{name}/parrot-licences/{id}`, or
|
||||
with `Reject`.
|
||||
- A stamp is a `QuoteAuthorization` naming both posts. A revoked stamp answers 410 and is announced with
|
||||
`Delete{stamp}`.
|
||||
- Followers-only posts and DMs can never be quoted.
|
||||
|
||||
**Interaction policies** (GoToSocial's `canReply`, `canLike`, `canAnnounce`, with the older `always` and
|
||||
`approvalRequired`):
|
||||
- **Read** on every remote post; one left out means anyone, automatically. A persona is let in at once when the rule
|
||||
names the public, the persona itself, the author's followers while it follows the author, or the accounts the author
|
||||
follows while the author follows it.
|
||||
- **Shut out:** the client API answers 422 to a reply, favourite or boost the rule does not allow at all.
|
||||
- **Asked first:** a `ReplyRequest`, `LikeRequest` or `AnnounceRequest` (the interaction as its `instrument`) goes to the
|
||||
author alone, and the interaction waits (`privapub.approval: pending`). The author's `Accept` brings an authorization
|
||||
(`result`), which must be on the author's origin, attributed to them and naming the interaction and the post; only then
|
||||
does the reply go out with `replyAuthorization`, the boost with `announceAuthorization` and the like with
|
||||
`likeAuthorization`. A `Reject` leaves it ours alone (`rejected`), and takes a like or a boost back.
|
||||
- **As a third party,** a reply that a post's policy does not let in at once is kept only with an authorization that
|
||||
verifies the same way. A rule naming a collection we cannot list (followers, following) lets the reply in.
|
||||
- **Shown** to clients as GoToSocial's `interaction_policy` (`can_favourite`, `can_reply`, `can_reblog`).
|
||||
- Our own posts state only `canQuote`: anyone may reply to, like and boost them.
|
||||
|
||||
**Replies its author approves** (FEP-5624's `canReply`, as PeerTube sets it on a video whose comments are moderated; a
|
||||
`null` one says nothing): a persona it names, or that the post mentions, may reply, and the reply waits
|
||||
(`privapub.approval: pending`), its `Create` going to the author alone. The author's `ApproveReply`, signed by the author
|
||||
and naming the post answered, lets it out to its audience with `replyApproval`; a `RejectReply` leaves it ours alone
|
||||
(`rejected`). An empty `canReply` refuses every reply (422).
|
||||
|
||||
**Custom emoji** (`Emoji` tags) are read on posts, display names, bios and profile fields, at most 64 per object.
|
||||
**Profiles** keep their header, profile fields, `manuallyApprovesFollowers`, `published`, `movedTo`, `indexable`,
|
||||
`memorial` and avatar and header descriptions. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show
|
||||
`name`. A content warning without its own text uses the title, or "Content warning".
|
||||
|
||||
Received `summary` is a content warning only on a `Note` or `Question`, or when `sensitive` is `true`. On an `Article`,
|
||||
`Page`, `Event`, `Video` or `Audio` it is an excerpt or description, kept as such and never hidden.
|
||||
|
||||
Visibility is expressed in `to`/`cc` the way Mastodon does it: public, unlisted, followers-only and direct. Inbound
|
||||
followers-only posts are recognised by the author's own `followers` collection.
|
||||
|
||||
## Link previews
|
||||
|
||||
For a public post that links to a page, PrivaPub builds a preview card. It uses, in order:
|
||||
1. a Link attachment's own `preview` (FEP-8967) or the post's own image, title and summary;
|
||||
2. otherwise, the linked page, read once by the server between 0 and 60 seconds after the post arrives (never when
|
||||
someone reads it), with an `Accept: text/html` request that reads at most 512 KB, through the same address checks as
|
||||
every other fetch.
|
||||
|
||||
The page's OpenGraph and Twitter tags give the title, description and image; the result is cached per address for 7
|
||||
days and shared by every account on the server. Images are served to clients only through PrivaPub's media proxy.
|
||||
`Federation:FetchLinkPreviews=false` turns page fetching off.
|
||||
|
||||
## Relays
|
||||
|
||||
PrivaPub reads from the relays `Federation:Relays` names (by their actor's address, or their inbox's), none by default.
|
||||
Its instance actor follows `Public` at each, as Mastodon subscribes (`Federation/Relays/Relays.cs`, a minute after start
|
||||
and every six hours: an unanswered or refused subscription is asked again a day later, and a relay no longer named gets
|
||||
the `Undo`). What an accepted relay passes on comes to the federated timeline, never to anyone's home: a public post it
|
||||
forwards as its author sent it (Activity-Relay), read again from its origin like any forwarded post, and a post it
|
||||
announces (aode-relay), kept as its author's and never as the relay's boost. Nothing else is taken from a relay. A
|
||||
persona's public post outside any group, its edit and its deletion also go to the relays that accepted us (owner decision
|
||||
2026-10-06), as Mastodon sends them; nothing less public, and no boost. Mastodon takes a post Activity-Relay forwards
|
||||
only with an LD signature or an FEP-8b32 proof: PrivaPub's carry a proof, so they reach Mastodon through both kinds of
|
||||
relay (checked live).
|
||||
|
||||
## Server descriptions and the crawler
|
||||
|
||||
PrivaPub keeps statistics about servers, never about accounts (see `/stargazing` on the server).
|
||||
- **Describing a server:** a server it exchanges activities with is described at most once a week, from:
|
||||
- `/.well-known/nodeinfo` and the NodeInfo document it links;
|
||||
- `/api/v2/instance`, falling back to `/api/v1/instance`.
|
||||
|
||||
These requests are unsigned, because they are not ActivityPub documents.
|
||||
- **The crawler** is on at privapub.thepra.dev. It identifies as
|
||||
`PrivaPub-Stargazer/<version> (+https://privapub.thepra.dev/stargazing)`.
|
||||
- **What it reads:** `/robots.txt`, then the documents above and `/api/v1/instance/peers`, and nothing else: no
|
||||
accounts, posts or directories.
|
||||
- **How often:** one server a minute, each at most weekly.
|
||||
- **Opting out:** in robots.txt, the crawler obeys the group `PrivaPub-Stargazer`, then `PrivaPub`, then `*`. A
|
||||
robots.txt that answers with a server error or times out also keeps it out, as do this server's domain blocks.
|
||||
|
||||
## Local-only posts
|
||||
|
||||
Posts with a location (shown to nearby users of this server) never leave the server, in any form.
|
||||
@@ -105,15 +394,71 @@ Posts with a location (shown to nearby users of this server) never leave the ser
|
||||
- **Origins.** An actor document is accepted only from the address it names as its `id`. A key only if its actor lists
|
||||
it with `owner` set to the actor and on the actor's origin. An activity's `id`, and any object it creates, updates or
|
||||
deletes, must be on its actor's origin. An embedded object from another origin is fetched from that origin.
|
||||
- **Another account of the same server.** An object attributed to another account of the actor's own server (Mobilizon's
|
||||
organiser creates, edits and deletes the group's event) is that server's to vouch for: a creation or an edit is
|
||||
taken as the server has the object, a deletion once it answers 404 or 410. Attributed to an account elsewhere, it is
|
||||
refused (400).
|
||||
- **Forwarded activities** (ActivityPub's inbox forwarding: a thread's server passing on the replies in it, signed with
|
||||
its own key) are answered 202 and believed only as far as their origin vouches for them: a created or edited post
|
||||
is read again from its author's server, a deletion of a public or unlisted post is applied once that server answers
|
||||
404 or 410, and anything else is dropped. LD signatures are not verified. A reply forwarded this way is kept when
|
||||
someone here follows the author of the post it answers, as Mastodon does.
|
||||
- **Follow requests** still unanswered are sent again after 15 minutes, an hour, 6 hours, a day, two and four days: a
|
||||
server can take a Follow and lose its answer (Lemmy 1.0 sends nothing it queued for a server before it started
|
||||
sending there), and one that holds the follow answers it again. Each is the same follow under a new id
|
||||
(`…/follow-<id>-again-<n>`), since Lemmy ignores an id it has seen; an answer naming any of them counts.
|
||||
- **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three
|
||||
redirects and read at most 1 MB.
|
||||
- **Threads.** A reply's missing parents are fetched, up to 10 levels. When someone here opens a public remote thread,
|
||||
its replies are read from their servers, at most once an hour: the thread's conversation container (FEP-171b's
|
||||
`contextHistory`: the posts its owner's `Add`s brought in) or its `context` collection (FEP-7888) if it has one, else
|
||||
its `replies` (PeerTube's `comments`) and theirs, two levels down; 5 pages and 100 posts at most. Only public and
|
||||
unlisted replies are kept, each fetched from its own origin. A thread collection read whole, which counts its posts or
|
||||
holds them all, is asked again with `If-None-Match` and its last ETag; a 304 ends the read.
|
||||
- **Reading our documents (SecureMode).** privapub.thepra.dev answers ActivityPub GETs only when they are signed, like
|
||||
Mastodon's authorized fetch; the server's own actors (the instance actor `/peasants/privapub`, the reporter
|
||||
`/peasants/privapub_reports` and the reader of feeds `/peasants/privapub_feeds`) are the exception, since their keys
|
||||
are needed first.
|
||||
A browser asking for HTML is redirected to the public page instead.
|
||||
- **Posts that are not public** (followers-only, direct, circle) are served to a signed request from someone they were
|
||||
for, or from the instance actor of a server where someone they were for lives, and to nobody else (404). Once deleted
|
||||
they answer those same readers 410. A direct message's `context`, `/peasants/{name}/whispers/{id}`, is an
|
||||
`OrderedCollection` of the conversation's posts for its participants.
|
||||
- **HTML.** Received HTML is sanitised to Mastodon's allowlist.
|
||||
- **Delivery.** Failed deliveries are retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused,
|
||||
starting at an hour and growing to a week.
|
||||
- **Keys we cannot fetch for now.** When a sender's key cannot be fetched because its server timed out or answered
|
||||
5xx, the inbox answers 503 with `Retry-After: 300` rather than 401.
|
||||
- **Activity ids.** `Create` and `Announce` ids dereference, a community's announces included, until the post they
|
||||
carry is deleted (then 410); a persona's boost id sends a browser to the boosted post. `Follow`, `Like`, `Block` and
|
||||
the `Accept`, `Reject` and `Undo` that answer them do not, because serving them would reveal who follows, likes and
|
||||
blocks whom. Neither do `Update`, `Delete`, `EmojiReact`, `QuoteRequest` and its answers, `Flag`, `Ignore` or poll
|
||||
votes. All of them are always sent with their object embedded. Every boost and every favourite is an activity of its
|
||||
own (`announce-{boost}`, `like-{favourite}`), so one given again after its `Undo` is new to the server it reaches.
|
||||
- **Hashtags.** A post's `Hashtag` links go to `/tags/{tag}`, a public page of this server's public posts with that tag.
|
||||
- **Delivery.** An activity goes once to each server, to its shared inbox when the server has one, whoever there it
|
||||
follows, names or answers (a circle post excepted: each member's copy names that member). Failed deliveries are
|
||||
retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused,
|
||||
starting at an hour and growing to a week: unreachable, timing out, or a gateway answering 502, 503 or 504. A 500 is
|
||||
the server failing on that one activity, retried on its own while its other deliveries go on. A server can also take a Follow (202) and drop it afterwards, as Pleroma
|
||||
does when it cannot yet fetch our actor, so a follow request still unanswered is sent again, same activity, when the
|
||||
persona follows once more, at most once an hour.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Polls and custom emoji are not implemented.
|
||||
- Remote media is fetched through this server's proxy when a local client displays it.
|
||||
- Collections expose counts, not members.
|
||||
- Only `rsa-sha256`-style keys are verified. RFC 9421 signatures are planned.
|
||||
- Our own posts carry no custom emoji: emoji are received and shown, not offered.
|
||||
- Remote media is fetched through this server's proxy when a local client displays it. Video and audio are streamed
|
||||
through it with byte ranges passed on, so a PeerTube file is never fetched whole for one viewer.
|
||||
- Collections expose counts, not members: `/groupies` and `/stalking` give the same totals as the Mastodon API's
|
||||
follower and following counts, and the outbox's `totalItems` is the persona's post count, though only public posts
|
||||
are listed. `/api/v1/instance/peers` is empty: which servers this one talks to is not published.
|
||||
- HTTP signatures are verified with RSA keys only, under draft-cavage or RFC 9421. What PrivaPub sends is signed with
|
||||
draft-cavage only, which every server reads.
|
||||
- **Integrity proofs** (FEP-8b32, FEP-521a). Every persona has an Ed25519 key of its own (never shared between personas),
|
||||
named in its actor's `assertionMethod` as a `Multikey` (`…#ed25519-key`, `publicKeyMultibase`); the terms are defined
|
||||
in the actor's own context, so no context document has to be fetched. A persona's activity going to a relay carries a
|
||||
`DataIntegrityProof` (`eddsa-jcs-2022`, `proofPurpose` `assertionMethod`) over the activity as delivered; Mastodon
|
||||
4.7 verifies it (checked against its own verifier), so what the relay forwards reaches it. Nothing else carries one:
|
||||
Mitra takes a proof over the HTTP signature, and refuses one by a key it has not read yet without reading the actor
|
||||
again, which every server that knew a persona before it had its key would do. What a persona passes on of others'
|
||||
carries theirs or none. Received: an actor's own Multikeys (at most five, under its id, controlled by it) are kept;
|
||||
a forwarded activity whose proof one of them verifies is taken as it came, instead of being read again from its origin
|
||||
(an unknown key of the actor's has the actor read again first).
|
||||
@@ -0,0 +1,103 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels.Admin
|
||||
{
|
||||
// the server's backups as the administrator's page shows them, with what is running and the last restore
|
||||
public class ViewBackups
|
||||
{
|
||||
public List<ViewBackup> Backups { get; set; } = [];
|
||||
public string Running { get; set; }//"backup" or "restore" while one runs
|
||||
public ViewRestoreWaiting Waiting { get; set; }
|
||||
public ViewRestore LastRestore { get; set; }
|
||||
public string Host { get; set; }//what a restore asks to be typed
|
||||
public long FreeBytes { get; set; }
|
||||
}
|
||||
|
||||
public class ViewBackup
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Kind { get; set; }//nightly, manual, pre-deploy, pre-restore, uploaded
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public long Bytes { get; set; }//the database's files
|
||||
public long MediaBytes { get; set; }
|
||||
public int MediaFiles { get; set; }
|
||||
public int MediaMissing { get; set; }
|
||||
public bool DbOnly { get; set; }
|
||||
public bool Consistent { get; set; }
|
||||
public int Collections { get; set; }
|
||||
public long Documents { get; set; }
|
||||
public string AppCommit { get; set; }
|
||||
public int MigrationNumber { get; set; }
|
||||
public int RootsDeletedSince { get; set; }//logins deleted after it, which a restore deletes again
|
||||
public string NotRestorable { get; set; }//why not, from its manifest; hashes are checked when asked
|
||||
}
|
||||
|
||||
public class ViewRestoreWaiting
|
||||
{
|
||||
public string Backup { get; set; }
|
||||
public string State { get; set; }
|
||||
public int Attempts { get; set; }
|
||||
public string RequestedBy { get; set; }
|
||||
public DateTime RequestedAt { get; set; }
|
||||
public string Error { get; set; }
|
||||
}
|
||||
|
||||
public class ViewRestore
|
||||
{
|
||||
public string Backup { get; set; }
|
||||
public DateTime BackupCreatedAt { get; set; }
|
||||
public string PreRestore { get; set; }
|
||||
public string RequestedBy { get; set; }
|
||||
public DateTime RestoredAt { get; set; }
|
||||
public bool Abandoned { get; set; }
|
||||
public string Error { get; set; }
|
||||
public long Documents { get; set; }
|
||||
public int Collections { get; set; }
|
||||
public int MediaRestored { get; set; }
|
||||
public int MediaMissing { get; set; }
|
||||
public int RootsDeleted { get; set; }
|
||||
public int PersonasDeleted { get; set; }
|
||||
public int GroupsDeleted { get; set; }
|
||||
public int PostsDeleted { get; set; }
|
||||
public List<string> RootsTombstoned { get; set; } = [];
|
||||
public List<string> PersonasTombstoned { get; set; } = [];
|
||||
public List<string> GroupsTombstoned { get; set; } = [];
|
||||
public int PostsGone { get; set; }
|
||||
public int MediaTrashed { get; set; }
|
||||
public int ProtectiveKept { get; set; }
|
||||
public int SessionsEnded { get; set; }
|
||||
}
|
||||
|
||||
// a backup downloaded or restored: the administrator's password again, and for a restore the server's host typed out
|
||||
public class BackupPasswordForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(200, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Password { get; set; }
|
||||
}
|
||||
|
||||
public class RestoreBackupForm : BackupPasswordForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(253, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Host { get; set; }
|
||||
}
|
||||
|
||||
// a download link good for a while, for one backup
|
||||
public class ViewBackupTicket
|
||||
{
|
||||
public string Url { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public long Bytes { get; set; }
|
||||
}
|
||||
|
||||
// a backup being uploaded in pieces: how much has arrived
|
||||
public class ViewBackupUpload
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public long Received { get; set; }
|
||||
public long ChunkBytes { get; set; }//the largest piece accepted
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace PrivaPub.ClientModels.Admin
|
||||
{
|
||||
// a report as moderators read it: who sent it ("local" for a persona here, never which one; or the remote actor), whom
|
||||
// and which posts it is about, and whether it was resolved
|
||||
public class ViewReport
|
||||
{
|
||||
public string ID { get; set; }
|
||||
public string Reporter { get; set; }
|
||||
public string TargetAccountId { get; set; }
|
||||
public string TargetActorURI { get; set; }
|
||||
public List<string> PostIds { get; set; } = new();
|
||||
public List<string> ObjectURIs { get; set; } = new();
|
||||
public string Category { get; set; }
|
||||
public string Comment { get; set; }
|
||||
public bool Forwarded { get; set; }
|
||||
public bool IsResolved { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? ResolvedAt { get; set; }
|
||||
public DateTime? RemoteResolvedAt { get; set; }//the server holding what was reported said it dealt with it
|
||||
public string RemoteResolvedBy { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
namespace PrivaPub.ClientModels.Admin
|
||||
{
|
||||
// /clientapi/admin/statistics/overview: the last days' federation at a glance; counters keyed as the ledger keys them
|
||||
public class ViewStatisticsOverview
|
||||
{
|
||||
public int Days { get; set; }
|
||||
public Dictionary<string, long> Channels { get; set; } = new();
|
||||
public Dictionary<string, long> Inbound { get; set; } = new();
|
||||
public Dictionary<string, long> Outbound { get; set; } = new();
|
||||
public double? DeliverySuccess { get; set; }
|
||||
public ViewLatency DeliveryLatencyMs { get; set; } = new();
|
||||
public ViewHostCounts Hosts { get; set; } = new();
|
||||
public int Accounts { get; set; }
|
||||
public ViewLedgerCounts Ledger { get; set; } = new();
|
||||
public ViewMediaTotals Media { get; set; } = new();
|
||||
}
|
||||
|
||||
// what media take on the server: the uploads and pictures kept, the remote media cache, the trash waiting its grace
|
||||
public class ViewMediaTotals
|
||||
{
|
||||
public long MediaBytes { get; set; }
|
||||
public long MediaFiles { get; set; }
|
||||
public long ProxyCacheBytes { get; set; }
|
||||
public long TrashBytes { get; set; }
|
||||
}
|
||||
|
||||
public class ViewLatency
|
||||
{
|
||||
public int? P50 { get; set; }
|
||||
public int? P95 { get; set; }
|
||||
}
|
||||
|
||||
public class ViewHostCounts
|
||||
{
|
||||
public int Active { get; set; }
|
||||
public long Known { get; set; }
|
||||
}
|
||||
|
||||
public class ViewLedgerCounts
|
||||
{
|
||||
public long Written { get; set; }
|
||||
public long Dropped { get; set; }
|
||||
public long Failed { get; set; }
|
||||
}
|
||||
|
||||
// /clientapi/admin/statistics/hosts: one page of the servers we exchanged with
|
||||
public class ViewHostsPage
|
||||
{
|
||||
public int Total { get; set; }
|
||||
public int Page { get; set; }
|
||||
public List<ViewHostRow> Hosts { get; set; } = new();
|
||||
public string Attribution { get; set; }
|
||||
}
|
||||
|
||||
public class ViewHostRow
|
||||
{
|
||||
public string Host { get; set; }
|
||||
public string Software { get; set; }
|
||||
public string Version { get; set; }
|
||||
public long In { get; set; }
|
||||
public long Out { get; set; }
|
||||
public long Refused { get; set; }
|
||||
public long Failures { get; set; }
|
||||
public int? LatencyP50 { get; set; }
|
||||
public int Accounts { get; set; }
|
||||
public DateTime? DescribedAt { get; set; }
|
||||
public ViewHostDelivery Delivery { get; set; }
|
||||
}
|
||||
|
||||
public class ViewHostDelivery
|
||||
{
|
||||
public int ConsecutiveFailures { get; set; }
|
||||
public DateTime? UnavailableUntil { get; set; }
|
||||
public DateTime? LastSuccessAt { get; set; }
|
||||
public DateTime? LastFailureAt { get; set; }
|
||||
}
|
||||
|
||||
// /clientapi/admin/statistics/crawler: how far the crawler got
|
||||
public class ViewCrawler
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public long Touched { get; set; }
|
||||
public long Crawled { get; set; }
|
||||
public long Due { get; set; }
|
||||
public long RobotsDisallowed { get; set; }
|
||||
public List<ViewCrawledHost> Recent { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ViewCrawledHost
|
||||
{
|
||||
public string Host { get; set; }
|
||||
public string Seen { get; set; }
|
||||
public DateTime? LastCrawledAt { get; set; }
|
||||
public int? PeersCount { get; set; }
|
||||
public bool? RobotsDisallowed { get; set; }
|
||||
public string CrawlError { get; set; }
|
||||
public string Software { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,6 @@
|
||||
public const int GroupInvitationLength = 64;
|
||||
|
||||
public const string PasswordRegex = @"^(?=.*)(?=.*[a-z])(?=.*[A-Z])(?!.*\s).*$";
|
||||
public const string UserNameRegex = "^[a-z0-9_]+$";
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ namespace PrivaPub.ClientModels.Group
|
||||
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(32, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
RegularExpression("^[a-z0-9_]+$", ErrorMessageResourceName = "EmptySpacesNotAllowed", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
RegularExpression(Constants.UserNameRegex, ErrorMessageResourceName = "InvalidUserName", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
NoWhiteSpaces]
|
||||
public string UserName { get; set; }
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace PrivaPub.ClientModels.Group
|
||||
{
|
||||
// someone in a group, or asking to join it, as its owner and moderators see them
|
||||
public class ViewGroupMember
|
||||
{
|
||||
public string ActorUri { get; set; }
|
||||
public string Handle { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string PictureUrl { get; set; }
|
||||
public bool IsLocal { get; set; }
|
||||
public bool IsPending { get; set; }
|
||||
public string Role { get; set; }//owner, moderator, member
|
||||
public DateTime Since { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ namespace PrivaPub.ClientModels
|
||||
public string InvitationCode { get; set; }
|
||||
|
||||
[StringLength(32, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
RegularExpression("^[a-z0-9_]+$", ErrorMessageResourceName = "EmptySpacesNotAllowed", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
RegularExpression(Constants.UserNameRegex, ErrorMessageResourceName = "InvalidUserName", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string AvatarUserName { get; set; }
|
||||
|
||||
[StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
|
||||
@@ -105,6 +105,15 @@ namespace PrivaPub.ClientModels.Resources {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Use only lower-case letters, digits and underscores..
|
||||
/// </summary>
|
||||
public static string InvalidUserName {
|
||||
get {
|
||||
return ResourceManager.GetString("InvalidUserName", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Invalid password, upper-case, lower-case and number characters required..
|
||||
/// </summary>
|
||||
|
||||
@@ -132,6 +132,9 @@
|
||||
<data name="InvalidEmail" xml:space="preserve">
|
||||
<value>{0} è invalida.</value>
|
||||
</data>
|
||||
<data name="InvalidUserName" xml:space="preserve">
|
||||
<value>Usa solo lettere minuscole, cifre e trattini bassi.</value>
|
||||
</data>
|
||||
<data name="InvalidPassword" xml:space="preserve">
|
||||
<value>Password non valida, deve avere almeno un carattere grande, un carattere piccolo ed un numero.</value>
|
||||
</data>
|
||||
|
||||
@@ -132,6 +132,9 @@
|
||||
<data name="InvalidEmail" xml:space="preserve">
|
||||
<value>{0} is invalid.</value>
|
||||
</data>
|
||||
<data name="InvalidUserName" xml:space="preserve">
|
||||
<value>Use only lower-case letters, digits and underscores.</value>
|
||||
</data>
|
||||
<data name="InvalidPassword" xml:space="preserve">
|
||||
<value>Invalid password, upper-case, lower-case and number characters required.</value>
|
||||
</data>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels
|
||||
{
|
||||
// The server's first login, made while it has no administrator (/clientapi/user/setup), with its first persona.
|
||||
public class SetupForm : LoginForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(32, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
RegularExpression(Constants.UserNameRegex, ErrorMessageResourceName = "InvalidUserName", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string AvatarUserName { get; set; }
|
||||
|
||||
[StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string AvatarName { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ namespace PrivaPub.ClientModels.User.Avatar
|
||||
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(Constants.MaxAvatarNameLength, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
RegularExpression(Constants.UserNameRegex, ErrorMessageResourceName = "InvalidUserName", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = nameof(UserName), ResourceType = typeof(FieldsNameResource))]
|
||||
public string UserName { get; set; }//preferredUsername
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels.User
|
||||
{
|
||||
// Deleting one's own login, and with it every persona and group it owns, asks for the password again.
|
||||
public class RemoveSelfForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
|
||||
public string Password { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace PrivaPub.ClientModels.User
|
||||
{
|
||||
// A persona's archive (owner decision 2026-10-07): made for it to take away, in Mastodon's account-archive layout, and
|
||||
// restored into it. States: none, queued, running, ready (export) or done (import), failed, stopped.
|
||||
public class ViewPersonaArchive
|
||||
{
|
||||
public string ExportState { get; set; }
|
||||
public DateTime? ExportAskedAt { get; set; }
|
||||
public DateTime? ExportReadyAt { get; set; }
|
||||
public DateTime? ExportExpiresAt { get; set; }
|
||||
public long ExportBytes { get; set; }
|
||||
public string ExportError { get; set; }
|
||||
|
||||
public string ImportState { get; set; }
|
||||
public DateTime? ImportAskedAt { get; set; }
|
||||
public DateTime? ImportEndedAt { get; set; }
|
||||
public List<string> ImportParts { get; set; } = [];
|
||||
public long ImportDone { get; set; }
|
||||
public long ImportTotal { get; set; }
|
||||
public Dictionary<string, int> ImportCounts { get; set; } = [];//what was imported and what skipped, by why
|
||||
public string ImportError { get; set; }
|
||||
public string ImportFrom { get; set; }//the archive's actor
|
||||
public bool ImportSameActor { get; set; }
|
||||
}
|
||||
|
||||
// the parts of an archive to import: posts (with their media), profile, follows, blocks, mutes, domainblocks, lists,
|
||||
// bookmarks, filters, tags, notificationpolicy, pins; and only when asked, located, scheduled, likes (which tell their
|
||||
// authors)
|
||||
public class ImportPersonaArchiveForm
|
||||
{
|
||||
public string UploadId { get; set; }
|
||||
public List<string> Parts { get; set; } = [];
|
||||
}
|
||||
|
||||
public class ViewArchiveTicket
|
||||
{
|
||||
public string Url { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public long Bytes { get; set; }
|
||||
}
|
||||
|
||||
public class ViewArchiveUpload
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public long Received { get; set; }
|
||||
public long ChunkBytes { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace PrivaPub.ClientModels.User
|
||||
{
|
||||
public class ViewServerSetup
|
||||
{
|
||||
// no administrator yet: the first login made through /clientapi/user/setup becomes it
|
||||
public bool IsPending { get; set; } = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace PrivaPub.ClientModels.User
|
||||
{
|
||||
// /clientapi/user/storage: what the login's media take (every persona's uploads and pictures), and its quota when the
|
||||
// server sets one
|
||||
public class ViewStorage
|
||||
{
|
||||
public long MediaBytes { get; set; }
|
||||
public long MediaFiles { get; set; }
|
||||
public long? QuotaBytes { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Auth;
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
|
||||
using System.Text;
|
||||
|
||||
namespace PrivaPub.Tests.Api
|
||||
{
|
||||
public class MastodonParamsTests
|
||||
{
|
||||
static async Task<MastodonParams> Read(string query = "", string contentType = default, string body = default)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.QueryString = new QueryString(query);
|
||||
if (body != default)
|
||||
{
|
||||
context.Request.ContentType = contentType;
|
||||
var bytes = Encoding.UTF8.GetBytes(body);
|
||||
context.Request.Body = new MemoryStream(bytes);
|
||||
context.Request.ContentLength = bytes.Length;
|
||||
}
|
||||
return await MastodonParams.Read(context.Request, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Absent_values_are_null_not_false_or_zero()
|
||||
{
|
||||
var parameters = await Read();
|
||||
|
||||
Assert.Null(parameters.Bool("sensitive"));
|
||||
Assert.Null(parameters.Int("limit"));
|
||||
Assert.Null(parameters.Get("status"));
|
||||
Assert.Empty(parameters.List("media_ids"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_rails_style_arrays_from_the_query()
|
||||
{
|
||||
var parameters = await Read("?id[]=1&id[]=2&limit=5&local=true");
|
||||
|
||||
Assert.Equal(new[] { "1", "2" }, parameters.List("id"));
|
||||
Assert.Equal(5, parameters.Int("limit"));
|
||||
Assert.True(parameters.Bool("local"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_forms_and_nested_keys()
|
||||
{
|
||||
var parameters = await Read(contentType: "application/x-www-form-urlencoded",
|
||||
body: "status=hi&media_ids[]=a&media_ids[]=b&poll[options][]=x&poll[options][]=y&sensitive=0");
|
||||
|
||||
Assert.Equal("hi", parameters.Get("status"));
|
||||
Assert.Equal(new[] { "a", "b" }, parameters.List("media_ids"));
|
||||
Assert.Equal(new[] { "x", "y" }, parameters.List("poll[options]"));
|
||||
Assert.False(parameters.Bool("sensitive"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_json_bodies_the_same_way()
|
||||
{
|
||||
var parameters = await Read(contentType: "application/json",
|
||||
body: """{"status":"hi","media_ids":["a","b"],"poll":{"options":["x","y"],"expires_in":300},"sensitive":true,"source":{"privacy":"unlisted"}}""");
|
||||
|
||||
Assert.Equal(new[] { "a", "b" }, parameters.List("media_ids"));
|
||||
Assert.Equal(new[] { "x", "y" }, parameters.List("poll[options]"));
|
||||
Assert.Equal(300, parameters.Int("poll[expires_in]"));
|
||||
Assert.True(parameters.Bool("sensitive"));
|
||||
Assert.Equal("unlisted", parameters.Get("source[privacy]"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(new[] { "read" }, "read:statuses", true)]
|
||||
[InlineData(new[] { "read:statuses" }, "read:statuses", true)]
|
||||
[InlineData(new[] { "read:accounts" }, "read:statuses", false)]
|
||||
[InlineData(new[] { "write" }, "read:statuses", false)]
|
||||
[InlineData(new[] { "follow" }, "write:follows", true)]
|
||||
[InlineData(new[] { "follow" }, "write:statuses", false)]
|
||||
public void Scopes_grant_like_mastodons(string[] granted, string required, bool expected) =>
|
||||
Assert.Equal(expected, MastodonScopes.Grants(granted, required));
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.ClientModels.Post;
|
||||
using PrivaPub.ClientModels.Social;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PrivaPub.Tests.Api
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class PersonaSeparationTests : IAsyncLifetime
|
||||
{
|
||||
Harness _harness;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_harness = await Harness.Start();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness != default)
|
||||
await _harness.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Nothing_shown_to_one_persona_names_its_sibling()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (root, alice) = await _harness.Persona("alice");
|
||||
var (_, sibling) = await _harness.Persona("sibling", root);
|
||||
var (bobRoot, bob) = await _harness.Persona("bob");
|
||||
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, token);
|
||||
await _harness.Follows.Follow(root, new FollowForm { AvatarId = sibling.Id, Target = bob.UserName }, token);
|
||||
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = sibling.Id, Text = "from the sibling" }, token);
|
||||
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "from alice" }, token);
|
||||
await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = "hi both" }, token);
|
||||
|
||||
var mapper = new MastodonMapper(_harness.Db, _harness.Local);
|
||||
var account = await mapper.Local(alice, withSource: true, token);
|
||||
var homeIds = (await _harness.Db.TimelineEntries.Match(e => e.AvatarId == alice.Id).ExecuteAsync(token)).Select(e => e.PostId).ToList();
|
||||
var home = await _harness.Db.Posts.Match(p => homeIds.Contains(p.ID)).ExecuteAsync(token);
|
||||
var statuses = await mapper.Statuses(home, alice.Id, token);
|
||||
var bobAsSeenByAlice = await mapper.Account(bob.Id, token);
|
||||
|
||||
var json = JsonSerializer.Serialize(new object[] { account, statuses, bobAsSeenByAlice }, MastodonJson.Options);
|
||||
Assert.DoesNotContain(sibling.Id, json);
|
||||
Assert.DoesNotContain(sibling.UserName, json);
|
||||
Assert.DoesNotContain(root, json);
|
||||
Assert.Contains("hi both", json);
|
||||
Assert.Equal(2, bobAsSeenByAlice.FollowersCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
using PrivaPub.Domain.Content;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Infrastructure.Http;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||
|
||||
namespace PrivaPub.Tests.Domain
|
||||
{
|
||||
public class ContentRendererTests
|
||||
{
|
||||
const string Base = "https://privapub.test";
|
||||
readonly ContentRenderer _renderer = new(new StubLocalActors(), new StubRemoteActors());
|
||||
|
||||
[Fact]
|
||||
public async Task Decorates_local_and_remote_mentions_and_hashtags()
|
||||
{
|
||||
var rendered = await _renderer.Markdown("hello @alice and @bob@m.example about #Fedi **now** <script>x</script>", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Contains("<span class=\"h-card\" translate=\"no\"><a href=\"https://privapub.test/peasants/alice\" class=\"u-url mention\"", rendered.Html);
|
||||
Assert.Contains("href=\"https://m.example/@bob\"", rendered.Html);
|
||||
Assert.Contains("href=\"https://privapub.test/tags/fedi\" class=\"mention hashtag\"", rendered.Html);
|
||||
Assert.Contains("<strong>now</strong>", rendered.Html);
|
||||
Assert.DoesNotContain("<script>", rendered.Html);
|
||||
Assert.Equal(new[] { "https://m.example/users/bob", "https://privapub.test/peasants/alice" }, rendered.Mentions.Select(m => m.ActorUri).Order(StringComparer.Ordinal));
|
||||
Assert.Equal("fedi", Assert.Single(rendered.Tags));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("`@alice` in code")]
|
||||
[InlineData("mail me@alice.example")]
|
||||
[InlineData("@nobody is unknown")]
|
||||
[InlineData("see https://x.example/page#alice")]
|
||||
public async Task Leaves_what_is_not_a_mention_alone(string text)
|
||||
{
|
||||
var rendered = await _renderer.Markdown(text, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Empty(rendered.Mentions);
|
||||
Assert.DoesNotContain("h-card", rendered.Html);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("issue #123")]
|
||||
[InlineData("it's fine")]
|
||||
[InlineData("[link](https://x.example/#tag)")]
|
||||
public async Task Leaves_what_is_not_a_hashtag_alone(string text) =>
|
||||
Assert.Empty((await _renderer.Markdown(text, TestContext.Current.CancellationToken)).Tags);
|
||||
|
||||
[Fact]
|
||||
public async Task Renders_plain_text_like_mastodon()
|
||||
{
|
||||
var rendered = await _renderer.PlainText("line one\nline <two>\n\nsee https://x.example/a?b=1. #Tag", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal("<p>line one<br>line <two></p><p>see <a href=\"https://x.example/a?b=1\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">https://x.example/a?b=1</a>. "
|
||||
+ "<a href=\"https://privapub.test/tags/tag\" class=\"mention hashtag\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">#<span>Tag</span></a></p>", rendered.Html);
|
||||
}
|
||||
|
||||
sealed class StubLocalActors : ILocalActorService
|
||||
{
|
||||
public string BaseAddress => Base;
|
||||
|
||||
public Task<LocalActor> FindByUserName(string userName, CancellationToken token) =>
|
||||
Task.FromResult(userName.Equals("alice", StringComparison.OrdinalIgnoreCase)
|
||||
? new LocalActor { Id = "a1", UserName = "alice", BaseAddress = Base, Kind = LocalActorKind.Person }
|
||||
: default);
|
||||
|
||||
public Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token) => throw new NotSupportedException();
|
||||
public Task<LocalActor> FindByUri(string actorUri, CancellationToken token) => throw new NotSupportedException();
|
||||
public Task<LocalActor> GetInstanceActor(CancellationToken token) => throw new NotSupportedException();
|
||||
public Task<bool> IsUserNameTaken(string userName, CancellationToken token) => throw new NotSupportedException();
|
||||
public Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token) => throw new NotSupportedException();
|
||||
public LocalActor FromAvatar(Avatar avatar) => throw new NotSupportedException();
|
||||
public LocalActor FromGroup(GroupEntity group) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
sealed class StubRemoteActors : IRemoteActorService
|
||||
{
|
||||
public Task<string> ResolveHandle(string handle, CancellationToken token) =>
|
||||
Task.FromResult(handle == "bob@m.example" ? "https://m.example/users/bob" : default);
|
||||
|
||||
public Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token) =>
|
||||
Task.FromResult(actorUri == "https://m.example/users/bob"
|
||||
? new ForeignAvatar { ID = "f1", ActorURI = actorUri, UserName = "bob", Url = "https://m.example/@bob", InboxURL = actorUri + "/inbox" }
|
||||
: default);
|
||||
|
||||
public Task<FetchedJson> FetchObject(string uri, CancellationToken token) => throw new NotSupportedException();
|
||||
public Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.ClientModels.Social;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Domain
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class FollowTests : IAsyncLifetime
|
||||
{
|
||||
Harness _harness;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_harness = await Harness.Start();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness != default)
|
||||
await _harness.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_local_follow_is_accepted_at_once_and_notified()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (root, alice) = await _harness.Persona("alice");
|
||||
var (_, bob) = await _harness.Persona("bob");
|
||||
|
||||
var result = await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = "@" + bob.UserName }, token);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
var following = await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id).ExecuteSingleAsync(token);
|
||||
Assert.Equal(FollowState.Accepted, following.State);
|
||||
Assert.True(following.TargetIsLocal);
|
||||
Assert.True((await DB.Default.Find<Follower>().Match(f => f.LocalActorId == bob.Id && f.ActorURI == alice.Uri).ExecuteSingleAsync(token)).IsAccepted);
|
||||
Assert.Equal(NotificationType.Follow, (await DB.Default.Find<Notification>().Match(n => n.AvatarId == bob.Id).ExecuteSingleAsync(token)).Type);
|
||||
Assert.Empty(await _harness.Outgoing(bob.Inbox));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Someone_elses_persona_cannot_be_made_to_follow()
|
||||
{
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var (_, bob) = await _harness.Persona("bob");
|
||||
|
||||
var result = await _harness.Follows.Follow("someone-else", new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(404, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_remote_follow_is_requested_then_accepted()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (root, alice) = await _harness.Persona("alice");
|
||||
var carol = new RemoteActor(_harness.Peer, "carol");
|
||||
|
||||
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = carol.Id }, token);
|
||||
|
||||
var following = await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id).ExecuteSingleAsync(token);
|
||||
Assert.Equal(FollowState.Requested, following.State);
|
||||
var follow = Assert.Single(await _harness.Outgoing(carol.Id + "/inbox"));
|
||||
Assert.Equal("Follow", follow["type"]!.GetValue<string>());
|
||||
Assert.Equal(alice.Uri, follow["actor"]!.GetValue<string>());
|
||||
Assert.StartsWith(alice.Uri + "/grunts/follow-", follow["id"]!.GetValue<string>());
|
||||
|
||||
var accept = new JsonObject
|
||||
{
|
||||
["id"] = $"{carol.Id}#accepts/{Guid.NewGuid():N}",
|
||||
["type"] = "Accept",
|
||||
["actor"] = carol.Id,
|
||||
["object"] = follow.DeepClone()
|
||||
};
|
||||
Assert.Equal(202, (await _harness.Deliver(carol, $"/peasants/{alice.UserName}/mouth", accept)).StatusCode);
|
||||
Assert.Equal(FollowState.Accepted, (await DB.Default.Find<Following>().OneAsync(following.ID, token)).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_accept_from_someone_else_changes_nothing_and_a_reject_ends_the_request()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (root, alice) = await _harness.Persona("alice");
|
||||
var carol = new RemoteActor(_harness.Peer, "carol");
|
||||
var mallory = new RemoteActor(_harness.Peer, "mallory");
|
||||
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = carol.Id }, token);
|
||||
var following = await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id).ExecuteSingleAsync(token);
|
||||
|
||||
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = $"{mallory.Id}#accepts/{Guid.NewGuid():N}", ["type"] = "Accept", ["actor"] = mallory.Id, ["object"] = following.FollowActivityURI
|
||||
});
|
||||
Assert.Equal(FollowState.Requested, (await DB.Default.Find<Following>().OneAsync(following.ID, token)).State);
|
||||
|
||||
await _harness.Deliver(carol, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = $"{carol.Id}#rejects/{Guid.NewGuid():N}", ["type"] = "Reject", ["actor"] = carol.Id, ["object"] = following.FollowActivityURI
|
||||
});
|
||||
Assert.Null(await DB.Default.Find<Following>().OneAsync(following.ID, token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unfollowing_a_remote_account_sends_an_undo()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (root, alice) = await _harness.Persona("alice");
|
||||
var carol = new RemoteActor(_harness.Peer, "carol");
|
||||
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = carol.Id }, token);
|
||||
|
||||
await _harness.Follows.Unfollow(root, new FollowForm { AvatarId = alice.Id, Target = carol.Id }, token);
|
||||
|
||||
Assert.False(await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id).ExecuteAnyAsync(token));
|
||||
var undo = (await _harness.Outgoing(carol.Id + "/inbox")).Single(a => a["type"]!.GetValue<string>() == "Undo");
|
||||
Assert.Equal("Follow", undo["object"]!["type"]!.GetValue<string>());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using NetVips;
|
||||
|
||||
using PrivaPub.Domain.Media;
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Media;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
namespace PrivaPub.Tests.Domain
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class MediaFlowTests : IAsyncLifetime
|
||||
{
|
||||
Harness _harness;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_harness = await Harness.Start();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness != default)
|
||||
await _harness.DisposeAsync();
|
||||
}
|
||||
|
||||
static byte[] Png(int width, int height)
|
||||
{
|
||||
using var image = (Image.Black(width, height, bands: 3) + new double[] { 10, 120, 200 }).Cast(Enums.BandFormat.Uchar);
|
||||
return image.WriteToBuffer(".png");
|
||||
}
|
||||
|
||||
static IFormFile Upload(byte[] bytes, string contentType) =>
|
||||
new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "picture") { Headers = new HeaderDictionary(), ContentType = contentType };
|
||||
|
||||
[Fact]
|
||||
public async Task An_upload_is_attached_once_and_federated_with_its_alt_text()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var (_, mallory) = await _harness.Persona("mallory");
|
||||
|
||||
var upload = await _harness.Media.Upload(alice, Upload(Png(300, 200), "image/png"), "a blue square", "0.25,-0.5", token);
|
||||
Assert.True(upload.Ok);
|
||||
Assert.True(File.Exists(Path.Combine(_harness.Media.Root, upload.Attachment.FilePath)));
|
||||
|
||||
var stolen = await _harness.Statuses.Publish(mallory, new StatusDraft { Text = "mine", MediaIds = new[] { upload.Attachment.ID } }, token);
|
||||
Assert.False(stolen.Ok);
|
||||
|
||||
var posted = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "look", MediaIds = new[] { upload.Attachment.ID } }, token);
|
||||
Assert.True(posted.Ok);
|
||||
Assert.Equal(posted.Post.ID, (await DB.Default.Find<MediaAttachment>().OneAsync(upload.Attachment.ID, token)).PostId);
|
||||
|
||||
var note = ActivityPubRenderer.Note(posted.Post, alice, default, default);
|
||||
var attachment = note["attachment"]![0]!;
|
||||
Assert.Equal("a blue square", attachment["name"]!.GetValue<string>());
|
||||
Assert.Equal(300, attachment["width"]!.GetValue<int>());
|
||||
Assert.Equal(-0.5f, attachment["focalPoint"]![1]!.GetValue<float>());
|
||||
Assert.StartsWith("https://privapub.test/media/files/", attachment["url"]!.GetValue<string>());
|
||||
|
||||
var reused = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "again", MediaIds = new[] { upload.Attachment.ID } }, token);
|
||||
Assert.False(reused.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsupported_files_are_refused()
|
||||
{
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
|
||||
var outcome = await _harness.Media.Upload(alice, Upload(System.Text.Encoding.UTF8.GetBytes("<svg/>"), "image/svg+xml"), default, default, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(outcome.Ok);
|
||||
Assert.Equal(422, outcome.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_proxy_serves_signed_remote_media_and_nothing_else()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var path = $"/files/{Guid.NewGuid():N}.png";
|
||||
_harness.Peer.ServeFile(path, Png(10, 10), "image/png");
|
||||
var proxy = new MediaProxy(_harness.Local, Peer.Http(), _harness.Media, new StaticOptions<MediaOptions>(new MediaOptions()));
|
||||
|
||||
var wrapped = proxy.Wrap(_harness.Peer.A + path);
|
||||
var parts = new Uri(wrapped).AbsolutePath.Split('/');
|
||||
var (file, contentType) = await proxy.Fetch(parts[3], parts[4], token);
|
||||
var (tampered, _) = await proxy.Fetch(parts[3].Replace(parts[3][0], parts[3][0] == 'A' ? 'B' : 'A'), parts[4], token);
|
||||
|
||||
Assert.StartsWith("https://privapub.test/media/proxy/", wrapped);
|
||||
Assert.Equal("image/png", contentType);
|
||||
Assert.True(File.Exists(file));
|
||||
Assert.StartsWith(_harness.Media.ProxyRoot, file);
|
||||
Assert.Null(tampered);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
using NetVips;
|
||||
|
||||
using PrivaPub.Domain.Media;
|
||||
|
||||
namespace PrivaPub.Tests.Domain
|
||||
{
|
||||
public class MediaProcessingTests
|
||||
{
|
||||
static byte[] JpegWithMetadata(int width, int height)
|
||||
{
|
||||
using var image = (Image.Black(width, height, bands: 3) + new double[] { 200, 40, 90 }).Cast(Enums.BandFormat.Uchar);
|
||||
using var tagged = image.Mutate(m =>
|
||||
{
|
||||
m.Set(GValue.GStrType, "exif-ifd0-ImageDescription", "where I live");
|
||||
m.Set(GValue.GStrType, "exif-ifd0-Artist", "Alice Smith");
|
||||
m.Set(GValue.GStrType, "exif-ifd2-UserComment", "at home");
|
||||
m.Set(GValue.BlobType, "xmp-data", System.Text.Encoding.UTF8.GetBytes("<x:xmpmeta xmlns:x='adobe:ns:meta/'><secret/></x:xmpmeta>"));
|
||||
});
|
||||
return tagged.WriteToBuffer(".jpg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Uploaded_images_lose_every_kind_of_metadata()
|
||||
{
|
||||
var input = JpegWithMetadata(800, 600);
|
||||
using (var original = Image.NewFromBuffer(input))
|
||||
Assert.Contains("exif-data", original.GetFields());
|
||||
|
||||
var processed = MediaService.ProcessImage(input, 4096, 640, animated: false);
|
||||
|
||||
using var output = Image.NewFromBuffer(processed.Bytes);
|
||||
var fields = output.GetFields();
|
||||
Assert.DoesNotContain("exif-data", fields);
|
||||
Assert.DoesNotContain("xmp-data", fields);
|
||||
Assert.DoesNotContain("iptc-data", fields);
|
||||
Assert.DoesNotContain(fields, f => f.StartsWith("exif-ifd"));
|
||||
Assert.Equal((800, 600), (processed.Width, processed.Height));
|
||||
Assert.Equal("image/jpeg", processed.ContentType);
|
||||
using var preview = Image.NewFromBuffer(processed.Preview);
|
||||
Assert.Equal(640, Math.Max(preview.Width, preview.Height));
|
||||
Assert.DoesNotContain("exif-data", preview.GetFields());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Large_images_are_capped()
|
||||
{
|
||||
var processed = MediaService.ProcessImage(JpegWithMetadata(5000, 2500), 4096, 640, animated: false);
|
||||
|
||||
Assert.Equal((4096, 2048), (processed.Width, processed.Height));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_blurhash_is_well_formed()
|
||||
{
|
||||
var processed = MediaService.ProcessImage(JpegWithMetadata(64, 64), 4096, 640, animated: false);
|
||||
|
||||
Assert.Equal(28, processed.Blurhash.Length);
|
||||
Assert.Equal('L', processed.Blurhash[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_white_image_encodes_white_as_its_average()
|
||||
{
|
||||
var pixels = Enumerable.Range(0, 16 * 16).SelectMany(_ => new byte[] { 255, 255, 255 }).ToArray();
|
||||
|
||||
var hash = Blurhash.Encode(pixels, 16, 16);
|
||||
|
||||
Assert.Equal(28, hash.Length);
|
||||
Assert.Equal("TSUA", hash[2..6]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.ClientModels.Post;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PrivaPub.Tests.Domain
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class NearbyTests : IAsyncLifetime
|
||||
{
|
||||
Harness _harness;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_harness = await Harness.Start();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness != default)
|
||||
await _harness.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_located_post_reaches_only_those_within_its_radius_and_never_leaves_the_server()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var latitude = 10 + Random.Shared.NextDouble() * 50;
|
||||
var longitude = 10 + Random.Shared.NextDouble() * 50;
|
||||
var (aliceRoot, alice) = await _harness.Persona("alice");
|
||||
var (bobRoot, bob) = await _harness.Persona("bob");
|
||||
var (carolRoot, carol) = await _harness.Persona("carol");
|
||||
var follower = new RemoteActor(_harness.Peer, "follower");
|
||||
await _harness.FollowedBy(alice, follower);
|
||||
|
||||
var created = await _harness.Posts.InsertPost(aliceRoot, new InsertPostForm
|
||||
{
|
||||
AvatarId = alice.Id, Text = "free bread at the bakery", Latitude = latitude, Longitude = longitude, RangeKm = 5
|
||||
}, token);
|
||||
var post = await DB.Default.Find<Post>().OneAsync(((ViewPost)created.Data).Id, token);
|
||||
|
||||
var three = (List<ViewPost>)(await _harness.Posts.Nearby(bobRoot, bob.Id, latitude + 0.027, longitude, token)).Data;
|
||||
var eight = (List<ViewPost>)(await _harness.Posts.Nearby(carolRoot, carol.Id, latitude + 0.072, longitude, token)).Data;
|
||||
|
||||
Assert.Equal(PostVisibility.LocalGeo, post.Visibility);
|
||||
Assert.Equal(Math.Round(latitude, 2), post.Geo.Coordinates[1]);
|
||||
Assert.Contains(three, p => p.Id == post.ID);
|
||||
Assert.InRange(three.First(p => p.Id == post.ID).DistanceKm.Value, 2.0, 4.0);
|
||||
Assert.DoesNotContain(eight, p => p.Id == post.ID);
|
||||
Assert.False(await VisibilityPolicy.CanSee(post, bob.Id, token));
|
||||
var outgoing = (await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver).ExecuteAsync(token))
|
||||
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
|
||||
.Where(p => p.Body.Contains(post.ID));
|
||||
Assert.Empty(outgoing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_located_post_cannot_be_direct_or_in_a_group()
|
||||
{
|
||||
var (root, alice) = await _harness.Persona("alice");
|
||||
|
||||
var result = await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft
|
||||
{
|
||||
Text = "x", Latitude = 45, Longitude = 7, Visibility = PostVisibility.Direct
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.ClientModels.Post;
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
namespace PrivaPub.Tests.Domain
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class OutboxTests : IAsyncLifetime
|
||||
{
|
||||
Harness _harness;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_harness = await Harness.Start();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness != default)
|
||||
await _harness.DisposeAsync();
|
||||
}
|
||||
|
||||
async Task<ForeignAvatar> Known(RemoteActor actor) =>
|
||||
await _harness.Remote.GetActor(actor.Id, refresh: false, TestContext.Current.CancellationToken);
|
||||
|
||||
[Fact]
|
||||
public async Task The_audience_follows_mastodons_table()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var follower = new RemoteActor(_harness.Peer, "follower");
|
||||
var mentioned = await Known(new RemoteActor(_harness.Peer, "mentioned"));
|
||||
var parentAuthor = await Known(new RemoteActor(_harness.Peer, "parent"));
|
||||
await _harness.FollowedBy(alice, follower);
|
||||
var mention = new PostMention { ActorURI = mentioned.ActorURI };
|
||||
|
||||
async Task<IReadOnlyList<string>> Audience(PostVisibility visibility, string replyTo = default) =>
|
||||
await _harness.Outbox.Audience(alice, new Post { Visibility = visibility, Mentions = new() { mention }, To = new() { mentioned.ActorURI }, InReplyToAccountId = replyTo }, token);
|
||||
|
||||
Assert.Equal(new[] { follower.SharedInbox, mentioned.InboxURL }, await Audience(PostVisibility.Public));
|
||||
Assert.Equal(new[] { follower.SharedInbox, mentioned.InboxURL, parentAuthor.InboxURL }, await Audience(PostVisibility.Unlisted, parentAuthor.ID));
|
||||
Assert.Equal(new[] { follower.SharedInbox, mentioned.InboxURL }, await Audience(PostVisibility.FollowersOnly));
|
||||
Assert.Equal(new[] { mentioned.InboxURL }, await Audience(PostVisibility.Direct, parentAuthor.ID));
|
||||
Assert.Empty(await Audience(PostVisibility.Circle));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_post_is_created_edited_and_deleted_for_its_audience()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (root, alice) = await _harness.Persona("alice");
|
||||
var follower = new RemoteActor(_harness.Peer, "follower");
|
||||
await _harness.FollowedBy(alice, follower);
|
||||
|
||||
var created = await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "first", Visibility = "followersonly" }, token);
|
||||
var postId = ((ViewPost)created.Data).Id;
|
||||
await _harness.Posts.UpdatePost(root, new UpdatePostForm { AvatarId = alice.Id, PostId = postId, Text = "second", SpoilerText = "cw" }, token);
|
||||
await _harness.Posts.DeletePost(root, new DeletePostForm { AvatarId = alice.Id, PostId = postId }, token);
|
||||
|
||||
var sent = await _harness.Outgoing(follower.SharedInbox);
|
||||
Assert.Equal(new[] { "Create", "Update", "Delete" }, sent.Select(a => a["type"]!.GetValue<string>()));
|
||||
Assert.DoesNotContain(sent[0]["to"]!.AsArray(), n => n!.GetValue<string>().EndsWith("#Public"));
|
||||
Assert.Equal("cw", sent[1]["object"]!["summary"]!.GetValue<string>());
|
||||
Assert.NotNull(sent[1]["object"]!["updated"]);
|
||||
Assert.Equal(sent[0]["to"]!.ToJsonString(), sent[2]["to"]!.ToJsonString());
|
||||
|
||||
var stored = await DB.Default.Find<Post>().OneAsync(postId, token);
|
||||
Assert.NotNull(stored.DeletedAt);
|
||||
Assert.Null(stored.ContentHtml);
|
||||
Assert.Empty(stored.Revisions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Someone_elses_post_cannot_be_edited()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (root, alice) = await _harness.Persona("alice");
|
||||
var (otherRoot, bob) = await _harness.Persona("bob");
|
||||
var created = await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "mine" }, token);
|
||||
|
||||
var result = await _harness.Posts.UpdatePost(otherRoot, new UpdatePostForm { AvatarId = bob.Id, PostId = ((ViewPost)created.Data).Id, Text = "yours" }, token);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(404, result.StatusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.ClientModels.Post;
|
||||
using PrivaPub.ClientModels.Social;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Domain
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class RelationshipTests : IAsyncLifetime
|
||||
{
|
||||
Harness _harness;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_harness = await Harness.Start();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness != default)
|
||||
await _harness.DisposeAsync();
|
||||
}
|
||||
|
||||
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
|
||||
|
||||
[Fact]
|
||||
public async Task A_block_cuts_follows_both_ways_and_hides_the_account()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (aliceRoot, alice) = await _harness.Persona("alice");
|
||||
var (bobRoot, bob) = await _harness.Persona("bob");
|
||||
await _harness.Follows.Follow(aliceRoot, new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, token);
|
||||
await _harness.Follows.Follow(bobRoot, new FollowForm { AvatarId = bob.Id, Target = alice.UserName }, token);
|
||||
|
||||
await _harness.Relationships.Block(alice, bob.Uri, bob.Id, token);
|
||||
await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = $"hey @{alice.UserName}" }, token);
|
||||
|
||||
Assert.False(await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id || f.AvatarId == bob.Id).ExecuteAnyAsync(token));
|
||||
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id || f.LocalActorId == bob.Id).ExecuteAnyAsync(token));
|
||||
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.AuthorAccountId == bob.Id).ExecuteAnyAsync(token));
|
||||
Assert.False(await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id).ExecuteAnyAsync(token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_blocked_remote_account_is_refused_when_it_follows()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var mallory = new RemoteActor(_harness.Peer, "mallory");
|
||||
await _harness.Relationships.Block(alice, mallory.Id, default, token);
|
||||
|
||||
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = $"{Origin(mallory)}/follows/{Guid.NewGuid():N}", ["type"] = "Follow", ["actor"] = mallory.Id, ["object"] = alice.Uri
|
||||
});
|
||||
|
||||
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id).ExecuteAnyAsync(token));
|
||||
Assert.Equal("Reject", Assert.Single(await _harness.Outgoing(mallory.Id + "/inbox"))["type"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_muted_account_stays_out_of_home_and_notifications()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (aliceRoot, alice) = await _harness.Persona("alice");
|
||||
var (bobRoot, bob) = await _harness.Persona("bob");
|
||||
await _harness.Follows.Follow(aliceRoot, new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, token);
|
||||
await _harness.Relationships.Mute(alice, bob.Uri, bob.Id, hideNotifications: true, default, token);
|
||||
|
||||
await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = $"hey @{alice.UserName}" }, token);
|
||||
|
||||
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id).ExecuteAnyAsync(token));
|
||||
Assert.False(await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id && n.Type == NotificationType.Mention).ExecuteAnyAsync(token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_report_is_forwarded_by_the_instance_not_the_persona()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var mallory = new RemoteActor(_harness.Peer, "mallory");
|
||||
var foreign = await _harness.Remote.GetActor(mallory.Id, refresh: false, token);
|
||||
|
||||
var report = await _harness.Reports.File(alice, foreign.ID, Array.Empty<string>(), "spam everywhere", "spam", forward: true, token);
|
||||
|
||||
Assert.True(report.Forwarded);
|
||||
var flag = Assert.Single(await _harness.Outgoing(mallory.Id + "/inbox"));
|
||||
Assert.Equal("Flag", flag["type"]!.GetValue<string>());
|
||||
Assert.EndsWith("/peasants/privapub", flag["actor"]!.GetValue<string>());
|
||||
Assert.DoesNotContain(alice.UserName, flag.ToJsonString());
|
||||
Assert.DoesNotContain(alice.Id, flag.ToJsonString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_inbound_flag_becomes_a_report_for_moderators()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (root, alice) = await _harness.Persona("alice");
|
||||
var reporter = new RemoteActor(_harness.Peer, "reporter");
|
||||
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "bad post" }, token);
|
||||
var post = await DB.Default.Find<PrivaPub.Models.Post.Post>().Match(p => p.GroupUserId == alice.Id).ExecuteSingleAsync(token);
|
||||
|
||||
await _harness.Deliver(reporter, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = $"{Origin(reporter)}/flags/{Guid.NewGuid():N}", ["type"] = "Flag", ["actor"] = reporter.Id,
|
||||
["content"] = "please look", ["object"] = new JsonArray(alice.Uri, post.ObjectURI)
|
||||
});
|
||||
|
||||
var report = await DB.Default.Find<Report>().Match(r => r.TargetAccountId == alice.Id).ExecuteSingleAsync(token);
|
||||
Assert.Equal(reporter.Id, report.ReporterActorURI);
|
||||
Assert.Equal(new[] { post.ID }, report.PostIds);
|
||||
Assert.Equal("please look", report.Comment);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.ClientModels.Post;
|
||||
using PrivaPub.ClientModels.Social;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Domain
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class TimelineTests : IAsyncLifetime
|
||||
{
|
||||
Harness _harness;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_harness = await Harness.Start();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness != default)
|
||||
await _harness.DisposeAsync();
|
||||
}
|
||||
|
||||
async Task<List<ViewPost>> Home(string root, string avatarId) =>
|
||||
(List<ViewPost>)(await _harness.Timelines.Home(root, avatarId, default, 40, TestContext.Current.CancellationToken)).Data;
|
||||
|
||||
async Task Accepted(string root, string avatarId, RemoteActor target)
|
||||
{
|
||||
await _harness.Follows.Follow(root, new FollowForm { AvatarId = avatarId, Target = target.Id }, TestContext.Current.CancellationToken);
|
||||
await DB.Default.Update<Following>().Match(f => f.AvatarId == avatarId && f.TargetActorURI == target.Id)
|
||||
.Modify(f => f.State, FollowState.Accepted).ExecuteAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
static JsonObject Create(RemoteActor author, string inReplyTo = default, string text = "hello", params (string Uri, string Name)[] mentions)
|
||||
{
|
||||
var origin = new Uri(author.Id).GetLeftPart(UriPartial.Authority);
|
||||
var note = new JsonObject
|
||||
{
|
||||
["id"] = $"{origin}/notes/{Guid.NewGuid():N}",
|
||||
["type"] = "Note",
|
||||
["attributedTo"] = author.Id,
|
||||
["content"] = $"<p>{text}</p>",
|
||||
["to"] = new JsonArray(Addressing.Public),
|
||||
["cc"] = new JsonArray(mentions.Select(m => (JsonNode)m.Uri).Prepend(author.Id + "/followers").ToArray()),
|
||||
["tag"] = new JsonArray(mentions.Select(m => (JsonNode)new JsonObject { ["type"] = "Mention", ["href"] = m.Uri, ["name"] = m.Name }).ToArray())
|
||||
};
|
||||
if (inReplyTo != default)
|
||||
note["inReplyTo"] = inReplyTo;
|
||||
return new JsonObject { ["id"] = $"{origin}/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Id, ["object"] = note };
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_local_post_reaches_the_author_and_local_followers_only()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (aliceRoot, alice) = await _harness.Persona("alice");
|
||||
var (bobRoot, bob) = await _harness.Persona("bob");
|
||||
var (carolRoot, carol) = await _harness.Persona("carol");
|
||||
await _harness.Follows.Follow(bobRoot, new FollowForm { AvatarId = bob.Id, Target = alice.UserName }, token);
|
||||
|
||||
await _harness.Posts.InsertPost(aliceRoot, new InsertPostForm { AvatarId = alice.Id, Text = "for my followers" }, token);
|
||||
|
||||
Assert.Single(await Home(aliceRoot, alice.Id));
|
||||
Assert.Equal("<p>for my followers</p>", Assert.Single(await Home(bobRoot, bob.Id)).ContentHtml);
|
||||
Assert.Empty(await Home(carolRoot, carol.Id));
|
||||
Assert.Equal(404, (await _harness.Timelines.Home(carolRoot, bob.Id, default, 20, token)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_followed_remote_account_is_kept_and_its_replies_follow_mastodons_rule()
|
||||
{
|
||||
var (bobRoot, bob) = await _harness.Persona("bob");
|
||||
var carol = new RemoteActor(_harness.Peer, "carol");
|
||||
var dave = new RemoteActor(_harness.Peer, "dave");
|
||||
await Accepted(bobRoot, bob.Id, carol);
|
||||
|
||||
var top = Create(carol, text: "top");
|
||||
await _harness.Deliver(carol, "/human-centipede", top);
|
||||
await _harness.Deliver(carol, "/human-centipede", Create(carol, top["object"]!["id"]!.GetValue<string>(), "self reply"));
|
||||
await _harness.Deliver(dave, "/human-centipede", Create(dave, text: "dave alone"));
|
||||
var daveRoot = Create(dave, text: "unfollowed root");
|
||||
await _harness.Deliver(carol, "/human-centipede", Create(carol, $"{new Uri(dave.Id).GetLeftPart(UriPartial.Authority)}/notes/unknown", "reply to a stranger"));
|
||||
|
||||
var home = (await Home(bobRoot, bob.Id)).Select(p => p.ContentHtml).ToList();
|
||||
Assert.Contains("<p>top</p>", home);
|
||||
Assert.Contains("<p>self reply</p>", home);
|
||||
Assert.DoesNotContain("<p>dave alone</p>", home);
|
||||
Assert.DoesNotContain("<p>reply to a stranger</p>", home);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reply_brings_its_public_parent_and_the_grandparent_is_queued()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (bobRoot, bob) = await _harness.Persona("bob");
|
||||
var carol = new RemoteActor(_harness.Peer, "carol");
|
||||
var dave = new RemoteActor(_harness.Peer, "dave");
|
||||
await Accepted(bobRoot, bob.Id, carol);
|
||||
var origin = new Uri(dave.Id).GetLeftPart(UriPartial.Authority);
|
||||
var grandparentId = $"{origin}/notes/{Guid.NewGuid():N}";
|
||||
var parentId = $"{origin}/notes/{Guid.NewGuid():N}";
|
||||
_harness.Peer.Serve(new Uri(parentId).AbsolutePath, new JsonObject
|
||||
{
|
||||
["id"] = parentId, ["type"] = "Note", ["attributedTo"] = dave.Id, ["content"] = "<p>parent</p>",
|
||||
["to"] = new JsonArray(Addressing.Public), ["inReplyTo"] = grandparentId
|
||||
}.ToJsonString());
|
||||
|
||||
await _harness.Deliver(carol, "/human-centipede", Create(carol, parentId, "reply to dave"));
|
||||
|
||||
var parent = await DB.Default.Find<PrivaPub.Models.Post.Post>().Match(p => p.ObjectURI == parentId).ExecuteSingleAsync(token);
|
||||
var reply = await DB.Default.Find<PrivaPub.Models.Post.Post>().Match(p => p.InReplyToURI == parentId).ExecuteSingleAsync(token);
|
||||
Assert.Equal(parent.ID, reply.AnsweringToPostId);
|
||||
Assert.Equal(parent.AuthorAccountId, reply.InReplyToAccountId);
|
||||
Assert.True(await DB.Default.Find<PrivaPub.Models.Jobs.Job>().Match(j => j.DedupeKey == "ancestors|" + parent.ID).ExecuteAnyAsync(token));
|
||||
Assert.DoesNotContain("<p>reply to dave</p>", (await Home(bobRoot, bob.Id)).Select(p => p.ContentHtml));
|
||||
Assert.DoesNotContain("<p>parent</p>", (await Home(bobRoot, bob.Id)).Select(p => p.ContentHtml));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_remote_delete_takes_the_post_out_of_home()
|
||||
{
|
||||
var (bobRoot, bob) = await _harness.Persona("bob");
|
||||
var carol = new RemoteActor(_harness.Peer, "carol");
|
||||
await Accepted(bobRoot, bob.Id, carol);
|
||||
var create = Create(carol, text: "soon gone");
|
||||
await _harness.Deliver(carol, "/human-centipede", create);
|
||||
Assert.Single(await Home(bobRoot, bob.Id));
|
||||
|
||||
await _harness.Deliver(carol, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = $"{new Uri(carol.Id).GetLeftPart(UriPartial.Authority)}/deletes/{Guid.NewGuid():N}", ["type"] = "Delete", ["actor"] = carol.Id,
|
||||
["object"] = new JsonObject { ["id"] = create["object"]!["id"]!.GetValue<string>(), ["type"] = "Tombstone" }
|
||||
});
|
||||
|
||||
Assert.Empty(await Home(bobRoot, bob.Id));
|
||||
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == bob.Id).ExecuteAnyAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_mention_notifies_the_persona()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (bobRoot, bob) = await _harness.Persona("bob");
|
||||
var carol = new RemoteActor(_harness.Peer, "carol");
|
||||
|
||||
await _harness.Deliver(carol, "/human-centipede", Create(carol, mentions: (bob.Uri, "@" + bob.Handle)));
|
||||
|
||||
var notifications = (List<ViewNotification>)(await _harness.Timelines.Notifications(bobRoot, bob.Id, default, 20, token)).Data;
|
||||
var mention = Assert.Single(notifications);
|
||||
Assert.Equal("Mention", mention.Type);
|
||||
Assert.Equal(carol.Id, mention.FromActorURI);
|
||||
await _harness.Timelines.MarkNotificationsRead(bobRoot, bob.Id, token);
|
||||
Assert.True(((List<ViewNotification>)(await _harness.Timelines.Notifications(bobRoot, bob.Id, default, 20, token)).Data).Single().IsRead);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||
|
||||
namespace PrivaPub.Tests.Domain
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public class VisibilityPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Who_sees_what()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var conversation = new DmGroup { Members = new() { new GroupMember { AvatarId = "alice" }, new GroupMember { AvatarId = "https://r.example/u/bob", IsForeign = true } } };
|
||||
var circle = new GroupEntity { UserName = $"c{Guid.NewGuid():N}"[..20], Members = new() { new GroupMember { AvatarId = "carol" } } };
|
||||
await DB.Default.SaveAsync(conversation, token);
|
||||
await DB.Default.SaveAsync(circle, token);
|
||||
|
||||
var direct = new Post { Visibility = PostVisibility.Direct, ConversationId = conversation.ID, GroupUserId = "dave" };
|
||||
var circlePost = new Post { Visibility = PostVisibility.Circle, GroupId = circle.ID };
|
||||
var followersOnly = new Post { Visibility = PostVisibility.FollowersOnly, Mentions = new() { new PostMention { IsLocal = true, AccountId = "erin" } } };
|
||||
var deleted = new Post { Visibility = PostVisibility.Public, DeletedAt = DateTime.UtcNow };
|
||||
|
||||
Assert.True(await VisibilityPolicy.CanSee(new Post(), default, token));
|
||||
Assert.True(await VisibilityPolicy.CanSee(direct, "alice", token));
|
||||
Assert.True(await VisibilityPolicy.CanSee(direct, "dave", token));
|
||||
Assert.False(await VisibilityPolicy.CanSee(direct, "carol", token));
|
||||
Assert.False(await VisibilityPolicy.CanSee(direct, default, token));
|
||||
Assert.True(await VisibilityPolicy.CanSee(circlePost, "carol", token));
|
||||
Assert.False(await VisibilityPolicy.CanSee(circlePost, "alice", token));
|
||||
Assert.True(await VisibilityPolicy.CanSee(followersOnly, "erin", token));
|
||||
Assert.False(await VisibilityPolicy.CanSee(followersOnly, "alice", token));
|
||||
Assert.False(await VisibilityPolicy.CanSee(deleted, "alice", token));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
using PrivaPub.Federation.Objects;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
public class ContentSanitizerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("<script>alert(1)</script><p>hi</p>", "<p>hi</p>")]
|
||||
[InlineData("<p onclick=\"alert(1)\">hi</p>", "<p>hi</p>")]
|
||||
[InlineData("<img src=x onerror=alert(1)>", "")]
|
||||
[InlineData("<a href=\"javascript:alert(1)\">x</a>", "<a rel=\"nofollow noopener noreferrer\" target=\"_blank\">x</a>")]
|
||||
[InlineData("<a href=\"data:text/html,<script>alert(1)</script>\">x</a>", "<a rel=\"nofollow noopener noreferrer\" target=\"_blank\">x</a>")]
|
||||
[InlineData("<a href=\"/relative\">x</a>", "<a rel=\"nofollow noopener noreferrer\" target=\"_blank\">x</a>")]
|
||||
[InlineData("<iframe src=\"https://evil.example\"></iframe>text", "text")]
|
||||
[InlineData("<style>body{display:none}</style><p>x</p>", "<p>x</p>")]
|
||||
[InlineData("<svg><script>alert(1)</script></svg>", "")]
|
||||
[InlineData("<p style=\"position:fixed\">x</p>", "<p>x</p>")]
|
||||
[InlineData("<form action=\"https://evil.example\"><input name=p></form>ok", "ok")]
|
||||
[InlineData("<p>a<!-- comment -->b</p>", "<p>ab</p>")]
|
||||
[InlineData("<div><p>kept</p></div>", "<p>kept</p>")]
|
||||
[InlineData("<h2>Title</h2>", "<p><strong>Title</strong></p>")]
|
||||
[InlineData("<span class=\"evil big\">x</span>", "<span>x</span>")]
|
||||
public void Removes_what_is_not_allowed(string input, string expected) =>
|
||||
Assert.Equal(expected, ContentSanitizer.Html(input));
|
||||
|
||||
[Fact]
|
||||
public void Keeps_mastodon_mention_and_hashtag_markup()
|
||||
{
|
||||
var html = "<p><span class=\"h-card\" translate=\"no\"><a href=\"https://m.example/@alice\" class=\"u-url mention\">@<span>alice</span></a></span> "
|
||||
+ "<a href=\"https://m.example/tags/fedi\" class=\"mention hashtag\" rel=\"tag\">#<span>fedi</span></a> "
|
||||
+ "<a href=\"https://example.org/a-long-link\"><span class=\"invisible\">https://</span><span class=\"ellipsis\">example.org/a-long</span></a></p>";
|
||||
|
||||
var sanitized = ContentSanitizer.Html(html);
|
||||
|
||||
Assert.Contains("class=\"h-card\"", sanitized);
|
||||
Assert.Contains("class=\"u-url mention\"", sanitized);
|
||||
Assert.Contains("class=\"mention hashtag\"", sanitized);
|
||||
Assert.Contains("class=\"invisible\"", sanitized);
|
||||
Assert.Contains("class=\"ellipsis\"", sanitized);
|
||||
Assert.Contains("href=\"https://m.example/@alice\"", sanitized);
|
||||
Assert.DoesNotContain("rel=\"tag\"", sanitized);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(null)]
|
||||
public void Empty_input_is_empty(string input) =>
|
||||
Assert.Equal(string.Empty, ContentSanitizer.Html(input));
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.ClientModels.Social;
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Federation.Signing;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class GroupTests : IAsyncLifetime
|
||||
{
|
||||
Harness _harness;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_harness = await Harness.Start();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness != default)
|
||||
await _harness.DisposeAsync();
|
||||
}
|
||||
|
||||
async Task<(GroupEntity Entity, PrivaPub.Federation.Actors.LocalActor Actor)> Group(GroupKind kind, PostingPolicy policy, string ownerId, params string[] remoteMembers)
|
||||
{
|
||||
var (privateKey, publicKey) = PrivaPub.Federation.Actors.Keys.NewKeyPair();
|
||||
var group = new GroupEntity
|
||||
{
|
||||
UserName = $"{kind}{Guid.NewGuid():N}"[..20].ToLowerInvariant(),
|
||||
Kind = kind,
|
||||
PostingPolicy = policy,
|
||||
PrivateKey = privateKey,
|
||||
PublicKey = publicKey,
|
||||
Members = new() { new GroupMember { AvatarId = ownerId, Role = GroupRole.Owner } }
|
||||
};
|
||||
group.Members.AddRange(remoteMembers.Select(m => new GroupMember { AvatarId = m, IsForeign = true }));
|
||||
await DB.Default.SaveAsync(group);
|
||||
return (group, _harness.Local.FromGroup(group));
|
||||
}
|
||||
|
||||
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
|
||||
|
||||
static JsonObject Create(RemoteActor author, IEnumerable<string> to, string audience = default)
|
||||
{
|
||||
var note = new JsonObject
|
||||
{
|
||||
["id"] = $"{Origin(author)}/notes/{Guid.NewGuid():N}",
|
||||
["type"] = "Note",
|
||||
["attributedTo"] = author.Id,
|
||||
["content"] = "<p>to the group</p>",
|
||||
["to"] = new JsonArray(to.Select(t => (JsonNode)t).ToArray())
|
||||
};
|
||||
if (audience != default)
|
||||
note["audience"] = audience;
|
||||
return new JsonObject { ["id"] = $"{Origin(author)}/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Id, ["object"] = note };
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_follower_posting_to_a_community_is_announced_activity_and_object()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, owner) = await _harness.Persona("owner");
|
||||
var (_, community) = await Group(GroupKind.Community, PostingPolicy.Followers, owner.Id);
|
||||
var lemmy = new RemoteActor(_harness.Peer, "lemmy");
|
||||
var stranger = new RemoteActor(_harness.Peer, "stranger");
|
||||
await _harness.FollowedBy(community, lemmy);
|
||||
|
||||
await _harness.Deliver(lemmy, "/human-centipede", Create(lemmy, new[] { community.Uri, Addressing.Public }, community.Uri));
|
||||
await _harness.Deliver(stranger, "/human-centipede", Create(stranger, new[] { community.Uri, Addressing.Public }, community.Uri));
|
||||
|
||||
Assert.True(await DB.Default.Find<Post>().Match(p => p.GroupId == community.Id && p.ActorURI == lemmy.Id).ExecuteAnyAsync(token));
|
||||
Assert.False(await DB.Default.Find<Post>().Match(p => p.GroupId == community.Id && p.ActorURI == stranger.Id).ExecuteAnyAsync(token));
|
||||
var announces = (await _harness.Outgoing(lemmy.SharedInbox)).Where(a => a["actor"]!.GetValue<string>() == community.Uri).ToList();
|
||||
Assert.Equal(2, announces.Count);
|
||||
Assert.Contains(announces, a => a["object"] is JsonObject inner && inner["type"]!.GetValue<string>() == "Create" && a["audience"]!.GetValue<string>() == community.Uri);
|
||||
Assert.Contains(announces, a => a["object"] is JsonValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_open_community_takes_posts_from_anyone()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, owner) = await _harness.Persona("owner");
|
||||
var (_, community) = await Group(GroupKind.Community, PostingPolicy.Anyone, owner.Id);
|
||||
var stranger = new RemoteActor(_harness.Peer, "stranger");
|
||||
|
||||
await _harness.Deliver(stranger, "/human-centipede", Create(stranger, new[] { community.Uri, Addressing.Public }));
|
||||
|
||||
Assert.True(await DB.Default.Find<Post>().Match(p => p.GroupId == community.Id && p.ActorURI == stranger.Id).ExecuteAnyAsync(token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mentioning_a_community_posts_into_it_as_a_titled_page()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var (_, community) = await Group(GroupKind.Community, PostingPolicy.Anyone, alice.Id);
|
||||
var follower = new RemoteActor(_harness.Peer, "follower");
|
||||
await _harness.FollowedBy(community, follower);
|
||||
|
||||
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = $"@{community.UserName} a new thread", PlainText = true }, token);
|
||||
|
||||
Assert.Equal(community.Id, outcome.Post.GroupId);
|
||||
var note = ActivityPubRenderer.Note(outcome.Post, alice, community, default);
|
||||
Assert.Equal("Page", note["type"]!.GetValue<string>());
|
||||
Assert.Equal(community.Uri, note["audience"]!.GetValue<string>());
|
||||
Assert.False(string.IsNullOrEmpty(note["name"]!.GetValue<string>()));
|
||||
Assert.Contains(await _harness.Outgoing(follower.SharedInbox), a => a["type"]!.GetValue<string>() == "Announce");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_circle_post_goes_only_to_members_and_only_members_may_read_it()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var member = new RemoteActor(_harness.Peer, "member");
|
||||
var outsider = new RemoteActor(_harness.Peer, "outsider");
|
||||
var (circleEntity, circle) = await Group(GroupKind.Circle, PostingPolicy.Followers, alice.Id, member.Id);
|
||||
await _harness.Remote.GetActor(member.Id, refresh: false, token);
|
||||
var follower = new RemoteActor(_harness.Peer, "follower");
|
||||
await _harness.FollowedBy(alice, follower);
|
||||
|
||||
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "just us", GroupId = circle.Id }, token);
|
||||
|
||||
Assert.Equal(PostVisibility.Circle, outcome.Post.Visibility);
|
||||
var create = Assert.Single(await _harness.Outgoing(member.Id + "/inbox"));
|
||||
Assert.Equal(new[] { circle.Uri, circle.Flock }, create["object"]!["to"]!.AsArray().Select(t => t!.GetValue<string>()));
|
||||
Assert.DoesNotContain(Addressing.Public, create.ToJsonString());
|
||||
Assert.Empty(await _harness.Outgoing(follower.SharedInbox));
|
||||
Assert.Empty((await _harness.Outgoing(member.SharedInbox)).Where(a => a["type"]!.GetValue<string>() == "Announce"));
|
||||
|
||||
var authorizer = new SignedFetchAuthorizer(_harness.Remote);
|
||||
var path = new Uri(outcome.Post.ObjectURI).AbsolutePath;
|
||||
var asMember = await authorizer.Requester(member.Get(Harness.Host, path), token);
|
||||
var asOutsider = await authorizer.Requester(outsider.Get(Harness.Host, path), token);
|
||||
Assert.True(SignedFetchAuthorizer.MayReadCircle(circleEntity, asMember));
|
||||
Assert.False(SignedFetchAuthorizer.MayReadCircle(circleEntity, asOutsider));
|
||||
Assert.False(SignedFetchAuthorizer.MayReadCircle(circleEntity, default));
|
||||
Assert.True(circle.IsCircle);
|
||||
Assert.False(circle.Discoverable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Only_circle_members_can_post_into_a_circle()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (aliceRoot, alice) = await _harness.Persona("alice");
|
||||
var member = new RemoteActor(_harness.Peer, "member");
|
||||
var outsider = new RemoteActor(_harness.Peer, "outsider");
|
||||
var (_, circle) = await Group(GroupKind.Circle, PostingPolicy.Followers, alice.Id, member.Id);
|
||||
|
||||
await _harness.Deliver(member, "/human-centipede", Create(member, new[] { circle.Uri, circle.Flock }));
|
||||
await _harness.Deliver(outsider, "/human-centipede", Create(outsider, new[] { circle.Uri, circle.Flock }));
|
||||
|
||||
var stored = await DB.Default.Find<Post>().Match(p => p.GroupId == circle.Id).ExecuteAsync(token);
|
||||
Assert.Equal(member.Id, Assert.Single(stored).ActorURI);
|
||||
Assert.Equal(PostVisibility.Circle, stored[0].Visibility);
|
||||
Assert.True(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.PostId == stored[0].ID).ExecuteAnyAsync(token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_followed_remote_community_announcing_a_post_puts_it_in_home()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (root, alice) = await _harness.Persona("alice");
|
||||
var lemmyCommunity = new RemoteActor(_harness.Peer, "cats", type: "Group");
|
||||
var poster = new RemoteActor(_harness.Peer, "poster");
|
||||
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = lemmyCommunity.Id }, token);
|
||||
await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
|
||||
var create = Create(poster, new[] { lemmyCommunity.Id, Addressing.Public }, lemmyCommunity.Id);
|
||||
var noteId = create["object"]!["id"]!.GetValue<string>();
|
||||
_harness.Peer.Serve(new Uri(noteId).AbsolutePath, create["object"]!.ToJsonString());
|
||||
|
||||
await _harness.Deliver(lemmyCommunity, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = $"{Origin(lemmyCommunity)}/activities/announce/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = lemmyCommunity.Id,
|
||||
["to"] = new JsonArray(Addressing.Public), ["object"] = create
|
||||
});
|
||||
|
||||
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(token);
|
||||
Assert.Equal(lemmyCommunity.Id, post.AudienceURI);
|
||||
Assert.True(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(token));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Signing;
|
||||
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
public class HttpSignaturesTests
|
||||
{
|
||||
const string Host = "privapub.test";
|
||||
const string KeyId = "https://mastodon.example/users/alice#main-key";
|
||||
static readonly DateTimeOffset Now = new(2026, 10, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
static readonly byte[] Body = Encoding.UTF8.GetBytes("""{"type":"Follow","actor":"https://mastodon.example/users/alice"}""");
|
||||
|
||||
readonly RSA _key = RSA.Create(2048);
|
||||
|
||||
string PublicKey => _key.ExportSubjectPublicKeyInfoPem();
|
||||
|
||||
DefaultHttpContext MastodonRequest(string signedTarget = "/peasants/bob/mouth", DateTimeOffset? signedAt = default,
|
||||
string headers = "(request-target) host date digest content-type", byte[] signedBody = default, string extra = "")
|
||||
{
|
||||
var date = (signedAt ?? Now).ToString("r", CultureInfo.InvariantCulture);
|
||||
var digest = HttpSignatures.Digest(signedBody ?? Body);
|
||||
var values = new Dictionary<string, string>
|
||||
{
|
||||
["(request-target)"] = $"post {signedTarget}",
|
||||
["host"] = Host,
|
||||
["date"] = date,
|
||||
["digest"] = digest,
|
||||
["content-type"] = "application/activity+json"
|
||||
};
|
||||
var signingString = string.Join("\n", headers.Split(' ').Select(h => $"{h}: {values[h]}"));
|
||||
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = "POST";
|
||||
context.Request.Host = new HostString(Host);
|
||||
context.Request.Path = "/peasants/bob/mouth";
|
||||
context.Features.Get<IHttpRequestFeature>().RawTarget = "/peasants/bob/mouth";
|
||||
context.Request.Headers["Date"] = date;
|
||||
context.Request.Headers["Digest"] = digest;
|
||||
context.Request.Headers["Content-Type"] = "application/activity+json";
|
||||
context.Request.Headers["Signature"] = $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"{headers}\",signature=\"{signature}\"{extra}";
|
||||
return context;
|
||||
}
|
||||
|
||||
string Check(HttpContext context, byte[] body = default)
|
||||
{
|
||||
var parameters = HttpSignatures.Parse(context.Request.Headers["Signature"].ToString());
|
||||
var problem = HttpSignatures.CheckRequest(context.Request, parameters, body ?? Body, Now);
|
||||
if (problem != default)
|
||||
return problem;
|
||||
return HttpSignatures.Verify(PublicKey, HttpSignatures.SigningString(context.Request, parameters), parameters.Signature)
|
||||
? default
|
||||
: "does not verify";
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accepts_a_mastodon_style_delivery() =>
|
||||
Assert.Null(Check(MastodonRequest()));
|
||||
|
||||
[Fact]
|
||||
public void Refuses_a_tampered_body() =>
|
||||
Assert.Equal("the digest does not match the body", Check(MastodonRequest(), Encoding.UTF8.GetBytes("""{"type":"Delete"}""")));
|
||||
|
||||
[Fact]
|
||||
public void Refuses_a_signature_for_another_inbox() =>
|
||||
Assert.Equal("does not verify", Check(MastodonRequest(signedTarget: "/peasants/carol/mouth")));
|
||||
|
||||
[Fact]
|
||||
public void Refuses_a_signature_older_than_an_hour() =>
|
||||
Assert.Equal("the Date header is outside the allowed window", Check(MastodonRequest(signedAt: Now.AddMinutes(-61))));
|
||||
|
||||
[Fact]
|
||||
public void Accepts_a_clock_slightly_ahead() =>
|
||||
Assert.Null(Check(MastodonRequest(signedAt: Now.AddMinutes(10))));
|
||||
|
||||
[Fact]
|
||||
public void Refuses_a_clock_far_ahead() =>
|
||||
Assert.Equal("the Date header is outside the allowed window", Check(MastodonRequest(signedAt: Now.AddMinutes(20))));
|
||||
|
||||
[Fact]
|
||||
public void Refuses_a_signature_without_the_request_target() =>
|
||||
Assert.Equal("(request-target) is not signed", Check(MastodonRequest(headers: "host date digest")));
|
||||
|
||||
[Fact]
|
||||
public void Refuses_a_signature_without_the_host() =>
|
||||
Assert.Equal("host is not signed", Check(MastodonRequest(headers: "(request-target) date digest")));
|
||||
|
||||
[Fact]
|
||||
public void Refuses_a_body_whose_digest_is_not_signed() =>
|
||||
Assert.Equal("the digest is not signed", Check(MastodonRequest(headers: "(request-target) host date")));
|
||||
|
||||
[Fact]
|
||||
public void Refuses_an_expired_signature() =>
|
||||
Assert.Equal("the signature has expired",
|
||||
Check(MastodonRequest(extra: $",expires=\"{Now.AddMinutes(-20).ToUnixTimeSeconds()}\"")));
|
||||
|
||||
[Fact]
|
||||
public void Signs_with_the_raw_request_target()
|
||||
{
|
||||
var context = MastodonRequest(signedTarget: "/peasants/b%6Fb/mouth");
|
||||
context.Features.Get<IHttpRequestFeature>().RawTarget = "/peasants/b%6Fb/mouth";
|
||||
|
||||
Assert.Null(Check(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verifies_its_own_outbound_signature()
|
||||
{
|
||||
var (privateKey, publicKey) = Keys.NewKeyPair();
|
||||
var signer = new LocalActor { UserName = "bob", BaseAddress = "https://privapub.test", PrivateKeyPem = privateKey };
|
||||
using var outbound = new HttpRequestMessage(HttpMethod.Post, "https://mastodon.example/users/alice/inbox");
|
||||
HttpSignatures.Sign(outbound, signer, Body);
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = "POST";
|
||||
context.Request.Host = new HostString("mastodon.example");
|
||||
context.Request.Path = "/users/alice/inbox";
|
||||
context.Features.Get<IHttpRequestFeature>().RawTarget = "/users/alice/inbox";
|
||||
foreach (var header in outbound.Headers)
|
||||
context.Request.Headers[header.Key] = string.Join(", ", header.Value);
|
||||
var parameters = HttpSignatures.Parse(context.Request.Headers["Signature"].ToString());
|
||||
|
||||
Assert.Equal("https://privapub.test/peasants/bob#main-key", parameters.KeyId);
|
||||
Assert.Null(HttpSignatures.CheckRequest(context.Request, parameters, Body));
|
||||
Assert.True(HttpSignatures.Verify(publicKey, HttpSignatures.SigningString(context.Request, parameters), parameters.Signature));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Domain.Timelines;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Inbox.Handlers;
|
||||
using PrivaPub.Federation.Inbox;
|
||||
using PrivaPub.Federation.Moderation;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Infrastructure.Jobs;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.StaticServices;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class InboxScenarioTests : IAsyncLifetime
|
||||
{
|
||||
const string Host = "privapub.test";
|
||||
const string Base = "https://" + Host;
|
||||
|
||||
Peer _peer;
|
||||
LocalActorService _local;
|
||||
InboxReceiver _receiver;
|
||||
InboxProcessor _processor;
|
||||
DomainBlocks _blocks;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_peer = await Peer.Start();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
_local = new LocalActorService(new DbEntities(), new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = Base }));
|
||||
var remote = new RemoteActorService(Peer.Http(cache), _local, cache, new DbEntities());
|
||||
var queue = new JobQueue();
|
||||
var delivery = new DeliveryService(new DbEntities(), queue);
|
||||
var db = new DbEntities();
|
||||
_blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance);
|
||||
_receiver = new InboxReceiver(_local, remote, queue, _blocks, NullLogger<InboxReceiver>.Instance);
|
||||
_processor = new InboxProcessor(remote, new IActivityHandler[]
|
||||
{
|
||||
new FollowHandler(db, _local, remote, delivery),
|
||||
new UndoHandler(db, _local),
|
||||
new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue), new GroupDistributor(delivery)),
|
||||
new DeleteHandler(db, _local, remote, delivery, new GroupDistributor(delivery)),
|
||||
new UpdateHandler(db, _local, remote, new GroupDistributor(delivery))
|
||||
}, NullLogger<InboxProcessor>.Instance);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_peer != default)
|
||||
await _peer.DisposeAsync();
|
||||
}
|
||||
|
||||
async Task<LocalActor> LocalAvatar(string name)
|
||||
{
|
||||
var (privateKey, publicKey) = Keys.NewKeyPair();
|
||||
var avatar = new Avatar { UserName = $"{name}{Guid.NewGuid():N}"[..20], PrivateKey = privateKey, PublicKey = publicKey };
|
||||
await DB.Default.SaveAsync(avatar, TestContext.Current.CancellationToken);
|
||||
return _local.FromAvatar(avatar);
|
||||
}
|
||||
|
||||
static JsonObject DirectCreate(RemoteActor author, string to, string context = default, string objectOrigin = default, string attributedTo = default)
|
||||
{
|
||||
var id = $"{objectOrigin ?? Origin(author.Id)}/notes/{Guid.NewGuid():N}";
|
||||
var note = new JsonObject
|
||||
{
|
||||
["id"] = id,
|
||||
["type"] = "Note",
|
||||
["attributedTo"] = attributedTo ?? author.Id,
|
||||
["content"] = "<p>psst</p>",
|
||||
["to"] = new JsonArray(to),
|
||||
["cc"] = new JsonArray()
|
||||
};
|
||||
if (context != default)
|
||||
note["context"] = context;
|
||||
return new JsonObject
|
||||
{
|
||||
["id"] = $"{Origin(author.Id)}/activities/{Guid.NewGuid():N}",
|
||||
["type"] = "Create",
|
||||
["actor"] = author.Id,
|
||||
["to"] = new JsonArray(to),
|
||||
["object"] = note
|
||||
};
|
||||
}
|
||||
|
||||
async Task<InboxResult> Deliver(RemoteActor sender, string path, JsonNode activity)
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var result = await _receiver.Receive(sender.Post(Host, path, activity), default, token);
|
||||
var dedupe = "inbox|" + (activity is JsonObject ? activity["id"]?.GetValue<string>() : default);
|
||||
var job = await DB.Default.Find<Job>().Match(j => j.DedupeKey == dedupe).ExecuteFirstAsync(token);
|
||||
if (job != default)
|
||||
Assert.Equal(JobResult.Done, (await _processor.Handle(job, token)).Result);
|
||||
return result;
|
||||
}
|
||||
|
||||
static string Origin(string uri) => new Uri(uri).GetLeftPart(UriPartial.Authority);
|
||||
|
||||
[Fact]
|
||||
public async Task A_context_cannot_pull_a_stranger_into_an_existing_conversation()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var alice = await LocalAvatar("alice");
|
||||
var bob = new RemoteActor(_peer, "bob");
|
||||
var mallory = new RemoteActor(_peer, "mallory");
|
||||
var context = $"{_peer.A}/contexts/{Guid.NewGuid():N}";
|
||||
|
||||
var first = await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri, context));
|
||||
var injected = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, context));
|
||||
|
||||
Assert.Equal(202, first.StatusCode);
|
||||
Assert.Equal(202, injected.StatusCode);
|
||||
var bobDm = await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id && p.Visibility == PostVisibility.Direct).ExecuteSingleAsync(token);
|
||||
var malloryDm = await DB.Default.Find<Post>().Match(p => p.ActorURI == mallory.Id).ExecuteSingleAsync(token);
|
||||
Assert.NotEqual(bobDm.ConversationId, malloryDm.ConversationId);
|
||||
var bobConversation = await DB.Default.Find<DmGroup>().OneAsync(bobDm.ConversationId, token);
|
||||
Assert.DoesNotContain(bobConversation.Members, m => m.AvatarId == mallory.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Replies_between_the_same_people_land_in_the_same_conversation()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var alice = await LocalAvatar("alice");
|
||||
var bob = new RemoteActor(_peer, "bob");
|
||||
|
||||
await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri));
|
||||
await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri));
|
||||
|
||||
var dms = await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id && p.Visibility == PostVisibility.Direct).ExecuteAsync(token);
|
||||
Assert.Equal(2, dms.Count);
|
||||
Assert.Single(dms.Select(d => d.ConversationId).Distinct());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_activity_id_on_another_origin_is_refused()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var alice = await LocalAvatar("alice");
|
||||
var mallory = new RemoteActor(_peer, "mallory");
|
||||
var create = DirectCreate(mallory, alice.Uri);
|
||||
create["id"] = $"{_peer.B}/activities/{Guid.NewGuid():N}";
|
||||
|
||||
var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", create);
|
||||
|
||||
Assert.Equal(400, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_note_put_in_someone_elses_mouth_is_refused()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var alice = await LocalAvatar("alice");
|
||||
var mallory = new RemoteActor(_peer, "mallory");
|
||||
var victim = new RemoteActor(_peer, "victim");
|
||||
|
||||
var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, attributedTo: victim.Id));
|
||||
|
||||
Assert.Equal(400, result.StatusCode);
|
||||
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == victim.Id).ExecuteAnyAsync(token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_cross_origin_object_is_fetched_from_its_origin_before_it_is_believed()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var alice = await LocalAvatar("alice");
|
||||
var mallory = new RemoteActor(_peer, "mallory");
|
||||
|
||||
var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, objectOrigin: _peer.B));
|
||||
|
||||
Assert.Equal(202, result.StatusCode);
|
||||
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == mallory.Id).ExecuteAnyAsync(token));
|
||||
}
|
||||
|
||||
static JsonObject PublicCreate(RemoteActor author, string inReplyTo = default, params string[] cc)
|
||||
{
|
||||
var id = $"{Origin(author.Id)}/notes/{Guid.NewGuid():N}";
|
||||
var note = new JsonObject
|
||||
{
|
||||
["id"] = id,
|
||||
["type"] = "Note",
|
||||
["attributedTo"] = author.Id,
|
||||
["content"] = "<p>hello</p>",
|
||||
["to"] = new JsonArray(Addressing.Public),
|
||||
["cc"] = new JsonArray(cc.Prepend(author.Id + "/followers").Select(c => (JsonNode)c).ToArray())
|
||||
};
|
||||
if (inReplyTo != default)
|
||||
note["inReplyTo"] = inReplyTo;
|
||||
return new JsonObject
|
||||
{
|
||||
["id"] = $"{Origin(author.Id)}/activities/{Guid.NewGuid():N}",
|
||||
["type"] = "Create",
|
||||
["actor"] = author.Id,
|
||||
["object"] = note
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_public_reply_to_a_local_post_is_kept_and_counted()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var alice = await LocalAvatar("alice");
|
||||
var parent = new Post { GroupUserId = alice.Id, AuthorAccountId = alice.Id, ActorURI = alice.Uri, Text = "hi" };
|
||||
parent.ID = (string)parent.GenerateNewID();
|
||||
parent.ObjectURI = alice.PostUri(parent.ID);
|
||||
await DB.Default.SaveAsync(parent, token);
|
||||
var bob = new RemoteActor(_peer, "bob");
|
||||
|
||||
await Deliver(bob, "/human-centipede", PublicCreate(bob, parent.ObjectURI));
|
||||
|
||||
var reply = await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token);
|
||||
Assert.Equal(PostVisibility.Public, reply.Visibility);
|
||||
Assert.Equal(parent.ID, reply.AnsweringToPostId);
|
||||
Assert.Equal(alice.Id, reply.InReplyToAccountId);
|
||||
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(parent.ID, token)).RepliesCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_public_post_nobody_here_asked_for_is_dropped()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var bob = new RemoteActor(_peer, "bob");
|
||||
|
||||
await Deliver(bob, "/human-centipede", PublicCreate(bob));
|
||||
|
||||
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteAnyAsync(token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_mention_is_kept_and_linked_to_the_local_persona()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var alice = await LocalAvatar("alice");
|
||||
var bob = new RemoteActor(_peer, "bob");
|
||||
var create = PublicCreate(bob, default, alice.Uri);
|
||||
create["object"]!["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@" + alice.Handle });
|
||||
|
||||
await Deliver(bob, "/human-centipede", create);
|
||||
|
||||
var post = await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token);
|
||||
var mention = Assert.Single(post.Mentions);
|
||||
Assert.True(mention.IsLocal);
|
||||
Assert.Equal(alice.Id, mention.AccountId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_suspended_domain_is_dropped_before_its_key_is_fetched()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var alice = await LocalAvatar("alice");
|
||||
var bob = new RemoteActor(_peer, "bob", _peer.B);
|
||||
await DB.Default.SaveAsync(new DomainBlock { Domain = "localhost", Severity = DomainBlockSeverity.Suspend }, token);
|
||||
try
|
||||
{
|
||||
await _blocks.Reload(token);
|
||||
var before = _peer.Requests.Count;
|
||||
|
||||
var result = await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri));
|
||||
|
||||
Assert.Equal(202, result.StatusCode);
|
||||
Assert.Equal(before, _peer.Requests.Count);
|
||||
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteAnyAsync(token));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DB.Default.DeleteAsync<DomainBlock>(b => b.Domain == "localhost");
|
||||
await _blocks.Reload(token);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_block_covers_subdomains_but_not_lookalikes()
|
||||
{
|
||||
var blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance);
|
||||
typeof(DomainBlocks).GetField("_blocks", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
||||
.SetValue(blocks, new Dictionary<string, DomainBlock> { ["evil.example"] = new() { Domain = "evil.example", Severity = DomainBlockSeverity.Silence } });
|
||||
typeof(DomainBlocks).GetField("_loadedAt", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
||||
.SetValue(blocks, DateTime.UtcNow);
|
||||
|
||||
Assert.NotNull(blocks.Find("evil.example"));
|
||||
Assert.NotNull(blocks.Find("A.Evil.Example."));
|
||||
Assert.Null(blocks.Find("notevil.example"));
|
||||
Assert.False(blocks.IsSuspended("evil.example"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_bad_signature_is_a_401()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var alice = await LocalAvatar("alice");
|
||||
var mallory = new RemoteActor(_peer, "mallory");
|
||||
var request = mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri));
|
||||
request.Headers["Signature"] = request.Headers["Signature"].ToString().Replace("signature=\"", "signature=\"AAAA");
|
||||
|
||||
Assert.Equal(401, (await _receiver.Receive(request, alice, token)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Junk_is_a_400_never_a_500()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var alice = await LocalAvatar("alice");
|
||||
var mallory = new RemoteActor(_peer, "mallory");
|
||||
|
||||
foreach (var junk in new JsonNode[] { new JsonArray(1, 2), JsonValue.Create("x"), new JsonObject { ["type"] = "Create" } })
|
||||
Assert.Equal(400, (await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", junk)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_circle_only_takes_follow_requests()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (privateKey, publicKey) = Keys.NewKeyPair();
|
||||
var circle = new GroupEntity { UserName = $"circle{Guid.NewGuid():N}"[..20], PrivateKey = privateKey, PublicKey = publicKey };
|
||||
await DB.Default.SaveAsync(circle, token);
|
||||
var bob = new RemoteActor(_peer, "bob");
|
||||
var actor = _local.FromGroup(circle);
|
||||
var follow = new JsonObject
|
||||
{
|
||||
["id"] = $"{bob.Id}/follows/{Guid.NewGuid():N}",
|
||||
["type"] = "Follow",
|
||||
["actor"] = bob.Id,
|
||||
["object"] = actor.Uri
|
||||
};
|
||||
|
||||
Assert.True(actor.IsCircle);
|
||||
Assert.True(actor.ManuallyApprovesFollowers);
|
||||
Assert.False(actor.Discoverable);
|
||||
Assert.Equal(202, (await Deliver(bob, "/human-centipede", follow)).StatusCode);
|
||||
var request = await DB.Default.Find<Follower>().Match(f => f.LocalActorId == circle.ID).ExecuteSingleAsync(token);
|
||||
Assert.False(request.IsAccepted);
|
||||
Assert.DoesNotContain(circle.Members, m => m.AvatarId == bob.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.ClientModels.Post;
|
||||
using PrivaPub.ClientModels.Social;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class InteractionTests : IAsyncLifetime
|
||||
{
|
||||
Harness _harness;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_harness = await Harness.Start();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness != default)
|
||||
await _harness.DisposeAsync();
|
||||
}
|
||||
|
||||
async Task<(string Root, Post Post)> LocalPost(string visibility = default)
|
||||
{
|
||||
var (root, alice) = await _harness.Persona("alice");
|
||||
var view = (ViewPost)(await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "like me", Visibility = visibility },
|
||||
TestContext.Current.CancellationToken)).Data;
|
||||
return (root, await DB.Default.Find<Post>().OneAsync(view.Id));
|
||||
}
|
||||
|
||||
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
|
||||
|
||||
[Fact]
|
||||
public async Task A_like_counts_once_notifies_and_can_be_undone()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, post) = await LocalPost();
|
||||
var carol = new RemoteActor(_harness.Peer, "carol");
|
||||
var like = new JsonObject { ["id"] = $"{Origin(carol)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = carol.Id, ["object"] = post.ObjectURI };
|
||||
|
||||
await _harness.Deliver(carol, "/human-centipede", like);
|
||||
await _harness.Deliver(carol, "/human-centipede", new JsonObject { ["id"] = $"{Origin(carol)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = carol.Id, ["object"] = post.ObjectURI });
|
||||
|
||||
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
|
||||
Assert.Equal(NotificationType.Favourite, (await DB.Default.Find<Notification>().Match(n => n.AvatarId == post.GroupUserId).ExecuteSingleAsync(token)).Type);
|
||||
|
||||
await _harness.Deliver(carol, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = $"{Origin(carol)}/undos/{Guid.NewGuid():N}", ["type"] = "Undo", ["actor"] = carol.Id, ["object"] = like
|
||||
});
|
||||
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_stranger_cannot_like_a_followers_only_post()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, post) = await LocalPost("followersonly");
|
||||
var mallory = new RemoteActor(_harness.Peer, "mallory");
|
||||
|
||||
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = $"{Origin(mallory)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = mallory.Id, ["object"] = post.ObjectURI
|
||||
});
|
||||
|
||||
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_boost_of_a_local_post_counts_notifies_and_can_be_undone()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, post) = await LocalPost();
|
||||
var carol = new RemoteActor(_harness.Peer, "carol");
|
||||
var announce = new JsonObject
|
||||
{
|
||||
["id"] = $"{Origin(carol)}/announces/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = carol.Id, ["object"] = post.ObjectURI,
|
||||
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(carol.Id + "/followers")
|
||||
};
|
||||
|
||||
await _harness.Deliver(carol, "/human-centipede", announce);
|
||||
|
||||
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).ReblogsCount);
|
||||
Assert.Equal(NotificationType.Reblog, (await DB.Default.Find<Notification>().Match(n => n.AvatarId == post.GroupUserId).ExecuteSingleAsync(token)).Type);
|
||||
|
||||
await _harness.Deliver(carol, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = $"{Origin(carol)}/undos/{Guid.NewGuid():N}", ["type"] = "Undo", ["actor"] = carol.Id, ["object"] = announce["id"]!.GetValue<string>()
|
||||
});
|
||||
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).ReblogsCount);
|
||||
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == announce["id"]!.GetValue<string>()).ExecuteAnyAsync(token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_followed_account_boosting_a_remote_post_puts_it_in_home()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (bobRoot, bob) = await _harness.Persona("bob");
|
||||
var carol = new RemoteActor(_harness.Peer, "carol");
|
||||
var dave = new RemoteActor(_harness.Peer, "dave");
|
||||
var stranger = new RemoteActor(_harness.Peer, "stranger");
|
||||
await _harness.Follows.Follow(bobRoot, new FollowForm { AvatarId = bob.Id, Target = carol.Id }, token);
|
||||
await DB.Default.Update<Following>().Match(f => f.AvatarId == bob.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
|
||||
var noteId = $"{Origin(dave)}/notes/{Guid.NewGuid():N}";
|
||||
_harness.Peer.Serve(new Uri(noteId).AbsolutePath, new JsonObject
|
||||
{
|
||||
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = dave.Id, ["content"] = "<p>worth sharing</p>", ["to"] = new JsonArray(Addressing.Public)
|
||||
}.ToJsonString());
|
||||
|
||||
await _harness.Deliver(stranger, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = $"{Origin(stranger)}/announces/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = stranger.Id, ["object"] = noteId,
|
||||
["to"] = new JsonArray(Addressing.Public)
|
||||
});
|
||||
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteAnyAsync(token));
|
||||
|
||||
await _harness.Deliver(carol, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = $"{Origin(carol)}/announces/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = carol.Id, ["object"] = noteId,
|
||||
["to"] = new JsonArray(Addressing.Public)
|
||||
});
|
||||
|
||||
var home = (List<ViewPost>)(await _harness.Timelines.Home(bobRoot, bob.Id, default, 20, token)).Data;
|
||||
var boosted = Assert.Single(home);
|
||||
Assert.Equal("<p>worth sharing</p>", boosted.ReblogOf.ContentHtml);
|
||||
Assert.Equal(carol.Id, boosted.AuthorActorURI);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Models.Post;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
public class NoteParserTests
|
||||
{
|
||||
const string MastodonNote = """
|
||||
{
|
||||
"id": "https://m.example/users/alice/statuses/1",
|
||||
"type": "Note",
|
||||
"summary": "spoilers",
|
||||
"inReplyTo": "https://privapub.test/peasants/bob/posts/abc",
|
||||
"published": "2026-09-30T10:00:00Z",
|
||||
"url": "https://m.example/@alice/1",
|
||||
"attributedTo": "https://m.example/users/alice",
|
||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"cc": ["https://m.example/users/alice/followers", "https://privapub.test/peasants/bob"],
|
||||
"sensitive": true,
|
||||
"conversation": "tag:m.example,2026-09-30:objectId=1:objectType=Conversation",
|
||||
"context": "https://m.example/contexts/1",
|
||||
"content": "<p><span class=\"h-card\"><a href=\"https://privapub.test/peasants/bob\" class=\"u-url mention\">@<span>bob</span></a></span> hi <a href=\"https://m.example/tags/Fedi\" class=\"mention hashtag\" rel=\"tag\">#<span>Fedi</span></a><script>x</script></p>",
|
||||
"contentMap": { "en": "<p>hi</p>" },
|
||||
"attachment": [{
|
||||
"type": "Document", "mediaType": "image/png", "url": "https://files.m.example/a.png",
|
||||
"name": "a cat", "blurhash": "UBL_:rOpGG-oBUNG,qRj2so|=eE1w^n4S5NH", "focalPoint": [0.5, -2.0], "width": 640, "height": 480
|
||||
}],
|
||||
"tag": [
|
||||
{ "type": "Mention", "href": "https://privapub.test/peasants/bob", "name": "@bob@privapub.test" },
|
||||
{ "type": "Hashtag", "href": "https://m.example/tags/fedi", "name": "#Fedi" },
|
||||
{ "type": "Emoji", "name": ":blob:", "icon": { "type": "Image", "url": "https://files.m.example/blob.png" } }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public void Reads_a_mastodon_note()
|
||||
{
|
||||
var note = NoteParser.Parse(JsonNode.Parse(MastodonNote));
|
||||
|
||||
Assert.Equal("https://m.example/users/alice", note.AttributedTo);
|
||||
Assert.Equal("spoilers", note.SpoilerText);
|
||||
Assert.True(note.Sensitive);
|
||||
Assert.Equal("en", note.Language);
|
||||
Assert.Equal("https://m.example/@alice/1", note.Url);
|
||||
Assert.Equal("https://m.example/contexts/1", note.Context);
|
||||
Assert.DoesNotContain("<script>", note.ContentHtml);
|
||||
Assert.Contains("u-url mention", note.ContentHtml);
|
||||
Assert.Equal("https://privapub.test/peasants/bob", Assert.Single(note.Mentions).ActorURI);
|
||||
Assert.Equal("fedi", Assert.Single(note.Tags));
|
||||
var media = Assert.Single(note.Attachments);
|
||||
Assert.Equal("a cat", media.Description);
|
||||
Assert.Equal(new[] { 0.5f, -1f }, media.Focus);
|
||||
Assert.Equal(new DateTime(2026, 9, 30, 10, 0, 0, DateTimeKind.Utc), note.Published);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reads_a_lemmy_page_with_a_title()
|
||||
{
|
||||
var note = NoteParser.Parse(JsonNode.Parse("""
|
||||
{
|
||||
"id": "https://l.example/post/7", "type": "Page", "name": "A title",
|
||||
"attributedTo": "https://l.example/u/carol", "audience": "https://l.example/c/cats",
|
||||
"content": "<p>body</p>", "to": ["https://l.example/c/cats", "https://www.w3.org/ns/activitystreams#Public"],
|
||||
"published": "2026-09-30T10:00:00.123456+00:00"
|
||||
}
|
||||
"""));
|
||||
|
||||
Assert.Equal("A title", note.Title);
|
||||
Assert.Equal("https://l.example/c/cats", note.Audience);
|
||||
Assert.False(note.Sensitive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Falls_back_to_misskey_content_and_takes_the_person_from_a_peertube_attribution()
|
||||
{
|
||||
var note = NoteParser.Parse(JsonNode.Parse("""
|
||||
{
|
||||
"id": "https://k.example/notes/9", "type": "Note",
|
||||
"attributedTo": [{ "type": "Group", "id": "https://k.example/c/chan" }, { "type": "Person", "id": "https://k.example/users/9" }],
|
||||
"_misskey_content": "hello <b>there</b>\nnext", "_misskey_quote": "https://k.example/notes/8"
|
||||
}
|
||||
"""));
|
||||
|
||||
Assert.Equal("https://k.example/users/9", note.AttributedTo);
|
||||
Assert.Equal("hello <b>there</b><br>next", note.ContentHtml);
|
||||
Assert.Equal("https://k.example/notes/8", note.QuoteUri);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{ "id": "https://x.example/1", "type": "Person" }""")]
|
||||
[InlineData("""{ "id": "not a uri", "type": "Note" }""")]
|
||||
[InlineData("""[1, 2]""")]
|
||||
public void Refuses_what_is_not_a_note(string json) =>
|
||||
Assert.Null(NoteParser.Parse(JsonNode.Parse(json)));
|
||||
}
|
||||
|
||||
public class AddressingTests
|
||||
{
|
||||
const string Followers = "https://m.example/users/alice/followers";
|
||||
|
||||
[Theory]
|
||||
[InlineData(new[] { Addressing.Public }, new[] { Followers }, PostVisibility.Public)]
|
||||
[InlineData(new[] { "as:Public" }, new string[0], PostVisibility.Public)]
|
||||
[InlineData(new[] { Followers }, new[] { Addressing.Public }, PostVisibility.Unlisted)]
|
||||
[InlineData(new[] { Followers }, new[] { "https://privapub.test/peasants/bob" }, PostVisibility.FollowersOnly)]
|
||||
[InlineData(new[] { "https://privapub.test/peasants/bob" }, new string[0], PostVisibility.Direct)]
|
||||
[InlineData(new[] { "https://m.example/users/mallory/followers" }, new string[0], PostVisibility.Direct)]
|
||||
public void Classifies_like_mastodon(string[] to, string[] cc, PostVisibility expected) =>
|
||||
Assert.Equal(expected, Addressing.Classify(to, cc, Followers));
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.StaticServices;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class RemoteActorServiceTests : IAsyncLifetime
|
||||
{
|
||||
Peer _peer;
|
||||
RemoteActorService _service;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_peer = await Peer.Start();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var local = new LocalActorService(new DbEntities(),
|
||||
new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = "https://privapub.test" }));
|
||||
_service = new RemoteActorService(Peer.Http(cache), local, cache, new DbEntities());
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_peer != default)
|
||||
await _peer.DisposeAsync();
|
||||
}
|
||||
|
||||
static string Actor(string id, string keyId = default, string owner = default, string pem = default, string type = "Person") =>
|
||||
new JsonObject
|
||||
{
|
||||
["id"] = id,
|
||||
["type"] = type,
|
||||
["preferredUsername"] = id[(id.LastIndexOf('/') + 1)..],
|
||||
["inbox"] = id + "/inbox",
|
||||
["publicKey"] = new JsonObject
|
||||
{
|
||||
["id"] = keyId ?? id + "#main-key",
|
||||
["owner"] = owner ?? id,
|
||||
["publicKeyPem"] = pem ?? Keys.NewKeyPair().PublicKeyPem
|
||||
}
|
||||
}.ToJsonString();
|
||||
|
||||
string Unique(string name) => $"/users/{name}{Guid.NewGuid():N}";
|
||||
|
||||
[Fact]
|
||||
public async Task Accepts_an_actor_whose_document_names_its_own_address()
|
||||
{
|
||||
var path = Unique("alice");
|
||||
_peer.Serve(path, Actor("{A}" + path));
|
||||
|
||||
var actor = await _service.GetActorByKeyId($"{_peer.A}{path}#main-key", refresh: false, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(actor);
|
||||
Assert.Equal(_peer.A + path, actor.ActorURI);
|
||||
Assert.Equal($"{_peer.A}{path}#main-key", actor.PublicKeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refuses_a_document_that_claims_another_origin()
|
||||
{
|
||||
var victim = Unique("bob");
|
||||
var mallory = Unique("mallory");
|
||||
_peer.Serve(mallory, Actor("{B}" + victim));
|
||||
|
||||
var actor = await _service.GetActor(_peer.A + mallory, refresh: false, TestContext.Current.CancellationToken);
|
||||
var byKey = await _service.GetActorByKeyId($"{_peer.B}{victim}#main-key", refresh: false, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Null(actor);
|
||||
Assert.Null(byKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refuses_a_key_owned_by_someone_else()
|
||||
{
|
||||
var eve = Unique("eve");
|
||||
_peer.Serve(eve, Actor("{A}" + eve, owner: "{A}/users/alice"));
|
||||
|
||||
Assert.Null(await _service.GetActorByKeyId($"{_peer.A}{eve}#main-key", refresh: false, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refuses_a_key_document_whose_owner_is_on_another_origin()
|
||||
{
|
||||
var alice = Unique("alice");
|
||||
var key = $"/keys/{Guid.NewGuid():N}";
|
||||
_peer.Serve(alice, Actor("{B}" + alice));
|
||||
_peer.Serve(key, new JsonObject { ["id"] = "{A}" + key, ["owner"] = "{B}" + alice, ["publicKeyPem"] = Keys.NewKeyPair().PublicKeyPem }.ToJsonString());
|
||||
|
||||
Assert.Null(await _service.GetActorByKeyId(_peer.A + key, refresh: false, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refuses_a_key_the_actor_does_not_list()
|
||||
{
|
||||
var alice = Unique("alice");
|
||||
_peer.Serve(alice, Actor("{A}" + alice));
|
||||
|
||||
Assert.Null(await _service.GetActorByKeyId($"{_peer.A}{alice}#other-key", refresh: false, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Follows_a_same_origin_alias_to_the_actor_it_names()
|
||||
{
|
||||
var alice = Unique("alice");
|
||||
var alias = $"/@alias{Guid.NewGuid():N}";
|
||||
_peer.Serve(alice, Actor("{A}" + alice));
|
||||
_peer.Serve(alias, Actor("{A}" + alice));
|
||||
|
||||
var actor = await _service.GetActor(_peer.A + alias, refresh: false, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(actor);
|
||||
Assert.Equal(_peer.A + alice, actor.ActorURI);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolves_a_gotosocial_style_key_address()
|
||||
{
|
||||
var gts = Unique("gts");
|
||||
var keyPath = gts + "/main-key";
|
||||
var pem = Keys.NewKeyPair().PublicKeyPem;
|
||||
_peer.Serve(gts, Actor("{A}" + gts, keyId: "{A}" + keyPath, pem: pem));
|
||||
_peer.Serve(keyPath, Actor("{A}" + gts, keyId: "{A}" + keyPath, pem: pem));
|
||||
|
||||
var actor = await _service.GetActorByKeyId(_peer.A + keyPath, refresh: false, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(actor);
|
||||
Assert.Equal(_peer.A + gts, actor.ActorURI);
|
||||
Assert.Equal(pem, actor.PublicKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_rotated_key_replaces_the_stored_one_but_only_once_per_interval()
|
||||
{
|
||||
var alice = Unique("alice");
|
||||
var keyId = $"{_peer.A}{alice}#main-key";
|
||||
var first = Keys.NewKeyPair().PublicKeyPem;
|
||||
var second = Keys.NewKeyPair().PublicKeyPem;
|
||||
var third = Keys.NewKeyPair().PublicKeyPem;
|
||||
_peer.Serve(alice, Actor("{A}" + alice, pem: first));
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
|
||||
Assert.Equal(first, (await _service.GetActorByKeyId(keyId, refresh: false, token)).PublicKey);
|
||||
_peer.Serve(alice, Actor("{A}" + alice, pem: second));
|
||||
Assert.Equal(first, (await _service.GetActorByKeyId(keyId, refresh: true, token)).PublicKey);
|
||||
|
||||
var fresh = new RemoteActorService(Peer.Http(), new LocalActorService(new DbEntities(),
|
||||
new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = "https://privapub.test" })),
|
||||
new MemoryCache(new MemoryCacheOptions()), new DbEntities());
|
||||
Assert.Equal(second, (await fresh.GetActorByKeyId(keyId, refresh: true, token)).PublicKey);
|
||||
_peer.Serve(alice, Actor("{A}" + alice, pem: third));
|
||||
Assert.Equal(second, (await fresh.GetActorByKeyId(keyId, refresh: true, token)).PublicKey);
|
||||
Assert.Single(await new DbEntities().ForeignAvatars.Match(a => a.ActorURI == _peer.A + alice).ExecuteAsync(token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Signs_every_fetch()
|
||||
{
|
||||
var alice = Unique("alice");
|
||||
_peer.Serve(alice, Actor("{A}" + alice));
|
||||
|
||||
await _service.GetActor(_peer.A + alice, refresh: false, TestContext.Current.CancellationToken);
|
||||
|
||||
var request = Assert.Single(_peer.Requests, r => r.Path == alice);
|
||||
Assert.Contains("keyId=\"https://privapub.test/peasants/privapub#main-key\"", request.Signature);
|
||||
}
|
||||
}
|
||||
|
||||
public class ActorDocumentTests
|
||||
{
|
||||
[Fact]
|
||||
public void Reads_a_mastodon_actor()
|
||||
{
|
||||
var document = ActorDocument.Parse(JsonDocument.Parse("""
|
||||
{
|
||||
"@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"],
|
||||
"id": "https://mastodon.example/users/alice",
|
||||
"type": "Person",
|
||||
"preferredUsername": "alice",
|
||||
"name": "Alice",
|
||||
"inbox": "https://mastodon.example/users/alice/inbox",
|
||||
"outbox": "https://mastodon.example/users/alice/outbox",
|
||||
"url": "https://mastodon.example/@alice",
|
||||
"discoverable": false,
|
||||
"endpoints": { "sharedInbox": "https://mastodon.example/inbox" },
|
||||
"icon": { "type": "Image", "url": "https://files.mastodon.example/a.png" },
|
||||
"publicKey": {
|
||||
"id": "https://mastodon.example/users/alice#main-key",
|
||||
"owner": "https://mastodon.example/users/alice",
|
||||
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIB\n-----END PUBLIC KEY-----\n"
|
||||
}
|
||||
}
|
||||
""").RootElement);
|
||||
|
||||
Assert.Equal("alice", document.PreferredUsername);
|
||||
Assert.Equal("https://mastodon.example/inbox", document.SharedInbox);
|
||||
Assert.Equal("https://files.mastodon.example/a.png", document.Icon);
|
||||
Assert.False(document.Discoverable);
|
||||
Assert.NotNull(document.Key("https://mastodon.example/users/alice#main-key"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keeps_only_keys_owned_by_the_actor_on_its_origin()
|
||||
{
|
||||
var document = ActorDocument.Parse(JsonDocument.Parse("""
|
||||
{
|
||||
"id": "https://a.example/users/alice",
|
||||
"type": "Person",
|
||||
"publicKey": [
|
||||
{ "id": "https://a.example/users/alice#main-key", "owner": "https://a.example/users/alice", "publicKeyPem": "x" },
|
||||
{ "id": "https://b.example/keys/1", "owner": "https://a.example/users/alice", "publicKeyPem": "y" },
|
||||
{ "id": "https://a.example/users/alice#other", "owner": "https://a.example/users/bob", "publicKeyPem": "z" },
|
||||
{ "id": "https://a.example/users/alice#unowned", "publicKeyPem": "w" }
|
||||
]
|
||||
}
|
||||
""").RootElement);
|
||||
|
||||
Assert.Equal("https://a.example/users/alice#main-key", Assert.Single(document.Keys).Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Refuses_an_inbox_on_another_origin()
|
||||
{
|
||||
var document = ActorDocument.Parse(JsonDocument.Parse("""
|
||||
{ "id": "https://a.example/users/alice", "type": "Person", "inbox": "https://b.example/inbox" }
|
||||
""").RootElement);
|
||||
|
||||
Assert.Null(document.Inbox);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Note")]
|
||||
[InlineData("Tombstone")]
|
||||
[InlineData("Collection")]
|
||||
public void Refuses_non_actor_types(string type) =>
|
||||
Assert.Null(ActorDocument.Parse(JsonDocument.Parse($$"""{ "id": "https://a.example/x", "type": "{{type}}" }""").RootElement));
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Post;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
public class RendererTests
|
||||
{
|
||||
static readonly LocalActor Alice = new()
|
||||
{
|
||||
Id = "a1",
|
||||
Kind = LocalActorKind.Person,
|
||||
UserName = "alice",
|
||||
Name = "Alice",
|
||||
Summary = "I **like** <b>cats</b>",
|
||||
PublicKeyPem = Keys.NewKeyPair().PublicKeyPem,
|
||||
Published = new DateTime(2026, 9, 30, 17, 45, 12, DateTimeKind.Utc),
|
||||
Fields = new Dictionary<string, string> { ["site"] = "https://alice.example", ["mood"] = "<i>ok</i>" },
|
||||
BaseAddress = "https://privapub.test"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void An_actor_looks_like_a_mastodon_actor_with_our_route_names()
|
||||
{
|
||||
var actor = ActivityPubRenderer.Actor(Alice);
|
||||
|
||||
Assert.Equal("https://privapub.test/peasants/alice", actor["id"]!.GetValue<string>());
|
||||
Assert.Equal("https://privapub.test/@alice", actor["url"]!.GetValue<string>());
|
||||
Assert.Equal("https://privapub.test/peasants/alice/mouth", actor["inbox"]!.GetValue<string>());
|
||||
Assert.Equal("https://privapub.test/peasants/alice/anus", actor["outbox"]!.GetValue<string>());
|
||||
Assert.Equal("https://privapub.test/peasants/alice/groupies", actor["followers"]!.GetValue<string>());
|
||||
Assert.Equal("https://privapub.test/peasants/alice/stalking", actor["following"]!.GetValue<string>());
|
||||
Assert.Equal("https://privapub.test/human-centipede", actor["endpoints"]!["sharedInbox"]!.GetValue<string>());
|
||||
Assert.Equal("2026-09-30T00:00:00Z", actor["published"]!.GetValue<string>());
|
||||
Assert.Equal("alice@privapub.test", actor["webfinger"]!.GetValue<string>());
|
||||
Assert.False(actor["indexable"]!.GetValue<bool>());
|
||||
Assert.Equal("<p>I <strong>like</strong> <b>cats</b></p>", actor["summary"]!.GetValue<string>());
|
||||
var fields = actor["attachment"]!.AsArray();
|
||||
Assert.Contains("rel=\"nofollow noopener noreferrer me\"", fields[0]!["value"]!.GetValue<string>());
|
||||
Assert.Equal("<i>ok</i>", fields[1]!["value"]!.GetValue<string>());
|
||||
Assert.Equal("https://privapub.test/peasants/alice#main-key", actor["publicKey"]!["id"]!.GetValue<string>());
|
||||
Assert.Equal(actor["id"]!.GetValue<string>(), actor["publicKey"]!["owner"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_titled_post_with_a_warning_keeps_the_title_visible_on_mastodon()
|
||||
{
|
||||
var post = new Post
|
||||
{
|
||||
ID = "p1",
|
||||
Title = "On <cats>",
|
||||
ContentHtml = "<p>body</p>",
|
||||
HasContentWarning = true,
|
||||
Language = "en",
|
||||
Mentions = new() { new PostMention { ActorURI = "https://m.example/users/bob", Handle = "@bob@m.example" } },
|
||||
Tags = new() { "cats" },
|
||||
CreationDate = new DateTime(2026, 9, 30, 17, 45, 12, DateTimeKind.Utc)
|
||||
};
|
||||
|
||||
var note = ActivityPubRenderer.Note(post, Alice, default, "https://m.example/users/bob/statuses/1");
|
||||
|
||||
Assert.Equal("https://privapub.test/peasants/alice/scribbles/p1", note["id"]!.GetValue<string>());
|
||||
Assert.Equal("https://privapub.test/@alice/p1", note["url"]!.GetValue<string>());
|
||||
Assert.Equal("<p><strong>On <cats></strong></p><p>body</p>", note["content"]!.GetValue<string>());
|
||||
Assert.Equal("On <cats>", note["summary"]!.GetValue<string>());
|
||||
Assert.True(note["sensitive"]!.GetValue<bool>());
|
||||
Assert.Equal("2026-09-30T17:45:12Z", note["published"]!.GetValue<string>());
|
||||
Assert.Equal(new[] { ActivityPubRenderer.Public }, note["to"]!.AsArray().Select(n => n!.GetValue<string>()));
|
||||
Assert.Equal(new[] { Alice.Followers, "https://m.example/users/bob" }, note["cc"]!.AsArray().Select(n => n!.GetValue<string>()));
|
||||
var tags = note["tag"]!.AsArray();
|
||||
Assert.Equal("@bob@m.example", tags[0]!["name"]!.GetValue<string>());
|
||||
Assert.Equal("https://privapub.test/tags/cats", tags[1]!["href"]!.GetValue<string>());
|
||||
Assert.Equal("https://m.example/users/bob/statuses/1", note["inReplyTo"]!.GetValue<string>());
|
||||
Assert.NotNull(note["contentMap"]!["en"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_warning_without_a_title_or_spoiler_gets_a_generic_summary()
|
||||
{
|
||||
var note = ActivityPubRenderer.Note(new Post { ID = "p2", ContentHtml = "<p>x</p>", HasContentWarning = true }, Alice, default, default);
|
||||
|
||||
Assert.Equal(ActivityPubRenderer.ContentWarning, note["summary"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_direct_note_is_addressed_to_its_recipients_only()
|
||||
{
|
||||
var post = new Post { ID = "p3", ContentHtml = "<p>psst</p>", Visibility = PostVisibility.Direct };
|
||||
|
||||
var note = ActivityPubRenderer.DirectNote(post, Alice, new[] { ("https://m.example/users/bob", "bob@m.example") },
|
||||
Alice.ConversationUri("c1"));
|
||||
|
||||
Assert.Equal(new[] { "https://m.example/users/bob" }, note["to"]!.AsArray().Select(n => n!.GetValue<string>()));
|
||||
Assert.Empty(note["cc"]!.AsArray());
|
||||
Assert.Equal("https://privapub.test/peasants/alice/whispers/c1", note["context"]!.GetValue<string>());
|
||||
Assert.StartsWith("<p><span class=\"h-card\"", note["content"]!.GetValue<string>());
|
||||
Assert.Equal("Mention", note["tag"]![0]!["type"]!.GetValue<string>());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using PrivaPub.Federation.Moderation;
|
||||
using PrivaPub.Infrastructure.Http;
|
||||
using PrivaPub.Models.Federation;
|
||||
|
||||
namespace PrivaPub.Tests.Infrastructure
|
||||
{
|
||||
public sealed class FederationHttpTests : IAsyncLifetime
|
||||
{
|
||||
WebApplication _peer;
|
||||
string _base;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
var builder = WebApplication.CreateSlimBuilder();
|
||||
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||
_peer = builder.Build();
|
||||
_peer.MapGet("/actor", () => Results.Text("{\"id\":\"x\"}", "application/activity+json"));
|
||||
_peer.MapGet("/html", () => Results.Text("<html></html>", "text/html"));
|
||||
_peer.MapGet("/big", () => Results.Text("{\"a\":\"" + new string('a', FederationHttp.MaxResponseBytes) + "\"}", "application/activity+json"));
|
||||
_peer.MapGet("/hop/{n:int}", (int n) => Results.Redirect(n == 0 ? "/actor" : $"/hop/{n - 1}"));
|
||||
_peer.MapGet("/gone", () => Results.StatusCode(410));
|
||||
await _peer.StartAsync();
|
||||
_base = _peer.Urls.First();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() => await _peer.DisposeAsync();
|
||||
|
||||
static FederationHttp Client(bool allowTestNetwork = true)
|
||||
{
|
||||
var options = new FederationOptions { AllowPrivateNetworks = allowTestNetwork, AllowPlainHttp = allowTestNetwork };
|
||||
var services = new ServiceCollection();
|
||||
services.AddHttpClient(FederationHttp.ClientName)
|
||||
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options));
|
||||
var provider = services.BuildServiceProvider();
|
||||
return new FederationHttp(provider.GetRequiredService<IHttpClientFactory>(), new MemoryCache(new MemoryCacheOptions()),
|
||||
new StaticOptionsMonitor(options), new StaticBlocks(), NullLogger<FederationHttp>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_a_json_document()
|
||||
{
|
||||
using var fetched = await Client().GetJson($"{_base}/actor", "application/activity+json", default, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(fetched);
|
||||
Assert.Equal("x", fetched.Root.GetProperty("id").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Follows_up_to_three_redirects_and_reports_the_final_url()
|
||||
{
|
||||
using var fetched = await Client().GetJson($"{_base}/hop/2", "application/activity+json", default, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(fetched);
|
||||
Assert.Equal($"{_base}/actor", fetched.FinalUri.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refuses_a_fourth_redirect() =>
|
||||
Assert.Null(await Client().GetJson($"{_base}/hop/3", "application/activity+json", default, TestContext.Current.CancellationToken));
|
||||
|
||||
[Fact]
|
||||
public async Task Refuses_html() =>
|
||||
Assert.Null(await Client().GetJson($"{_base}/html", "application/activity+json", default, TestContext.Current.CancellationToken));
|
||||
|
||||
[Fact]
|
||||
public async Task Refuses_a_body_over_the_limit() =>
|
||||
Assert.Null(await Client().GetJson($"{_base}/big", "application/activity+json", default, TestContext.Current.CancellationToken));
|
||||
|
||||
[Fact]
|
||||
public async Task Refuses_an_error_status() =>
|
||||
Assert.Null(await Client().GetJson($"{_base}/gone", "application/activity+json", default, TestContext.Current.CancellationToken));
|
||||
|
||||
[Fact]
|
||||
public async Task Refuses_a_private_network_in_production_mode() =>
|
||||
Assert.Null(await Client(allowTestNetwork: false).GetJson($"{_base}/actor", "application/activity+json", default, TestContext.Current.CancellationToken));
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://mastodon.social/users/Gargron", true)]
|
||||
[InlineData("http://mastodon.social/users/Gargron", false)]
|
||||
[InlineData("https://user:pass@mastodon.social/", false)]
|
||||
[InlineData("https://localhost/", false)]
|
||||
[InlineData("https://printer.local/", false)]
|
||||
[InlineData("https://metadata.google.internal/", false)]
|
||||
[InlineData("https://127.0.0.1/", false)]
|
||||
[InlineData("https://[::1]/", false)]
|
||||
[InlineData("ftp://example.org/", false)]
|
||||
public void IsAllowed_takes_https_dns_names_only(string url, bool allowed) =>
|
||||
Assert.Equal(allowed, Client(allowTestNetwork: false).IsAllowed(new Uri(url)));
|
||||
|
||||
[Fact]
|
||||
public void IsAllowed_refuses_a_suspended_domain_and_its_subdomains()
|
||||
{
|
||||
var http = new FederationHttp(new ServiceCollection().AddHttpClient().BuildServiceProvider().GetRequiredService<IHttpClientFactory>(),
|
||||
new MemoryCache(new MemoryCacheOptions()), new StaticOptionsMonitor(new FederationOptions()), new StaticBlocks("evil.example"),
|
||||
NullLogger<FederationHttp>.Instance);
|
||||
|
||||
Assert.False(http.IsAllowed(new Uri("https://evil.example/users/x")));
|
||||
Assert.False(http.IsAllowed(new Uri("https://cdn.evil.example/a.png")));
|
||||
Assert.True(http.IsAllowed(new Uri("https://notevil.example/users/x")));
|
||||
}
|
||||
|
||||
sealed class StaticBlocks : IDomainBlocks
|
||||
{
|
||||
readonly string[] _suspended;
|
||||
public StaticBlocks(params string[] suspended) => _suspended = suspended;
|
||||
public DomainBlock Find(string host) => _suspended.Any(s => host == s || host.EndsWith("." + s))
|
||||
? new DomainBlock { Domain = host, Severity = DomainBlockSeverity.Suspend }
|
||||
: default;
|
||||
public bool IsSuspended(string host) => Find(host) != default;
|
||||
public Task Reload(CancellationToken token) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
sealed class StaticOptionsMonitor : IOptionsMonitor<FederationOptions>
|
||||
{
|
||||
public StaticOptionsMonitor(FederationOptions value) => CurrentValue = value;
|
||||
public FederationOptions CurrentValue { get; }
|
||||
public FederationOptions Get(string name) => CurrentValue;
|
||||
public IDisposable OnChange(Action<FederationOptions, string> listener) => default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Infrastructure.Data;
|
||||
using PrivaPub.Infrastructure.Data.Migrations;
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
namespace PrivaPub.Tests.Infrastructure
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public class IndexTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Duplicates_are_removed_and_the_unique_indexes_then_hold()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
await DB.Default.Index<ForeignAvatar>().DropAllAsync(token);
|
||||
await DB.Default.Index<Follower>().DropAllAsync(token);
|
||||
var actorUri = $"https://r.example/users/{Guid.NewGuid():N}";
|
||||
var localId = Guid.NewGuid().ToString("N")[..24];
|
||||
await DB.Default.SaveAsync(new[]
|
||||
{
|
||||
new ForeignAvatar { ActorURI = actorUri, Name = "old", UpdatedAt = DateTime.UtcNow.AddDays(-2) },
|
||||
new ForeignAvatar { ActorURI = actorUri, Name = "new", UpdatedAt = DateTime.UtcNow }
|
||||
}, token);
|
||||
await DB.Default.SaveAsync(new[]
|
||||
{
|
||||
new Follower { LocalActorId = localId, ActorURI = actorUri, IsAccepted = false },
|
||||
new Follower { LocalActorId = localId, ActorURI = actorUri, IsAccepted = true }
|
||||
}, token);
|
||||
|
||||
await new _001_deduplicate_before_unique_indexes().UpgradeAsync();
|
||||
await Indexes.Create(token);
|
||||
|
||||
Assert.Equal("new", (await DB.Default.Find<ForeignAvatar>().Match(a => a.ActorURI == actorUri).ExecuteSingleAsync(token)).Name);
|
||||
Assert.True((await DB.Default.Find<Follower>().Match(f => f.LocalActorId == localId).ExecuteSingleAsync(token)).IsAccepted);
|
||||
var duplicate = await Assert.ThrowsAsync<MongoWriteException>(async () =>
|
||||
await DB.Default.SaveAsync(new ForeignAvatar { ActorURI = actorUri }, token));
|
||||
Assert.Equal(ServerErrorCategory.DuplicateKey, duplicate.WriteError.Category);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_username_is_reserved_once_across_personas_and_groups()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var local = new LocalActorService(new DbEntities(), new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = "https://privapub.test" }));
|
||||
var name = $"n{Guid.NewGuid():N}"[..20];
|
||||
|
||||
Assert.True(await local.TryReserveUserName(name, LocalActorKind.Person, "a", token));
|
||||
Assert.False(await local.TryReserveUserName(name.ToUpperInvariant(), LocalActorKind.Group, "b", token));
|
||||
Assert.True(await local.IsUserNameTaken(name, token));
|
||||
Assert.False(await local.TryReserveUserName("admin", LocalActorKind.Person, "c", token));
|
||||
Assert.False(await local.TryReserveUserName(LocalActorService.InstanceUserName, LocalActorKind.Person, "c", token));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Infrastructure.Jobs;
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Infrastructure
|
||||
{
|
||||
public class BackoffTests
|
||||
{
|
||||
[Fact]
|
||||
public void Grows_like_mastodons()
|
||||
{
|
||||
Assert.InRange(Backoff.After(1).TotalSeconds, 16, 16 + 9 * 2);
|
||||
Assert.InRange(Backoff.After(10).TotalSeconds, 10015, 10015 + 9 * 11);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quarantines_a_host_only_past_the_threshold()
|
||||
{
|
||||
Assert.Equal(TimeSpan.Zero, Backoff.HostQuarantine(9, 10));
|
||||
Assert.Equal(TimeSpan.FromHours(1), Backoff.HostQuarantine(10, 10));
|
||||
Assert.Equal(TimeSpan.FromHours(4), Backoff.HostQuarantine(12, 10));
|
||||
Assert.Equal(TimeSpan.FromDays(7), Backoff.HostQuarantine(40, 10));
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class JobQueueTests : IAsyncLifetime
|
||||
{
|
||||
Peer _peer;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_peer = await Peer.Start();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_peer != default)
|
||||
await _peer.DisposeAsync();
|
||||
}
|
||||
|
||||
static Job NewJob(string host = default, string dedupe = default) => new()
|
||||
{
|
||||
Kind = (JobKind)99,
|
||||
Host = host,
|
||||
DedupeKey = dedupe,
|
||||
Payload = "{}"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task A_dedupe_key_is_queued_once()
|
||||
{
|
||||
var queue = new JobQueue();
|
||||
var key = Guid.NewGuid().ToString("N");
|
||||
|
||||
var inserted = await queue.EnqueueMany(new[] { NewJob(dedupe: key), NewJob(dedupe: key) }, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(1, inserted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_busy_host_is_skipped_and_a_failed_job_backs_off()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var queue = new JobQueue();
|
||||
var kind = (JobKind)(100 + Random.Shared.Next(1000));
|
||||
var busy = $"busy{Guid.NewGuid():N}.example";
|
||||
var free = $"free{Guid.NewGuid():N}.example";
|
||||
await queue.EnqueueMany(new[] { new Job { Kind = kind, Host = busy }, new Job { Kind = kind, Host = free } }, token);
|
||||
|
||||
var leased = await queue.Lease(kind, new[] { busy }, token);
|
||||
Assert.Equal(free, leased.Host);
|
||||
Assert.Equal(1, leased.Attempts);
|
||||
|
||||
await queue.Finish(leased, JobOutcome.Retry("boom"), maxAttempts: 3, token);
|
||||
var after = await DB.Default.Find<Job>().OneAsync(leased.ID, token);
|
||||
Assert.Equal(JobState.Pending, after.State);
|
||||
Assert.True(after.RunAt > DateTime.UtcNow.AddSeconds(10));
|
||||
|
||||
leased.Attempts = 3;
|
||||
await queue.Finish(leased, JobOutcome.Retry("boom"), maxAttempts: 3, token);
|
||||
Assert.Equal(JobState.Dead, (await DB.Default.Find<Job>().OneAsync(leased.ID, token)).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_expired_lease_returns_to_the_queue()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var job = new Job { Kind = (JobKind)98, State = JobState.Running, LeasedUntil = DateTime.UtcNow.AddMinutes(-1) };
|
||||
await DB.Default.SaveAsync(job, token);
|
||||
|
||||
await new JobQueue().Reap(token);
|
||||
|
||||
Assert.Equal(JobState.Pending, (await DB.Default.Find<Job>().OneAsync(job.ID, token)).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_dead_host_does_not_hold_up_deliveries_to_live_ones()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (privateKey, publicKey) = Keys.NewKeyPair();
|
||||
var avatar = new Avatar { UserName = $"sender{Guid.NewGuid():N}"[..20], PrivateKey = privateKey, PublicKey = publicKey };
|
||||
await DB.Default.SaveAsync(avatar, token);
|
||||
var local = new LocalActorService(new DbEntities(), new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = "https://privapub.test" }));
|
||||
var sender = local.FromAvatar(avatar);
|
||||
var queue = new JobQueue();
|
||||
var delivery = new DeliveryService(new DbEntities(), queue);
|
||||
_peer.Answer("/dead/inbox", 503, TimeSpan.FromMilliseconds(300));
|
||||
_peer.Answer("/live/inbox", 202);
|
||||
|
||||
var deadInboxes = Enumerable.Range(0, 60).Select(i => $"{_peer.B}/dead/inbox?{i}");
|
||||
await delivery.Enqueue(sender, deadInboxes, new JsonObject { ["id"] = $"https://privapub.test/a/{Guid.NewGuid():N}", ["type"] = "Create" }, token);
|
||||
await delivery.Enqueue(sender, new[] { $"{_peer.A}/live/inbox" }, new JsonObject { ["id"] = $"https://privapub.test/a/{Guid.NewGuid():N}", ["type"] = "Create" }, token);
|
||||
|
||||
var handler = new DeliveryJobHandler(local, Peer.Http(), new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions())), NullLogger<DeliveryJobHandler>.Instance);
|
||||
using var worker = new JobWorker(queue, new IJobHandler[] { handler }, NullLogger<JobWorker>.Instance);
|
||||
await worker.StartAsync(token);
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (DateTime.UtcNow < deadline && !_peer.Requests.Any(r => r.Path == "/live/inbox"))
|
||||
await Task.Delay(50, token);
|
||||
var live = Assert.Single(_peer.Requests, r => r.Path == "/live/inbox");
|
||||
Assert.True(_peer.Requests.Count(r => r.Path == "/dead/inbox") < 30);
|
||||
while (DateTime.UtcNow < deadline && !await DB.Default.Find<RemoteInstance>().Match(i => i.Host == "localhost").ExecuteAnyAsync(token))
|
||||
await Task.Delay(50, token);
|
||||
await worker.StopAsync(token);
|
||||
|
||||
Assert.Contains($"keyId=\"{sender.KeyId}\"", live.Signature);
|
||||
var instance = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == "localhost").ExecuteFirstAsync(token);
|
||||
Assert.True(instance.ConsecutiveFailures > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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> <b>raw</b></p>", migratedLocal.ContentHtml);
|
||||
Assert.Equal(ContentFormat.Markdown, migratedLocal.ContentFormat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Direct_posts_move_into_posts_with_their_conversation()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var dm = new DmPost { GroupId = "conversation1", Text = "psst", ObjectURI = $"https://privapub.test/{Guid.NewGuid():N}", GroupUserId = "a1" };
|
||||
await DB.Default.SaveAsync(dm, token);
|
||||
|
||||
await new _005_direct_posts_join_posts().UpgradeAsync();
|
||||
|
||||
var post = await DB.Default.Find<Post>().OneAsync(dm.ID, token);
|
||||
Assert.Equal(PostVisibility.Direct, post.Visibility);
|
||||
Assert.Equal("conversation1", post.ConversationId);
|
||||
Assert.Equal("a1", post.AuthorAccountId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
using MongoDB.Bson;
|
||||
|
||||
using PrivaPub.Infrastructure.Ids;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||
|
||||
namespace PrivaPub.Tests.Infrastructure
|
||||
{
|
||||
public class PrivacyIdsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Persona_ids_carry_only_the_day_and_share_nothing_else()
|
||||
{
|
||||
var first = (string)new Avatar().GenerateNewID();
|
||||
var second = (string)new Avatar().GenerateNewID();
|
||||
|
||||
Assert.True(ObjectId.TryParse(first, out var a));
|
||||
Assert.True(ObjectId.TryParse(second, out var b));
|
||||
Assert.Equal(DateTime.UtcNow.Date, a.CreationTime);
|
||||
Assert.Equal(a.CreationTime, b.CreationTime);
|
||||
Assert.NotEqual(first[8..14], second[8..14]);
|
||||
Assert.Equal(DateTime.UtcNow.Date, ObjectId.Parse((string)new GroupEntity().GenerateNewID()).CreationTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_remote_post_id_sorts_by_its_published_time()
|
||||
{
|
||||
var older = PrivacyIds.At(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
var newer = PrivacyIds.At(new DateTime(2025, 1, 2, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
Assert.True(string.CompareOrdinal(older, newer) < 0);
|
||||
Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), ObjectId.Parse(older).CreationTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_published_time_in_the_future_is_clamped() =>
|
||||
Assert.True(ObjectId.Parse(PrivacyIds.At(DateTime.UtcNow.AddYears(50))).CreationTime < DateTime.UtcNow.AddDays(2));
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<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>
|
||||
@@ -1,141 +0,0 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Domain.Content;
|
||||
using PrivaPub.Domain.Media;
|
||||
using PrivaPub.Domain.Relationships;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Domain.Timelines;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Inbox;
|
||||
using PrivaPub.Federation.Inbox.Handlers;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Infrastructure.Jobs;
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Resources;
|
||||
using PrivaPub.Services;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Support
|
||||
{
|
||||
public sealed class Harness : IAsyncDisposable
|
||||
{
|
||||
public const string Host = "privapub.test";
|
||||
public const string Base = "https://" + Host;
|
||||
|
||||
Harness(Peer peer)
|
||||
{
|
||||
Peer = peer;
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
Local = new LocalActorService(Db, new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = Base }));
|
||||
Remote = new RemoteActorService(Peer.Http(cache), Local, cache, Db);
|
||||
Delivery = new DeliveryService(Db, Queue);
|
||||
Fanout = new Fanout(Db);
|
||||
Groups = new GroupDistributor(Delivery);
|
||||
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue);
|
||||
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance);
|
||||
Processor = new InboxProcessor(Remote, new IActivityHandler[]
|
||||
{
|
||||
new FollowHandler(Db, Local, Remote, Delivery),
|
||||
new AcceptHandler(Db, Local),
|
||||
new RejectHandler(Db, Local),
|
||||
new UndoHandler(Db, Local),
|
||||
new LikeHandler(Db),
|
||||
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote),
|
||||
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups),
|
||||
new DeleteHandler(Db, Local, Remote, Delivery, Groups),
|
||||
new UpdateHandler(Db, Local, Remote, Groups),
|
||||
new FlagHandler(Db, Local)
|
||||
}, NullLogger<InboxProcessor>.Instance);
|
||||
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
|
||||
Content = new ContentRenderer(Local, Remote);
|
||||
Outbox = new OutboxPublisher(Db, Local, Delivery);
|
||||
Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }),
|
||||
Local, default, NullLogger<MediaService>.Instance);
|
||||
Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout, Media, Groups);
|
||||
Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer<GenericRes>(), NullLogger<PostsService>.Instance);
|
||||
Timelines = new TimelineService(Db, new KeyLocalizer<GenericRes>());
|
||||
Relationships = new RelationshipService(Db, Follows, Delivery);
|
||||
Reports = new ReportService(Db, Local, Delivery);
|
||||
}
|
||||
|
||||
public Peer Peer { get; }
|
||||
public DbEntities Db { get; } = new();
|
||||
public JobQueue Queue { get; } = new();
|
||||
public LocalActorService Local { get; }
|
||||
public RemoteActorService Remote { get; }
|
||||
public DeliveryService Delivery { get; }
|
||||
public InboxReceiver Receiver { get; }
|
||||
public InboxProcessor Processor { get; }
|
||||
public FollowService Follows { get; }
|
||||
public ContentRenderer Content { get; }
|
||||
public OutboxPublisher Outbox { get; }
|
||||
public PostsService Posts { get; }
|
||||
public StatusService Statuses { get; }
|
||||
public MediaService Media { get; }
|
||||
public GroupDistributor Groups { get; }
|
||||
public Fanout Fanout { get; }
|
||||
public RemotePosts RemotePosts { get; }
|
||||
public TimelineService Timelines { get; }
|
||||
public RelationshipService Relationships { get; }
|
||||
public ReportService Reports { get; }
|
||||
|
||||
public async Task FollowedBy(LocalActor local, RemoteActor follower) =>
|
||||
await DB.Default.SaveAsync(new Follower
|
||||
{
|
||||
LocalActorId = local.Id,
|
||||
LocalActorKind = local.Kind,
|
||||
ActorURI = follower.Id,
|
||||
InboxURL = follower.Id + "/inbox",
|
||||
SharedInboxURL = follower.SharedInbox
|
||||
});
|
||||
|
||||
public static async Task<Harness> Start() => new(await Peer.Start());
|
||||
|
||||
public async Task<(string RootId, LocalActor Avatar)> Persona(string name, string rootId = default)
|
||||
{
|
||||
rootId ??= Guid.NewGuid().ToString("N")[..24];
|
||||
var (privateKey, publicKey) = Keys.NewKeyPair();
|
||||
var avatar = new Avatar { UserName = $"{name}{Guid.NewGuid():N}"[..20], Name = name, PrivateKey = privateKey, PublicKey = publicKey };
|
||||
await DB.Default.SaveAsync(avatar);
|
||||
await DB.Default.SaveAsync(new RootToAvatar { RootId = rootId, AvatarId = avatar.ID });
|
||||
return (rootId, Local.FromAvatar(avatar));
|
||||
}
|
||||
|
||||
public async Task<InboxResult> Deliver(RemoteActor sender, string path, JsonNode activity)
|
||||
{
|
||||
var result = await Receiver.Receive(sender.Post(Host, path, activity), default, CancellationToken.None);
|
||||
var dedupe = "inbox|" + (activity is JsonObject ? activity["id"]?.GetValue<string>() : default);
|
||||
var job = await DB.Default.Find<Job>().Match(j => j.DedupeKey == dedupe).ExecuteFirstAsync();
|
||||
if (job != default)
|
||||
Assert.Equal(JobResult.Done, (await Processor.Handle(job, CancellationToken.None)).Result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<List<JsonObject>> Outgoing(string inbox) =>
|
||||
(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver).ExecuteAsync())
|
||||
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
|
||||
.Where(p => p.Inbox == inbox)
|
||||
.Select(p => JsonNode.Parse(p.Body)!.AsObject())
|
||||
.ToList();
|
||||
|
||||
public async ValueTask DisposeAsync() => await Peer.DisposeAsync();
|
||||
}
|
||||
|
||||
public sealed class KeyLocalizer<T> : IStringLocalizer<T>
|
||||
{
|
||||
public LocalizedString this[string name] => new(name, name);
|
||||
public LocalizedString this[string name, params object[] arguments] => new(name, string.Format(name, arguments));
|
||||
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) => Enumerable.Empty<LocalizedString>();
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization;
|
||||
using MongoDB.Bson.Serialization.Serializers;
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Infrastructure.Data;
|
||||
|
||||
[assembly: AssemblyFixture(typeof(PrivaPub.Tests.Support.MongoFixture))]
|
||||
|
||||
namespace PrivaPub.Tests.Support
|
||||
{
|
||||
public sealed class MongoFixture : IAsyncLifetime
|
||||
{
|
||||
public const string Skip = "set PRIVAPUB_TEST_MONGOD=1 (and optionally PRIVAPUB_TEST_MONGO) to run the tests that need a mongod";
|
||||
|
||||
public static bool Enabled => Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGOD") == "1";
|
||||
|
||||
public string Database { get; } = $"PrivaPubTests_{Guid.NewGuid():N}";
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
if (!Enabled)
|
||||
return;
|
||||
try
|
||||
{
|
||||
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
|
||||
}
|
||||
catch (BsonSerializationException)
|
||||
{
|
||||
}
|
||||
var connection = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGO") ?? "mongodb://127.0.0.1:27017";
|
||||
await DB.InitAsync(Database, MongoClientSettings.FromConnectionString(connection));
|
||||
EntityMaps.Warm();
|
||||
await Indexes.Create();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Enabled)
|
||||
await DB.Default.Database().Client.DropDatabaseAsync(Database);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using PrivaPub.Federation.Moderation;
|
||||
using PrivaPub.Infrastructure.Http;
|
||||
using PrivaPub.Models.Federation;
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace PrivaPub.Tests.Support
|
||||
{
|
||||
public sealed class Peer : IAsyncDisposable
|
||||
{
|
||||
readonly WebApplication _app;
|
||||
readonly ConcurrentDictionary<string, string> _documents = new();
|
||||
readonly ConcurrentDictionary<string, (int Status, TimeSpan Delay)> _answers = new();
|
||||
readonly ConcurrentDictionary<string, (byte[] Bytes, string ContentType)> _files = 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._files.TryGetValue(key, out var file))
|
||||
{
|
||||
context.Response.ContentType = file.ContentType;
|
||||
await context.Response.Body.WriteAsync(file.Bytes);
|
||||
return;
|
||||
}
|
||||
if (peer._answers.TryGetValue(key, out var answer))
|
||||
{
|
||||
if (answer.Delay > TimeSpan.Zero)
|
||||
await Task.Delay(answer.Delay);
|
||||
context.Response.StatusCode = answer.Status;
|
||||
return;
|
||||
}
|
||||
if (!peer._documents.TryGetValue(key, out var document))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
context.Response.ContentType = "application/activity+json";
|
||||
await context.Response.WriteAsync(document.Replace("{A}", peer.A).Replace("{B}", peer.B));
|
||||
});
|
||||
await app.StartAsync();
|
||||
peer = new Peer(app, new Uri(app.Urls.First()).Port);
|
||||
return peer;
|
||||
}
|
||||
|
||||
public void Serve(string path, string json) => _documents[path] = json;
|
||||
|
||||
public void ServeFile(string path, byte[] bytes, string contentType) => _files[path] = (bytes, contentType);
|
||||
|
||||
public void Answer(string path, int status, TimeSpan delay = default) => _answers[path] = (status, delay);
|
||||
|
||||
public static FederationHttp Http(IMemoryCache cache = default, IDomainBlocks blocks = default)
|
||||
{
|
||||
var options = new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true };
|
||||
var services = new ServiceCollection();
|
||||
services.AddHttpClient(FederationHttp.ClientName)
|
||||
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options));
|
||||
return new FederationHttp(services.BuildServiceProvider().GetRequiredService<IHttpClientFactory>(),
|
||||
cache ?? new MemoryCache(new MemoryCacheOptions()), new StaticOptions<FederationOptions>(options),
|
||||
blocks ?? new NoBlocks(), NullLogger<FederationHttp>.Instance);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() => await _app.DisposeAsync();
|
||||
}
|
||||
|
||||
public sealed class NoBlocks : IDomainBlocks
|
||||
{
|
||||
public DomainBlock Find(string host) => default;
|
||||
public bool IsSuspended(string host) => false;
|
||||
public Task Reload(CancellationToken token) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
public sealed record HttpRequestRecord(string Method, string Path, string Signature);
|
||||
|
||||
public sealed class StaticOptions<T> : IOptionsMonitor<T>
|
||||
{
|
||||
public StaticOptions(T value) => CurrentValue = value;
|
||||
public T CurrentValue { get; }
|
||||
public T Get(string name) => CurrentValue;
|
||||
public IDisposable OnChange(Action<T, string> listener) => default;
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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, string type = "Person")
|
||||
{
|
||||
Name = $"{name}{Guid.NewGuid():N}"[..20];
|
||||
Id = $"{origin ?? peer.A}/users/{Name}";
|
||||
Type = type;
|
||||
peer.Serve($"/users/{Name}", Document().ToJsonString());
|
||||
}
|
||||
|
||||
public string Type { get; }
|
||||
|
||||
public string Name { get; }
|
||||
public string Id { get; }
|
||||
public string KeyId => Id + "#main-key";
|
||||
public string SharedInbox => Id.Split("/users/")[0] + "/inbox";
|
||||
|
||||
public JsonObject Document() => new()
|
||||
{
|
||||
["id"] = Id,
|
||||
["type"] = Type,
|
||||
["preferredUsername"] = Name,
|
||||
["inbox"] = Id + "/inbox",
|
||||
["followers"] = Id + "/followers",
|
||||
["publicKey"] = new JsonObject
|
||||
{
|
||||
["id"] = KeyId,
|
||||
["owner"] = Id,
|
||||
["publicKeyPem"] = _key.ExportSubjectPublicKeyInfoPem()
|
||||
}
|
||||
};
|
||||
|
||||
public HttpRequest Get(string host, string path)
|
||||
{
|
||||
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
|
||||
var signingString = $"(request-target): get {path}\nhost: {host}\ndate: {date}";
|
||||
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = "GET";
|
||||
context.Request.Host = new HostString(host);
|
||||
context.Request.Path = path;
|
||||
context.Features.Get<IHttpRequestFeature>().RawTarget = path;
|
||||
context.Request.Headers["Date"] = date;
|
||||
context.Request.Headers["Signature"] = $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date\",signature=\"{signature}\"";
|
||||
return context.Request;
|
||||
}
|
||||
|
||||
public HttpRequest Post(string host, string path, JsonNode activity)
|
||||
{
|
||||
var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PrivaPub", "PrivaPub\PrivaP
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PrivaPub.ClientModels", "PrivaPub.ClientModels\PrivaPub.ClientModels.csproj", "{5E63C68C-0E60-4418-A53A-FF4EF0007080}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PrivaPub.Tests", "PrivaPub.Tests\PrivaPub.Tests.csproj", "{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -43,18 +41,6 @@ Global
|
||||
{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
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -23,10 +23,7 @@ namespace PrivaPub.Api.Mastodon.Auth
|
||||
try
|
||||
{
|
||||
await Task.Delay(Interval, stoppingToken);
|
||||
using var scope = _services.CreateScope();
|
||||
var threshold = DateTimeOffset.UtcNow - Threshold;
|
||||
var tokens = await scope.ServiceProvider.GetRequiredService<IOpenIddictTokenManager>().PruneAsync(threshold, stoppingToken);
|
||||
var authorizations = await scope.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>().PruneAsync(threshold, stoppingToken);
|
||||
var (tokens, authorizations) = await Prune(DateTimeOffset.UtcNow - Threshold, stoppingToken);
|
||||
if (tokens + authorizations > 0)
|
||||
_logger.LogInformation("{Service} removed {Tokens} tokens and {Authorizations} authorizations", nameof(OAuthPruner), tokens, authorizations);
|
||||
}
|
||||
@@ -40,5 +37,14 @@ namespace PrivaPub.Api.Mastodon.Auth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//one pass: tokens and authorizations created before the threshold that are no longer valid
|
||||
public async Task<(long Tokens, long Authorizations)> Prune(DateTimeOffset threshold, CancellationToken token)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var tokens = await scope.ServiceProvider.GetRequiredService<IOpenIddictTokenManager>().PruneAsync(threshold, token);
|
||||
var authorizations = await scope.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>().PruneAsync(threshold, token);
|
||||
return (tokens, authorizations);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,14 @@ namespace PrivaPub.Api.Mastodon.Auth
|
||||
.SetTokenEndpointUris("/oauth/token")
|
||||
.SetRevocationEndpointUris("/oauth/revoke")
|
||||
.SetConfigurationEndpointUris("/.well-known/openid-configuration", "/.well-known/oauth-authorization-server");
|
||||
options.AllowAuthorizationCodeFlow().AllowClientCredentialsFlow();
|
||||
options.AllowAuthorizationCodeFlow().AllowClientCredentialsFlow().AllowTokenExchangeFlow();
|
||||
//the only subject token accepted is the root JWT, for PersonaExchange; OpenIddict's own tokens are not exchanged
|
||||
options.Configure(server =>
|
||||
{
|
||||
server.SubjectTokenTypes.Clear();
|
||||
server.SubjectTokenTypes.Add(PersonaExchange.SubjectTokenType);
|
||||
});
|
||||
options.AddEventHandler(RootJwtSubjectToken.Descriptor);
|
||||
options.RegisterScopes(MastodonScopes.All);
|
||||
options.UseReferenceAccessTokens();
|
||||
options.SetAccessTokenLifetime(null);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
|
||||
using PrivaPub.Services;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Security.Claims;
|
||||
|
||||
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||||
using static OpenIddict.Server.OpenIddictServerEvents;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Auth
|
||||
{
|
||||
// One sign-in for the first-party client (decePubClient): it signs in on /clientapi, then exchanges that root JWT for one
|
||||
// persona's Mastodon token (RFC 8693, grant_type urn:ietf:params:oauth:grant-type:token-exchange, subject_token_type
|
||||
// urn:ietf:params:oauth:token-type:jwt, avatar_id). Only the seeded first-party application holds the grant permission,
|
||||
// and the issued token is the same kind the authorization code flow issues: subject the avatar, never the root.
|
||||
public static class PersonaExchange
|
||||
{
|
||||
public const string SubjectTokenType = "urn:ietf:params:oauth:token-type:jwt";
|
||||
public const string AvatarParameter = "avatar_id";
|
||||
//the root the subject token belongs to; it lives on the subject token's principal only, which is never stored or issued
|
||||
public const string RootClaim = "privapub_root";
|
||||
|
||||
public static string FirstPartyClientId(IConfiguration configuration) =>
|
||||
configuration["ClientApi:FirstPartyClientId"] is { Length: > 0 } clientId ? clientId : "decepub";
|
||||
|
||||
//the first-party application: public (a browser keeps no secret), token exchange only, no redirect
|
||||
public static async Task EnsureFirstPartyClient(IServiceProvider services, CancellationToken token)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var applications = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
|
||||
var clientId = FirstPartyClientId(scope.ServiceProvider.GetRequiredService<IConfiguration>());
|
||||
var descriptor = new OpenIddictApplicationDescriptor
|
||||
{
|
||||
ClientId = clientId,
|
||||
ClientType = ClientTypes.Public,
|
||||
ConsentType = ConsentTypes.Implicit,
|
||||
DisplayName = "decePubClient",
|
||||
Permissions =
|
||||
{
|
||||
Permissions.Endpoints.Token,
|
||||
Permissions.Endpoints.Revocation,
|
||||
Permissions.GrantTypes.TokenExchange
|
||||
}
|
||||
};
|
||||
foreach (var scopeName in new[] { "read", "write", "follow" })
|
||||
descriptor.Permissions.Add(Permissions.Prefixes.Scope + scopeName);
|
||||
|
||||
var existing = await applications.FindByClientIdAsync(clientId, token);
|
||||
if (existing == default)
|
||||
{
|
||||
await applications.CreateAsync(descriptor, token);
|
||||
return;
|
||||
}
|
||||
await applications.PopulateAsync(existing, descriptor, token);
|
||||
await applications.UpdateAsync(existing, token);
|
||||
}
|
||||
}
|
||||
|
||||
// Validates a root JWT given as a token exchange subject token, before OpenIddict tries it as one of its own tokens.
|
||||
// The principal it builds names the root under PersonaExchange.RootClaim for TokenController, which signs in a fresh
|
||||
// identity for the avatar.
|
||||
public sealed class RootJwtSubjectToken : IOpenIddictServerHandler<ValidateTokenContext>
|
||||
{
|
||||
public static OpenIddictServerHandlerDescriptor Descriptor { get; } = OpenIddictServerHandlerDescriptor.CreateBuilder<ValidateTokenContext>()
|
||||
.UseScopedHandler<RootJwtSubjectToken>()
|
||||
.SetOrder(OpenIddictServerHandlers.Protection.ValidateIdentityModelToken.Descriptor.Order - 500)
|
||||
.SetType(OpenIddictServerHandlerType.Custom)
|
||||
.Build();
|
||||
|
||||
readonly IConfiguration _configuration;
|
||||
readonly DbEntities _dbEntities;
|
||||
|
||||
public RootJwtSubjectToken(IConfiguration configuration, DbEntities dbEntities)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
public async ValueTask HandleAsync(ValidateTokenContext context)
|
||||
{
|
||||
if (context.ValidTokenTypes.Count != 1 || !context.ValidTokenTypes.Contains(PersonaExchange.SubjectTokenType))
|
||||
return;
|
||||
|
||||
var root = await RootJwt.Root(context.Token, _configuration, _dbEntities, context.Transaction.CancellationToken);
|
||||
if (root == default)
|
||||
{
|
||||
context.Reject(Errors.InvalidGrant, "The session token is not valid.");
|
||||
return;
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);
|
||||
identity.SetClaim(PersonaExchange.RootClaim, root.ID);
|
||||
var principal = new ClaimsPrincipal(identity)
|
||||
.SetTokenType(PersonaExchange.SubjectTokenType)
|
||||
.SetCreationDate(DateTimeOffset.UtcNow);
|
||||
if (context.ValidPresenters.Count > 0)
|
||||
principal.SetPresenters(context.ValidPresenters);
|
||||
context.Principal = principal;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.AspNetCore;
|
||||
using Microsoft.AspNetCore;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
@@ -48,9 +48,33 @@ namespace PrivaPub.Api.Mastodon.Auth
|
||||
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
if (request.IsTokenExchangeGrantType())
|
||||
return await Persona(request, token);
|
||||
|
||||
return Refuse(Errors.UnsupportedGrantType, "The grant type is not supported.");
|
||||
}
|
||||
|
||||
//PersonaExchange: the root JWT, validated by RootJwtSubjectToken, for a token of one of the root's personas
|
||||
async Task<IActionResult> Persona(OpenIddictRequest request, CancellationToken token)
|
||||
{
|
||||
var subject = (await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)).Principal;
|
||||
var rootId = subject?.GetClaim(PersonaExchange.RootClaim);
|
||||
var avatarId = (string)request[PersonaExchange.AvatarParameter];
|
||||
if (string.IsNullOrEmpty(rootId) || string.IsNullOrEmpty(avatarId))
|
||||
return Refuse(Errors.InvalidGrant, "The persona can no longer be used.");
|
||||
var owns = await _dbEntities.RootToAvatars.Match(r => r.RootId == rootId && r.AvatarId == avatarId).ExecuteAnyAsync(token);
|
||||
var avatar = owns ? await _dbEntities.Avatars.MatchID(avatarId).ExecuteFirstAsync(token) : default;
|
||||
if (avatar == default || !await Usable(avatarId, token))
|
||||
return Refuse(Errors.InvalidGrant, "The persona can no longer be used.");
|
||||
|
||||
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);
|
||||
identity.SetClaim(Claims.Subject, avatar.ID);
|
||||
identity.SetClaim(Claims.Name, avatar.UserName);
|
||||
identity.SetScopes(MastodonScopes.Parse(request.Scope).Intersect(new[] { "read", "write", "follow" }));
|
||||
identity.SetDestinations(_ => new[] { Destinations.AccessToken });
|
||||
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
async Task<bool> Usable(string avatarId, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(avatarId))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Entities;
|
||||
@@ -16,6 +17,7 @@ using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
using PrivaPub.Infrastructure;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
@@ -30,10 +32,12 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
readonly IFollowService _follows;
|
||||
readonly IOutboxPublisher _outbox;
|
||||
readonly IRelationshipService _relationships;
|
||||
readonly IFeedFollows _feeds;
|
||||
|
||||
public AccountsController(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors,
|
||||
IFollowService follows, IOutboxPublisher outbox, IRelationshipService relationships)
|
||||
IFollowService follows, IOutboxPublisher outbox, IRelationshipService relationships, IFeedFollows feeds)
|
||||
{
|
||||
_feeds = feeds;
|
||||
_relationships = relationships;
|
||||
_mapper = mapper;
|
||||
_dbEntities = dbEntities;
|
||||
@@ -46,18 +50,33 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
[HttpGet("/api/v1/accounts/verify_credentials"), Scope("read:accounts")]
|
||||
public async Task<IActionResult> VerifyCredentials(CancellationToken token) => Json(await _mapper.Local(Me, withSource: true, token));
|
||||
|
||||
[HttpPatch("/api/v1/accounts/update_credentials"), Scope("write:accounts"), RequestSizeLimit(20 * 1024 * 1024),
|
||||
[HttpPatch("/api/v1/accounts/update_credentials"), Scope("write:accounts"), EnableRateLimiting(RateLimiting.Uploads), RequestSizeLimit(20 * 1024 * 1024),
|
||||
RequestFormLimits(MultipartBodyLengthLimit = 20 * 1024 * 1024)]
|
||||
public async Task<IActionResult> UpdateCredentials([FromServices] IMediaService media, CancellationToken token)
|
||||
{
|
||||
var avatar = await _dbEntities.Avatars.MatchID(Me.Id).ExecuteFirstAsync(token);
|
||||
// a new picture is a row of its own; the one it replaces goes to the trash once the profile is saved
|
||||
var replaced = new List<Models.Media.MediaAttachment>();
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync(token);
|
||||
if (form.Files["avatar"] is { } picture && await media.ProfileImage(picture, 400, 400, token) is { } pictureUrl)
|
||||
avatar.PictureURL = pictureUrl;
|
||||
if (form.Files["header"] is { } header && await media.ProfileImage(header, 1500, 500, token) is { } headerUrl)
|
||||
avatar.ThumbnailURL = headerUrl;
|
||||
foreach (var (field, kind, width, height) in new[] { ("avatar", "avatar", 400, 400), ("header", "header", 1500, 500) })
|
||||
{
|
||||
if (form.Files[field] is not { } file)
|
||||
continue;
|
||||
var outcome = await media.ProfileImage(avatar.ID, kind, file, width, height, token);
|
||||
if (!outcome.Ok)
|
||||
{
|
||||
foreach (var made in replaced)
|
||||
await media.Trash(m => m.ID == made.ID, "profile not saved", token);
|
||||
return Error(outcome.Status, outcome.Error);
|
||||
}
|
||||
replaced.Add(outcome.Attachment);
|
||||
if (kind == "avatar")
|
||||
avatar.PictureURL = media.Url(outcome.Attachment.FilePath);
|
||||
else
|
||||
avatar.ThumbnailURL = media.Url(outcome.Attachment.FilePath);
|
||||
}
|
||||
}
|
||||
if (Params.Has("display_name"))
|
||||
avatar.Name = Params.Get("display_name")?.Trim();
|
||||
@@ -71,14 +90,15 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
avatar.Settings.IsDiscoverable = discoverable;
|
||||
if (Params.Bool("indexable") is { } indexable)
|
||||
avatar.Settings.IsIndexable = indexable;
|
||||
if (Params.Bool("hide_collections") is { } hide)
|
||||
avatar.Settings.HideCollections = hide;
|
||||
// hide_collections stays true: who follows whom is never listed here, only counted (FEDERATION.md)
|
||||
if (Params.Get("source[privacy]") is { } privacy && privacy is "public" or "unlisted" or "private")
|
||||
avatar.Settings.DefaultVisibility = privacy;
|
||||
if (Params.Bool("source[sensitive]") is { } sensitive)
|
||||
avatar.Settings.DefaultSensitive = sensitive;
|
||||
if (Params.Has("source[language]"))
|
||||
avatar.Settings.DefaultLanguage = Params.Get("source[language]");
|
||||
if (Params.Get("source[quote_policy]") is { } quotePolicy && QuotePolicies.IsKnown(quotePolicy))
|
||||
avatar.Settings.QuotePolicy = quotePolicy;
|
||||
var fields = new Dictionary<string, string>();
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
@@ -97,12 +117,36 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
avatar.Fields = fields;
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
await DB.Default.SaveAsync(avatar, token);
|
||||
foreach (var made in replaced)
|
||||
await media.Trash(m => m.ProfileOfAvatarId == avatar.ID && m.Kind == made.Kind && m.ID != made.ID, "replaced", token);
|
||||
|
||||
var actor = _localActors.FromAvatar(avatar);
|
||||
await _outbox.PublishProfile(actor, token);
|
||||
return Json(await _mapper.Local(actor, withSource: true, token));
|
||||
}
|
||||
|
||||
// Mastodon's: the picture goes (to the trash), the profile shows the default again, and followers are told
|
||||
[HttpDelete("/api/v1/profile/avatar"), Scope("write:accounts")]
|
||||
public Task<IActionResult> DeleteAvatar([FromServices] IMediaService media, CancellationToken token) => DeletePicture(media, "avatar", token);
|
||||
|
||||
[HttpDelete("/api/v1/profile/header"), Scope("write:accounts")]
|
||||
public Task<IActionResult> DeleteHeader([FromServices] IMediaService media, CancellationToken token) => DeletePicture(media, "header", token);
|
||||
|
||||
async Task<IActionResult> DeletePicture(IMediaService media, string kind, CancellationToken token)
|
||||
{
|
||||
var avatar = await _dbEntities.Avatars.MatchID(Me.Id).ExecuteFirstAsync(token);
|
||||
if (kind == "avatar")
|
||||
avatar.PictureURL = default;
|
||||
else
|
||||
avatar.ThumbnailURL = default;
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
await DB.Default.SaveAsync(avatar, token);
|
||||
await media.Trash(m => m.ProfileOfAvatarId == avatar.ID && m.Kind == kind, "removed", token);
|
||||
var actor = _localActors.FromAvatar(avatar);
|
||||
await _outbox.PublishProfile(actor, token);
|
||||
return Json(await _mapper.Local(actor, withSource: true, token));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/accounts/lookup"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Lookup(CancellationToken token)
|
||||
{
|
||||
@@ -114,11 +158,11 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var local = await _localActors.FindByUserName(parts[0], token);
|
||||
return local is { IsFederated: true, IsCircle: false, Kind: not LocalActorKind.Application } ? Json(await _mapper.Local(local, false, token)) : NotFoundError();
|
||||
return local is { IsFederated: true, IsCircle: false, IsServerActor: false } ? Json(await _mapper.Local(local, false, token)) : NotFoundError();
|
||||
}
|
||||
var userName = parts[0];
|
||||
var domain = parts[1].ToLowerInvariant();
|
||||
var foreign = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain).ExecuteFirstAsync(token);
|
||||
var foreign = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain && !f.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||
return foreign == default ? NotFoundError() : Json(_mapper.Foreign(foreign));
|
||||
}
|
||||
|
||||
@@ -133,7 +177,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
|
||||
[HttpGet("/api/v1/accounts/search"), Scope("read:accounts")]
|
||||
public async Task<IActionResult> Search([FromServices] AccountSearch search, CancellationToken token) =>
|
||||
Json(await search.Find(Params.Get("q"), Params.Bool("resolve") == true && MyId != default, Limit(), token));
|
||||
Json(Params.Bool("following") == true && MyId != default
|
||||
? await search.Followed(MyId, Params.Get("q"), Limit(), token)
|
||||
: await search.Find(Params.Get("q"), Params.Bool("resolve") == true && MyId != default, Limit(), token));
|
||||
|
||||
[HttpGet("/api/v1/accounts/{id}"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Get(string id, CancellationToken token)
|
||||
@@ -151,17 +197,25 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
if (Params.Bool("pinned") == true)
|
||||
{
|
||||
if (local == default)
|
||||
return Json(Array.Empty<Status>());
|
||||
{
|
||||
var featured = (await DB.Default.Find<RemoteFeatured>().Match(f => f.ActorURI == remote.ActorURI).Sort(f => f.Position, Order.Ascending)
|
||||
.Limit(Federation.Actors.FeaturedPosts.MaxFeatured).ExecuteAsync(token)).Select(f => f.PostId).ToList();
|
||||
var featuredPosts = await _dbEntities.Posts.Match(p => featured.Contains(p.ID) && !p.DeletedAt.HasValue).Match(VisibilityPolicy.IsPublic).ExecuteAsync(token);
|
||||
return Json(await _mapper.Statuses(featured.Select(id => featuredPosts.FirstOrDefault(p => p.ID == id)).Where(p => p != default).ToList(), MyId, token));
|
||||
}
|
||||
var pinIds = (await DB.Default.Find<Pin>().Match(p => p.AvatarId == local.Id).Sort(p => p.ID, Order.Descending).ExecuteAsync(token)).Select(p => p.PostId).ToList();
|
||||
var pinnedPosts = await _dbEntities.Posts.Match(p => pinIds.Contains(p.ID) && !p.DeletedAt.HasValue).Match(VisibilityPolicy.IsPublic).ExecuteAsync(token);
|
||||
var pinnedPosts = await _dbEntities.Posts.Match(p => pinIds.Contains(p.ID)).Match(VisibilityPolicy.IsPublic).ExecuteAsync(token);
|
||||
return Json(await _mapper.Statuses(pinIds.Select(id => pinnedPosts.FirstOrDefault(p => p.ID == id)).Where(p => p != default).ToList(), MyId, token));
|
||||
}
|
||||
|
||||
var query = local != default
|
||||
? _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue)
|
||||
: _dbEntities.Posts.Match(p => p.ActorURI == remote.ActorURI && p.IsFederatedCopy && !p.DeletedAt.HasValue);
|
||||
var viewerFollows = MyId != default && local != default
|
||||
&& await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetAccountId == local.Id && f.State == FollowState.Accepted).ExecuteAnyAsync(token);
|
||||
var query = local is { Kind: LocalActorKind.Group }
|
||||
? _dbEntities.Posts.Match(p => p.GroupId == local.Id).Match(VisibilityPolicy.IsShown)
|
||||
: local != default
|
||||
? _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy).Match(VisibilityPolicy.IsShown)
|
||||
: _dbEntities.Posts.Match(p => p.ActorURI == remote.ActorURI && p.IsFederatedCopy).Match(VisibilityPolicy.IsShown);
|
||||
var viewerFollows = MyId != default && (local != default
|
||||
? await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetAccountId == local.Id && f.State == FollowState.Accepted).ExecuteAnyAsync(token)
|
||||
: await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetActorURI == remote.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token));
|
||||
if (MyId != id)
|
||||
query.Match(viewerFollows
|
||||
? p => p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted || p.Visibility == PostVisibility.FollowersOnly
|
||||
@@ -169,7 +223,10 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
else
|
||||
query.Match(p => p.Visibility != PostVisibility.LocalGeo);
|
||||
if (Params.Bool("exclude_replies") == true)
|
||||
query.Match(p => p.InReplyToURI == null);
|
||||
{
|
||||
var self = local?.Id ?? remote.ID;
|
||||
query.Match(p => p.InReplyToURI == null || p.InReplyToAccountId == self);
|
||||
}
|
||||
if (Params.Bool("exclude_reblogs") == true)
|
||||
query.Match(p => p.ReblogOfPostId == null);
|
||||
if (Params.Bool("only_media") == true)
|
||||
@@ -189,29 +246,49 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
var (local, _) = await Find(id, token);
|
||||
if (local == default || local.Id != MyId)
|
||||
return Json(Array.Empty<Account>());
|
||||
var followers = await _dbEntities.Followers.Match(f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted)
|
||||
.Sort(f => f.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
|
||||
var followers = await Page.From(Params, Limit(40, 80))
|
||||
.Fetch(_dbEntities.Followers.Match(f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted), f => f.ID, token);
|
||||
Link($"/api/v1/accounts/{id}/followers", followers.LastOrDefault()?.ID, followers.FirstOrDefault()?.ID);
|
||||
return Json(await AccountsFor(followers.Select(f => f.ActorURI), token));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/accounts/{id}/following"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Following(string id, CancellationToken token)
|
||||
{
|
||||
var (local, _) = await Find(id, token);
|
||||
var (local, remote) = await Find(id, token);
|
||||
// a feed's following is what it is: its communities, as its server lists them
|
||||
if (remote is { AvatarType: AvatarType.Feed })
|
||||
return Json(await AccountsFor(remote.FeedCommunities.Take(Limit(40, 80)), token));
|
||||
if (local == default || local.Id != MyId)
|
||||
return Json(Array.Empty<Account>());
|
||||
var following = await _dbEntities.Followings.Match(f => f.AvatarId == local.Id && f.State == FollowState.Accepted)
|
||||
.Sort(f => f.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
|
||||
var following = await Page.From(Params, Limit(40, 80))
|
||||
.Fetch(_dbEntities.Followings.Match(f => f.AvatarId == local.Id && f.State == FollowState.Accepted), f => f.ID, token);
|
||||
Link($"/api/v1/accounts/{id}/following", following.LastOrDefault()?.ID, following.FirstOrDefault()?.ID);
|
||||
var accounts = await _mapper.Accounts(following.Select(f => f.TargetAccountId), token);
|
||||
return Json(following.Select(f => accounts.GetValueOrDefault(f.TargetAccountId)).Where(a => a != default).ToList());
|
||||
}
|
||||
|
||||
// PrivaPub's own: the feeds the persona reads (a feed's timeline is /api/v1/timelines/feed/:id)
|
||||
[HttpGet("/api/v1/feeds"), Scope("read:follows")]
|
||||
public async Task<IActionResult> Feeds(CancellationToken token)
|
||||
{
|
||||
var read = await DB.Default.Find<FeedSubscription>().Match(s => s.AvatarId == MyId).Sort(s => s.ID, MongoDB.Entities.Order.Descending).ExecuteAsync(token);
|
||||
var accounts = await _mapper.Accounts(read.Select(s => s.FeedAccountId), token);
|
||||
return Json(read.Select(s => accounts.GetValueOrDefault(s.FeedAccountId)).Where(a => a != default).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/accounts/{id}/follow"), Scope("write:follows")]
|
||||
public async Task<IActionResult> Follow(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
if (local == default && remote == default)
|
||||
return NotFoundError();
|
||||
// a feed is read through the server, whose reader follows its communities; the persona follows nothing
|
||||
if (remote is { AvatarType: AvatarType.Feed })
|
||||
{
|
||||
await _feeds.Subscribe(Me, remote, true, token);
|
||||
return Json(await Relationship(id, token));
|
||||
}
|
||||
var following = await _follows.FollowAs(Me, local?.Uri ?? remote.ActorURI, Params.Bool("reblogs") != false, token);
|
||||
return following == default ? Error(StatusCodes.Status403Forbidden, "This action is not allowed") : Json(await Relationship(id, token));
|
||||
}
|
||||
@@ -222,7 +299,10 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
var (local, remote) = await Find(id, token);
|
||||
if (local == default && remote == default)
|
||||
return NotFoundError();
|
||||
await _follows.UnfollowAs(Me, local?.Uri ?? remote.ActorURI, token);
|
||||
if (remote is { AvatarType: AvatarType.Feed })
|
||||
await _feeds.Subscribe(Me, remote, false, token);
|
||||
else
|
||||
await _follows.UnfollowAs(Me, local?.Uri ?? remote.ActorURI, token);
|
||||
return Json(await Relationship(id, token));
|
||||
}
|
||||
|
||||
@@ -233,9 +313,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
var uri = local?.Uri ?? remote?.ActorURI;
|
||||
if (uri == default)
|
||||
return NotFoundError();
|
||||
await DB.Default.DeleteAsync<Follower>(f => f.LocalActorId == Me.Id && f.ActorURI == uri);
|
||||
if (local != default)
|
||||
await DB.Default.DeleteAsync<Following>(f => f.AvatarId == local.Id && f.TargetActorURI == Me.Uri);
|
||||
await _relationships.RemoveFollower(Me, uri, id, token);
|
||||
return Json(await Relationship(id, token));
|
||||
}
|
||||
|
||||
@@ -335,9 +413,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
[HttpGet("/api/v1/accounts/{id}/featured_tags"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult FeaturedTags(string id) => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/accounts/{id}/lists"), Scope("read:lists")]
|
||||
public IActionResult Lists(string id) => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/accounts/familiar_followers"), Scope("read:follows")]
|
||||
public IActionResult FamiliarFollowers() => Json(Params.List("id").Select(id => new { id, accounts = Array.Empty<Account>() }).ToList());
|
||||
|
||||
@@ -375,11 +450,15 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
var (local, remote) = await Find(id, token);
|
||||
var uri = local?.Uri ?? remote?.ActorURI;
|
||||
var following = uri == default ? default : await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetActorURI == uri).ExecuteFirstAsync(token);
|
||||
var reading = remote is { AvatarType: AvatarType.Feed } && await _feeds.IsSubscribed(MyId, remote.ActorURI, token);
|
||||
var followedBy = uri == default ? default : await _dbEntities.Followers.Match(f => f.LocalActorId == MyId && f.ActorURI == uri).ExecuteFirstAsync(token);
|
||||
var blocking = uri != default && await DB.Default.Find<Block>().Match(b => b.AvatarId == MyId && b.TargetActorURI == uri).ExecuteAnyAsync(token);
|
||||
var mute = uri == default ? default : await DB.Default.Find<Mute>().Match(m => m.AvatarId == MyId && m.TargetActorURI == uri).ExecuteFirstAsync(token);
|
||||
var blockedBy = local != default && await DB.Default.Find<Block>().Match(b => b.AvatarId == local.Id && b.TargetActorURI == Me.Uri).ExecuteAnyAsync(token);
|
||||
var domainBlocked = remote != default && await DB.Default.Find<AccountDomainBlock>().Match(b => b.AvatarId == MyId && b.Domain == remote.Domain).ExecuteAnyAsync(token);
|
||||
var blockedBy = local != default
|
||||
? await DB.Default.Find<Block>().Match(b => b.AvatarId == local.Id && b.TargetActorURI == Me.Uri).ExecuteAnyAsync(token)
|
||||
: remote != default && await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == MyId && b.ActorURI == remote.ActorURI).ExecuteAnyAsync(token);
|
||||
var domainBlocked = remote != default && (await DB.Default.Find<AccountDomainBlock>().Match(b => b.AvatarId == MyId).ExecuteAsync(token))
|
||||
.Any(b => Hidden.Blocks(b.Domain, remote.ActorURI, remote.Domain));
|
||||
return new Relationship
|
||||
{
|
||||
Id = id,
|
||||
@@ -388,7 +467,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
Muting = mute != default && (mute.ExpiresAt == default || mute.ExpiresAt > DateTime.UtcNow),
|
||||
MutingNotifications = mute?.HideNotifications == true,
|
||||
DomainBlocking = domainBlocked,
|
||||
Following = following?.State == FollowState.Accepted,
|
||||
Following = following?.State == FollowState.Accepted || reading,
|
||||
Requested = following?.State == FollowState.Requested,
|
||||
ShowingReblogs = following?.ShowReblogs ?? false,
|
||||
FollowedBy = followedBy?.IsAccepted == true,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Domain.Statistics;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// The CDNs in front of the servers this server knows, each named by its own domain (owner decision 2026-10-04): how many
|
||||
// servers are behind it, how it is recognised, and week by week who joined and who left.
|
||||
public class CdnsController : MastodonController
|
||||
{
|
||||
const int MaxWeeks = 260;
|
||||
|
||||
readonly IServerPlaces _places;
|
||||
|
||||
public CdnsController(IServerPlaces places) => _places = places;
|
||||
|
||||
[HttpGet("/api/privapub/v1/cdns"), Scope("read", requiresUser: false), AllowAnonymous]
|
||||
public async Task<IActionResult> List(CancellationToken token) => Json((await _places.Cdns(token)).Select(Summary).ToList());
|
||||
|
||||
[HttpGet("/api/privapub/v1/cdns/{key}"), Scope("read", requiresUser: false), AllowAnonymous]
|
||||
public async Task<IActionResult> Detail(string key, CancellationToken token)
|
||||
{
|
||||
var history = await _places.Cdn(key, Math.Clamp(Params.Int("weeks") ?? 52, 1, MaxWeeks), token);
|
||||
if (history == default)
|
||||
return NotFoundError();
|
||||
var detail = new CdnDetail
|
||||
{
|
||||
Hosts = history.Hosts,
|
||||
HostsTruncated = history.Group.Servers > history.Hosts.Count,
|
||||
Weeks = history.Weeks.Select(w => new CdnWeekEntry { Week = w.Week, Servers = w.Servers, Joined = w.Joined, Left = w.Left }).ToList()
|
||||
};
|
||||
Fill(detail, history.Group);
|
||||
return Json(detail);
|
||||
}
|
||||
|
||||
static CdnSummary Summary(CdnGroup group)
|
||||
{
|
||||
var summary = new CdnSummary();
|
||||
Fill(summary, group);
|
||||
return summary;
|
||||
}
|
||||
|
||||
static void Fill(CdnSummary summary, CdnGroup group)
|
||||
{
|
||||
summary.Key = group.Key;
|
||||
summary.Name = group.Name;
|
||||
summary.Domain = group.Domain;
|
||||
summary.Servers = group.Servers;
|
||||
summary.Ranges = group.Ranges;
|
||||
summary.RangesUpdatedAt = group.RangesUpdatedAt is { } at ? MastodonJson.Time(at) : default;
|
||||
summary.DetectedBy = group.DetectedBy;
|
||||
}
|
||||
|
||||
public class CdnSummary
|
||||
{
|
||||
public string Key { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Domain { get; set; }
|
||||
public int Servers { get; set; }
|
||||
public int Ranges { get; set; }
|
||||
public string RangesUpdatedAt { get; set; }
|
||||
public List<string> DetectedBy { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CdnDetail : CdnSummary
|
||||
{
|
||||
public List<string> Hosts { get; set; } = new();
|
||||
public bool HostsTruncated { get; set; }
|
||||
public List<CdnWeekEntry> Weeks { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CdnWeekEntry
|
||||
{
|
||||
public string Week { get; set; }
|
||||
public int Servers { get; set; }
|
||||
public List<string> Joined { get; set; } = new();
|
||||
public List<string> Left { get; set; } = new();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Entities;
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Mastodon's filters (owner decision 2026-10-04, back from the cut list): v2 filters with their keywords and statuses,
|
||||
// and the v1 API, whose filter is one keyword of a v2 filter. Statuses carry what they match in `filtered`; clients
|
||||
// apply the context and the action. Nothing here leaves the server.
|
||||
public class FiltersController : MastodonController
|
||||
{
|
||||
const int MaxFilters = 100;
|
||||
const int MaxEntries = 100;
|
||||
const int MaxKeywordLength = 100;
|
||||
static readonly Regex Attribute = new(@"^keywords_attributes\[(\d*)\]\[(\w+)\]$", RegexOptions.Compiled);
|
||||
|
||||
readonly DbEntities _dbEntities;
|
||||
|
||||
public FiltersController(DbEntities dbEntities)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
static string NewId() => ObjectId.GenerateNewId().ToString();
|
||||
|
||||
Task<PersonaFilter> Mine(string id, CancellationToken token) =>
|
||||
DB.Default.Find<PersonaFilter>().Match(f => f.ID == id && f.AvatarId == MyId).ExecuteFirstAsync(token);
|
||||
|
||||
Task<PersonaFilter> WithKeyword(string keywordId, CancellationToken token) =>
|
||||
DB.Default.Find<PersonaFilter>().Match(f => f.AvatarId == MyId && f.Keywords.Any(k => k.Id == keywordId)).ExecuteFirstAsync(token);
|
||||
|
||||
Task<PersonaFilter> WithStatus(string statusFilterId, CancellationToken token) =>
|
||||
DB.Default.Find<PersonaFilter>().Match(f => f.AvatarId == MyId && f.Statuses.Any(s => s.Id == statusFilterId)).ExecuteFirstAsync(token);
|
||||
|
||||
static JsonResult Invalid(string message) => Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: " + message);
|
||||
|
||||
// title, context[], filter_action, expires_in: what a create or an update sets; an error message when one is wrong
|
||||
string Apply(PersonaFilter filter, bool creating)
|
||||
{
|
||||
var title = Params.Get("title")?.Trim();
|
||||
if (title != default || creating)
|
||||
{
|
||||
if (string.IsNullOrEmpty(title) || title.Length > 200)
|
||||
return "Title can't be blank";
|
||||
filter.Title = title;
|
||||
}
|
||||
if (Params.Has("context") || creating)
|
||||
{
|
||||
var context = Params.List("context").Distinct().ToList();
|
||||
if (context.Count == 0 || context.Any(c => !FilterContexts.IsValid(c)))
|
||||
return "Context can't be blank";
|
||||
filter.Context = context;
|
||||
}
|
||||
if (Params.Get("filter_action") is { } action)
|
||||
{
|
||||
if (!FilterActions.IsValid(action))
|
||||
return "Filter action is not included in the list";
|
||||
filter.Action = action;
|
||||
}
|
||||
if (Params.Has("expires_in"))
|
||||
filter.ExpiresAt = Params.Int("expires_in") is > 0 and var seconds ? DateTime.UtcNow.AddSeconds(seconds) : default;
|
||||
return default;
|
||||
}
|
||||
|
||||
// keywords_attributes, as Rails takes them: a JSON array of objects, or form fields [][keyword], [][whole_word], [][id],
|
||||
// [][_destroy], or numbered [0][keyword]
|
||||
List<Dictionary<string, string>> KeywordAttributes()
|
||||
{
|
||||
if (Params.Json is { ValueKind: System.Text.Json.JsonValueKind.Object } body
|
||||
&& body.TryGetProperty("keywords_attributes", out var array) && array.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
return array.EnumerateArray().Where(o => o.ValueKind == System.Text.Json.JsonValueKind.Object)
|
||||
.Select(o => o.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.ValueKind switch
|
||||
{
|
||||
System.Text.Json.JsonValueKind.String => p.Value.GetString(),
|
||||
System.Text.Json.JsonValueKind.True => "true",
|
||||
System.Text.Json.JsonValueKind.False => "false",
|
||||
_ => p.Value.ToString()
|
||||
}))
|
||||
.ToList();
|
||||
var numbered = new SortedDictionary<int, Dictionary<string, string>>();
|
||||
var listed = new Dictionary<string, IReadOnlyList<string>>();
|
||||
foreach (var name in Params.Names)
|
||||
{
|
||||
var match = Attribute.Match(name);
|
||||
if (!match.Success)
|
||||
continue;
|
||||
if (match.Groups[1].Value.Length == 0)
|
||||
listed[match.Groups[2].Value] = Params.List(name);
|
||||
else
|
||||
{
|
||||
var index = int.Parse(match.Groups[1].Value);
|
||||
if (!numbered.TryGetValue(index, out var item))
|
||||
numbered[index] = item = new Dictionary<string, string>();
|
||||
item[match.Groups[2].Value] = Params.Get(name);
|
||||
}
|
||||
}
|
||||
var items = numbered.Values.ToList();
|
||||
var count = listed.Count == 0 ? 0 : listed.Values.Max(v => v.Count);
|
||||
for (var i = 0; i < count; i++)
|
||||
items.Add(listed.Where(kv => i < kv.Value.Count).ToDictionary(kv => kv.Key, kv => kv.Value[i]));
|
||||
return items;
|
||||
}
|
||||
|
||||
static bool IsTrue(string value) => value?.ToLowerInvariant() is "true" or "1" or "on" or "yes";
|
||||
|
||||
// adds, changes or removes keywords; an error message when one is wrong
|
||||
static string ApplyKeywords(PersonaFilter filter, List<Dictionary<string, string>> items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
var id = item.GetValueOrDefault("id");
|
||||
var existing = id == default ? default : filter.Keywords.FirstOrDefault(k => k.Id == id);
|
||||
if (existing != default && IsTrue(item.GetValueOrDefault("_destroy")))
|
||||
{
|
||||
filter.Keywords.Remove(existing);
|
||||
continue;
|
||||
}
|
||||
var keyword = item.GetValueOrDefault("keyword")?.Trim();
|
||||
if (existing == default && string.IsNullOrEmpty(keyword))
|
||||
return "Keyword can't be blank";
|
||||
if (keyword?.Length > MaxKeywordLength)
|
||||
return "Keyword is too long";
|
||||
var target = existing ?? new FilterKeyword { Id = NewId() };
|
||||
if (!string.IsNullOrEmpty(keyword))
|
||||
target.Keyword = keyword;
|
||||
if (item.ContainsKey("whole_word"))
|
||||
target.WholeWord = IsTrue(item["whole_word"]);
|
||||
else if (existing == default)
|
||||
target.WholeWord = true;
|
||||
if (existing == default)
|
||||
filter.Keywords.Add(target);
|
||||
}
|
||||
return filter.Keywords.Count > MaxEntries ? "Too many keywords" : default;
|
||||
}
|
||||
|
||||
// -- v2
|
||||
|
||||
[HttpGet("/api/v2/filters"), Scope("read:filters")]
|
||||
public async Task<IActionResult> All(CancellationToken token) =>
|
||||
Json((await DB.Default.Find<PersonaFilter>().Match(f => f.AvatarId == MyId).Sort(f => f.ID, Order.Ascending).ExecuteAsync(token))
|
||||
.Select(Filters.View).ToList());
|
||||
|
||||
[HttpGet("/api/v2/filters/{id}"), Scope("read:filters")]
|
||||
public async Task<IActionResult> One(string id, CancellationToken token) =>
|
||||
await Mine(id, token) is { } filter ? Json(Filters.View(filter)) : NotFoundError();
|
||||
|
||||
[HttpPost("/api/v2/filters"), Scope("write:filters")]
|
||||
public async Task<IActionResult> Create(CancellationToken token)
|
||||
{
|
||||
if (await DB.Default.CountAsync<PersonaFilter>(f => f.AvatarId == MyId, token) >= MaxFilters)
|
||||
return Invalid("Too many filters");
|
||||
var filter = new PersonaFilter { AvatarId = MyId };
|
||||
if ((Apply(filter, creating: true) ?? ApplyKeywords(filter, KeywordAttributes())) is { } problem)
|
||||
return Invalid(problem);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(Filters.View(filter));
|
||||
}
|
||||
|
||||
[HttpPut("/api/v2/filters/{id}"), Scope("write:filters")]
|
||||
public async Task<IActionResult> Update(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
if ((Apply(filter, creating: false) ?? ApplyKeywords(filter, KeywordAttributes())) is { } problem)
|
||||
return Invalid(problem);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(Filters.View(filter));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/v2/filters/{id}"), Scope("write:filters")]
|
||||
public async Task<IActionResult> Delete(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
await DB.Default.DeleteAsync<PersonaFilter>(filter.ID);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/filters/{id}/keywords"), Scope("read:filters")]
|
||||
public async Task<IActionResult> Keywords(string id, CancellationToken token) =>
|
||||
await Mine(id, token) is { } filter ? Json(filter.Keywords.Select(Filters.Keyword).ToList()) : NotFoundError();
|
||||
|
||||
[HttpPost("/api/v2/filters/{id}/keywords"), Scope("write:filters")]
|
||||
public async Task<IActionResult> AddKeyword(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
var item = new Dictionary<string, string> { ["keyword"] = Params.Get("keyword") };
|
||||
if (Params.Has("whole_word"))
|
||||
item["whole_word"] = Params.Get("whole_word");
|
||||
if (ApplyKeywords(filter, new List<Dictionary<string, string>> { item }) is { } problem)
|
||||
return Invalid(problem);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(Filters.Keyword(filter.Keywords[^1]));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/filters/keywords/{id}"), Scope("read:filters")]
|
||||
public async Task<IActionResult> Keyword(string id, CancellationToken token) =>
|
||||
await WithKeyword(id, token) is { } filter ? Json(Filters.Keyword(filter.Keywords.First(k => k.Id == id))) : NotFoundError();
|
||||
|
||||
[HttpPut("/api/v2/filters/keywords/{id}"), Scope("write:filters")]
|
||||
public async Task<IActionResult> UpdateKeyword(string id, CancellationToken token)
|
||||
{
|
||||
if (await WithKeyword(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
var item = new Dictionary<string, string> { ["id"] = id, ["keyword"] = Params.Get("keyword") };
|
||||
if (Params.Has("whole_word"))
|
||||
item["whole_word"] = Params.Get("whole_word");
|
||||
if (ApplyKeywords(filter, new List<Dictionary<string, string>> { item }) is { } problem)
|
||||
return Invalid(problem);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(Filters.Keyword(filter.Keywords.First(k => k.Id == id)));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/v2/filters/keywords/{id}"), Scope("write:filters")]
|
||||
public async Task<IActionResult> DeleteKeyword(string id, CancellationToken token)
|
||||
{
|
||||
if (await WithKeyword(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
filter.Keywords.RemoveAll(k => k.Id == id);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/filters/{id}/statuses"), Scope("read:filters")]
|
||||
public async Task<IActionResult> Statuses(string id, CancellationToken token) =>
|
||||
await Mine(id, token) is { } filter ? Json(filter.Statuses.Select(Filters.Status).ToList()) : NotFoundError();
|
||||
|
||||
// a status the persona can see, once per filter
|
||||
[HttpPost("/api/v2/filters/{id}/statuses"), Scope("write:filters")]
|
||||
public async Task<IActionResult> AddStatus(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
var statusId = Params.Get("status_id");
|
||||
var post = string.IsNullOrEmpty(statusId) ? default : await _dbEntities.Posts.Match(p => p.ID == statusId).ExecuteFirstAsync(token);
|
||||
if (post == default || !await Domain.Privacy.VisibilityPolicy.CanSee(post, MyId, token))
|
||||
return NotFoundError();
|
||||
if (filter.Statuses.FirstOrDefault(s => s.PostId == statusId) is { } present)
|
||||
return Json(Filters.Status(present));
|
||||
if (filter.Statuses.Count >= MaxEntries)
|
||||
return Invalid("Too many statuses");
|
||||
var added = new FilterStatus { Id = NewId(), PostId = statusId };
|
||||
filter.Statuses.Add(added);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(Filters.Status(added));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/filters/statuses/{id}"), Scope("read:filters")]
|
||||
public async Task<IActionResult> Status(string id, CancellationToken token) =>
|
||||
await WithStatus(id, token) is { } filter ? Json(Filters.Status(filter.Statuses.First(s => s.Id == id))) : NotFoundError();
|
||||
|
||||
[HttpDelete("/api/v2/filters/statuses/{id}"), Scope("write:filters")]
|
||||
public async Task<IActionResult> DeleteStatus(string id, CancellationToken token)
|
||||
{
|
||||
if (await WithStatus(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
filter.Statuses.RemoveAll(s => s.Id == id);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
// -- v1: each keyword is a filter of its own, with its parent's context, action and expiry
|
||||
|
||||
static FilterV1Entity V1(PersonaFilter filter, FilterKeyword keyword) => new()
|
||||
{
|
||||
Id = keyword.Id,
|
||||
Phrase = keyword.Keyword,
|
||||
Context = filter.Context,
|
||||
WholeWord = keyword.WholeWord,
|
||||
ExpiresAt = filter.ExpiresAt.HasValue ? MastodonJson.Time(filter.ExpiresAt.Value) : default,
|
||||
Irreversible = filter.Action == FilterActions.Hide
|
||||
};
|
||||
|
||||
[HttpGet("/api/v1/filters"), Scope("read:filters")]
|
||||
public async Task<IActionResult> AllV1(CancellationToken token) =>
|
||||
Json((await DB.Default.Find<PersonaFilter>().Match(f => f.AvatarId == MyId).Sort(f => f.ID, Order.Ascending).ExecuteAsync(token))
|
||||
.SelectMany(f => f.Keywords.Select(k => V1(f, k))).ToList());
|
||||
|
||||
[HttpGet("/api/v1/filters/{id}"), Scope("read:filters")]
|
||||
public async Task<IActionResult> OneV1(string id, CancellationToken token) =>
|
||||
await WithKeyword(id, token) is { } filter ? Json(V1(filter, filter.Keywords.First(k => k.Id == id))) : NotFoundError();
|
||||
|
||||
// phrase, context[], irreversible, whole_word, expires_in
|
||||
string ApplyV1(PersonaFilter filter, FilterKeyword keyword, bool creating)
|
||||
{
|
||||
var phrase = Params.Get("phrase")?.Trim();
|
||||
if (phrase != default || creating)
|
||||
{
|
||||
if (string.IsNullOrEmpty(phrase))
|
||||
return "Phrase can't be blank";
|
||||
if (phrase.Length > MaxKeywordLength)
|
||||
return "Phrase is too long";
|
||||
keyword.Keyword = phrase;
|
||||
if (creating)
|
||||
filter.Title = phrase;
|
||||
}
|
||||
if (Params.Has("context") || creating)
|
||||
{
|
||||
var context = Params.List("context").Distinct().ToList();
|
||||
if (context.Count == 0 || context.Any(c => !FilterContexts.IsValid(c)))
|
||||
return "Context can't be blank";
|
||||
filter.Context = context;
|
||||
}
|
||||
if (Params.Bool("whole_word") is { } wholeWord)
|
||||
keyword.WholeWord = wholeWord;
|
||||
if (Params.Bool("irreversible") is { } irreversible)
|
||||
filter.Action = irreversible ? FilterActions.Hide : FilterActions.Warn;
|
||||
if (Params.Has("expires_in"))
|
||||
filter.ExpiresAt = Params.Int("expires_in") is > 0 and var seconds ? DateTime.UtcNow.AddSeconds(seconds) : default;
|
||||
return default;
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/filters"), Scope("write:filters")]
|
||||
public async Task<IActionResult> CreateV1(CancellationToken token)
|
||||
{
|
||||
if (await DB.Default.CountAsync<PersonaFilter>(f => f.AvatarId == MyId, token) >= MaxFilters)
|
||||
return Invalid("Too many filters");
|
||||
var keyword = new FilterKeyword { Id = NewId(), WholeWord = true };
|
||||
var filter = new PersonaFilter { AvatarId = MyId, Keywords = new List<FilterKeyword> { keyword } };
|
||||
if (ApplyV1(filter, keyword, creating: true) is { } problem)
|
||||
return Invalid(problem);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(V1(filter, keyword));
|
||||
}
|
||||
|
||||
[HttpPut("/api/v1/filters/{id}"), Scope("write:filters")]
|
||||
public async Task<IActionResult> UpdateV1(string id, CancellationToken token)
|
||||
{
|
||||
if (await WithKeyword(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
var keyword = filter.Keywords.First(k => k.Id == id);
|
||||
if (ApplyV1(filter, keyword, creating: false) is { } problem)
|
||||
return Invalid(problem);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(V1(filter, keyword));
|
||||
}
|
||||
|
||||
// the keyword goes, and its filter with it when nothing else is left in it
|
||||
[HttpDelete("/api/v1/filters/{id}"), Scope("write:filters")]
|
||||
public async Task<IActionResult> DeleteV1(string id, CancellationToken token)
|
||||
{
|
||||
if (await WithKeyword(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
filter.Keywords.RemoveAll(k => k.Id == id);
|
||||
if (filter.Keywords.Count == 0 && filter.Statuses.Count == 0)
|
||||
await DB.Default.DeleteAsync<PersonaFilter>(filter.ID);
|
||||
else
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(new { });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Infrastructure;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
@@ -18,34 +20,64 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
const string Description = "A small ActivityPub server where one private login keeps several unlinkable public personas.";
|
||||
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IOptionsMonitor<RegistrationOptions> _registrations;
|
||||
|
||||
public InstanceController(ILocalActorService localActors)
|
||||
public InstanceController(ILocalActorService localActors, IOptionsMonitor<RegistrationOptions> registrations)
|
||||
{
|
||||
_localActors = localActors;
|
||||
_registrations = registrations;
|
||||
}
|
||||
|
||||
bool Open => _registrations.CurrentValue.IsOpen;
|
||||
|
||||
// the languages this server is set up for (AppConfiguration:SupportedLanguages), "en" when none are named
|
||||
public static IReadOnlyList<string> LanguageCodes(IServiceProvider services)
|
||||
{
|
||||
var configured = services.GetRequiredService<Microsoft.Extensions.Options.IOptionsMonitor<Models.AppConfiguration>>().CurrentValue.SupportedLanguages;
|
||||
var codes = (configured ?? new List<string>()).Where(c => !string.IsNullOrWhiteSpace(c)).Select(c => c.Split('-')[0].ToLowerInvariant()).Distinct().ToList();
|
||||
return codes.Count == 0 ? new[] { "en" } : codes;
|
||||
}
|
||||
|
||||
string Domain => new Uri(_localActors.BaseAddress).Authority;
|
||||
|
||||
static object Statuses => new { max_characters = 5000, max_media_attachments = 4, characters_reserved_per_url = 23 };
|
||||
// where clients open their streams (StreamingController): this server, as ws or wss
|
||||
string Streaming => new UriBuilder(_localActors.BaseAddress) { Scheme = _localActors.BaseAddress.StartsWith("https", StringComparison.OrdinalIgnoreCase) ? "wss" : "ws" }
|
||||
.Uri.GetLeftPart(UriPartial.Authority);
|
||||
|
||||
static object MediaAttachments => new
|
||||
// every limit is what the server enforces, read from where it is enforced
|
||||
static object Statuses => new { max_characters = PrivaPub.Domain.Statuses.StatusService.MaxCharacters, max_media_attachments = 4, characters_reserved_per_url = 23 };
|
||||
|
||||
object MediaAttachments
|
||||
{
|
||||
supported_mime_types = new[] { "image/jpeg", "image/png", "image/gif", "image/webp", "video/mp4", "video/webm", "audio/mpeg", "audio/ogg" },
|
||||
image_size_limit = 16 * 1024 * 1024,
|
||||
image_matrix_limit = 16_777_216,
|
||||
video_size_limit = 99 * 1024 * 1024,
|
||||
video_frame_rate_limit = 60,
|
||||
video_matrix_limit = 2_304_000
|
||||
};
|
||||
get
|
||||
{
|
||||
var media = HttpContext.RequestServices.GetRequiredService<IOptionsMonitor<PrivaPub.Domain.Media.MediaOptions>>().CurrentValue;
|
||||
return new
|
||||
{
|
||||
supported_mime_types = PrivaPub.Domain.Media.MediaService.SupportedTypes,
|
||||
image_size_limit = media.MaxImageBytes,
|
||||
image_matrix_limit = media.MaxPixels,
|
||||
video_size_limit = media.MaxVideoBytes,
|
||||
video_frame_rate_limit = (int)media.MaxFrameRate,
|
||||
video_matrix_limit = media.MaxVideoPixels
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static object Polls => new { max_options = 4, max_characters_per_option = 50, min_expiration = 300, max_expiration = 2_629_746 };
|
||||
static object Polls => new
|
||||
{
|
||||
max_options = PrivaPub.Domain.Statuses.PollService.MaxOptions,
|
||||
max_characters_per_option = PrivaPub.Domain.Statuses.PollService.MaxOptionLength,
|
||||
min_expiration = PrivaPub.Domain.Statuses.PollService.MinExpiration,
|
||||
max_expiration = PrivaPub.Domain.Statuses.PollService.MaxExpiration
|
||||
};
|
||||
|
||||
[HttpGet("/api/v1/instance"), AllowAnonymous]
|
||||
public async Task<IActionResult> V1(CancellationToken token)
|
||||
{
|
||||
var users = await DB.Default.CountAsync<Avatar>(a => !a.DeletionAt.HasValue, token);
|
||||
var statuses = await DB.Default.CountAsync<PostEntity>(f => f.Where(p => !p.IsFederatedCopy) & f.Where(VisibilityPolicy.IsPublic), token);
|
||||
var domains = (await DB.Default.Find<ForeignAvatar, string>().Project(a => a.Domain).ExecuteAsync(token)).Distinct().Count();
|
||||
var users = await Counted.Users(token);
|
||||
var statuses = await Counted.LocalPosts(token);
|
||||
var domains = await KnownServers(token);
|
||||
return Json(new
|
||||
{
|
||||
uri = Domain,
|
||||
@@ -54,40 +86,47 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
description = Description,
|
||||
email = string.Empty,
|
||||
version = Version,
|
||||
urls = new { streaming_api = $"wss://{Domain}" },
|
||||
urls = new { streaming_api = Streaming },
|
||||
stats = new { user_count = users, status_count = statuses, domain_count = domains },
|
||||
thumbnail = $"{_localActors.BaseAddress}/media/missing-header.png",
|
||||
languages = new[] { "en" },
|
||||
registrations = false,
|
||||
languages = LanguageCodes(HttpContext.RequestServices),
|
||||
registrations = Open,
|
||||
approval_required = false,
|
||||
invites_enabled = false,
|
||||
invites_enabled = true,//a group invitation always creates an account
|
||||
configuration = new { accounts = new { max_featured_tags = 0 }, statuses = Statuses, media_attachments = MediaAttachments, polls = Polls },
|
||||
contact_account = default(object),
|
||||
rules = Array.Empty<object>()
|
||||
});
|
||||
}
|
||||
|
||||
// the servers we have exchanged activities with, as the statistics count them. Which ones is not published
|
||||
// (/instance/peers stays empty, as on a Mastodon with its peers API off): on a small server it would say who its
|
||||
// personas talk to.
|
||||
static async Task<long> KnownServers(CancellationToken token) =>
|
||||
await DB.Default.CountAsync<Models.Jobs.RemoteInstance>(i => i.Seen == "touched", token);
|
||||
|
||||
[HttpGet("/api/v2/instance"), AllowAnonymous]
|
||||
public IActionResult V2() => Json(new
|
||||
public async Task<IActionResult> V2(CancellationToken token) => Json(new
|
||||
{
|
||||
domain = Domain,
|
||||
title = "PrivaPub",
|
||||
version = Version,
|
||||
api_versions = new { mastodon = 7 },
|
||||
source_url = "https://git.thepra.dev/thepra/SocialPub",
|
||||
description = Description,
|
||||
usage = new { users = new { active_month = 0 } },
|
||||
usage = new { users = new { active_month = await Counted.ActiveUsers(30, token) } },
|
||||
thumbnail = new { url = $"{_localActors.BaseAddress}/media/missing-header.png" },
|
||||
languages = new[] { "en" },
|
||||
languages = LanguageCodes(HttpContext.RequestServices),
|
||||
configuration = new
|
||||
{
|
||||
urls = new { streaming = $"wss://{Domain}" },
|
||||
accounts = new { max_featured_tags = 0, max_pinned_statuses = 0 },
|
||||
urls = new { streaming = Streaming },
|
||||
accounts = new { max_featured_tags = 0, max_pinned_statuses = StatusesController.MaxPins },
|
||||
statuses = Statuses,
|
||||
media_attachments = MediaAttachments,
|
||||
polls = Polls,
|
||||
translation = new { enabled = false }
|
||||
},
|
||||
registrations = new { enabled = false, approval_required = false, message = default(string) },
|
||||
registrations = new { enabled = Open, approval_required = false, message = Open ? default : RegistrationOptions.ClosedMessage },
|
||||
contact = new { email = string.Empty, account = default(object) },
|
||||
rules = Array.Empty<object>()
|
||||
});
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Mastodon's lists (owner decision 2026-10-04, back from the cut list): a persona groups accounts it follows, reads them
|
||||
// as a timeline, and may keep an exclusive list's members out of its home. Nothing here leaves the server.
|
||||
public class ListsController : MastodonController
|
||||
{
|
||||
const int MaxLists = 50;
|
||||
const int MaxMembers = 500;
|
||||
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly MastodonMapper _mapper;
|
||||
|
||||
public ListsController(DbEntities dbEntities, MastodonMapper mapper)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
static object View(PersonaList list) => new { id = list.ID, title = list.Title, replies_policy = list.RepliesPolicy, exclusive = list.Exclusive };
|
||||
|
||||
Task<PersonaList> Mine(string id, CancellationToken token) =>
|
||||
DB.Default.Find<PersonaList>().Match(l => l.ID == id && l.AvatarId == MyId).ExecuteFirstAsync(token);
|
||||
|
||||
// a list holds only accounts the persona still follows: whatever ended the follow (an unfollow, a Reject, a Block, the
|
||||
// account's deletion), its membership goes with it, pruned here on the next read
|
||||
async Task<List<string>> Members(PersonaList list, CancellationToken token)
|
||||
{
|
||||
var members = (await DB.Default.Find<PersonaListMember>().Match(m => m.ListId == list.ID).ExecuteAsync(token)).Select(m => m.AccountId).ToList();
|
||||
if (members.Count == 0)
|
||||
return members;
|
||||
var followed = (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.State == FollowState.Accepted && members.Contains(f.TargetAccountId))
|
||||
.ExecuteAsync(token)).Select(f => f.TargetAccountId).ToHashSet();
|
||||
var gone = members.Where(m => !followed.Contains(m)).ToList();
|
||||
if (gone.Count > 0)
|
||||
await DB.Default.DeleteAsync<PersonaListMember>(m => m.ListId == list.ID && gone.Contains(m.AccountId));
|
||||
return members.Where(followed.Contains).ToList();
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/lists"), Scope("read:lists")]
|
||||
public async Task<IActionResult> All(CancellationToken token) =>
|
||||
Json((await DB.Default.Find<PersonaList>().Match(l => l.AvatarId == MyId).Sort(l => l.Title, MongoDB.Entities.Order.Ascending)
|
||||
.ExecuteAsync(token)).Select(View).ToList());
|
||||
|
||||
[HttpGet("/api/v1/lists/{id}"), Scope("read:lists")]
|
||||
public async Task<IActionResult> One(string id, CancellationToken token) =>
|
||||
await Mine(id, token) is { } list ? Json(View(list)) : NotFoundError();
|
||||
|
||||
[HttpPost("/api/v1/lists"), Scope("write:lists")]
|
||||
public async Task<IActionResult> Create(CancellationToken token)
|
||||
{
|
||||
var title = Params.Get("title")?.Trim();
|
||||
var policy = Params.Get("replies_policy") ?? ListRepliesPolicy.List;
|
||||
if (string.IsNullOrEmpty(title) || title.Length > 200)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Title can't be blank");
|
||||
if (!ListRepliesPolicy.IsValid(policy))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Replies policy is not included in the list");
|
||||
if (await DB.Default.CountAsync<PersonaList>(l => l.AvatarId == MyId, token) >= MaxLists)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Too many lists");
|
||||
var list = new PersonaList { AvatarId = MyId, Title = title, RepliesPolicy = policy, Exclusive = Params.Bool("exclusive") == true };
|
||||
await DB.Default.SaveAsync(list, token);
|
||||
return Json(View(list));
|
||||
}
|
||||
|
||||
[HttpPut("/api/v1/lists/{id}"), Scope("write:lists")]
|
||||
public async Task<IActionResult> Update(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } list)
|
||||
return NotFoundError();
|
||||
var title = Params.Get("title")?.Trim();
|
||||
var policy = Params.Get("replies_policy");
|
||||
if (title != default)
|
||||
{
|
||||
if (title.Length == 0 || title.Length > 200)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Title can't be blank");
|
||||
list.Title = title;
|
||||
}
|
||||
if (policy != default)
|
||||
{
|
||||
if (!ListRepliesPolicy.IsValid(policy))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Replies policy is not included in the list");
|
||||
list.RepliesPolicy = policy;
|
||||
}
|
||||
if (Params.Bool("exclusive") is { } exclusive)
|
||||
list.Exclusive = exclusive;
|
||||
await DB.Default.SaveAsync(list, token);
|
||||
return Json(View(list));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/v1/lists/{id}"), Scope("write:lists")]
|
||||
public async Task<IActionResult> Delete(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } list)
|
||||
return NotFoundError();
|
||||
await DB.Default.DeleteAsync<PersonaListMember>(m => m.ListId == list.ID);
|
||||
await DB.Default.DeleteAsync<PersonaList>(list.ID);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/lists/{id}/accounts"), Scope("read:lists")]
|
||||
public async Task<IActionResult> Accounts(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } list)
|
||||
return NotFoundError();
|
||||
await Members(list, token);
|
||||
var members = await Page.From(Params, Limit(40, 80)).Fetch(DB.Default.Find<PersonaListMember>().Match(m => m.ListId == list.ID), m => m.ID, token);
|
||||
var accounts = await _mapper.Accounts(members.Select(m => m.AccountId), token);
|
||||
Link($"/api/v1/lists/{id}/accounts", members.LastOrDefault()?.ID, members.FirstOrDefault()?.ID);
|
||||
return Json(members.Where(m => accounts.ContainsKey(m.AccountId)).Select(m => accounts[m.AccountId]).ToList());
|
||||
}
|
||||
|
||||
// only accounts the persona follows may join its list, as on Mastodon
|
||||
[HttpPost("/api/v1/lists/{id}/accounts"), Scope("write:lists")]
|
||||
public async Task<IActionResult> Add(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } list)
|
||||
return NotFoundError();
|
||||
var ids = Params.List("account_ids").Distinct().ToList();
|
||||
if (ids.Count == 0)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Account ids can't be blank");
|
||||
var followed = (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.State == FollowState.Accepted && ids.Contains(f.TargetAccountId))
|
||||
.ExecuteAsync(token)).Select(f => f.TargetAccountId).ToHashSet();
|
||||
if (ids.Any(a => !followed.Contains(a)))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: You must follow this account to add it to a list");
|
||||
var present = (await DB.Default.Find<PersonaListMember>().Match(m => m.ListId == list.ID && ids.Contains(m.AccountId)).ExecuteAsync(token))
|
||||
.Select(m => m.AccountId).ToHashSet();
|
||||
if (await DB.Default.CountAsync<PersonaListMember>(m => m.ListId == list.ID, token) + ids.Count(a => !present.Contains(a)) > MaxMembers)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Too many accounts in the list");
|
||||
foreach (var account in ids.Where(a => !present.Contains(a)))
|
||||
await DB.Default.SaveAsync(new PersonaListMember { ListId = list.ID, AvatarId = MyId, AccountId = account }, token);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpDelete("/api/v1/lists/{id}/accounts"), Scope("write:lists")]
|
||||
public async Task<IActionResult> Remove(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } list)
|
||||
return NotFoundError();
|
||||
var ids = Params.List("account_ids");
|
||||
await DB.Default.DeleteAsync<PersonaListMember>(m => m.ListId == list.ID && ids.Contains(m.AccountId));
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/accounts/{id}/lists"), Scope("read:lists")]
|
||||
public async Task<IActionResult> Containing(string id, CancellationToken token)
|
||||
{
|
||||
var listIds = (await DB.Default.Find<PersonaListMember>().Match(m => m.AvatarId == MyId && m.AccountId == id).ExecuteAsync(token))
|
||||
.Select(m => m.ListId).ToList();
|
||||
return Json((await DB.Default.Find<PersonaList>().Match(l => l.AvatarId == MyId && listIds.Contains(l.ID)).ExecuteAsync(token))
|
||||
.Select(View).ToList());
|
||||
}
|
||||
|
||||
// the list's timeline: the persona's home entries by its members. Replies are kept as Mastodon keeps them: a member's
|
||||
// replies to itself and to the persona always, to others as the policy says (anyone followed, the list's members, no one)
|
||||
[HttpGet("/api/v1/timelines/list/{id}"), Scope("read:lists")]
|
||||
public async Task<IActionResult> Timeline(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } list)
|
||||
return NotFoundError();
|
||||
var members = await Members(list, token);
|
||||
var entries = await Page.From(Params, Limit()).Fetch(
|
||||
_dbEntities.TimelineEntries.Match(e => e.AvatarId == MyId && members.Contains(e.AuthorAccountId)), e => e.PostId, token);
|
||||
var ids = entries.Select(e => e.PostId).ToList();
|
||||
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token)).ToDictionary(p => p.ID);
|
||||
var answered = list.RepliesPolicy switch
|
||||
{
|
||||
ListRepliesPolicy.Followed => (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.State == FollowState.Accepted).ExecuteAsync(token))
|
||||
.Select(f => f.TargetAccountId).Append(MyId).ToHashSet(),
|
||||
ListRepliesPolicy.List => members.Append(MyId).ToHashSet(),
|
||||
_ => new HashSet<string> { MyId }
|
||||
};
|
||||
var shown = ids.Where(posts.ContainsKey).Select(i => posts[i])
|
||||
.Where(p => p.ReblogOfPostId != default || string.IsNullOrEmpty(p.AnsweringToPostId) && string.IsNullOrEmpty(p.InReplyToURI)
|
||||
|| p.InReplyToAccountId != default && (p.InReplyToAccountId == p.AuthorAccountId || answered.Contains(p.InReplyToAccountId)))
|
||||
.ToList();
|
||||
Link($"/api/v1/timelines/list/{id}", entries.LastOrDefault()?.PostId, entries.FirstOrDefault()?.PostId);
|
||||
return Json(await _mapper.Statuses(shown, MyId, token));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using PrivaPub.Infrastructure.Statistics;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -6,6 +8,8 @@ using MongoDB.Entities;
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Domain.Media;
|
||||
using PrivaPub.Models.Media;
|
||||
using PrivaPub.Infrastructure;
|
||||
using PrivaPub.Infrastructure.Jobs;
|
||||
|
||||
using System.Globalization;
|
||||
|
||||
@@ -17,64 +21,132 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
|
||||
readonly IMediaService _media;
|
||||
readonly IMediaProxy _proxy;
|
||||
readonly IJobQueue _jobs;
|
||||
|
||||
public MediaController(IMediaService media, IMediaProxy proxy)
|
||||
readonly IInteractionLedger _ledger;
|
||||
|
||||
public MediaController(IMediaService media, IMediaProxy proxy, IJobQueue jobs, IInteractionLedger ledger = default)
|
||||
{
|
||||
_media = media;
|
||||
_proxy = proxy;
|
||||
_jobs = jobs;
|
||||
_ledger = ledger;
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/media"), HttpPost("/api/v2/media"), Scope("write:media"), RequestSizeLimit(UploadLimit),
|
||||
[HttpPost("/api/v1/media"), HttpPost("/api/v2/media"), Scope("write:media"), EnableRateLimiting(RateLimiting.Uploads), RequestSizeLimit(UploadLimit),
|
||||
RequestFormLimits(MultipartBodyLengthLimit = UploadLimit)]
|
||||
public async Task<IActionResult> Upload(CancellationToken token)
|
||||
{
|
||||
if (!Request.HasFormContentType)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: File can't be blank");
|
||||
var form = await Request.ReadFormAsync(token);
|
||||
var outcome = await _media.Upload(Me, form.Files["file"], form["description"], form["focus"], token);
|
||||
return outcome.Ok ? Json(View(outcome.Attachment)) : Error(outcome.Status, outcome.Error);
|
||||
// v2 may answer before audio or video is processed (202, its url null until then), as Mastodon does; v1 waits
|
||||
var later = Request.Path.StartsWithSegments("/api/v2/media");
|
||||
var outcome = await _media.Upload(Me, form.Files["file"], form["description"], form["focus"], later, token);
|
||||
if (!outcome.Ok)
|
||||
return Error(outcome.Status, outcome.Error);
|
||||
if (outcome.Attachment.ProcessingState != "pending")
|
||||
return Json(View(outcome.Attachment));
|
||||
await _jobs.EnqueueMany(new[] { ProcessMediaJob.JobFor(outcome.Attachment, new Uri(Me.BaseAddress).Host) }, token);
|
||||
return new JsonResult(View(outcome.Attachment)) { StatusCode = StatusCodes.Status202Accepted };
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/media/{id}"), Scope("write:media")]
|
||||
public async Task<IActionResult> Get(string id, CancellationToken token)
|
||||
{
|
||||
var attachment = await DB.Default.Find<MediaAttachment>().Match(m => m.ID == id && m.OwnerAvatarId == MyId).ExecuteFirstAsync(token);
|
||||
return attachment == default ? NotFoundError() : Json(View(attachment));
|
||||
var attachment = await DB.Default.Find<MediaAttachment>().Match(m => m.ID == id && m.OwnerAvatarId == MyId && m.TrashedAt == null && m.ProfileOfAvatarId == null).ExecuteFirstAsync(token);
|
||||
return attachment?.ProcessingState switch
|
||||
{
|
||||
null when attachment == default => NotFoundError(),
|
||||
"pending" => new JsonResult(View(attachment)) { StatusCode = StatusCodes.Status206PartialContent },
|
||||
"failed" => Error(StatusCodes.Status422UnprocessableEntity, attachment.ProcessingError ?? "Validation failed: The file could not be processed"),
|
||||
_ => Json(View(attachment))
|
||||
};
|
||||
}
|
||||
|
||||
// as on Mastodon, only media not posted yet: a posted one's description and focus change by editing its post (media_attributes)
|
||||
[HttpPut("/api/v1/media/{id}"), Scope("write:media")]
|
||||
public async Task<IActionResult> Update(string id, CancellationToken token)
|
||||
{
|
||||
var attachment = await DB.Default.Find<MediaAttachment>().Match(m => m.ID == id && m.OwnerAvatarId == MyId).ExecuteFirstAsync(token);
|
||||
var attachment = await DB.Default.Find<MediaAttachment>().Match(m => m.ID == id && m.OwnerAvatarId == MyId && m.TrashedAt == null && m.ProfileOfAvatarId == null && m.PostId == null).ExecuteFirstAsync(token);
|
||||
if (attachment == default)
|
||||
return NotFoundError();
|
||||
if (Params.Has("description"))
|
||||
attachment.Description = Params.Get("description")?.Trim() is { Length: > 0 } description ? description[..Math.Min(description.Length, 1500)] : default;
|
||||
if (Params.Get("focus")?.Split(',') is [var x, var y]
|
||||
&& float.TryParse(x, NumberStyles.Float, CultureInfo.InvariantCulture, out var fx) && float.TryParse(y, NumberStyles.Float, CultureInfo.InvariantCulture, out var fy))
|
||||
attachment.Focus = new[] { Math.Clamp(fx, -1, 1), Math.Clamp(fy, -1, 1) };
|
||||
if (FocalPoint.Parse(Params.Get("focus")) is { } focus)
|
||||
attachment.Focus = focus;
|
||||
await DB.Default.SaveAsync(attachment, token);
|
||||
return Json(View(attachment));
|
||||
}
|
||||
|
||||
[HttpGet("/media/proxy/{signature}/{encoded}"), AllowAnonymous, ApiExplorerSettings(IgnoreApi = true)]
|
||||
[HttpGet("/media/proxy/{signature}/{encoded}"), AllowAnonymous, EnableRateLimiting(RateLimiting.Proxy), ApiExplorerSettings(IgnoreApi = true)]
|
||||
public async Task<IActionResult> Proxy(string signature, string encoded, CancellationToken token)
|
||||
{
|
||||
var (path, contentType) = await _proxy.Fetch(signature, encoded, token);
|
||||
if (path == default)
|
||||
var url = _proxy.Verified(signature, encoded);
|
||||
if (url == default || _proxy.Refuses(url))
|
||||
return NotFound();
|
||||
Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||
Response.Headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
|
||||
Response.Headers["Cache-Control"] = "public, max-age=604800";
|
||||
return PhysicalFile(path, contentType);
|
||||
if (_proxy.Cached(url) is { Path: not null } cached && Serve(cached.Path, cached.ContentType) is { } hit)
|
||||
{
|
||||
_ledger?.Count(Interactions.HostOf(url), "media:hit");
|
||||
return hit;
|
||||
}
|
||||
if (Request.Headers.Range.Count == 0)
|
||||
{
|
||||
var (outcome, path, contentType) = await _proxy.Download(url, token);
|
||||
if (outcome == ProxyOutcome.Cached && Serve(path, contentType) is { } fetched)
|
||||
return fetched;
|
||||
if (outcome == ProxyOutcome.Failed)
|
||||
return NotFound();
|
||||
}
|
||||
return await Stream(url, token);
|
||||
}
|
||||
|
||||
object View(MediaAttachment attachment) => new
|
||||
// a cached file, opened before it is answered: trimmed meanwhile, what is open still reads; cached by browsers only
|
||||
// once there is something to cache
|
||||
IActionResult Serve(string path, string contentType)
|
||||
{
|
||||
FileStream file;
|
||||
try
|
||||
{
|
||||
file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 64 * 1024, useAsync: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
Response.Headers["Cache-Control"] = "public, max-age=604800";
|
||||
return File(file, contentType, enableRangeProcessing: true);
|
||||
}
|
||||
|
||||
async Task<IActionResult> Stream(string url, CancellationToken token)
|
||||
{
|
||||
var range = System.Net.Http.Headers.RangeHeaderValue.TryParse(Request.Headers.Range.ToString(), out var asked) ? asked : default;
|
||||
using var upstream = await _proxy.Open(url, range, token);
|
||||
if (upstream == default)
|
||||
return NotFound();
|
||||
if (upstream.IsSuccessStatusCode)
|
||||
Response.Headers["Cache-Control"] = "public, max-age=604800";
|
||||
Response.StatusCode = (int)upstream.StatusCode;
|
||||
Response.ContentType = upstream.Content.Headers.ContentType?.ToString() ?? "application/octet-stream";
|
||||
if (upstream.Content.Headers.ContentLength is { } length)
|
||||
Response.ContentLength = length;
|
||||
if (upstream.Content.Headers.ContentRange is { } contentRange)
|
||||
Response.Headers.ContentRange = contentRange.ToString();
|
||||
Response.Headers.AcceptRanges = "bytes";
|
||||
await upstream.Content.CopyToAsync(Response.Body, token);
|
||||
return new EmptyResult();
|
||||
}
|
||||
|
||||
object View(MediaAttachment attachment) => View(_media, attachment);
|
||||
|
||||
internal static object View(IMediaService media, MediaAttachment attachment) => new
|
||||
{
|
||||
id = attachment.ID,
|
||||
type = attachment.Kind,
|
||||
url = _media.Url(attachment.FilePath),
|
||||
preview_url = _media.Url(attachment.PreviewPath ?? attachment.FilePath),
|
||||
url = media.Url(attachment.FilePath),
|
||||
preview_url = media.Url(attachment.PreviewPath ?? attachment.FilePath),
|
||||
remote_url = default(string),
|
||||
text_url = default(string),
|
||||
meta = new
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Mastodon's notification policy and requests (4.3): what the persona's notifications from strangers, new accounts,
|
||||
// unasked private mentions and silenced accounts become, and the requests that gather the filtered ones, one per
|
||||
// account, let in or dismissed
|
||||
public class NotificationRequestsController : MastodonController
|
||||
{
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly DbEntities _dbEntities;
|
||||
|
||||
public NotificationRequestsController(MastodonMapper mapper, DbEntities dbEntities)
|
||||
{
|
||||
_mapper = mapper;
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
static string Name(NotificationAction action) => action switch
|
||||
{
|
||||
NotificationAction.Filter => "filter",
|
||||
NotificationAction.Drop => "drop",
|
||||
_ => "accept"
|
||||
};
|
||||
|
||||
static NotificationAction? Parse(string value) => value?.ToLowerInvariant() switch
|
||||
{
|
||||
"accept" => NotificationAction.Accept,
|
||||
"filter" => NotificationAction.Filter,
|
||||
"drop" => NotificationAction.Drop,
|
||||
_ => null
|
||||
};
|
||||
|
||||
async Task<object> Summary(CancellationToken token) => new
|
||||
{
|
||||
pending_requests_count = await DB.Default.CountAsync<NotificationRequest>(r => r.AvatarId == MyId, token),
|
||||
pending_notifications_count = await DB.Default.CountAsync<Notification>(n => n.AvatarId == MyId && n.Filtered, token)
|
||||
};
|
||||
|
||||
async Task<object> V2(NotificationPolicy policy, CancellationToken token) => new
|
||||
{
|
||||
for_not_following = Name(policy.ForNotFollowing),
|
||||
for_not_followers = Name(policy.ForNotFollowers),
|
||||
for_new_accounts = Name(policy.ForNewAccounts),
|
||||
for_private_mentions = Name(policy.ForPrivateMentions),
|
||||
for_limited_accounts = Name(policy.ForLimitedAccounts),
|
||||
summary = await Summary(token)
|
||||
};
|
||||
|
||||
// the first version spoke of filtering only: dropped is filtered for it
|
||||
async Task<object> V1(NotificationPolicy policy, CancellationToken token) => new
|
||||
{
|
||||
filter_not_following = policy.ForNotFollowing != NotificationAction.Accept,
|
||||
filter_not_followers = policy.ForNotFollowers != NotificationAction.Accept,
|
||||
filter_new_accounts = policy.ForNewAccounts != NotificationAction.Accept,
|
||||
filter_private_mentions = policy.ForPrivateMentions != NotificationAction.Accept,
|
||||
summary = await Summary(token)
|
||||
};
|
||||
|
||||
[HttpGet("/api/v2/notifications/policy"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> Policy(CancellationToken token) => Json(await V2(await NotificationPolicies.Of(MyId, token), token));
|
||||
|
||||
[HttpPatch("/api/v2/notifications/policy"), Scope("write:notifications")]
|
||||
public async Task<IActionResult> SetPolicy(CancellationToken token)
|
||||
{
|
||||
var policy = await NotificationPolicies.Of(MyId, token);
|
||||
policy.ForNotFollowing = Parse(Params.Get("for_not_following")) ?? policy.ForNotFollowing;
|
||||
policy.ForNotFollowers = Parse(Params.Get("for_not_followers")) ?? policy.ForNotFollowers;
|
||||
policy.ForNewAccounts = Parse(Params.Get("for_new_accounts")) ?? policy.ForNewAccounts;
|
||||
policy.ForPrivateMentions = Parse(Params.Get("for_private_mentions")) ?? policy.ForPrivateMentions;
|
||||
policy.ForLimitedAccounts = Parse(Params.Get("for_limited_accounts")) ?? policy.ForLimitedAccounts;
|
||||
await NotificationPolicies.Save(policy, token);
|
||||
return Json(await V2(policy, token));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/notifications/policy"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> PolicyV1(CancellationToken token) => Json(await V1(await NotificationPolicies.Of(MyId, token), token));
|
||||
|
||||
[HttpPut("/api/v1/notifications/policy"), HttpPatch("/api/v1/notifications/policy"), Scope("write:notifications")]
|
||||
public async Task<IActionResult> SetPolicyV1(CancellationToken token)
|
||||
{
|
||||
var policy = await NotificationPolicies.Of(MyId, token);
|
||||
static NotificationAction Filter(bool? filter, NotificationAction current) =>
|
||||
filter switch { true => current == NotificationAction.Drop ? NotificationAction.Drop : NotificationAction.Filter, false => NotificationAction.Accept, _ => current };
|
||||
policy.ForNotFollowing = Filter(Params.Bool("filter_not_following"), policy.ForNotFollowing);
|
||||
policy.ForNotFollowers = Filter(Params.Bool("filter_not_followers"), policy.ForNotFollowers);
|
||||
policy.ForNewAccounts = Filter(Params.Bool("filter_new_accounts"), policy.ForNewAccounts);
|
||||
policy.ForPrivateMentions = Filter(Params.Bool("filter_private_mentions"), policy.ForPrivateMentions);
|
||||
await NotificationPolicies.Save(policy, token);
|
||||
return Json(await V1(policy, token));
|
||||
}
|
||||
|
||||
async Task<object> Map(NotificationRequest request, CancellationToken token)
|
||||
{
|
||||
var account = await _mapper.Account(request.FromAccountId, token);
|
||||
if (account == default)
|
||||
return default;
|
||||
var post = string.IsNullOrEmpty(request.LastPostId) ? default
|
||||
: await _dbEntities.Posts.Match(p => p.ID == request.LastPostId && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
return new
|
||||
{
|
||||
id = request.ID,
|
||||
created_at = MastodonJson.Time(request.CreatedAt),
|
||||
updated_at = MastodonJson.Time(request.UpdatedAt),
|
||||
notifications_count = request.NotificationsCount.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
account,
|
||||
last_status = post == default ? default : await _mapper.Status(post, MyId, token)
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/notifications/requests"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> Requests(CancellationToken token)
|
||||
{
|
||||
var requests = await Page.From(Params, Limit(40, 80)).Fetch(DB.Default.Find<NotificationRequest>().Match(r => r.AvatarId == MyId), r => r.ID, token);
|
||||
Link("/api/v1/notifications/requests", requests.LastOrDefault()?.ID, requests.FirstOrDefault()?.ID);
|
||||
var mapped = new List<object>();
|
||||
foreach (var request in requests)
|
||||
if (await Map(request, token) is { } one)
|
||||
mapped.Add(one);
|
||||
return Json(mapped);
|
||||
}
|
||||
|
||||
Task<NotificationRequest> Mine(string id, CancellationToken token) =>
|
||||
DB.Default.Find<NotificationRequest>().Match(r => r.ID == id && r.AvatarId == MyId).ExecuteFirstAsync(token);
|
||||
|
||||
[HttpGet("/api/v1/notifications/requests/merged"), Scope("read:notifications")]
|
||||
public IActionResult Merged() => Json(new { merged = true });
|
||||
|
||||
[HttpGet("/api/v1/notifications/requests/{id}"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> Request(string id, CancellationToken token) =>
|
||||
await Mine(id, token) is { } request && await Map(request, token) is { } mapped ? Json(mapped) : NotFoundError();
|
||||
|
||||
[HttpPost("/api/v1/notifications/requests/{id}/accept"), Scope("write:notifications")]
|
||||
public async Task<IActionResult> Accept(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } request)
|
||||
return NotFoundError();
|
||||
await NotificationPolicies.Accept(request, token);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/notifications/requests/{id}/dismiss"), Scope("write:notifications")]
|
||||
public async Task<IActionResult> Dismiss(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } request)
|
||||
return NotFoundError();
|
||||
await NotificationPolicies.Dismiss(request, token);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/notifications/requests/accept"), Scope("write:notifications")]
|
||||
public async Task<IActionResult> AcceptMany(CancellationToken token)
|
||||
{
|
||||
foreach (var id in Params.List("id").Take(100))
|
||||
if (await Mine(id, token) is { } request)
|
||||
await NotificationPolicies.Accept(request, token);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/notifications/requests/dismiss"), Scope("write:notifications")]
|
||||
public async Task<IActionResult> DismissMany(CancellationToken token)
|
||||
{
|
||||
foreach (var id in Params.List("id").Take(100))
|
||||
if (await Mine(id, token) is { } request)
|
||||
await NotificationPolicies.Dismiss(request, token);
|
||||
return Json(new { });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
public class PollsController : MastodonController
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly IPollService _polls;
|
||||
|
||||
public PollsController(DbEntities dbEntities, MastodonMapper mapper, IPollService polls)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_mapper = mapper;
|
||||
_polls = polls;
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/polls/{id}"), Scope("read:statuses", requiresUser: false), AllowAnonymous]
|
||||
public async Task<IActionResult> Get(string id, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(id, token);
|
||||
return post?.Poll == default ? NotFoundError() : Json(_mapper.Poll(post, MyId, await _polls.OwnVotes(post.ID, MyId, token)));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/polls/{id}/votes"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> Vote(string id, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(id, token);
|
||||
if (post?.Poll == default)
|
||||
return NotFoundError();
|
||||
var choices = Params.List("choices").Select(c => int.TryParse(c, out var index) ? index : -1).ToList();
|
||||
var outcome = await _polls.Vote(Me, post, choices, token);
|
||||
if (!outcome.Ok)
|
||||
return Error(outcome.Status, outcome.Error);
|
||||
return Json(_mapper.Poll(outcome.Post, MyId, await _polls.OwnVotes(post.ID, MyId, token)));
|
||||
}
|
||||
|
||||
async Task<PostEntity> Visible(string id, CancellationToken token)
|
||||
{
|
||||
var post = await _dbEntities.Posts.Match(p => p.ID == id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
return post != default && post.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(post, MyId, token) ? post : default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Domain.Statistics;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Infrastructure;
|
||||
using PrivaPub.Infrastructure.Geo;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
public class ProvenanceController : MastodonController
|
||||
{
|
||||
const int MaxHosts = 40;
|
||||
|
||||
readonly DbEntities _dbEntities;
|
||||
const int MaxWeeks = 260;
|
||||
|
||||
readonly ISelfLocation _self;
|
||||
readonly IServerPlaces _places;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IOptionsMonitor<RegistrationOptions> _registrations;
|
||||
|
||||
public ProvenanceController(DbEntities dbEntities, ISelfLocation self, IServerPlaces places, ILocalActorService localActors,
|
||||
IOptionsMonitor<RegistrationOptions> registrations)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_self = self;
|
||||
_places = places;
|
||||
_localActors = localActors;
|
||||
_registrations = registrations;
|
||||
}
|
||||
|
||||
[HttpGet("/api/privapub/v1/statuses/{id}/provenance"), Scope("read:statuses", requiresUser: false), AllowAnonymous]
|
||||
public async Task<IActionResult> Status(string id, CancellationToken token)
|
||||
{
|
||||
var post = await _dbEntities.Posts.Match(p => p.ID == id).Match(VisibilityPolicy.IsShown).ExecuteFirstAsync(token);
|
||||
if (post?.ReblogOfPostId != default)
|
||||
post = await _dbEntities.Posts.Match(p => p.ID == post.ReblogOfPostId).Match(VisibilityPolicy.IsShown).ExecuteFirstAsync(token);
|
||||
if (post == default || post.Visibility == PostVisibility.LocalGeo || !await VisibilityPolicy.CanSee(post, MyId, token))
|
||||
return NotFoundError();
|
||||
if (!post.IsFederatedCopy)
|
||||
return Json(new Provenance { ObjectUri = post.ObjectURI, Url = post.Url, Path = "local" });
|
||||
|
||||
var record = await DB.Default.Find<ObjectRecord>().Match(r => r.PostId == post.ID).ExecuteFirstAsync(token)
|
||||
?? await DB.Default.Find<ObjectRecord>().Match(r => r.ObjectURI == post.ObjectURI).ExecuteFirstAsync(token);
|
||||
var host = record?.Host ?? (Uri.TryCreate(post.ObjectURI, UriKind.Absolute, out var uri) ? uri.Host.ToLowerInvariant() : default);
|
||||
var instance = host == default ? default : await DB.Default.Find<RemoteInstance>().Match(i => i.Host == host).ExecuteFirstAsync(token);
|
||||
var beforeCdn = instance?.Geo?.Cdn == default ? default : (await _places.BeforeCdn(new[] { instance.Host }, token)).GetValueOrDefault(instance.Host);
|
||||
return Json(Describe(post, record, instance, beforeCdn));
|
||||
}
|
||||
|
||||
[HttpGet("/api/privapub/v1/instances/{host}"), Scope("read", requiresUser: false), AllowAnonymous]
|
||||
public async Task<IActionResult> Instance(string host, CancellationToken token)
|
||||
{
|
||||
var described = await Describe(new[] { host }, token);
|
||||
return described.Count == 0 ? NotFoundError() : Json(described[0]);
|
||||
}
|
||||
|
||||
//up to MaxHosts at once (host[]=...), in the order asked; hosts this server does not know are left out
|
||||
[HttpGet("/api/privapub/v1/instances"), Scope("read", requiresUser: false), AllowAnonymous]
|
||||
public async Task<IActionResult> Instances(CancellationToken token) => Json(await Describe(Params.List("host"), token));
|
||||
|
||||
//the server's weekly snapshots, newest first (weeks=, at most MaxWeeks): its place as shown publicly, its CDN, its size
|
||||
[HttpGet("/api/privapub/v1/instances/{host}/history"), Scope("read", requiresUser: false), AllowAnonymous]
|
||||
public async Task<IActionResult> History(string host, CancellationToken token)
|
||||
{
|
||||
host = host?.Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(host) || host.Length > 253 || !await DB.Default.Find<RemoteInstance>().Match(i => i.Host == host).ExecuteAnyAsync(token))
|
||||
return NotFoundError();
|
||||
var weeks = Math.Clamp(Params.Int("weeks") ?? 52, 1, MaxWeeks);
|
||||
return Json((await _places.History(host, weeks, token)).Select(s => new InstanceWeek
|
||||
{
|
||||
Week = s.Week,
|
||||
TakenAt = MastodonJson.Time(s.TakenAt),
|
||||
Reachable = s.Reachable,
|
||||
Software = s.Software,
|
||||
Version = s.SoftwareVersion,
|
||||
UsersTotal = s.UsersTotal,
|
||||
UsersActiveMonth = s.UsersActiveMonth,
|
||||
LocalPosts = s.LocalPosts,
|
||||
OpenRegistrations = s.OpenRegistrations,
|
||||
Geo = Located(PublicGeo.Project(new InstanceGeo
|
||||
{
|
||||
Country = s.Country, City = s.City, Latitude = s.Latitude, Longitude = s.Longitude, Asn = s.Asn,
|
||||
Cdn = s.Cdn, CdnDomain = s.CdnDomain, CdnSource = s.CdnSource, Ipv6 = s.Ipv6
|
||||
}), new InstanceGeo { Cdn = s.Cdn, CdnDomain = s.CdnDomain ?? CdnCatalog.ByKey(s.Cdn)?.Key, CdnSource = s.CdnSource })
|
||||
}).ToList());
|
||||
}
|
||||
|
||||
async Task<List<InstanceDescription>> Describe(IEnumerable<string> hosts, CancellationToken token)
|
||||
{
|
||||
var wanted = hosts.Select(h => h?.Trim().ToLowerInvariant()).Where(h => !string.IsNullOrEmpty(h) && h.Length <= 253)
|
||||
.Distinct().Take(MaxHosts).ToList();
|
||||
var selfHost = Uri.TryCreate(_localActors.BaseAddress, UriKind.Absolute, out var self) ? self.Host.ToLowerInvariant() : default;
|
||||
var remote = await DB.Default.Find<RemoteInstance>().Match(i => wanted.Contains(i.Host)).ExecuteAsync(token);
|
||||
var beforeCdn = await _places.BeforeCdn(remote.Where(i => i.Geo?.Cdn != default).Select(i => i.Host), token);
|
||||
var described = new List<InstanceDescription>();
|
||||
foreach (var host in wanted)
|
||||
{
|
||||
if (host == selfHost)
|
||||
described.Add(await DescribeSelf(host, token));
|
||||
else if (remote.FirstOrDefault(i => i.Host == host) is { } instance)
|
||||
described.Add(Describe(instance, beforeCdn.GetValueOrDefault(host)));
|
||||
}
|
||||
return described;
|
||||
}
|
||||
|
||||
async Task<InstanceDescription> DescribeSelf(string host, CancellationToken token)
|
||||
{
|
||||
var geo = await _self.Get(token);
|
||||
return new InstanceDescription
|
||||
{
|
||||
Host = host,
|
||||
Software = "privapub",
|
||||
Version = BuildInfo.Ref,
|
||||
NodeName = "PrivaPub",
|
||||
Protocols = new() { "activitypub" },
|
||||
OpenRegistrations = _registrations.CurrentValue.IsOpen,
|
||||
DescribedAt = MastodonJson.Time(DateTime.UtcNow),
|
||||
Geo = Located(PublicGeo.Project(geo), geo)
|
||||
};
|
||||
}
|
||||
|
||||
//the public projection of a server's place (PublicGeo); for a CDN-fronted one, the CDN's domain, how it was found and
|
||||
//where the server was before it
|
||||
static InstanceLocation Located(PublicLocation location, InstanceGeo geo, PlaceBeforeCdn beforeCdn = default) => location == default ? default : new InstanceLocation
|
||||
{
|
||||
Precision = location.Cdn != default ? "cdn" : location.Latitude != default ? "city" : "country",
|
||||
Country = location.Country,
|
||||
City = location.City,
|
||||
Latitude = location.Latitude,
|
||||
Longitude = location.Longitude,
|
||||
Network = location.Network,
|
||||
Cdn = location.Cdn,
|
||||
CdnDomain = location.Cdn != default ? ServerPlaces.KeyOf(geo) : default,
|
||||
CdnSource = location.Cdn != default ? geo?.CdnSource : default,
|
||||
BeforeCdn = location.Cdn == default || beforeCdn == default ? default : new InstancePlace
|
||||
{
|
||||
Week = beforeCdn.Week, Country = beforeCdn.Country, City = beforeCdn.City, Latitude = beforeCdn.Latitude, Longitude = beforeCdn.Longitude
|
||||
},
|
||||
Source = geo?.Source,
|
||||
LocatedAt = geo?.LocatedAt is { } at ? MastodonJson.Time(at) : default
|
||||
};
|
||||
|
||||
Provenance Describe(PostEntity post, ObjectRecord record, RemoteInstance instance, PlaceBeforeCdn beforeCdn)
|
||||
{
|
||||
var raw = record?.Raw == default ? default : JsonNode.Parse(record.Raw) as JsonObject;
|
||||
return new Provenance
|
||||
{
|
||||
ObjectUri = post.ObjectURI,
|
||||
ObjectType = record?.ObjectType ?? post.ObjectType,
|
||||
Url = post.Url,
|
||||
Path = record == default ? "unrecorded" : record.Path == ObjectPath.Delivered ? "delivered" : "fetched",
|
||||
Refetched = record?.Refetched == true,
|
||||
Activity = record?.ActivityId == default ? default : new ProvenanceActivity
|
||||
{
|
||||
Id = record.ActivityId, Type = record.ActivityType, Actor = record.ActivityActorURI
|
||||
},
|
||||
Inbox = record?.Inbox,
|
||||
FetchedBy = record?.Path == ObjectPath.Fetched ? "instance-actor" : default,
|
||||
Signature = record?.KeyId == default || record.Path == ObjectPath.Fetched ? default : new ProvenanceSignature
|
||||
{
|
||||
Scheme = record.SignatureScheme, KeyId = record.KeyId, Algorithm = record.Algorithm, Headers = record.SignedHeaders
|
||||
},
|
||||
ReceivedAt = record == default ? default : MastodonJson.Time(record.ReceivedAt),
|
||||
Published = record?.Published is { } published ? MastodonJson.Time(published) : default,
|
||||
Updated = record?.Updated is { } updated ? MastodonJson.Time(updated) : default,
|
||||
Extensions = record?.Extensions ?? ObjectFeatures.Detect(raw),
|
||||
ContextNamespaces = record?.ContextNamespaces ?? ObjectFeatures.Namespaces(raw, record?.ActivityContext),
|
||||
Raw = raw,
|
||||
RawBytes = record?.RawBytes ?? 0,
|
||||
RawHash = record?.RawHash,
|
||||
RawTruncated = record?.RawTruncated == true,
|
||||
Revisions = record?.Revisions.Select(r => new ProvenanceRevision
|
||||
{
|
||||
ActivityId = r.ActivityId,
|
||||
Updated = r.Updated is { } at ? MastodonJson.Time(at) : default,
|
||||
ReceivedAt = MastodonJson.Time(r.ReceivedAt),
|
||||
RawHash = r.RawHash,
|
||||
Raw = r.Raw == default ? default : JsonNode.Parse(r.Raw)
|
||||
}).ToList() ?? new(),
|
||||
Instance = instance == default ? default : Describe(instance, beforeCdn)
|
||||
};
|
||||
}
|
||||
|
||||
static InstanceDescription Describe(RemoteInstance instance, PlaceBeforeCdn beforeCdn) => new()
|
||||
{
|
||||
Host = instance.Host,
|
||||
Software = instance.Software,
|
||||
Version = instance.SoftwareVersion,
|
||||
NodeName = instance.NodeName,
|
||||
Protocols = instance.Protocols ?? new(),
|
||||
OpenRegistrations = instance.OpenRegistrations,
|
||||
DescribedAt = instance.DescribedAt is { } described ? MastodonJson.Time(described) : default,
|
||||
DescriptionError = instance.DescriptionError,
|
||||
NodeInfo = instance.NodeInfo == default ? default : JsonNode.Parse(instance.NodeInfo),
|
||||
Delivery = new InstanceDelivery
|
||||
{
|
||||
ConsecutiveFailures = instance.ConsecutiveFailures,
|
||||
UnavailableUntil = instance.UnavailableUntil is { } until ? MastodonJson.Time(until) : default,
|
||||
LastSuccessAt = instance.LastSuccessAt is { } success ? MastodonJson.Time(success) : default,
|
||||
LastFailureAt = instance.LastFailureAt is { } failure ? MastodonJson.Time(failure) : default
|
||||
},
|
||||
Geo = Located(PublicGeo.Project(instance.Geo), instance.Geo, beforeCdn)
|
||||
};
|
||||
|
||||
public class Provenance
|
||||
{
|
||||
public string ObjectUri { get; set; }
|
||||
public string ObjectType { get; set; }
|
||||
public string Url { get; set; }
|
||||
public string Path { get; set; }
|
||||
public bool Refetched { get; set; }
|
||||
public ProvenanceActivity Activity { get; set; }
|
||||
public string Inbox { get; set; }
|
||||
public string FetchedBy { get; set; }
|
||||
public ProvenanceSignature Signature { get; set; }
|
||||
public string ReceivedAt { get; set; }
|
||||
public string Published { get; set; }
|
||||
public string Updated { get; set; }
|
||||
public List<string> Extensions { get; set; } = new();
|
||||
public List<string> ContextNamespaces { get; set; } = new();
|
||||
public JsonNode Raw { get; set; }
|
||||
public int RawBytes { get; set; }
|
||||
public string RawHash { get; set; }
|
||||
public bool RawTruncated { get; set; }
|
||||
public List<ProvenanceRevision> Revisions { get; set; } = new();
|
||||
public InstanceDescription Instance { get; set; }
|
||||
}
|
||||
|
||||
public class ProvenanceActivity
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Type { get; set; }
|
||||
public string Actor { get; set; }
|
||||
}
|
||||
|
||||
public class ProvenanceSignature
|
||||
{
|
||||
public string Scheme { get; set; }
|
||||
public string KeyId { get; set; }
|
||||
public string Algorithm { get; set; }
|
||||
public List<string> Headers { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ProvenanceRevision
|
||||
{
|
||||
public string ActivityId { get; set; }
|
||||
public string Updated { get; set; }
|
||||
public string ReceivedAt { get; set; }
|
||||
public string RawHash { get; set; }
|
||||
public JsonNode Raw { get; set; }
|
||||
}
|
||||
|
||||
public class InstanceDescription
|
||||
{
|
||||
public string Host { get; set; }
|
||||
public string Software { get; set; }
|
||||
public string Version { get; set; }
|
||||
public string NodeName { get; set; }
|
||||
public List<string> Protocols { get; set; } = new();
|
||||
public bool? OpenRegistrations { get; set; }
|
||||
public string DescribedAt { get; set; }
|
||||
public string DescriptionError { get; set; }
|
||||
public JsonNode NodeInfo { get; set; }
|
||||
public InstanceDelivery Delivery { get; set; }
|
||||
public InstanceLocation Geo { get; set; }
|
||||
}
|
||||
|
||||
//precision: "city" (coordinates to 0.1°, city and network), "country" (located only to a country) or "cdn" (the
|
||||
//CDN's name only)
|
||||
public class InstanceLocation
|
||||
{
|
||||
public string Precision { get; set; }
|
||||
public string Country { get; set; }
|
||||
public string City { get; set; }
|
||||
public double? Latitude { get; set; }
|
||||
public double? Longitude { get; set; }
|
||||
public string Network { get; set; }
|
||||
public string Cdn { get; set; }
|
||||
public string CdnDomain { get; set; }
|
||||
public string CdnSource { get; set; }//"ranges", "headers" or "asn"
|
||||
public InstancePlace BeforeCdn { get; set; }//where a CDN-fronted server was located before the CDN, by its snapshots
|
||||
public string Source { get; set; }
|
||||
public string LocatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class InstancePlace
|
||||
{
|
||||
public string Week { get; set; }
|
||||
public string Country { get; set; }
|
||||
public string City { get; set; }
|
||||
public double? Latitude { get; set; }
|
||||
public double? Longitude { get; set; }
|
||||
}
|
||||
|
||||
public class InstanceWeek
|
||||
{
|
||||
public string Week { get; set; }
|
||||
public string TakenAt { get; set; }
|
||||
public bool Reachable { get; set; }
|
||||
public string Software { get; set; }
|
||||
public string Version { get; set; }
|
||||
public long? UsersTotal { get; set; }
|
||||
public long? UsersActiveMonth { get; set; }
|
||||
public long? LocalPosts { get; set; }
|
||||
public bool? OpenRegistrations { get; set; }
|
||||
public InstanceLocation Geo { get; set; }
|
||||
}
|
||||
|
||||
public class InstanceDelivery
|
||||
{
|
||||
public int ConsecutiveFailures { get; set; }
|
||||
public string UnavailableUntil { get; set; }
|
||||
public string LastSuccessAt { get; set; }
|
||||
public string LastFailureAt { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
public class ReactionsController : MastodonController
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly IReactions _reactions;
|
||||
|
||||
public ReactionsController(DbEntities dbEntities, MastodonMapper mapper, IReactions reactions)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_mapper = mapper;
|
||||
_reactions = reactions;
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/pleroma/statuses/{id}/reactions"), Scope("read:statuses", requiresUser: false), AllowAnonymous]
|
||||
public async Task<IActionResult> List(string id, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(id, token);
|
||||
if (post == default)
|
||||
return NotFoundError();
|
||||
var reactions = await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID).ExecuteAsync(token);
|
||||
var accounts = await _mapper.Accounts(reactions.Select(r => r.AccountId), token);
|
||||
return Json(reactions.GroupBy(r => r.Emoji).Select(group => new
|
||||
{
|
||||
name = group.Key,
|
||||
count = group.Count(),
|
||||
me = MyId != default && group.Any(r => r.AccountId == MyId),
|
||||
url = _mapper.ProxiedUrl(group.First().EmojiURL),
|
||||
accounts = group.Select(r => accounts.GetValueOrDefault(r.AccountId)).Where(a => a != default).ToList()
|
||||
}).OrderByDescending(r => r.count).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("/api/v1/pleroma/statuses/{id}/reactions/{emoji}"), Scope("write:favourites")]
|
||||
public async Task<IActionResult> React(string id, string emoji, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(id, token);
|
||||
if (post == default)
|
||||
return NotFoundError();
|
||||
if (!await _reactions.React(Me, post, Uri.UnescapeDataString(emoji), token))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: That emoji cannot be used here");
|
||||
return Json(await _mapper.Status(post, MyId, token));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/v1/pleroma/statuses/{id}/reactions/{emoji}"), Scope("write:favourites")]
|
||||
public async Task<IActionResult> Unreact(string id, string emoji, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(id, token);
|
||||
if (post == default)
|
||||
return NotFoundError();
|
||||
await _reactions.Unreact(Me, post, Uri.UnescapeDataString(emoji), token);
|
||||
return Json(await _mapper.Status(post, MyId, token));
|
||||
}
|
||||
|
||||
async Task<PostEntity> Visible(string id, CancellationToken token)
|
||||
{
|
||||
var post = await _dbEntities.Posts.Match(p => p.ID == id && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
|
||||
return post != default && post.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(post, MyId, token) ? post : default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Domain.Media;
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Infrastructure.Jobs;
|
||||
using PrivaPub.Models.Media;
|
||||
using PrivaPub.Models.Social;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Mastodon's scheduled statuses (owner decision 2026-10-04, back from the cut list): the persona's posts waiting for
|
||||
// their time, moved to another time or dropped. POST /api/v1/statuses with scheduled_at makes them.
|
||||
public class ScheduledStatusesController : MastodonController
|
||||
{
|
||||
readonly IMediaService _media;
|
||||
readonly IJobQueue _jobs;
|
||||
|
||||
public ScheduledStatusesController(IMediaService media, IJobQueue jobs)
|
||||
{
|
||||
_media = media;
|
||||
_jobs = jobs;
|
||||
}
|
||||
|
||||
public static async Task<object> View(ScheduledStatus scheduled, IMediaService media, CancellationToken token)
|
||||
{
|
||||
var p = scheduled.Params;
|
||||
var ids = p.MediaIds;
|
||||
var attachments = ids.Count == 0
|
||||
? new List<MediaAttachment>()
|
||||
: await DB.Default.Find<MediaAttachment>().Match(m => ids.Contains(m.ID)).ExecuteAsync(token);
|
||||
return new
|
||||
{
|
||||
id = scheduled.ID,
|
||||
scheduled_at = MastodonJson.Time(scheduled.ScheduledAt),
|
||||
@params = new
|
||||
{
|
||||
text = p.Text,
|
||||
poll = p.Poll == default ? default : new { options = p.Poll.Options, expires_in = p.Poll.ExpiresIn, multiple = p.Poll.Multiple, hide_totals = p.Poll.HideTotals },
|
||||
media_ids = ids.Count == 0 ? default : ids,
|
||||
sensitive = p.Sensitive,
|
||||
spoiler_text = p.SpoilerText,
|
||||
visibility = p.Visibility,
|
||||
in_reply_to_id = p.InReplyToId,
|
||||
language = p.Language,
|
||||
application_id = default(string),
|
||||
scheduled_at = default(string),
|
||||
idempotency = p.Idempotency,
|
||||
with_rate_limit = false,
|
||||
quoted_status_id = p.QuotedStatusId
|
||||
},
|
||||
media_attachments = ids.Select(id => attachments.FirstOrDefault(m => m.ID == id)).Where(m => m != default)
|
||||
.Select(m => MediaController.View(media, m)).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
Task<ScheduledStatus> Mine(string id, CancellationToken token) =>
|
||||
DB.Default.Find<ScheduledStatus>().Match(s => s.ID == id && s.AvatarId == MyId).ExecuteFirstAsync(token);
|
||||
|
||||
[HttpGet("/api/v1/scheduled_statuses"), Scope("read:statuses")]
|
||||
public async Task<IActionResult> All(CancellationToken token)
|
||||
{
|
||||
var page = await Page.From(Params, Limit(20, 40)).Fetch(DB.Default.Find<ScheduledStatus>().Match(s => s.AvatarId == MyId), s => s.ID, token);
|
||||
Link("/api/v1/scheduled_statuses", page.LastOrDefault()?.ID, page.FirstOrDefault()?.ID);
|
||||
var views = new List<object>();
|
||||
foreach (var scheduled in page)
|
||||
views.Add(await View(scheduled, _media, token));
|
||||
return Json(views);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/scheduled_statuses/{id}"), Scope("read:statuses")]
|
||||
public async Task<IActionResult> One(string id, CancellationToken token) =>
|
||||
await Mine(id, token) is { } scheduled ? Json(await View(scheduled, _media, token)) : NotFoundError();
|
||||
|
||||
[HttpPut("/api/v1/scheduled_statuses/{id}"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> Move(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } scheduled)
|
||||
return NotFoundError();
|
||||
if (!DateTime.TryParse(Params.Get("scheduled_at"), System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var at))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Scheduled at is invalid");
|
||||
if (await ScheduledStatuses.Refusal(MyId, at, scheduled.ID, token) is { } refusal)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: " + refusal);
|
||||
scheduled.ScheduledAt = at;
|
||||
await DB.Default.Update<ScheduledStatus>().MatchID(scheduled.ID).Modify(s => s.ScheduledAt, at).ExecuteAsync(token);
|
||||
await _jobs.EnqueueMany(new[] { ScheduledStatuses.JobFor(scheduled, new Uri(Me.BaseAddress).Host) }, token);
|
||||
return Json(await View(scheduled, _media, token));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/v1/scheduled_statuses/{id}"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> Drop(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } scheduled)
|
||||
return NotFoundError();
|
||||
await DB.Default.DeleteAsync<ScheduledStatus>(scheduled.ID);
|
||||
// its media were the scheduled post's alone, never posted
|
||||
await _media.Trash(m => m.ScheduledStatusId == scheduled.ID, "scheduled post dropped", token);
|
||||
return Json(new { });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
public class SearchController : MastodonController
|
||||
public partial class SearchController : MastodonController
|
||||
{
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly DbEntities _dbEntities;
|
||||
@@ -35,11 +35,15 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
_search = search;
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/search"), Scope("read:search", requiresUser: false)]
|
||||
// Anyone may search, as on Mastodon; only resolving (which fetches from other servers) and paging need a sign-in.
|
||||
[HttpGet("/api/v2/search"), Scope("read:search", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Search(CancellationToken token)
|
||||
{
|
||||
var q = Params.Get("q")?.Trim();
|
||||
var type = Params.Get("type");
|
||||
var offset = Params.Int("offset") ?? 0;
|
||||
if (MyId == default && (Params.Bool("resolve") == true || offset > 0))
|
||||
return Error(StatusCodes.Status401Unauthorized, "Search queries that resolve uris or use offset require the read:search scope");
|
||||
var resolve = Params.Bool("resolve") == true && MyId != default;
|
||||
var results = new SearchResults();
|
||||
if (string.IsNullOrEmpty(q))
|
||||
@@ -51,7 +55,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
if (post == default && resolve && type is null or "statuses")
|
||||
post = await _remotePosts.StoreContext(q, 0, token);
|
||||
if (post != default && type is null or "statuses" && await VisibilityPolicy.CanSee(post, MyId, token))
|
||||
results.Statuses.Add(await _mapper.Status(post, MyId, token));
|
||||
if (await _mapper.Status(post, MyId, token) is { } status)
|
||||
results.Statuses.Add(status);
|
||||
if (results.Statuses.Count == 0 && type is null or "accounts")
|
||||
{
|
||||
var local = await _localActors.FindByUri(q, token);
|
||||
@@ -66,61 +71,93 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
|
||||
if (type is null or "accounts")
|
||||
{
|
||||
results.Accounts = await _search.Find(q, resolve, Limit(), token);
|
||||
results.Accounts = await _search.Find(q, resolve, Limit(), Math.Max(0, offset), token);
|
||||
}
|
||||
if (type is null or "hashtags" && q.TrimStart('#') is { Length: > 0 } tag)
|
||||
results.Hashtags.Add(new { name = tag.ToLowerInvariant(), url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag.ToLowerInvariant())}", history = Array.Empty<object>() });
|
||||
// an account's handle asks for the account, never for posts that happen to share its words
|
||||
if (type is null or "statuses" && MyId != default && !q.StartsWith('#') && !Handle().IsMatch(q))
|
||||
results.Statuses = await Words(q, Math.Max(0, offset), Limit(), token);
|
||||
if (type is null or "hashtags" && TagsController.Normalise(q) is var tag && TagsController.IsHashtag(tag))
|
||||
results.Hashtags.Add(new { name = tag, url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag)}", history = Array.Empty<object>() });
|
||||
return Json(results);
|
||||
}
|
||||
}
|
||||
|
||||
public partial class SearchController
|
||||
{
|
||||
const int WordCandidates = 400;
|
||||
|
||||
[System.Text.RegularExpressions.GeneratedRegex(@"^@?[^\s@]+@[^\s@]+$|^@[^\s@]+$")]
|
||||
private static partial System.Text.RegularExpressions.Regex Handle();
|
||||
|
||||
// Posts by their words, as Mastodon searches them: those the persona wrote, boosted, favourited, bookmarked or was
|
||||
// named in, and public posts of authors who let themselves be indexed; newest first, what the persona may see only
|
||||
async Task<List<Status>> Words(string q, int offset, int limit, CancellationToken token)
|
||||
{
|
||||
var candidates = await _dbEntities.Posts.Match(f => f.Text(q)).Match(p => !p.DeletedAt.HasValue && p.ReblogOfPostId == null && !p.AuthorGone)
|
||||
.Sort(p => p.ID, MongoDB.Entities.Order.Descending).Limit(WordCandidates).ExecuteAsync(token);
|
||||
if (candidates.Count == 0)
|
||||
return new List<Status>();
|
||||
var ids = candidates.Select(p => p.ID).ToList();
|
||||
var mine = new HashSet<string>();
|
||||
mine.UnionWith((await _dbEntities.Favourites.Match(f => f.AccountId == MyId && ids.Contains(f.PostId)).ExecuteAsync(token)).Select(f => f.PostId));
|
||||
mine.UnionWith((await MongoDB.Entities.DB.Default.Find<Models.Social.Bookmark>().Match(b => b.AvatarId == MyId && ids.Contains(b.PostId)).ExecuteAsync(token))
|
||||
.Select(b => b.PostId));
|
||||
mine.UnionWith((await _dbEntities.Posts.Match(p => p.AuthorAccountId == MyId && ids.Contains(p.ReblogOfPostId) && !p.DeletedAt.HasValue).ExecuteAsync(token))
|
||||
.Select(p => p.ReblogOfPostId));
|
||||
var authors = candidates.Select(p => p.AuthorAccountId ?? p.GroupUserId).Where(a => a != default).Distinct().ToList();
|
||||
var indexable = (await _dbEntities.Avatars.Match(a => authors.Contains(a.ID) && a.Settings.IsIndexable).ExecuteAsync(token)).Select(a => a.ID).ToHashSet();
|
||||
// a remote author's searchableBy (FEP-268d) outranks its indexable, and a post's own outranks both
|
||||
var remoteAuthors = (await _dbEntities.ForeignAvatars.Match(f => authors.Contains(f.ID)).ExecuteAsync(token)).ToDictionary(f => f.ID);
|
||||
var stating = remoteAuthors.Values.Where(f => f.SearchableBy != default).Select(f => f.ActorURI)
|
||||
.Concat(candidates.Where(p => p.SearchableBy != default).Select(p => p.ActorURI))
|
||||
.Where(uri => uri != default).Distinct().ToList();
|
||||
var followed = stating.Count == 0
|
||||
? new HashSet<string>()
|
||||
: (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && stating.Contains(f.TargetActorURI) && f.State == Models.Social.FollowState.Accepted)
|
||||
.ExecuteAsync(token)).Select(f => f.TargetActorURI).ToHashSet();
|
||||
bool Searchable(PostEntity post, string author)
|
||||
{
|
||||
var remote = remoteAuthors.GetValueOrDefault(author);
|
||||
var rule = post.SearchableBy ?? remote?.SearchableBy;
|
||||
if (rule != default)
|
||||
return Federation.Objects.ObjectShapes.Searchable(rule, remote?.FollowersURL, followed.Contains(post.ActorURI ?? string.Empty));
|
||||
return remote?.IsIndexable == true || indexable.Contains(author);
|
||||
}
|
||||
var found = new List<PostEntity>();
|
||||
foreach (var post in candidates)
|
||||
{
|
||||
var author = post.AuthorAccountId ?? post.GroupUserId;
|
||||
var reachable = author == MyId && !post.IsFederatedCopy || mine.Contains(post.ID) || post.Mentions.Any(m => m.AccountId == MyId)
|
||||
|| post.Visibility == PostVisibility.Public && Searchable(post, author);
|
||||
if (reachable && await VisibilityPolicy.CanSee(post, MyId, token))
|
||||
found.Add(post);
|
||||
if (found.Count >= offset + limit)
|
||||
break;
|
||||
}
|
||||
return await _mapper.Statuses(found.Skip(offset).Take(limit).ToList(), MyId, token);
|
||||
}
|
||||
}
|
||||
|
||||
public class StubsController : MastodonController
|
||||
{
|
||||
[HttpGet("/api/v1/custom_emojis"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult CustomEmojis() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/filters"), Scope("read:filters")]
|
||||
public IActionResult FiltersV1() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v2/filters"), Scope("read:filters")]
|
||||
public IActionResult FiltersV2() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/lists"), Scope("read:lists")]
|
||||
public IActionResult Lists() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/announcements"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult Announcements() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/trends"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult Trends() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/trends/tags"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult TrendTags() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/trends/statuses"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult TrendStatuses() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/trends/links"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult TrendLinks() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/suggestions"), Scope("read")]
|
||||
public IActionResult SuggestionsV1() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v2/suggestions"), Scope("read")]
|
||||
public IActionResult SuggestionsV2() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/followed_tags"), Scope("read:follows")]
|
||||
public IActionResult FollowedTags() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/endorsements"), Scope("read:accounts")]
|
||||
public IActionResult Endorsements() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/featured_tags"), Scope("read:accounts")]
|
||||
public IActionResult FeaturedTags() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/scheduled_statuses"), Scope("read:statuses")]
|
||||
public IActionResult ScheduledStatuses() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/preferences"), Scope("read:accounts")]
|
||||
public IActionResult Preferences() => Json(new Dictionary<string, object>
|
||||
{
|
||||
@@ -133,5 +170,32 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
|
||||
[HttpGet("/api/v1/push/subscription"), Scope("push")]
|
||||
public IActionResult PushSubscription() => Error(StatusCodes.Status404NotFound, "Record not found");
|
||||
|
||||
// Routes Mastodon answers that we have nothing behind: an empty or default answer lets clients degrade, where a
|
||||
// 404 breaks some of them (Mastodon invariant 5)
|
||||
[HttpGet("/api/v1/timelines/link"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult LinkTimeline() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/accounts/{id}/identity_proofs"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult IdentityProofs(string id) => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/instance/languages"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult Languages() => Json(InstanceController.LanguageCodes(HttpContext.RequestServices)
|
||||
.Select(code => new { code, name = System.Globalization.CultureInfo.GetCultureInfo(code).EnglishName }));
|
||||
|
||||
[HttpGet("/api/v1/instance/translation_languages"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult TranslationLanguages() => Json(new { });
|
||||
|
||||
[HttpGet("/api/v1/instance/domain_blocks"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult InstanceDomainBlocks() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/instance/privacy_policy"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult PrivacyPolicy() => Json(new
|
||||
{
|
||||
updated_at = "2026-10-04T00:00:00.000Z",
|
||||
content = "<p>One private login owns several public personas, and nobody but this server's admin can tell they belong "
|
||||
+ "together. What a persona posts is shared as its visibility says; a located post never leaves this server. "
|
||||
+ "Uploads lose their metadata. Statistics name servers, never people: see /stargazing.</p>"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,10 @@ using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
@@ -24,9 +28,24 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly IMemoryCache _cache;
|
||||
readonly IQuoteService _quotes;
|
||||
readonly IOutboxPublisher _outbox;
|
||||
readonly PrivaPub.Infrastructure.Jobs.IJobQueue _jobs;
|
||||
readonly Domain.Media.IMediaService _media;
|
||||
|
||||
public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache)
|
||||
readonly IParticipations _participations;
|
||||
readonly Federation.Actors.IWalls _walls;
|
||||
|
||||
public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache, IQuoteService quotes,
|
||||
IOutboxPublisher outbox, PrivaPub.Infrastructure.Jobs.IJobQueue jobs, Domain.Media.IMediaService media, IParticipations participations = default,
|
||||
Federation.Actors.IWalls walls = default)
|
||||
{
|
||||
_walls = walls;
|
||||
_participations = participations;
|
||||
_jobs = jobs;
|
||||
_media = media;
|
||||
_quotes = quotes;
|
||||
_outbox = outbox;
|
||||
_statuses = statuses;
|
||||
_mapper = mapper;
|
||||
_dbEntities = dbEntities;
|
||||
@@ -44,20 +63,31 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
if (existing != default)
|
||||
return Json(await _mapper.Status(existing, MyId, token));
|
||||
}
|
||||
if (Params.Has("poll[options]"))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Polls are not supported yet");
|
||||
|
||||
var outcome = await _statuses.Publish(Me, new StatusDraft
|
||||
var asked = new ScheduledParams
|
||||
{
|
||||
Text = Params.Get("status"),
|
||||
PlainText = true,
|
||||
SpoilerText = Params.Get("spoiler_text"),
|
||||
Sensitive = Params.Bool("sensitive") ?? Me.Settings.DefaultSensitive,
|
||||
Visibility = Visibility(Params.Get("visibility") ?? Me.Settings.DefaultVisibility),
|
||||
InReplyTo = Params.Get("in_reply_to_id"),
|
||||
Visibility = Params.Get("visibility") ?? Me.Settings.DefaultVisibility,
|
||||
InReplyToId = Params.Get("in_reply_to_id"),
|
||||
Language = Params.Get("language") ?? Me.Settings.DefaultLanguage,
|
||||
MediaIds = Params.List("media_ids")
|
||||
}, token);
|
||||
MediaIds = Params.List("media_ids").ToList(),
|
||||
QuotedStatusId = Params.Get("quoted_status_id"),
|
||||
QuotePolicy = Params.Get("quote_approval_policy"),
|
||||
Idempotency = string.IsNullOrEmpty(idempotency) ? default : idempotency,
|
||||
Poll = Params.Has("poll[options]")
|
||||
? new ScheduledPoll
|
||||
{
|
||||
Options = Params.List("poll[options]").ToList(),
|
||||
ExpiresIn = Params.Int("poll[expires_in]") ?? 0,
|
||||
Multiple = Params.Bool("poll[multiple]") == true,
|
||||
HideTotals = Params.Bool("poll[hide_totals]") == true
|
||||
}
|
||||
: default
|
||||
};
|
||||
if (Params.Get("scheduled_at") is { Length: > 0 } scheduledAt)
|
||||
return await Schedule(asked, scheduledAt, cacheKey, token);
|
||||
var outcome = await _statuses.Publish(Me, ScheduledStatuses.Draft(asked), token);
|
||||
if (!outcome.Ok)
|
||||
return Error(outcome.Status, outcome.Error);
|
||||
if (cacheKey != default)
|
||||
@@ -65,6 +95,48 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
return Json(await _mapper.Status(outcome.Post, MyId, token));
|
||||
}
|
||||
|
||||
// scheduled_at: the post waits, as asked, for a PublishScheduled job at that time; what it would need then (words,
|
||||
// media of the persona's own, not yet posted) is checked now
|
||||
async Task<IActionResult> Schedule(ScheduledParams asked, string scheduledAt, string cacheKey, CancellationToken token)
|
||||
{
|
||||
if (cacheKey != default && _cache.TryGetValue(cacheKey + ":scheduled", out string earlier)
|
||||
&& await DB.Default.Find<ScheduledStatus>().MatchID(earlier).ExecuteFirstAsync(token) is { } waiting)
|
||||
return Json(await ScheduledStatusesController.View(waiting, _media, token));
|
||||
if (!DateTime.TryParse(scheduledAt, System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var at))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Scheduled at is invalid");
|
||||
if (await ScheduledStatuses.Refusal(MyId, at, default, token) is { } refusal)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: " + refusal);
|
||||
if (string.IsNullOrWhiteSpace(asked.Text) && asked.MediaIds.Count == 0 && asked.Poll == default)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Text can't be blank");
|
||||
var mediaIds = asked.MediaIds.Distinct().ToList();
|
||||
if (mediaIds.Count > 4 || mediaIds.Count > 0 && await DB.Default.CountAsync<Models.Media.MediaAttachment>(
|
||||
m => mediaIds.Contains(m.ID) && m.OwnerAvatarId == MyId && m.PostId == null && m.ScheduledStatusId == null
|
||||
&& m.TrashedAt == null && m.ProfileOfAvatarId == null, token) != mediaIds.Count)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not yours, already posted or too many");
|
||||
|
||||
var scheduled = new ScheduledStatus { AvatarId = MyId, ScheduledAt = at, Params = asked };
|
||||
scheduled.ID = (string)scheduled.GenerateNewID();
|
||||
// reserved in one conditional update: a post or another scheduled post taking one meanwhile wins
|
||||
if (mediaIds.Count > 0)
|
||||
{
|
||||
var reserved = await DB.Default.Update<Models.Media.MediaAttachment>()
|
||||
.Match(m => mediaIds.Contains(m.ID) && m.PostId == null && m.ScheduledStatusId == null && m.TrashedAt == null)
|
||||
.Modify(m => m.ScheduledStatusId, scheduled.ID).ExecuteAsync(token);
|
||||
if (reserved.MatchedCount != mediaIds.Count)
|
||||
{
|
||||
await DB.Default.Update<Models.Media.MediaAttachment>().Match(m => m.ScheduledStatusId == scheduled.ID)
|
||||
.Modify(m => m.ScheduledStatusId, null).ExecuteAsync(token);
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not yours, already posted or too many");
|
||||
}
|
||||
}
|
||||
await DB.Default.SaveAsync(scheduled, token);
|
||||
await _jobs.EnqueueMany(new[] { ScheduledStatuses.JobFor(scheduled, new Uri(Me.BaseAddress).Host) }, token);
|
||||
if (cacheKey != default)
|
||||
_cache.Set(cacheKey + ":scheduled", scheduled.ID, TimeSpan.FromHours(1));
|
||||
return Json(await ScheduledStatusesController.View(scheduled, _media, token));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/statuses"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Many(CancellationToken token)
|
||||
{
|
||||
@@ -81,7 +153,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
public async Task<IActionResult> Get(string id, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(id, token);
|
||||
return post == default ? NotFoundError() : Json(await _mapper.Status(post, MyId, token));
|
||||
var status = post == default ? default : await _mapper.Status(post, MyId, token);
|
||||
return status == default ? NotFoundError() : Json(status);
|
||||
}
|
||||
|
||||
[HttpPut("/api/v1/statuses/{id}"), Scope("write:statuses")]
|
||||
@@ -94,17 +167,47 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
SpoilerText = Params.Get("spoiler_text"),
|
||||
Sensitive = Params.Bool("sensitive") ?? false,
|
||||
Language = Params.Get("language"),
|
||||
MediaIds = Params.Has("media_ids") ? Params.List("media_ids") : default
|
||||
MediaIds = Params.Has("media_ids") ? Params.List("media_ids") : default,
|
||||
MediaChanges = MediaAttributes()
|
||||
}, token);
|
||||
return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error);
|
||||
}
|
||||
|
||||
// Mastodon's media_attributes: a JSON body's array of objects, or a form's media_attributes[i][field] or
|
||||
// media_attributes[][field] (each field then listed in the same order)
|
||||
IReadOnlyList<MediaChange> MediaAttributes()
|
||||
{
|
||||
var changes = new List<MediaChange>();
|
||||
if (Params.Json is { ValueKind: System.Text.Json.JsonValueKind.Object } json && json.TryGetProperty("media_attributes", out var array)
|
||||
&& array.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in array.EnumerateArray().Where(i => i.ValueKind == System.Text.Json.JsonValueKind.Object).Take(4))
|
||||
{
|
||||
var hasDescription = item.TryGetProperty("description", out var description);
|
||||
changes.Add(new MediaChange(Text(item, "id"), hasDescription, hasDescription ? Text(item, "description") : default, Text(item, "focus")));
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
for (var i = 0; i < 4 && Params.Get($"media_attributes[{i}][id]") is { } id; i++)
|
||||
changes.Add(new MediaChange(id, Params.Has($"media_attributes[{i}][description]"), Params.Get($"media_attributes[{i}][description]"), Params.Get($"media_attributes[{i}][focus]")));
|
||||
var ids = Params.List("media_attributes[][id]");
|
||||
var descriptions = Params.List("media_attributes[][description]");
|
||||
var foci = Params.List("media_attributes[][focus]");
|
||||
for (var i = 0; i < Math.Min(ids.Count, 4); i++)
|
||||
changes.Add(new MediaChange(ids[i], i < descriptions.Count, i < descriptions.Count ? descriptions[i] : default, i < foci.Count ? foci[i] : default));
|
||||
return changes;
|
||||
|
||||
static string Text(System.Text.Json.JsonElement item, string name) => item.TryGetProperty(name, out var value)
|
||||
? value.ValueKind == System.Text.Json.JsonValueKind.String ? value.GetString() : value.ValueKind is System.Text.Json.JsonValueKind.Null ? default : value.GetRawText()
|
||||
: default;
|
||||
}
|
||||
|
||||
[HttpDelete("/api/v1/statuses/{id}"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> Delete(string id, CancellationToken token)
|
||||
{
|
||||
var before = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (before == default)
|
||||
return NotFoundError();
|
||||
return await OffWall(id, token);
|
||||
var status = await _mapper.Status(before, MyId, token);
|
||||
var outcome = await _statuses.Remove(Me, id, token);
|
||||
if (!outcome.Ok)
|
||||
@@ -117,6 +220,20 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
return Json(status);
|
||||
}
|
||||
|
||||
// someone's post on the persona's wall (FEP-400e), taken off it: as on Smithereen, the owner of a wall deletes what is on it
|
||||
async Task<IActionResult> OffWall(string id, CancellationToken token)
|
||||
{
|
||||
var walled = _walls == default
|
||||
? default
|
||||
: await _dbEntities.Posts.Match(p => p.ID == id && p.WallOwnerAccountId == MyId && p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (walled == default)
|
||||
return NotFoundError();
|
||||
var status = await _mapper.Status(walled, MyId, token);
|
||||
if (!await _walls.Remove(Me, walled, token))
|
||||
return NotFoundError();
|
||||
return Json(status);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/statuses/{id}/context"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Context(string id, CancellationToken token)
|
||||
{
|
||||
@@ -149,6 +266,11 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
frontier.Add(child.ID);
|
||||
}
|
||||
}
|
||||
// a persona opening a public remote thread has its replies read from their servers, for the next look (once an hour)
|
||||
if (MyId != default)
|
||||
await _jobs.EnqueueMany(new[] { post, ancestors.FirstOrDefault() }
|
||||
.Where(p => p is { IsFederatedCopy: true, Visibility: PostVisibility.Public or PostVisibility.Unlisted }).DistinctBy(p => p.ID)
|
||||
.Select(p => Federation.Inbox.RepliesJobHandler.For(p, 1)), token);
|
||||
return Json(new Context
|
||||
{
|
||||
Ancestors = await _mapper.Statuses(ancestors, MyId, token),
|
||||
@@ -189,12 +311,35 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
return post == default ? NotFoundError() : Json(new StatusSource { Id = post.ID, Text = post.Text ?? string.Empty, SpoilerText = post.SpoilerText ?? string.Empty });
|
||||
}
|
||||
|
||||
// a persona joins or leaves a remote event (owner decision 2026-10-05); the status says how it stands
|
||||
// (privapub.event.participation)
|
||||
[HttpPost("/api/privapub/v1/statuses/{id}/join"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> Join(string id, CancellationToken token)
|
||||
{
|
||||
var outcome = await _participations.Join(Me, id, token);
|
||||
return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error);
|
||||
}
|
||||
|
||||
[HttpPost("/api/privapub/v1/statuses/{id}/leave"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> Leave(string id, CancellationToken token)
|
||||
{
|
||||
var outcome = await _participations.Leave(Me, id, token);
|
||||
return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error);
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/favourite"), Scope("write:favourites")]
|
||||
public Task<IActionResult> Favourite(string id, CancellationToken token) => Toggle(_statuses.Favourite(Me, id, true, token), id, token);
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/unfavourite"), Scope("write:favourites")]
|
||||
public Task<IActionResult> Unfavourite(string id, CancellationToken token) => Toggle(_statuses.Favourite(Me, id, false, token), id, token);
|
||||
|
||||
// PrivaPub's own, for the threadiverse's downvotes (a favourite is the upvote)
|
||||
[HttpPost("/api/v1/statuses/{id}/downvote"), Scope("write:favourites")]
|
||||
public Task<IActionResult> Downvote(string id, CancellationToken token) => Toggle(_statuses.Downvote(Me, id, true, token), id, token);
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/undownvote"), Scope("write:favourites")]
|
||||
public Task<IActionResult> Undownvote(string id, CancellationToken token) => Toggle(_statuses.Downvote(Me, id, false, token), id, token);
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/reblog"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> Reblog(string id, CancellationToken token)
|
||||
{
|
||||
@@ -220,11 +365,52 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
if (await Visible(id, token) == default)
|
||||
return NotFoundError();
|
||||
var reblogs = await _dbEntities.Posts.Match(p => p.ReblogOfPostId == id && !p.DeletedAt.HasValue).Sort(p => p.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
|
||||
var reblogs = await _dbEntities.Posts.Match(p => p.ReblogOfPostId == id).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
|
||||
var accounts = await _mapper.Accounts(reblogs.Select(MastodonMapper.AuthorOf), token);
|
||||
return Json(reblogs.Select(r => accounts.GetValueOrDefault(MastodonMapper.AuthorOf(r))).Where(a => a != default).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/statuses/{id}/quotes"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Quotes(string id, CancellationToken token)
|
||||
{
|
||||
if (await Visible(id, token) == default)
|
||||
return NotFoundError();
|
||||
var query = _dbEntities.Posts.Match(p => p.QuotedPostId == id && p.QuoteState == QuoteState.Accepted && !p.DeletedAt.HasValue);
|
||||
var quotes = await Page.From(Params, Limit()).Fetch(query, p => p.ID, token);
|
||||
var visible = new List<PostEntity>();
|
||||
foreach (var quote in quotes)
|
||||
if (quote.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(quote, MyId, token))
|
||||
visible.Add(quote);
|
||||
Link($"/api/v1/statuses/{id}/quotes", quotes.LastOrDefault()?.ID, quotes.FirstOrDefault()?.ID);
|
||||
return Json(await _mapper.Statuses(visible, MyId, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/quotes/{quotingId}/revoke"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> RevokeQuote(string id, string quotingId, CancellationToken token)
|
||||
{
|
||||
var quoted = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
var quoting = quoted == default ? default : await _dbEntities.Posts.Match(p => p.ID == quotingId && p.QuotedPostId == quoted.ID).ExecuteFirstAsync(token);
|
||||
if (quoting == default || !await _quotes.RevokeLicence(quoted, quoting, token))
|
||||
return NotFoundError();
|
||||
return Json(await _mapper.Status(await _dbEntities.Posts.MatchID(quoting.ID).ExecuteFirstAsync(token), MyId, token));
|
||||
}
|
||||
|
||||
[HttpPut("/api/v1/statuses/{id}/interaction_policy"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> InteractionPolicy(string id, CancellationToken token)
|
||||
{
|
||||
var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (post == default)
|
||||
return NotFoundError();
|
||||
var policy = Params.Get("quote_approval_policy");
|
||||
if (!QuotePolicies.IsKnown(policy))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Unknown quote approval policy");
|
||||
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.LocalQuotePolicy, policy).ExecuteAsync(token);
|
||||
post.LocalQuotePolicy = policy;
|
||||
if (!post.IsLocalOnly)
|
||||
await _outbox.PublishUpdate(Me, post, $"policy-{DateTime.UtcNow.Ticks}", token);
|
||||
return Json(await _mapper.Status(post, MyId, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/bookmark"), Scope("write:bookmarks")]
|
||||
public async Task<IActionResult> Bookmark(string id, CancellationToken token)
|
||||
{
|
||||
@@ -274,7 +460,11 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.ReblogOfPostId == null)
|
||||
.ExecuteFirstAsync(token);
|
||||
if (post == default)
|
||||
return NotFoundError();
|
||||
return await Visible(id, token) is { } other
|
||||
? Error(StatusCodes.Status422UnprocessableEntity, other.ReblogOfPostId == null
|
||||
? "Validation failed: Someone else's post cannot be pinned"
|
||||
: "Validation failed: A boost cannot be pinned")
|
||||
: NotFoundError();
|
||||
if (post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Only public posts can be pinned");
|
||||
if (await DB.Default.CountAsync<Models.Social.Pin>(p => p.AvatarId == MyId, token) >= MaxPins)
|
||||
@@ -282,6 +472,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(new Models.Social.Pin { AvatarId = MyId, PostId = post.ID }, token);
|
||||
await _outbox.PublishFeatured(MyId, post, featured: true, token);
|
||||
}
|
||||
catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
@@ -292,7 +483,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
[HttpPost("/api/v1/statuses/{id}/unpin"), Scope("write:accounts")]
|
||||
public async Task<IActionResult> Unpin(string id, CancellationToken token)
|
||||
{
|
||||
await DB.Default.DeleteAsync<Models.Social.Pin>(p => p.AvatarId == MyId && p.PostId == id);
|
||||
var unpinned = await DB.Default.DeleteAsync<Models.Social.Pin>(p => p.AvatarId == MyId && p.PostId == id);
|
||||
if (unpinned is { IsAcknowledged: true, DeletedCount: > 0 } && await _dbEntities.Posts.MatchID(id).ExecuteFirstAsync(token) is { } post)
|
||||
await _outbox.PublishFeatured(MyId, post, featured: false, token);
|
||||
return await Unchanged(id, token);
|
||||
}
|
||||
|
||||
@@ -308,7 +501,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
async Task<IActionResult> Unchanged(string id, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(id, token);
|
||||
return post == default ? NotFoundError() : Json(await _mapper.Status(post, MyId, token));
|
||||
var status = post == default ? default : await _mapper.Status(post, MyId, token);
|
||||
return status == default ? NotFoundError() : Json(status);
|
||||
}
|
||||
|
||||
async Task<PostEntity> Visible(string id, CancellationToken token)
|
||||
@@ -317,12 +511,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
return post != default && post.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(post, MyId, token) ? post : default;
|
||||
}
|
||||
|
||||
static PostVisibility Visibility(string value) => value?.ToLowerInvariant() switch
|
||||
{
|
||||
"unlisted" => PostVisibility.Unlisted,
|
||||
"private" => PostVisibility.FollowersOnly,
|
||||
"direct" => PostVisibility.Direct,
|
||||
_ => PostVisibility.Public
|
||||
};
|
||||
static PostVisibility Visibility(string value) => ScheduledStatuses.Visibility(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Domain.Timelines;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Mastodon's streaming API: one WebSocket on /api/v1/streaming that subscribes to streams by message, or one stream
|
||||
// per request as server-sent events on /api/v1/streaming/{stream}. Every stream needs a signed-in persona (the token
|
||||
// comes as access_token, in the Authorization header, or as the WebSocket's protocol). What a stream tells is mapped
|
||||
// for that persona as it is sent: a post it may not see, or whose author it blocked or muted, is never sent.
|
||||
public class StreamingController : MastodonController
|
||||
{
|
||||
static readonly TimeSpan KeepAlive = TimeSpan.FromSeconds(15);
|
||||
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly DbEntities _dbEntities;
|
||||
|
||||
public StreamingController(MastodonMapper mapper, DbEntities dbEntities)
|
||||
{
|
||||
_mapper = mapper;
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/streaming/health"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult Health() => Content("OK", "text/plain");
|
||||
|
||||
// the stream a name and its parameter make, or why not: a hashtag needs its tag, a list must be the persona's own
|
||||
async Task<(Streams.Key Key, string Error)> Resolve(string name, string tag, string list, CancellationToken token)
|
||||
{
|
||||
switch (name)
|
||||
{
|
||||
case Streams.User or Streams.UserNotification or Streams.Public or Streams.PublicLocal or Streams.PublicRemote:
|
||||
return (new Streams.Key(name), default);
|
||||
case Streams.Hashtag or Streams.HashtagLocal:
|
||||
var normal = TagsController.Normalise(tag);
|
||||
return normal.Length == 0 ? (default, "Missing tag name parameter") : (new Streams.Key(name, normal), default);
|
||||
case Streams.List:
|
||||
return !string.IsNullOrEmpty(list) && await DB.Default.Find<PersonaList>().Match(l => l.ID == list && l.AvatarId == MyId).ExecuteAnyAsync(token)
|
||||
? (new Streams.Key(name, list), default)
|
||||
: (default, "Unknown list");
|
||||
default:
|
||||
return (default, "Unknown stream type");
|
||||
}
|
||||
}
|
||||
|
||||
// what the event says, for this persona: a status or a notification as JSON, a deleted id as itself; null when it
|
||||
// is not the persona's to see
|
||||
async Task<string> Payload(Streams.Event e, CancellationToken token)
|
||||
{
|
||||
if (e.Name == "delete")
|
||||
return e.Id;
|
||||
if (e.Name == "notification")
|
||||
{
|
||||
var notification = await _dbEntities.Notifications.Match(n => n.ID == e.Id && n.AvatarId == MyId).ExecuteFirstAsync(token);
|
||||
var mapped = notification == default ? default : (await _mapper.Notifications(new List<Notification> { notification }, MyId, token)).FirstOrDefault();
|
||||
return mapped == default ? default : JsonSerializer.Serialize(mapped, MastodonJson.Options);
|
||||
}
|
||||
var post = await _dbEntities.Posts.MatchID(e.Id).ExecuteFirstAsync(token);
|
||||
if (post == default || !await VisibilityPolicy.CanSee(post, MyId, token))
|
||||
return default;
|
||||
var status = (await _mapper.Statuses(new[] { post }, MyId, token)).FirstOrDefault();
|
||||
return status == default ? default : JsonSerializer.Serialize(status, MastodonJson.Options);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/streaming"), Scope("read")]
|
||||
public async Task<IActionResult> Socket(CancellationToken token)
|
||||
{
|
||||
if (!HttpContext.WebSockets.IsWebSocketRequest)
|
||||
return Error(StatusCodes.Status400BadRequest, "This is a WebSocket endpoint; server-sent events are at /api/v1/streaming/{stream}");
|
||||
var protocol = HttpContext.WebSockets.WebSocketRequestedProtocols.FirstOrDefault();
|
||||
using var socket = await HttpContext.WebSockets.AcceptWebSocketAsync(protocol);
|
||||
var listener = Streams.Open(MyId);
|
||||
var sending = new SemaphoreSlim(1, 1);
|
||||
async Task Send(JsonObject message)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(message.ToJsonString());
|
||||
await sending.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
await socket.SendAsync(bytes, WebSocketMessageType.Text, true, token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
sending.Release();
|
||||
}
|
||||
}
|
||||
async Task Subscribe(string type, string name, string tag, string list)
|
||||
{
|
||||
var (key, error) = await Resolve(name, tag, list, token);
|
||||
if (error != default)
|
||||
{
|
||||
await Send(new JsonObject { ["error"] = error, ["status"] = 400 });
|
||||
return;
|
||||
}
|
||||
if (type == "unsubscribe")
|
||||
listener.Stop(key);
|
||||
else
|
||||
listener.Listen(key);
|
||||
}
|
||||
try
|
||||
{
|
||||
if (Params.Get("stream") is { Length: > 0 } first)
|
||||
await Subscribe("subscribe", first, Params.Get("tag"), Params.Get("list"));
|
||||
using var done = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
var reading = Task.Run(async () =>
|
||||
{
|
||||
var buffer = new byte[4096];
|
||||
while (socket.State == WebSocketState.Open && !done.IsCancellationRequested)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
WebSocketReceiveResult received;
|
||||
do
|
||||
{
|
||||
received = await socket.ReceiveAsync(buffer, done.Token);
|
||||
if (received.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
done.Cancel();
|
||||
return;
|
||||
}
|
||||
text.Append(Encoding.UTF8.GetString(buffer, 0, received.Count));
|
||||
}
|
||||
while (!received.EndOfMessage && text.Length < 16384);
|
||||
JsonNode message;
|
||||
try
|
||||
{
|
||||
message = JsonNode.Parse(text.ToString());
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (message?["type"]?.GetValue<string>() is "subscribe" or "unsubscribe" && message["stream"]?.GetValue<string>() is { } stream)
|
||||
await Subscribe(message["type"]!.GetValue<string>(), stream, message["tag"]?.GetValue<string>(), message["list"]?.GetValue<string>());
|
||||
}
|
||||
}, done.Token);
|
||||
await foreach (var (key, e) in listener.Queue.Reader.ReadAllAsync(done.Token))
|
||||
{
|
||||
if (!listener.Hears(key) || await Payload(e, done.Token) is not { } payload)
|
||||
continue;
|
||||
await Send(new JsonObject { ["stream"] = new JsonArray(key.Wire.Select(w => (JsonNode)w).ToArray()), ["event"] = e.Name, ["payload"] = payload });
|
||||
}
|
||||
await reading;
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
Streams.Close(listener);
|
||||
if (socket.State == WebSocketState.Open)
|
||||
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, default, CancellationToken.None);
|
||||
}
|
||||
return new EmptyResult();
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/streaming/{*stream}"), Scope("read")]
|
||||
public async Task<IActionResult> Events(string stream, CancellationToken token)
|
||||
{
|
||||
var name = stream.Replace('/', ':');
|
||||
var (key, error) = await Resolve(name, Params.Get("tag"), Params.Get("list"), token);
|
||||
if (error != default)
|
||||
return Error(StatusCodes.Status400BadRequest, error);
|
||||
Response.ContentType = "text/event-stream";
|
||||
Response.Headers.CacheControl = "no-cache";
|
||||
Response.Headers["X-Accel-Buffering"] = "no";
|
||||
var listener = Streams.Open(MyId);
|
||||
listener.Listen(key);
|
||||
var writing = new SemaphoreSlim(1, 1);
|
||||
async Task Write(string text)
|
||||
{
|
||||
await writing.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
await Response.WriteAsync(text, token);
|
||||
await Response.Body.FlushAsync(token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
writing.Release();
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
await Write(":)\n\n");
|
||||
var beating = Task.Run(async () =>
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(KeepAlive, token);
|
||||
await Write(":thump\n\n");
|
||||
}
|
||||
}, token);
|
||||
await foreach (var (_, e) in listener.Queue.Reader.ReadAllAsync(token))
|
||||
{
|
||||
if (await Payload(e, token) is not { } payload)
|
||||
continue;
|
||||
await Write($"event: {e.Name}\ndata: {payload}\n\n");
|
||||
}
|
||||
await beating;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
Streams.Close(listener);
|
||||
}
|
||||
return new EmptyResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Text;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Hashtags as Mastodon shows them (owner decision 2026-10-04, followed tags back from the cut list): a tag with the
|
||||
// last week of its use in public posts PrivaPub holds, and whether the persona follows it; following one brings its
|
||||
// public posts to the persona's home as they arrive (Fanout).
|
||||
public class TagsController : MastodonController
|
||||
{
|
||||
const int MaxFollowed = 500;
|
||||
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
|
||||
public TagsController(DbEntities dbEntities, ILocalActorService localActors)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
}
|
||||
|
||||
public static string Normalise(string name) => name?.Trim().TrimStart('#').Normalize(NormalizationForm.FormKC).ToLowerInvariant() ?? string.Empty;
|
||||
|
||||
// what a hashtag can be, as Mastodon has it: letters, marks, digits and underscores, with a letter or underscore
|
||||
public static bool IsHashtag(string normal) =>
|
||||
normal.Length is > 0 and <= 100 && normal.All(c => char.IsLetterOrDigit(c) || c == '_' || char.GetUnicodeCategory(c) is
|
||||
System.Globalization.UnicodeCategory.NonSpacingMark or System.Globalization.UnicodeCategory.SpacingCombiningMark)
|
||||
&& normal.Any(c => char.IsLetter(c) || c == '_');
|
||||
|
||||
// a day's uses and authors, newest first, for the last week, as Mastodon gives them (strings, days as unix time)
|
||||
public static async Task<List<object>> History(DbEntities dbEntities, string tag, CancellationToken token)
|
||||
{
|
||||
var today = DateTime.UtcNow.Date;
|
||||
var since = today.AddDays(-6);
|
||||
var posts = await dbEntities.Posts
|
||||
.Match(p => p.Tags.Contains(tag) && p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && p.CreationDate >= since)
|
||||
.Project(p => p.Include(x => x.CreationDate).Include(x => x.AuthorAccountId).Include(x => x.GroupUserId))
|
||||
.ExecuteAsync(token);
|
||||
return Enumerable.Range(0, 7).Select(back => today.AddDays(-back)).Select(day =>
|
||||
{
|
||||
var that = posts.Where(p => p.CreationDate >= day && p.CreationDate < day.AddDays(1)).ToList();
|
||||
return (object)new
|
||||
{
|
||||
day = new DateTimeOffset(day, TimeSpan.Zero).ToUnixTimeSeconds().ToString(),
|
||||
uses = that.Count.ToString(),
|
||||
accounts = that.Select(p => p.AuthorAccountId ?? p.GroupUserId).Distinct().Count().ToString()
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
async Task<object> View(string tag, bool? following, CancellationToken token) => new
|
||||
{
|
||||
name = tag,
|
||||
url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag)}",
|
||||
history = await History(_dbEntities, tag, token),
|
||||
following = following ?? (MyId != default && await DB.Default.Find<FollowedTag>().Match(t => t.AvatarId == MyId && t.Name == tag).ExecuteAnyAsync(token))
|
||||
};
|
||||
|
||||
[HttpGet("/api/v1/tags/{name}"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Tag(string name, CancellationToken token)
|
||||
{
|
||||
var tag = Normalise(name);
|
||||
return tag.Length == 0 ? NotFoundError() : Json(await View(tag, default, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/tags/{name}/follow"), Scope("write:follows")]
|
||||
public async Task<IActionResult> Follow(string name, CancellationToken token)
|
||||
{
|
||||
var tag = Normalise(name);
|
||||
if (tag.Length == 0 || tag.Length > 100)
|
||||
return NotFoundError();
|
||||
if (!await DB.Default.Find<FollowedTag>().Match(t => t.AvatarId == MyId && t.Name == tag).ExecuteAnyAsync(token))
|
||||
{
|
||||
if (await DB.Default.CountAsync<FollowedTag>(t => t.AvatarId == MyId, token) >= MaxFollowed)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Too many followed hashtags");
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(new FollowedTag { AvatarId = MyId, Name = tag }, token);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
}
|
||||
}
|
||||
return Json(await View(tag, true, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/tags/{name}/unfollow"), Scope("write:follows")]
|
||||
public async Task<IActionResult> Unfollow(string name, CancellationToken token)
|
||||
{
|
||||
var tag = Normalise(name);
|
||||
await DB.Default.DeleteAsync<FollowedTag>(t => t.AvatarId == MyId && t.Name == tag);
|
||||
return Json(await View(tag, false, token));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/followed_tags"), Scope("read:follows")]
|
||||
public async Task<IActionResult> Followed(CancellationToken token)
|
||||
{
|
||||
var page = await Page.From(Params, Limit(100, 200)).Fetch(DB.Default.Find<FollowedTag>().Match(t => t.AvatarId == MyId), t => t.ID, token);
|
||||
Link("/api/v1/followed_tags", page.LastOrDefault()?.ID, page.FirstOrDefault()?.ID);
|
||||
var views = new List<object>();
|
||||
foreach (var followed in page)
|
||||
views.Add(await View(followed.Name, true, token));
|
||||
return Json(views);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Entities;
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
@@ -30,9 +33,19 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
[HttpGet("/api/v1/timelines/home"), Scope("read:statuses")]
|
||||
public async Task<IActionResult> Home(CancellationToken token)
|
||||
{
|
||||
var entries = await Page.From(Params, Limit()).Fetch(_dbEntities.TimelineEntries.Match(e => e.AvatarId == MyId), e => e.PostId, token);
|
||||
//an exclusive list's members are read in that list, not at home
|
||||
var exclusive = (await DB.Default.Find<PersonaList>().Match(l => l.AvatarId == MyId && l.Exclusive).ExecuteAsync(token)).Select(l => l.ID).ToList();
|
||||
var apart = exclusive.Count == 0
|
||||
? new List<string>()
|
||||
: (await DB.Default.Find<PersonaListMember>().Match(m => exclusive.Contains(m.ListId)).ExecuteAsync(token)).Select(m => m.AccountId).Distinct().ToList();
|
||||
var home = _dbEntities.TimelineEntries.Match(e => e.AvatarId == MyId);
|
||||
if (apart.Count > 0)
|
||||
home.Match(e => !apart.Contains(e.AuthorAccountId));
|
||||
var entries = await Page.From(Params, Limit()).Fetch(home, e => e.PostId, token);
|
||||
var ids = entries.Select(e => e.PostId).ToList();
|
||||
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID);
|
||||
// (located posts once reached their author's home: the entries left from then are passed over)
|
||||
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && p.Visibility != PostVisibility.LocalGeo).Match(VisibilityPolicy.IsShown)
|
||||
.ExecuteAsync(token)).ToDictionary(p => p.ID);
|
||||
var statuses = await _mapper.Statuses(ids.Where(posts.ContainsKey).Select(id => posts[id]).ToList(), MyId, token);
|
||||
Link("/api/v1/timelines/home", entries.LastOrDefault()?.PostId, entries.FirstOrDefault()?.PostId);
|
||||
return Json(statuses);
|
||||
@@ -41,7 +54,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
[HttpGet("/api/v1/timelines/public"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Public(CancellationToken token)
|
||||
{
|
||||
var query = _dbEntities.Posts.Match(p => p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && p.ReblogOfPostId == null);
|
||||
var query = _dbEntities.Posts.Match(p => p.Visibility == PostVisibility.Public && p.ReblogOfPostId == null).Match(VisibilityPolicy.IsPublic);
|
||||
if (Params.Bool("local") == true)
|
||||
query.Match(p => !p.IsFederatedCopy);
|
||||
if (Params.Bool("remote") == true)
|
||||
@@ -51,19 +64,36 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
return await Respond(query, "/api/v1/timelines/public", token);
|
||||
}
|
||||
|
||||
// PrivaPub's own: a feed the persona reads (Lemmy's multi-community, PieFed's feed), its communities' threads,
|
||||
// newest first, as the server's reader of feeds brings them in (owner decision 2026-10-07)
|
||||
[HttpGet("/api/v1/timelines/feed/{id}"), Scope("read:statuses")]
|
||||
public async Task<IActionResult> Feed(string id, CancellationToken token)
|
||||
{
|
||||
var feed = await _dbEntities.ForeignAvatars.MatchID(id).ExecuteFirstAsync(token);
|
||||
if (feed is not { AvatarType: Models.User.AvatarType.Feed }
|
||||
|| !await DB.Default.Find<FeedSubscription>().Match(s => s.AvatarId == MyId && s.FeedActorURI == feed.ActorURI).ExecuteAnyAsync(token))
|
||||
return NotFoundError();
|
||||
var communities = feed.FeedCommunities;
|
||||
var ours = Me.BaseAddress + "/peasants/";
|
||||
var names = communities.Where(c => c.StartsWith(ours, StringComparison.Ordinal)).Select(c => c[ours.Length..]).ToList();
|
||||
var groups = names.Count == 0
|
||||
? new List<string>()
|
||||
: (await _dbEntities.Groups.Match(g => names.Contains(g.UserName) && !g.DeletionAt.HasValue).ExecuteAsync(token)).Select(g => g.ID).ToList();
|
||||
var query = _dbEntities.Posts.Match(p => (communities.Contains(p.AudienceURI) || groups.Contains(p.GroupId))
|
||||
&& p.ReblogOfPostId == null && p.InReplyToURI == null).Match(VisibilityPolicy.IsPublic);
|
||||
return await Respond(query, $"/api/v1/timelines/feed/{id}", token);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/timelines/tag/{hashtag}"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Tag(string hashtag, CancellationToken token)
|
||||
{
|
||||
var tag = hashtag.TrimStart('#').ToLowerInvariant();
|
||||
var query = _dbEntities.Posts.Match(p => p.Tags.Contains(tag) && p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && p.ReblogOfPostId == null);
|
||||
var query = _dbEntities.Posts.Match(p => p.Tags.Contains(tag) && p.Visibility == PostVisibility.Public && p.ReblogOfPostId == null).Match(VisibilityPolicy.IsPublic);
|
||||
if (Params.Bool("local") == true)
|
||||
query.Match(p => !p.IsFederatedCopy);
|
||||
return await Respond(query, $"/api/v1/timelines/tag/{hashtag}", token);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/timelines/list/{id}"), Scope("read:lists")]
|
||||
public IActionResult List(string id) => Json(Array.Empty<Status>());
|
||||
|
||||
[HttpGet("/api/v1/favourites"), Scope("read:favourites")]
|
||||
public async Task<IActionResult> Favourites(CancellationToken token)
|
||||
{
|
||||
@@ -78,41 +108,79 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
return Json(await _mapper.Statuses(visible, MyId, token));
|
||||
}
|
||||
|
||||
// the persona's direct conversations, by their newest post (paged like Mastodon's by that post's id), each with its
|
||||
// other members and whether its newest post, someone else's, is newer than what the persona read; the ones it took
|
||||
// off its list stay off until a newer message
|
||||
[HttpGet("/api/v1/conversations"), Scope("read:statuses")]
|
||||
public async Task<IActionResult> Conversations(CancellationToken token)
|
||||
{
|
||||
var groups = await _dbEntities.DmGroups.Match(g => !g.DeletionAt.HasValue && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId))
|
||||
.Sort(g => g.UpdatedAt, Order.Descending).Limit(Limit()).ExecuteAsync(token);
|
||||
var conversations = new List<Conversation>();
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID && !p.DeletedAt.HasValue).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token);
|
||||
if (last == default)
|
||||
continue;
|
||||
var others = new List<string>();
|
||||
foreach (var member in group.Members.Where(m => !(m.AvatarId == MyId && !m.IsForeign)))
|
||||
others.Add(member.IsForeign
|
||||
? (await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == member.AvatarId).ExecuteFirstAsync(token))?.ID
|
||||
: member.AvatarId);
|
||||
var accounts = await _mapper.Accounts(others, token);
|
||||
conversations.Add(new Conversation
|
||||
{
|
||||
Id = group.ID,
|
||||
Accounts = accounts.Values.ToList(),
|
||||
LastStatus = await _mapper.Status(last, MyId, token)
|
||||
});
|
||||
}
|
||||
return Json(conversations);
|
||||
var groups = await Page.From(Params, Limit(20, 40)).Fetch(
|
||||
_dbEntities.DmGroups.Match(g => !g.DeletionAt.HasValue && g.LastPostId != null && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId)),
|
||||
g => g.LastPostId, token);
|
||||
Link("/api/v1/conversations", groups.LastOrDefault()?.LastPostId, groups.FirstOrDefault()?.LastPostId);
|
||||
return Json(await ConversationsOf(groups, token));
|
||||
}
|
||||
|
||||
async Task<List<Conversation>> ConversationsOf(List<DmGroup> groups, CancellationToken token)
|
||||
{
|
||||
var ids = groups.Select(g => g.ID).ToList();
|
||||
var states = (await DB.Default.Find<ConversationState>().Match(s => s.AvatarId == MyId && ids.Contains(s.ConversationId)).ExecuteAsync(token))
|
||||
.ToDictionary(s => s.ConversationId);
|
||||
groups = groups.Where(g => !ConversationStates.IsHidden(states.GetValueOrDefault(g.ID), g.LastPostId)).ToList();
|
||||
|
||||
// the newest post each conversation still shows: usually its LastPostId, else (deleted) the one before
|
||||
var newestIds = groups.Select(g => g.LastPostId).ToList();
|
||||
var newest = (await _dbEntities.Posts.Match(p => newestIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token))
|
||||
.ToDictionary(p => p.ConversationId);
|
||||
foreach (var group in groups.Where(g => !newest.ContainsKey(g.ID)))
|
||||
if (await _dbEntities.Posts.Match(p => p.ConversationId == group.ID).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending)
|
||||
.ExecuteFirstAsync(token) is { } shown)
|
||||
newest[group.ID] = shown;
|
||||
|
||||
var foreignUris = groups.SelectMany(g => g.Members).Where(m => m.IsForeign).Select(m => m.AvatarId).Distinct().ToList();
|
||||
var foreignIds = (await _dbEntities.ForeignAvatars.Match(f => foreignUris.Contains(f.ActorURI)).ExecuteAsync(token))
|
||||
.GroupBy(f => f.ActorURI).ToDictionary(g => g.Key, g => g.First().ID);
|
||||
List<string> Others(DmGroup group) => group.Members.Where(m => !(m.AvatarId == MyId && !m.IsForeign))
|
||||
.Select(m => m.IsForeign ? foreignIds.GetValueOrDefault(m.AvatarId) : m.AvatarId).Where(id => id != default).ToList();
|
||||
var accounts = await _mapper.Accounts(groups.SelectMany(Others).Distinct(), token);
|
||||
var statuses = (await _mapper.Statuses(newest.Values.ToList(), MyId, token)).ToDictionary(s => s.Id);
|
||||
|
||||
return groups.Where(g => newest.ContainsKey(g.ID) && statuses.ContainsKey(newest[g.ID].ID))
|
||||
.Select(g => new Conversation
|
||||
{
|
||||
Id = g.ID,
|
||||
Unread = ConversationStates.IsUnread(states.GetValueOrDefault(g.ID), newest[g.ID].ID, newest[g.ID].AuthorAccountId, MyId),
|
||||
Accounts = Others(g).Select(id => accounts.GetValueOrDefault(id)).Where(a => a != default).ToList(),
|
||||
LastStatus = statuses[newest[g.ID].ID]
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
async Task<DmGroup> MyConversation(string id, CancellationToken token) =>
|
||||
await _dbEntities.DmGroups.Match(g => g.ID == id && !g.DeletionAt.HasValue && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId))
|
||||
.ExecuteFirstAsync(token);
|
||||
|
||||
[HttpPost("/api/v1/conversations/{id}/read"), Scope("write:conversations")]
|
||||
public async Task<IActionResult> ReadConversation(string id, CancellationToken token)
|
||||
{
|
||||
var group = await _dbEntities.DmGroups.Match(g => g.ID == id && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId)).ExecuteFirstAsync(token);
|
||||
var group = await MyConversation(id, token);
|
||||
if (group == default)
|
||||
return NotFoundError();
|
||||
var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID && !p.DeletedAt.HasValue).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token);
|
||||
return Json(new Conversation { Id = group.ID, LastStatus = last == default ? default : await _mapper.Status(last, MyId, token) });
|
||||
await ConversationStates.Read(MyId, group.ID, group.LastPostId, token);
|
||||
var conversation = (await ConversationsOf(new List<DmGroup> { group }, token)).FirstOrDefault();
|
||||
return Json(conversation ?? new Conversation { Id = group.ID });
|
||||
}
|
||||
|
||||
// off the persona's list until a newer message; the messages themselves stay
|
||||
[HttpDelete("/api/v1/conversations/{id}"), Scope("write:conversations")]
|
||||
public async Task<IActionResult> RemoveConversation(string id, CancellationToken token)
|
||||
{
|
||||
var group = await MyConversation(id, token);
|
||||
if (group == default)
|
||||
return NotFoundError();
|
||||
await ConversationStates.Hide(MyId, group.ID, group.LastPostId, token);
|
||||
await ConversationStates.Read(MyId, group.ID, group.LastPostId, token);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/markers"), Scope("read:statuses")]
|
||||
@@ -141,6 +209,10 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
.Option(o => o.IsUpsert = true)
|
||||
.ExecuteAsync(token);
|
||||
result[timeline] = new Entities.Marker { LastReadId = marker.LastReadId, Version = marker.Version, UpdatedAt = MastodonJson.Time(marker.UpdatedAt) };
|
||||
// what the marker passes is read: the unread count (v1 and v2) counts only what is newer
|
||||
if (timeline == "notifications")
|
||||
await DB.Default.Update<NotificationEntity>().Match(n => n.AvatarId == MyId && !n.IsRead)
|
||||
.Match(f => f.Lte(n => n.ID, lastRead)).Modify(n => n.IsRead, true).ExecuteAsync(token);
|
||||
}
|
||||
return Json(result);
|
||||
}
|
||||
@@ -155,14 +227,18 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
|
||||
public class NotificationsController : MastodonController
|
||||
{
|
||||
static readonly Dictionary<NotificationType, string> Names = new()
|
||||
internal static readonly Dictionary<NotificationType, string> Names = new()
|
||||
{
|
||||
[NotificationType.Mention] = "mention",
|
||||
[NotificationType.Follow] = "follow",
|
||||
[NotificationType.FollowRequest] = "follow_request",
|
||||
[NotificationType.Favourite] = "favourite",
|
||||
[NotificationType.Reblog] = "reblog",
|
||||
[NotificationType.Update] = "update"
|
||||
[NotificationType.Update] = "update",
|
||||
[NotificationType.Poll] = "poll",
|
||||
[NotificationType.Reaction] = "pleroma:emoji_reaction",
|
||||
[NotificationType.Quote] = "quote",
|
||||
[NotificationType.ModerationWarning] = "moderation_warning"
|
||||
};
|
||||
|
||||
readonly MastodonMapper _mapper;
|
||||
@@ -174,28 +250,157 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/notifications"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> List(CancellationToken token)
|
||||
Find<NotificationEntity, NotificationEntity> Filtered()
|
||||
{
|
||||
var types = Params.List("types").Select(Parse).Where(t => t.HasValue).Select(t => t.Value).ToList();
|
||||
var excluded = Params.List("exclude_types").Select(Parse).Where(t => t.HasValue).Select(t => t.Value).ToList();
|
||||
var query = _dbEntities.Notifications.Match(n => n.AvatarId == MyId);
|
||||
// what the persona's notification policy filtered stays in its requests, unless asked for (a request's own page)
|
||||
if (Params.Bool("include_filtered") != true)
|
||||
query.Match(NotFiltered);
|
||||
if (types.Count > 0)
|
||||
query.Match(n => types.Contains(n.Type));
|
||||
if (excluded.Count > 0)
|
||||
query.Match(n => !excluded.Contains(n.Type));
|
||||
if (Params.Get("account_id") is { } accountId)
|
||||
query.Match(n => n.FromAccountId == accountId);
|
||||
var notifications = await Page.From(Params, Limit(15, 30)).Fetch(query, n => n.ID, token);
|
||||
return query;
|
||||
}
|
||||
|
||||
// (notifications made before the policy have no Filtered at all)
|
||||
static readonly FilterDefinition<NotificationEntity> NotFiltered = Builders<NotificationEntity>.Filter.Ne(n => n.Filtered, true);
|
||||
|
||||
[HttpGet("/api/v1/notifications"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> List(CancellationToken token)
|
||||
{
|
||||
var notifications = await Page.From(Params, Limit(15, 30)).Fetch(Filtered(), n => n.ID, token);
|
||||
Link("/api/v1/notifications", notifications.LastOrDefault()?.ID, notifications.FirstOrDefault()?.ID);
|
||||
return Json(await Map(notifications, token));
|
||||
}
|
||||
|
||||
// Grouped notifications (Mastodon 4.3): api_versions.mastodon says we speak that API, so clients call it. Likes and
|
||||
// boosts of one post group together, as do follows within an hour; everything else stands alone.
|
||||
internal static readonly HashSet<string> DefaultGrouped = new() { "favourite", "reblog", "follow" };
|
||||
|
||||
HashSet<string> Grouped() => Params.List("grouped_types") is { Count: > 0 } chosen ? chosen.ToHashSet() : DefaultGrouped;
|
||||
|
||||
internal static string GroupKey(NotificationEntity n, ISet<string> grouped) =>
|
||||
!grouped.Contains(Names[n.Type]) ? $"ungrouped-{n.ID}"
|
||||
: n.Type == NotificationType.Follow ? $"grouped-follow-{n.CreatedAt:yyyyMMddHH}"
|
||||
: string.IsNullOrEmpty(n.PostId) ? $"ungrouped-{n.ID}"
|
||||
: $"grouped-{Names[n.Type]}-{n.PostId}";
|
||||
|
||||
// the notifications a group key stands for
|
||||
System.Linq.Expressions.Expression<Func<NotificationEntity, bool>> InGroup(string key)
|
||||
{
|
||||
var me = MyId;
|
||||
if (key.StartsWith("ungrouped-", StringComparison.Ordinal))
|
||||
{
|
||||
var id = key["ungrouped-".Length..];
|
||||
return n => n.AvatarId == me && n.ID == id;
|
||||
}
|
||||
if (key.StartsWith("grouped-follow-", StringComparison.Ordinal)
|
||||
&& DateTime.TryParseExact(key["grouped-follow-".Length..], "yyyyMMddHH", System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var hour))
|
||||
{
|
||||
var end = hour.AddHours(1);
|
||||
return n => n.AvatarId == me && n.Type == NotificationType.Follow && n.CreatedAt >= hour && n.CreatedAt < end;
|
||||
}
|
||||
if (key.Split('-') is ["grouped", var name, var postId] && Parse(name) is { } type)
|
||||
return n => n.AvatarId == me && n.Type == type && n.PostId == postId;
|
||||
return default;
|
||||
}
|
||||
|
||||
async Task<List<NotificationEntity>> Members(string key, CancellationToken token) =>
|
||||
InGroup(key) is { } filter
|
||||
? await _dbEntities.Notifications.Match(filter).Match(NotFiltered).Sort(n => n.ID, Order.Descending).Limit(80).ExecuteAsync(token)
|
||||
: new List<NotificationEntity>();
|
||||
|
||||
async Task<object> Groups(List<NotificationEntity> page, ISet<string> grouped, CancellationToken token)
|
||||
{
|
||||
var mapped = (await Map(page, token)).ToDictionary(n => n.Id);
|
||||
var shown = page.Where(n => mapped.ContainsKey(n.ID)).ToList();
|
||||
var groups = new List<object>();
|
||||
foreach (var group in shown.GroupBy(n => GroupKey(n, grouped)))
|
||||
{
|
||||
var newest = mapped[group.First().ID];
|
||||
var total = group.Key.StartsWith("ungrouped-", StringComparison.Ordinal) ? 1
|
||||
: await DB.Default.CountAsync(Builders<NotificationEntity>.Filter.And(Builders<NotificationEntity>.Filter.Where(InGroup(group.Key)), NotFiltered), token);
|
||||
groups.Add(new
|
||||
{
|
||||
group_key = group.Key,
|
||||
notifications_count = Math.Max(total, group.Count()),
|
||||
type = newest.Type,
|
||||
most_recent_notification_id = newest.Id,
|
||||
page_min_id = group.Last().ID,
|
||||
page_max_id = group.First().ID,
|
||||
latest_page_notification_at = newest.CreatedAt,
|
||||
sample_account_ids = group.Select(n => mapped[n.ID].Account.Id).Distinct().Take(8).ToList(),
|
||||
status_id = newest.Status?.Id,
|
||||
emoji = newest.Emoji,
|
||||
emoji_url = newest.EmojiUrl,
|
||||
moderation_warning = newest.ModerationWarning
|
||||
});
|
||||
}
|
||||
return new
|
||||
{
|
||||
accounts = mapped.Values.Select(n => n.Account).DistinctBy(a => a.Id).ToList(),
|
||||
statuses = mapped.Values.Where(n => n.Status != default).Select(n => n.Status).DistinctBy(s => s.Id).ToList(),
|
||||
notification_groups = groups
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/notifications"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> GroupedList(CancellationToken token)
|
||||
{
|
||||
var notifications = await Page.From(Params, Limit(40, 80)).Fetch(Filtered(), n => n.ID, token);
|
||||
Link("/api/v2/notifications", notifications.LastOrDefault()?.ID, notifications.FirstOrDefault()?.ID);
|
||||
return Json(await Groups(notifications, Grouped(), token));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/notifications/unread_count"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> GroupedUnreadCount(CancellationToken token)
|
||||
{
|
||||
var unread = await Filtered().Match(n => !n.IsRead).Sort(n => n.ID, Order.Descending)
|
||||
.Limit(Math.Clamp(Params.Int("limit") ?? 100, 1, 1000)).ExecuteAsync(token);
|
||||
var shown = (await Map(unread, token)).Select(n => n.Id).ToHashSet();
|
||||
var grouped = Grouped();
|
||||
return Json(new { count = unread.Where(n => shown.Contains(n.ID)).Select(n => GroupKey(n, grouped)).Distinct().Count() });
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/notifications/{groupKey}"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> Group(string groupKey, CancellationToken token)
|
||||
{
|
||||
var members = await Members(groupKey, token);
|
||||
if (members.Count == 0)
|
||||
return NotFoundError();
|
||||
return Json(await Groups(members, new HashSet<string>(DefaultGrouped.Append(Names[members[0].Type])), token));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/notifications/{groupKey}/accounts"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> GroupAccounts(string groupKey, CancellationToken token)
|
||||
{
|
||||
var members = await Members(groupKey, token);
|
||||
if (members.Count == 0)
|
||||
return NotFoundError();
|
||||
return Json((await Map(members, token)).Select(n => n.Account).DistinctBy(a => a.Id).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("/api/v2/notifications/{groupKey}/dismiss"), Scope("write:notifications")]
|
||||
public async Task<IActionResult> DismissGroup(string groupKey, CancellationToken token)
|
||||
{
|
||||
if (InGroup(groupKey) is not { } filter)
|
||||
return NotFoundError();
|
||||
await DB.Default.DeleteAsync(filter);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/notifications/{id}"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> Get(string id, CancellationToken token)
|
||||
{
|
||||
var notification = await _dbEntities.Notifications.Match(n => n.ID == id && n.AvatarId == MyId).ExecuteFirstAsync(token);
|
||||
return notification == default ? NotFoundError() : Json((await Map(new List<NotificationEntity> { notification }, token)).FirstOrDefault());
|
||||
var mapped = notification == default ? default : (await Map(new List<NotificationEntity> { notification }, token)).FirstOrDefault();
|
||||
return mapped == default ? NotFoundError() : Json(mapped);
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/notifications/clear"), Scope("write:notifications")]
|
||||
@@ -212,32 +417,19 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
// counts what the list would show (Map drops notifications from gone accounts and about hidden posts), at most
|
||||
// `limit` of them, as Mastodon does
|
||||
[HttpGet("/api/v1/notifications/unread_count"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> UnreadCount(CancellationToken token) =>
|
||||
Json(new { count = await DB.Default.CountAsync<NotificationEntity>(n => n.AvatarId == MyId && !n.IsRead, token) });
|
||||
|
||||
async Task<List<Entities.Notification>> Map(List<NotificationEntity> notifications, CancellationToken token)
|
||||
public async Task<IActionResult> UnreadCount(CancellationToken token)
|
||||
{
|
||||
var accounts = await _mapper.Accounts(notifications.Select(n => n.FromAccountId), token);
|
||||
var postIds = notifications.Where(n => n.PostId != default).Select(n => n.PostId).Distinct().ToList();
|
||||
var posts = postIds.Count == 0
|
||||
? new List<PostEntity>()
|
||||
: await _dbEntities.Posts.Match(p => postIds.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token);
|
||||
var statuses = (await _mapper.Statuses(posts, MyId, token)).ToDictionary(s => s.Id);
|
||||
return notifications
|
||||
.Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty) && (n.PostId == default || statuses.ContainsKey(n.PostId)))
|
||||
.Select(n => new Entities.Notification
|
||||
{
|
||||
Id = n.ID,
|
||||
Type = Names[n.Type],
|
||||
CreatedAt = MastodonJson.Time(n.CreatedAt),
|
||||
GroupKey = $"ungrouped-{n.ID}",
|
||||
Account = accounts[n.FromAccountId],
|
||||
Status = n.PostId == default ? default : statuses[n.PostId]
|
||||
})
|
||||
.ToList();
|
||||
var unread = await DB.Default.Find<NotificationEntity>().Match(n => n.AvatarId == MyId && !n.IsRead).Match(NotFiltered)
|
||||
.Sort(n => n.ID, Order.Descending).Limit(Math.Clamp(Params.Int("limit") ?? 100, 1, 1000)).ExecuteAsync(token);
|
||||
return Json(new { count = (await Map(unread, token)).Count });
|
||||
}
|
||||
|
||||
Task<List<Entities.Notification>> Map(List<NotificationEntity> notifications, CancellationToken token) =>
|
||||
_mapper.Notifications(notifications, MyId, token);
|
||||
|
||||
static NotificationType? Parse(string name) => Names.FirstOrDefault(n => n.Value == name) is { Value: not null } pair ? pair.Key : (NotificationType?)null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Trends and the directory (owner decision 2026-10-04, back from the cut list), from what PrivaPub already holds
|
||||
// (Trends). The directory lists only accounts that let themselves be discovered.
|
||||
public class TrendsController : MastodonController
|
||||
{
|
||||
readonly Trends _trends;
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
|
||||
public TrendsController(Trends trends, MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors)
|
||||
{
|
||||
_trends = trends;
|
||||
_mapper = mapper;
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
}
|
||||
|
||||
static List<object> History(Trends.Trend trend) => trend.History.Select(h => (object)new
|
||||
{
|
||||
day = new DateTimeOffset(h.Day, TimeSpan.Zero).ToUnixTimeSeconds().ToString(),
|
||||
uses = h.Uses.ToString(),
|
||||
accounts = h.Accounts.ToString()
|
||||
}).ToList();
|
||||
|
||||
IEnumerable<T> Window<T>(IEnumerable<T> items, int fallback, int max) =>
|
||||
items.Skip(Math.Max(0, Params.Int("offset") ?? 0)).Take(Limit(fallback, max));
|
||||
|
||||
[HttpGet("/api/v1/trends"), HttpGet("/api/v1/trends/tags"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Tags(CancellationToken token)
|
||||
{
|
||||
var followed = MyId == default
|
||||
? new HashSet<string>()
|
||||
: (await DB.Default.Find<Models.Social.FollowedTag>().Match(t => t.AvatarId == MyId).ExecuteAsync(token)).Select(t => t.Name).ToHashSet();
|
||||
return Json(Window(await _trends.Tags(token), 10, 20).Select(t => new
|
||||
{
|
||||
name = t.Key,
|
||||
url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(t.Key)}",
|
||||
history = History(t),
|
||||
following = MyId == default ? default(bool?) : followed.Contains(t.Key)
|
||||
}).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/trends/statuses"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Statuses(CancellationToken token)
|
||||
{
|
||||
var ids = Window(await _trends.Statuses(token), 20, 40).ToList();
|
||||
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token)).ToDictionary(p => p.ID);
|
||||
return Json(await _mapper.Statuses(ids.Where(posts.ContainsKey).Select(id => posts[id]).ToList(), MyId, token));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/trends/links"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Links(CancellationToken token)
|
||||
{
|
||||
var trends = Window(await _trends.Links(token), 10, 20).ToList();
|
||||
var sampleIds = trends.Select(t => t.SamplePostId).ToList();
|
||||
var samples = (await _dbEntities.Posts.Match(p => sampleIds.Contains(p.ID)).ExecuteAsync(token)).ToDictionary(p => p.ID);
|
||||
return Json(trends.Where(t => samples.ContainsKey(t.SamplePostId)).Select(t =>
|
||||
{
|
||||
var link = samples[t.SamplePostId].Link;
|
||||
var card = _mapper.LinkCard(t.Key, link.Title, link.Description, link.ImageURL);
|
||||
return card == default ? default : (object)new
|
||||
{
|
||||
card.Url, card.Title, card.Description, card.Type, card.AuthorName, card.AuthorUrl, card.ProviderName, card.ProviderUrl,
|
||||
card.Html, card.Width, card.Height, card.Image, card.ImageDescription, card.EmbedUrl, card.Blurhash, card.Language,
|
||||
card.PublishedAt, card.Authors, History = History(t)
|
||||
};
|
||||
}).Where(c => c != default).ToList());
|
||||
}
|
||||
|
||||
// order=active: by their latest public post PrivaPub holds; order=new: by when they say they joined, which for a
|
||||
// persona is its published day (never its creation, so personas made together are not told apart by their order)
|
||||
[HttpGet("/api/v1/directory"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Directory(CancellationToken token)
|
||||
{
|
||||
var offset = Math.Max(0, Params.Int("offset") ?? 0);
|
||||
var limit = Limit(40, 80);
|
||||
var localOnly = Params.Bool("local") == true;
|
||||
var barred = await Counted.BarredPersonas(token);
|
||||
var wanted = offset + limit;
|
||||
var ordered = new List<string>();
|
||||
if (Params.Get("order") == "new")
|
||||
{
|
||||
// many share a day: the first `wanted` are read with every other of the last one's day, so each page is cut the same way
|
||||
var personas = await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID))
|
||||
.Sort(a => a.PublishedOn, Order.Descending).Limit(wanted).ExecuteAsync(token);
|
||||
if (personas.Count == wanted)
|
||||
{
|
||||
var edge = personas[^1].PublishedOn;
|
||||
var known = personas.Select(a => a.ID).ToList();
|
||||
personas.AddRange(await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID)
|
||||
&& a.PublishedOn == edge && !known.Contains(a.ID)).ExecuteAsync(token));
|
||||
}
|
||||
var remote = localOnly
|
||||
? new List<ForeignAvatar>()
|
||||
: await _dbEntities.ForeignAvatars.Match(f => f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue && f.Published != null)
|
||||
.Sort(f => f.Published, Order.Descending).Limit(wanted).ExecuteAsync(token);
|
||||
if (remote.Count == wanted)
|
||||
{
|
||||
var edge = remote[^1].Published;
|
||||
var known = remote.Select(f => f.ID).ToList();
|
||||
remote.AddRange(await _dbEntities.ForeignAvatars.Match(f => f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue
|
||||
&& f.Published == edge && !known.Contains(f.ID)).ExecuteAsync(token));
|
||||
}
|
||||
ordered = personas.Select(a => (a.ID, Joined: a.PublishedOn, Tie: Tie(a.UserName)))
|
||||
.Concat(remote.Select(f => (f.ID, Joined: f.Published.Value, Tie: Tie(f.ActorURI))))
|
||||
.OrderByDescending(a => a.Joined).ThenBy(a => a.Tie, StringComparer.Ordinal)
|
||||
.Select(a => a.ID).Skip(offset).Take(limit).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
var discoverableLocal = (await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID))
|
||||
.Project(a => a.Include(x => x.ID)).ExecuteAsync(token)).Select(a => a.ID).ToHashSet();
|
||||
var seen = new HashSet<string>();
|
||||
var maxId = default(string);
|
||||
for (var round = 0; round < 20 && ordered.Count < wanted; round++)
|
||||
{
|
||||
var page = _dbEntities.Posts.Match(p => p.Visibility == Models.Post.PostVisibility.Public && !p.DeletedAt.HasValue && p.ReblogOfPostId == null);
|
||||
if (maxId != default)
|
||||
page.Match(f => f.Lt(p => p.ID, maxId));
|
||||
if (localOnly)
|
||||
page.Match(p => !p.IsFederatedCopy);
|
||||
var batch = await page.Sort(p => p.ID, Order.Descending).Limit(500)
|
||||
.Project(p => p.Include(x => x.ID).Include(x => x.AuthorAccountId).Include(x => x.GroupUserId).Include(x => x.IsFederatedCopy))
|
||||
.ExecuteAsync(token);
|
||||
if (batch.Count == 0)
|
||||
break;
|
||||
maxId = batch[^1].ID;
|
||||
var fresh = batch.Select(p => (Id: p.AuthorAccountId ?? p.GroupUserId, p.IsFederatedCopy)).Where(a => a.Id != default && seen.Add(a.Id)).ToList();
|
||||
var remoteIds = fresh.Where(a => a.IsFederatedCopy).Select(a => a.Id).ToList();
|
||||
var discoverableRemote = remoteIds.Count == 0
|
||||
? new HashSet<string>()
|
||||
: (await _dbEntities.ForeignAvatars.Match(f => remoteIds.Contains(f.ID) && f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue)
|
||||
.Project(f => f.Include(x => x.ID)).ExecuteAsync(token)).Select(f => f.ID).ToHashSet();
|
||||
ordered.AddRange(fresh.Where(a => a.IsFederatedCopy ? discoverableRemote.Contains(a.Id) : discoverableLocal.Contains(a.Id)).Select(a => a.Id));
|
||||
}
|
||||
ordered = ordered.Skip(offset).Take(limit).ToList();
|
||||
}
|
||||
var accounts = await _mapper.Accounts(ordered, token);
|
||||
return Json(ordered.Where(accounts.ContainsKey).Select(id => accounts[id]).ToList());
|
||||
}
|
||||
|
||||
static string Tie(string name) =>
|
||||
Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(name ?? string.Empty)));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace PrivaPub.Api.Mastodon.Entities
|
||||
namespace PrivaPub.Api.Mastodon.Entities
|
||||
{
|
||||
public class Account
|
||||
{
|
||||
@@ -25,10 +25,23 @@ namespace PrivaPub.Api.Mastodon.Entities
|
||||
public string LastStatusAt { get; set; }
|
||||
public bool? HideCollections { get; set; }
|
||||
public bool Noindex { get; set; } = true;
|
||||
public List<object> Emojis { get; set; } = new();
|
||||
public List<CustomEmojiEntity> Emojis { get; set; } = new();
|
||||
public List<object> Roles { get; set; } = new();
|
||||
public bool? Memorial { get; set; }
|
||||
public string AvatarDescription { get; set; } = string.Empty;
|
||||
public string HeaderDescription { get; set; } = string.Empty;
|
||||
public List<Field> Fields { get; set; } = new();
|
||||
public Source Source { get; set; }
|
||||
public Account Moved { get; set; }//the account it moved to, when that account names it among its aliases
|
||||
}
|
||||
|
||||
public class CustomEmojiEntity
|
||||
{
|
||||
public string Shortcode { get; set; }
|
||||
public string Url { get; set; }
|
||||
public string StaticUrl { get; set; }
|
||||
public bool VisibleInPicker { get; set; }
|
||||
public string Category { get; set; }
|
||||
}
|
||||
|
||||
public class Field
|
||||
@@ -47,6 +60,239 @@ namespace PrivaPub.Api.Mastodon.Entities
|
||||
public List<Field> Fields { get; set; } = new();
|
||||
public int FollowRequestsCount { get; set; }
|
||||
public string AttributionDomains { get; set; }
|
||||
public string QuotePolicy { get; set; } = "public";
|
||||
}
|
||||
|
||||
public class PrivaPubStatus
|
||||
{
|
||||
public string ObjectType { get; set; }
|
||||
public bool Locked { get; set; }//its community's moderators locked it: a reply is refused
|
||||
public string Approval { get; set; }//our reply or boost its target's author must approve: "pending", "rejected"
|
||||
public string Title { get; set; }
|
||||
public string Excerpt { get; set; }
|
||||
public string Cover { get; set; }
|
||||
public PrivaPubSource Source { get; set; }
|
||||
public PrivaPubLink Link { get; set; }
|
||||
public PrivaPubVideo Video { get; set; }
|
||||
public PrivaPubAudio Audio { get; set; }
|
||||
public PrivaPubEvent Event { get; set; }
|
||||
public PrivaPubPlace Place { get; set; }//a remote post's own place (Pixelfed), shown only
|
||||
public Account Wall { get; set; }//whose wall it was written on (FEP-400e), when not its author's
|
||||
public PrivaPubVotes Votes { get; set; } = new();
|
||||
public List<PrivaPubFlair> Flairs { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PrivaPubSource
|
||||
{
|
||||
public string Content { get; set; }
|
||||
public string MediaType { get; set; }
|
||||
}
|
||||
|
||||
public class PrivaPubLink
|
||||
{
|
||||
public string Url { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Description { get; set; }
|
||||
public string Image { get; set; }
|
||||
public bool FromPublisher { get; set; }
|
||||
}
|
||||
|
||||
public class PrivaPubVariant
|
||||
{
|
||||
public string Url { get; set; }
|
||||
public string MediaType { get; set; }
|
||||
public int? Width { get; set; }
|
||||
public int? Height { get; set; }
|
||||
public double? Fps { get; set; }
|
||||
public long? Size { get; set; }
|
||||
public List<string> Streams { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PrivaPubVideo
|
||||
{
|
||||
public double? Duration { get; set; }
|
||||
public List<PrivaPubVariant> Variants { get; set; } = new();
|
||||
public bool IsLive { get; set; }
|
||||
public int? State { get; set; }
|
||||
public long? Views { get; set; }
|
||||
public int? CommentsPolicy { get; set; }
|
||||
public bool? DownloadEnabled { get; set; }
|
||||
public string Licence { get; set; }
|
||||
public string Category { get; set; }
|
||||
public string Channel { get; set; }
|
||||
public List<PrivaPubCaption> Captions { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PrivaPubCaption
|
||||
{
|
||||
public string Language { get; set; }
|
||||
public string Url { get; set; }
|
||||
}
|
||||
|
||||
public class PrivaPubAudio
|
||||
{
|
||||
public double? Duration { get; set; }
|
||||
public List<PrivaPubVariant> Variants { get; set; } = new();
|
||||
public string Album { get; set; }
|
||||
public int? Position { get; set; }
|
||||
public string Licence { get; set; }
|
||||
}
|
||||
|
||||
public class PrivaPubEvent
|
||||
{
|
||||
public string Start { get; set; }
|
||||
public string End { get; set; }
|
||||
public string TimeZone { get; set; }
|
||||
public bool Floating { get; set; }
|
||||
public List<PrivaPubPlace> Places { get; set; } = new();
|
||||
public string OnlineUrl { get; set; }
|
||||
public string JoinMode { get; set; }
|
||||
public int? Participants { get; set; }
|
||||
public int? Capacity { get; set; }
|
||||
public string Status { get; set; }
|
||||
public string Category { get; set; }
|
||||
public string Participation { get; set; }//the viewer's: "pending", "accepted" or "rejected"
|
||||
}
|
||||
|
||||
public class PrivaPubPlace
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Address { get; set; }
|
||||
public string Country { get; set; }//a post's own place only
|
||||
public double? Latitude { get; set; }
|
||||
public double? Longitude { get; set; }
|
||||
public string Url { get; set; }
|
||||
}
|
||||
|
||||
public class PrivaPubFlair
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Slug { get; set; }
|
||||
public string Description { get; set; }
|
||||
public string Color { get; set; }
|
||||
public string TextColor { get; set; }
|
||||
public string BackgroundColor { get; set; }
|
||||
public bool BlurImages { get; set; }
|
||||
}
|
||||
|
||||
public class PrivaPubVotes
|
||||
{
|
||||
public int Up { get; set; }
|
||||
public int Down { get; set; }
|
||||
public bool? Downvoted { get; set; }//the viewer's own downvote; null with no viewer
|
||||
}
|
||||
|
||||
public class QuoteEntity
|
||||
{
|
||||
public string State { get; set; }
|
||||
public Status QuotedStatus { get; set; }
|
||||
public string QuotedStatusId { get; set; }
|
||||
}
|
||||
|
||||
public class QuoteApprovalEntity
|
||||
{
|
||||
public List<string> Automatic { get; set; } = new();
|
||||
public List<string> Manual { get; set; } = new();
|
||||
public string CurrentUser { get; set; } = "denied";
|
||||
}
|
||||
|
||||
public class FilterEntity
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Title { get; set; }
|
||||
public List<string> Context { get; set; } = new();
|
||||
public string ExpiresAt { get; set; }
|
||||
public string FilterAction { get; set; }
|
||||
public List<FilterKeywordEntity> Keywords { get; set; } = new();
|
||||
public List<FilterStatusEntity> Statuses { get; set; } = new();
|
||||
}
|
||||
|
||||
public class FilterKeywordEntity
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Keyword { get; set; }
|
||||
public bool WholeWord { get; set; }
|
||||
}
|
||||
|
||||
public class FilterStatusEntity
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string StatusId { get; set; }
|
||||
}
|
||||
|
||||
public class FilterResultEntity
|
||||
{
|
||||
public FilterEntity Filter { get; set; }
|
||||
public List<string> KeywordMatches { get; set; }
|
||||
public List<string> StatusMatches { get; set; }
|
||||
}
|
||||
|
||||
// the v1 filter, which is one keyword of a v2 filter
|
||||
public class FilterV1Entity
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Phrase { get; set; }
|
||||
public List<string> Context { get; set; } = new();
|
||||
public bool WholeWord { get; set; }
|
||||
public string ExpiresAt { get; set; }
|
||||
public bool Irreversible { get; set; }
|
||||
}
|
||||
|
||||
public class EmojiReactionEntity
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public int Count { get; set; }
|
||||
public bool Me { get; set; }
|
||||
public string Url { get; set; }
|
||||
public string StaticUrl { get; set; }
|
||||
}
|
||||
|
||||
public class PleromaStatus
|
||||
{
|
||||
public List<EmojiReactionEntity> EmojiReactions { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PollEntity
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string ExpiresAt { get; set; }
|
||||
public bool Expired { get; set; }
|
||||
public bool Multiple { get; set; }
|
||||
public int VotesCount { get; set; }
|
||||
public int? VotersCount { get; set; }
|
||||
public bool? Voted { get; set; }
|
||||
public List<int> OwnVotes { get; set; }
|
||||
public List<PollOptionEntity> Options { get; set; } = new();
|
||||
public List<CustomEmojiEntity> Emojis { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PollOptionEntity
|
||||
{
|
||||
public string Title { get; set; }
|
||||
public int? VotesCount { get; set; }
|
||||
}
|
||||
|
||||
public class PreviewCard
|
||||
{
|
||||
public string Url { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = "link";
|
||||
public string AuthorName { get; set; } = string.Empty;
|
||||
public string AuthorUrl { get; set; } = string.Empty;
|
||||
public string ProviderName { get; set; } = string.Empty;
|
||||
public string ProviderUrl { get; set; } = string.Empty;
|
||||
public string Html { get; set; } = string.Empty;
|
||||
public int Width { get; set; }
|
||||
public int Height { get; set; }
|
||||
public string Image { get; set; }
|
||||
public string ImageDescription { get; set; } = string.Empty;
|
||||
public string EmbedUrl { get; set; } = string.Empty;
|
||||
public string Blurhash { get; set; }
|
||||
public string Language { get; set; }
|
||||
public string PublishedAt { get; set; }
|
||||
public List<object> Authors { get; set; } = new();
|
||||
}
|
||||
|
||||
public class Status
|
||||
@@ -61,7 +307,7 @@ namespace PrivaPub.Api.Mastodon.Entities
|
||||
public string Content { get; set; } = string.Empty;
|
||||
public string CreatedAt { get; set; }
|
||||
public string EditedAt { get; set; }
|
||||
public List<object> Emojis { get; set; } = new();
|
||||
public List<CustomEmojiEntity> Emojis { get; set; } = new();
|
||||
public int RepliesCount { get; set; }
|
||||
public int ReblogsCount { get; set; }
|
||||
public int FavouritesCount { get; set; }
|
||||
@@ -76,12 +322,33 @@ namespace PrivaPub.Api.Mastodon.Entities
|
||||
public List<MediaAttachment> MediaAttachments { get; set; } = new();
|
||||
public List<StatusMention> Mentions { get; set; } = new();
|
||||
public List<StatusTag> Tags { get; set; } = new();
|
||||
public object Card { get; set; }
|
||||
public object Poll { get; set; }
|
||||
public PreviewCard Card { get; set; }
|
||||
public PollEntity Poll { get; set; }
|
||||
public Application Application { get; set; }
|
||||
public string Language { get; set; }
|
||||
public string Text { get; set; }
|
||||
public List<object> Filtered { get; set; } = new();
|
||||
public List<FilterResultEntity> Filtered { get; set; } = new();
|
||||
public PrivaPubStatus Privapub { get; set; }
|
||||
public List<EmojiReactionEntity> EmojiReactions { get; set; } = new();
|
||||
public PleromaStatus Pleroma { get; set; }
|
||||
public QuoteEntity Quote { get; set; }
|
||||
public int QuotesCount { get; set; }
|
||||
public QuoteApprovalEntity QuoteApproval { get; set; }
|
||||
public InteractionPolicyEntity InteractionPolicy { get; set; }//GoToSocial's: who may like, reply to and boost a remote post
|
||||
}
|
||||
|
||||
// GoToSocial's interaction_policy: "public", "followers", "following", "author", "me", or an account's URI
|
||||
public class InteractionPolicyEntity
|
||||
{
|
||||
public InteractionRuleEntity CanFavourite { get; set; }
|
||||
public InteractionRuleEntity CanReply { get; set; }
|
||||
public InteractionRuleEntity CanReblog { get; set; }
|
||||
}
|
||||
|
||||
public class InteractionRuleEntity
|
||||
{
|
||||
public List<string> AutomaticApproval { get; set; } = new();
|
||||
public List<string> ManualApproval { get; set; } = new();
|
||||
}
|
||||
|
||||
public class MediaAttachment
|
||||
@@ -150,6 +417,20 @@ namespace PrivaPub.Api.Mastodon.Entities
|
||||
public string GroupKey { get; set; }
|
||||
public Account Account { get; set; }
|
||||
public Status Status { get; set; }
|
||||
public string Emoji { get; set; }
|
||||
public string EmojiUrl { get; set; }
|
||||
public AccountWarning ModerationWarning { get; set; }
|
||||
}
|
||||
|
||||
public class AccountWarning
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Action { get; set; } = "none";
|
||||
public string Text { get; set; } = string.Empty;
|
||||
public List<string> StatusIds { get; set; } = new();
|
||||
public Account TargetAccount { get; set; }
|
||||
public object Appeal { get; set; }
|
||||
public string CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class Context
|
||||
@@ -166,7 +447,7 @@ namespace PrivaPub.Api.Mastodon.Entities
|
||||
public string CreatedAt { get; set; }
|
||||
public Account Account { get; set; }
|
||||
public List<MediaAttachment> MediaAttachments { get; set; } = new();
|
||||
public List<object> Emojis { get; set; } = new();
|
||||
public List<CustomEmojiEntity> Emojis { get; set; } = new();
|
||||
}
|
||||
|
||||
public class StatusSource
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Infrastructure
|
||||
{
|
||||
public static class MastodonErrors
|
||||
{
|
||||
public static IApplicationBuilder UseMastodonErrorBodies(this IApplicationBuilder app) =>
|
||||
app.UseWhen(context => context.Request.Path.StartsWithSegments("/api"), api => api.UseStatusCodePages(async pages =>
|
||||
{
|
||||
var response = pages.HttpContext.Response;
|
||||
response.ContentType = "application/json; charset=utf-8";
|
||||
await response.WriteAsync(JsonSerializer.Serialize(new { error = Message(response.StatusCode) }, MastodonJson.Options));
|
||||
}));
|
||||
|
||||
public static string Message(int status) => status switch
|
||||
{
|
||||
StatusCodes.Status401Unauthorized => "The access token is invalid",
|
||||
StatusCodes.Status403Forbidden => "This action is outside the authorized scopes",
|
||||
StatusCodes.Status404NotFound => "Record not found",
|
||||
StatusCodes.Status405MethodNotAllowed => "Method not allowed",
|
||||
StatusCodes.Status413PayloadTooLarge => "Payload too large",
|
||||
StatusCodes.Status415UnsupportedMediaType => "Unsupported media type",
|
||||
StatusCodes.Status422UnprocessableEntity => "Validation failed",
|
||||
StatusCodes.Status429TooManyRequests => "Too many requests",
|
||||
>= 500 => "Something went wrong.",
|
||||
_ => "Bad request"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,19 @@ namespace PrivaPub.Api.Mastodon.Infrastructure
|
||||
{
|
||||
readonly Dictionary<string, List<string>> _values;
|
||||
|
||||
MastodonParams(Dictionary<string, List<string>> values)
|
||||
MastodonParams(Dictionary<string, List<string>> values, JsonElement? json = default)
|
||||
{
|
||||
_values = values;
|
||||
Json = json;
|
||||
}
|
||||
|
||||
// a JSON body as it came, for what flattening loses: which fields belong to the same object of an array
|
||||
public JsonElement? Json { get; }
|
||||
|
||||
public static async Task<MastodonParams> Read(HttpRequest request, CancellationToken token)
|
||||
{
|
||||
var values = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
JsonElement? json = default;
|
||||
foreach (var pair in request.Query)
|
||||
foreach (var value in pair.Value)
|
||||
Add(values, pair.Key, value);
|
||||
@@ -33,12 +38,13 @@ namespace PrivaPub.Api.Mastodon.Infrastructure
|
||||
{
|
||||
using var document = await JsonDocument.ParseAsync(request.Body, cancellationToken: token);
|
||||
Flatten(values, default, document.RootElement);
|
||||
json = document.RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
return new MastodonParams(values);
|
||||
return new MastodonParams(values, json);
|
||||
}
|
||||
|
||||
public static MastodonParams From(IEnumerable<KeyValuePair<string, string>> pairs)
|
||||
@@ -51,6 +57,8 @@ namespace PrivaPub.Api.Mastodon.Infrastructure
|
||||
|
||||
public bool Has(string name) => _values.ContainsKey(name);
|
||||
|
||||
public IEnumerable<string> Names => _values.Keys;
|
||||
|
||||
public string Get(string name) => _values.TryGetValue(name, out var list) && list.Count > 0 ? list[^1] : default;
|
||||
|
||||
public IReadOnlyList<string> List(string name) => _values.TryGetValue(name, out var list) ? list : (IReadOnlyList<string>)Array.Empty<string>();
|
||||
@@ -81,6 +89,9 @@ namespace PrivaPub.Api.Mastodon.Infrastructure
|
||||
Flatten(values, prefix == default ? property.Name : $"{prefix}[{property.Name}]", property.Value);
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
// an empty array is still said: media_ids: [] on an edit takes every attachment away
|
||||
if (prefix != default && element.GetArrayLength() == 0)
|
||||
values.TryAdd(prefix, new List<string>());
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
|
||||
|
||||
@@ -19,7 +19,34 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
_remoteActors = remoteActors;
|
||||
}
|
||||
|
||||
public async Task<List<Account>> Find(string q, bool resolve, int limit, CancellationToken token)
|
||||
public async Task<List<Account>> Find(string q, bool resolve, int limit, CancellationToken token) => await Find(q, resolve, limit, 0, token);
|
||||
|
||||
// following=true: only accounts the persona follows, matched by the start of their name, display name or server
|
||||
// (what a client offers when adding to a list), read from what PrivaPub holds and never resolved
|
||||
public async Task<List<Account>> Followed(string viewerId, string q, int limit, CancellationToken token)
|
||||
{
|
||||
q = q?.Trim().TrimStart('@') ?? string.Empty;
|
||||
var ids = (await _dbEntities.Followings.Match(f => f.AvatarId == viewerId && f.State == Models.Social.FollowState.Accepted).ExecuteAsync(token))
|
||||
.Select(f => f.TargetAccountId).Where(id => !string.IsNullOrEmpty(id)).ToList();
|
||||
if (ids.Count == 0)
|
||||
return new List<Account>();
|
||||
var parts = q.Split('@');
|
||||
var localDomain = new Uri(_localActors.BaseAddress).Authority;
|
||||
bool Matches(string userName, string name, string domain) =>
|
||||
userName?.StartsWith(parts[0], StringComparison.OrdinalIgnoreCase) == true
|
||||
&& (parts.Length < 2 || domain?.StartsWith(parts[1], StringComparison.OrdinalIgnoreCase) == true)
|
||||
|| parts.Length == 1 && name?.Contains(q, StringComparison.OrdinalIgnoreCase) == true;
|
||||
var found = (await _dbEntities.Avatars.Match(a => ids.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token))
|
||||
.Where(a => Matches(a.UserName, a.Name, localDomain)).Select(a => (a.UserName, a.ID))
|
||||
.Concat((await _dbEntities.ForeignAvatars.Match(f => ids.Contains(f.ID) && !f.DeletionAt.HasValue).ExecuteAsync(token))
|
||||
.Where(f => Matches(f.UserName, f.Name, f.Domain)).Select(f => (f.UserName, f.ID)))
|
||||
.OrderBy(a => a.UserName, StringComparer.OrdinalIgnoreCase).Take(limit).Select(a => a.ID).ToList();
|
||||
var accounts = await _mapper.Accounts(found, token);
|
||||
return found.Where(accounts.ContainsKey).Select(id => accounts[id]).ToList();
|
||||
}
|
||||
|
||||
// gone accounts (remote ones that deleted themselves, personas of banned or deleted roots) are never found
|
||||
public async Task<List<Account>> Find(string q, bool resolve, int limit, int offset, CancellationToken token)
|
||||
{
|
||||
q = q?.Trim().TrimStart('@');
|
||||
if (string.IsNullOrEmpty(q))
|
||||
@@ -31,24 +58,31 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
{
|
||||
var userName = parts[0];
|
||||
var domain = parts[1].ToLowerInvariant();
|
||||
var known = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain).ExecuteFirstAsync(token);
|
||||
var known = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain && !f.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (known == default && resolve)
|
||||
{
|
||||
var actorUri = await _remoteActors.ResolveHandle(q, token);
|
||||
known = actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token);
|
||||
}
|
||||
if (known != default)
|
||||
if (known is { DeletionAt: null } && offset == 0)
|
||||
results.Add(_mapper.Foreign(known));
|
||||
return results;
|
||||
}
|
||||
|
||||
var prefix = parts[0].ToLowerInvariant();
|
||||
foreach (var avatar in await _dbEntities.Avatars.Match(a => a.UserName.StartsWith(prefix) && !a.DeletionAt.HasValue && a.Settings.IsDiscoverable != false)
|
||||
.Limit(limit).ExecuteAsync(token))
|
||||
var barred = await Domain.Privacy.Counted.BarredPersonas(token);
|
||||
var locals = await _dbEntities.Avatars
|
||||
.Match(a => a.UserName.StartsWith(prefix) && !a.DeletionAt.HasValue && a.Settings.IsDiscoverable != false && !barred.Contains(a.ID))
|
||||
.Sort(a => a.UserName, MongoDB.Entities.Order.Ascending)
|
||||
.Limit(offset + limit).ExecuteAsync(token);
|
||||
foreach (var avatar in locals.Skip(offset))
|
||||
results.Add(await _mapper.Local(_localActors.FromAvatar(avatar), false, token));
|
||||
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => f.UserName.StartsWith(parts[0]) && f.IsDiscoverable)
|
||||
.Limit(Math.Max(0, limit - results.Count)).ExecuteAsync(token))
|
||||
results.Add(_mapper.Foreign(foreign));
|
||||
var skipRemote = Math.Max(0, offset - locals.Count);
|
||||
if (results.Count < limit)
|
||||
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => f.UserName.StartsWith(parts[0]) && f.IsDiscoverable && !f.DeletionAt.HasValue)
|
||||
.Sort(f => f.UserName, MongoDB.Entities.Order.Ascending)
|
||||
.Skip(skipRemote).Limit(limit - results.Count).ExecuteAsync(token))
|
||||
results.Add(_mapper.Foreign(foreign));
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using MongoDB.Entities;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Entities;
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
@@ -7,11 +7,15 @@ using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
|
||||
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||
@@ -34,6 +38,43 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
|
||||
string Proxied(string url) => _proxy == default ? url : _proxy.Wrap(url);
|
||||
|
||||
public string ProxiedUrl(string url) => Proxied(url);
|
||||
|
||||
// notifications as the persona sees them: one whose account or post it can no longer see is left out
|
||||
public async Task<List<Entities.Notification>> Notifications(List<Models.Social.Notification> notifications, string viewerId, CancellationToken token)
|
||||
{
|
||||
var accounts = await Accounts(notifications.Select(n => n.FromAccountId), token);
|
||||
var postIds = notifications.Where(n => n.PostId != default).Select(n => n.PostId).Distinct().ToList();
|
||||
var posts = postIds.Count == 0
|
||||
? new List<PostEntity>()
|
||||
: await _dbEntities.Posts.Match(p => postIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token);
|
||||
var statuses = (await Statuses(posts, viewerId, token)).ToDictionary(s => s.Id);
|
||||
var warned = notifications.Any(n => n.Type == NotificationType.ModerationWarning) && viewerId != default ? await Account(viewerId, token) : default;
|
||||
return notifications
|
||||
.Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty) && (n.PostId == default || statuses.ContainsKey(n.PostId)))
|
||||
.Select(n => new Entities.Notification
|
||||
{
|
||||
Id = n.ID,
|
||||
Type = Controllers.NotificationsController.Names[n.Type],
|
||||
CreatedAt = MastodonJson.Time(n.CreatedAt),
|
||||
//the group it belongs to by default, so a notification told alone (v1, a stream) joins its group in a client
|
||||
GroupKey = Controllers.NotificationsController.GroupKey(n, Controllers.NotificationsController.DefaultGrouped),
|
||||
Account = accounts[n.FromAccountId],
|
||||
Status = n.PostId == default || n.Type == NotificationType.ModerationWarning ? default : statuses[n.PostId],
|
||||
Emoji = n.Emoji == default ? default : n.EmojiURL == default ? n.Emoji : $":{n.Emoji}:",
|
||||
EmojiUrl = Proxied(n.EmojiURL),
|
||||
ModerationWarning = n.Type != NotificationType.ModerationWarning ? default : new AccountWarning
|
||||
{
|
||||
Id = n.ID,
|
||||
Text = n.Text ?? string.Empty,
|
||||
StatusIds = n.PostId == default ? new List<string>() : new List<string> { n.PostId },
|
||||
TargetAccount = warned,
|
||||
CreatedAt = MastodonJson.Time(n.CreatedAt)
|
||||
}
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
string MissingAvatar => $"{_localActors.BaseAddress}/media/missing-avatar.png";
|
||||
string MissingHeader => $"{_localActors.BaseAddress}/media/missing-header.png";
|
||||
|
||||
@@ -44,15 +85,11 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
? await DB.Default.CountAsync<Following>(f => f.AvatarId == actor.Id && f.State == FollowState.Accepted, token)
|
||||
: 0;
|
||||
var latest = await _dbEntities.Posts
|
||||
.Match(p => (actor.Kind == LocalActorKind.Group ? p.GroupId == actor.Id : p.GroupUserId == actor.Id) && !p.IsFederatedCopy
|
||||
&& !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct && p.ReblogOfPostId == null)
|
||||
.Match(Counted.By(actor))
|
||||
.Sort(p => p.ID, Order.Descending)
|
||||
.Limit(1)
|
||||
.ExecuteFirstAsync(token);
|
||||
var statuses = latest == default
|
||||
? 0
|
||||
: await DB.Default.CountAsync<PostEntity>(p => (actor.Kind == LocalActorKind.Group ? p.GroupId == actor.Id : p.GroupUserId == actor.Id)
|
||||
&& !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct && p.ReblogOfPostId == null, token);
|
||||
var statuses = latest == default ? 0 : await Counted.PostsBy(actor, token);
|
||||
|
||||
var account = new Account
|
||||
{
|
||||
@@ -65,7 +102,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
Discoverable = actor.Discoverable,
|
||||
Indexable = actor.Indexable,
|
||||
Noindex = !actor.Indexable,
|
||||
HideCollections = actor.Settings.HideCollections,
|
||||
HideCollections = true,//follower and following lists are never shown to others, only their counts
|
||||
Group = actor.Kind == LocalActorKind.Group,
|
||||
CreatedAt = MastodonJson.Day(actor.Published),
|
||||
Note = ActivityPubRenderer.Html(actor.Summary),
|
||||
@@ -87,6 +124,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
Privacy = actor.Settings.DefaultVisibility ?? "public",
|
||||
Sensitive = actor.Settings.DefaultSensitive,
|
||||
Language = actor.Settings.DefaultLanguage,
|
||||
QuotePolicy = actor.Settings.QuotePolicy ?? QuotePolicies.Public,
|
||||
Note = actor.Summary ?? string.Empty,
|
||||
Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(),
|
||||
FollowRequestsCount = (int)await DB.Default.CountAsync<Follower>(f => f.LocalActorId == actor.Id && !f.IsAccepted, token)
|
||||
@@ -102,15 +140,25 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
DisplayName = foreign.Name ?? foreign.UserName ?? string.Empty,
|
||||
Bot = foreign.AvatarType is AvatarType.Service or AvatarType.Application,
|
||||
Group = foreign.AvatarType == AvatarType.Group,
|
||||
Locked = foreign.IsLocked,
|
||||
Discoverable = foreign.IsDiscoverable,
|
||||
CreatedAt = MastodonJson.Day(foreign.CreatedAt),
|
||||
Indexable = foreign.IsIndexable,
|
||||
Memorial = foreign.IsMemorial ? true : default(bool?),
|
||||
CreatedAt = MastodonJson.Day(foreign.Published ?? foreign.CreatedAt),
|
||||
Emojis = Emojis(foreign.Emojis),
|
||||
Fields = foreign.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(),
|
||||
AvatarDescription = foreign.PictureDescription ?? string.Empty,
|
||||
HeaderDescription = foreign.HeaderDescription ?? string.Empty,
|
||||
Note = foreign.Biography ?? string.Empty,
|
||||
Url = foreign.Url ?? foreign.ActorURI,
|
||||
Uri = foreign.ActorURI,
|
||||
Avatar = Proxied(foreign.PictureURL) ?? MissingAvatar,
|
||||
AvatarStatic = Proxied(foreign.PictureURL) ?? MissingAvatar,
|
||||
Header = Proxied(foreign.ThumbnailURL) ?? MissingHeader,
|
||||
HeaderStatic = Proxied(foreign.ThumbnailURL) ?? MissingHeader
|
||||
HeaderStatic = Proxied(foreign.ThumbnailURL) ?? MissingHeader,
|
||||
FollowersCount = foreign.FollowersCount ?? 0,//as its server publishes them (AccountCountsJob); 0 while unknown
|
||||
FollowingCount = foreign.FollowingCount ?? 0,
|
||||
StatusesCount = foreign.StatusesCount ?? 0
|
||||
};
|
||||
|
||||
public async Task<Account> Account(string id, CancellationToken token) =>
|
||||
@@ -127,39 +175,83 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
accounts[avatar.ID] = await Local(_localActors.FromAvatar(avatar), false, token);
|
||||
foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue && g.Kind == GroupKind.Community).ExecuteAsync(token))
|
||||
accounts[group.ID] = await Local(_localActors.FromGroup(group), false, token);
|
||||
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID)).ExecuteAsync(token))
|
||||
var foreigns = await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID) && !f.DeletionAt.HasValue).ExecuteAsync(token);
|
||||
foreach (var foreign in foreigns)
|
||||
accounts[foreign.ID] = Foreign(foreign);
|
||||
// an account that moved shows where to, once the new account names it as one of its own (as a Move is believed)
|
||||
var movedTo = foreigns.Where(f => !string.IsNullOrEmpty(f.MovedToURL)).Select(f => f.MovedToURL).Distinct().ToList();
|
||||
if (movedTo.Count > 0)
|
||||
{
|
||||
var targets = (await _dbEntities.ForeignAvatars.Match(f => movedTo.Contains(f.ActorURI) && !f.DeletionAt.HasValue).ExecuteAsync(token))
|
||||
.ToDictionary(f => f.ActorURI);
|
||||
foreach (var foreign in foreigns)
|
||||
if (foreign.MovedToURL != default && targets.TryGetValue(foreign.MovedToURL, out var target) && target.AlsoKnownAs.Contains(foreign.ActorURI))
|
||||
accounts[foreign.ID].Moved = Foreign(target);
|
||||
}
|
||||
return accounts;
|
||||
}
|
||||
|
||||
public async Task<Status> Status(PostEntity post, string viewerId, CancellationToken token) =>
|
||||
(await Statuses(new[] { post }, viewerId, token)).FirstOrDefault();
|
||||
(await Statuses(new[] { post }, viewerId, nested: false, token, hideFromViewer: false)).FirstOrDefault();
|
||||
|
||||
public async Task<List<Status>> Statuses(IReadOnlyCollection<PostEntity> posts, string viewerId, CancellationToken token)
|
||||
public Task<List<Status>> Statuses(IReadOnlyCollection<PostEntity> posts, string viewerId, CancellationToken token) =>
|
||||
Statuses(posts, viewerId, nested: false, token);
|
||||
|
||||
// Lists leave out what the viewer blocked or muted; a status asked for by its id is still shown, as Mastodon shows it
|
||||
// (only its author's block of the viewer hides it, and that is decided before mapping)
|
||||
async Task<List<Status>> Statuses(IReadOnlyCollection<PostEntity> posts, string viewerId, bool nested, CancellationToken token,
|
||||
bool hideFromViewer = true)
|
||||
{
|
||||
var originalIds = posts.Where(p => p.ReblogOfPostId != default).Select(p => p.ReblogOfPostId).Distinct().ToList();
|
||||
var originals = originalIds.Count == 0
|
||||
? new Dictionary<string, PostEntity>()
|
||||
: (await _dbEntities.Posts.Match(p => originalIds.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID);
|
||||
: (await _dbEntities.Posts.Match(p => originalIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token)).ToDictionary(p => p.ID);
|
||||
var all = posts.Concat(originals.Values).ToList();
|
||||
|
||||
var mentionUris = all.SelectMany(p => p.Mentions).Where(m => !m.IsLocal).Select(m => m.ActorURI).Distinct().ToList();
|
||||
var mentionAccounts = mentionUris.Count == 0
|
||||
? new Dictionary<string, ForeignAvatar>()
|
||||
: (await _dbEntities.ForeignAvatars.Match(f => mentionUris.Contains(f.ActorURI)).ExecuteAsync(token)).ToDictionary(f => f.ActorURI);
|
||||
var accounts = await Accounts(all.Select(AuthorOf), token);
|
||||
var accounts = await Accounts(all.Select(AuthorOf).Concat(all.Select(p => p.WallOwnerAccountId).Where(w => w != default)), token);
|
||||
|
||||
var ids = all.Select(p => p.ID).ToList();
|
||||
var favourited = viewerId == default
|
||||
? new HashSet<string>()
|
||||
: (await _dbEntities.Favourites.Match(f => f.AccountId == viewerId && ids.Contains(f.PostId)).ExecuteAsync(token)).Select(f => f.PostId).ToHashSet();
|
||||
var downvoted = viewerId == default
|
||||
? new HashSet<string>()
|
||||
: (await DB.Default.Find<Downvote>().Match(d => d.AccountId == viewerId && ids.Contains(d.PostId)).ExecuteAsync(token)).Select(d => d.PostId).ToHashSet();
|
||||
var bookmarked = viewerId == default
|
||||
? new HashSet<string>()
|
||||
: (await DB.Default.Find<Bookmark>().Match(b => b.AvatarId == viewerId && ids.Contains(b.PostId)).ExecuteAsync(token)).Select(b => b.PostId).ToHashSet();
|
||||
var pinned = (await DB.Default.Find<Pin>().Match(p => ids.Contains(p.PostId)).ExecuteAsync(token)).Select(p => p.PostId).ToHashSet();
|
||||
var hidden = viewerId == default
|
||||
var reactions = (await DB.Default.Find<Reaction>().Match(r => ids.Contains(r.PostId)).ExecuteAsync(token))
|
||||
.GroupBy(r => r.PostId)
|
||||
.ToDictionary(g => g.Key, g => g.GroupBy(r => r.Emoji)
|
||||
.Select(e => new EmojiReactionEntity
|
||||
{
|
||||
Name = e.Key,
|
||||
Count = e.Count(),
|
||||
Me = viewerId != default && e.Any(r => r.AccountId == viewerId),
|
||||
Url = Proxied(e.First().EmojiURL),
|
||||
StaticUrl = Proxied(e.First().EmojiURL)
|
||||
})
|
||||
.OrderByDescending(e => e.Count)
|
||||
.ToList());
|
||||
var eventIds = all.Where(p => p.Event != default && p.IsFederatedCopy).Select(p => p.ID).ToList();
|
||||
var participations = viewerId == default || eventIds.Count == 0
|
||||
? new Dictionary<string, Models.Social.ParticipationState>()
|
||||
: (await DB.Default.Find<Models.Social.Participation>().Match(p => p.AvatarId == viewerId && eventIds.Contains(p.PostId)).ExecuteAsync(token))
|
||||
.ToDictionary(p => p.PostId, p => p.State);
|
||||
var pollIds = all.Where(p => p.Poll != default).Select(p => p.ID).ToList();
|
||||
var ownVotes = viewerId == default || pollIds.Count == 0
|
||||
? new Dictionary<string, List<int>>()
|
||||
: (await DB.Default.Find<PollVote>().Match(v => pollIds.Contains(v.PostId) && v.VoterAccountId == viewerId && v.IsLocalVoter).ExecuteAsync(token))
|
||||
.GroupBy(v => v.PostId).ToDictionary(g => g.Key, g => g.Select(v => v.Choice).ToList());
|
||||
var hidden = viewerId == default || !hideFromViewer
|
||||
? new HashSet<string>()
|
||||
: await Domain.Relationships.Hidden.AuthorsHiddenFrom(viewerId, all.Select(p => p.ActorURI), forNotifications: false, token);
|
||||
var filters = await Domain.Social.Filters.Of(viewerId, token);
|
||||
var reblogged = viewerId == default
|
||||
? new HashSet<string>()
|
||||
: (await _dbEntities.Posts.Match(p => p.AuthorAccountId == viewerId && ids.Contains(p.ReblogOfPostId) && !p.DeletedAt.HasValue).ExecuteAsync(token))
|
||||
@@ -167,8 +259,8 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
|
||||
Status Map(PostEntity post)
|
||||
{
|
||||
if (!accounts.TryGetValue(AuthorOf(post), out var account))
|
||||
return default;
|
||||
if (AuthorOf(post) is not { } author || !accounts.TryGetValue(author, out var account))
|
||||
return default;//a post whose author we cannot show is left out, never a 500
|
||||
var status = new Status
|
||||
{
|
||||
Id = post.ID,
|
||||
@@ -188,30 +280,74 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
Bookmarked = bookmarked.Contains(post.ID),
|
||||
Pinned = pinned.Contains(post.ID),
|
||||
Sensitive = post.HasContentWarning,
|
||||
SpoilerText = post.SpoilerText ?? (post.HasContentWarning ? post.Title ?? ActivityPubRenderer.ContentWarning : string.Empty),
|
||||
//a remote post keeps its own summary: sensitive with none (Akkoma's sensitive media, Lemmy's NSFW) hides the
|
||||
//media, never the words behind an invented warning; a local post warned without words gets the default one
|
||||
SpoilerText = post.SpoilerText ?? (post.HasContentWarning && !post.IsFederatedCopy ? post.Title ?? ActivityPubRenderer.ContentWarning : string.Empty),
|
||||
Visibility = Visibility(post.Visibility),
|
||||
Language = post.Language,
|
||||
MediaAttachments = post.Media.Select(Media).ToList(),
|
||||
Mentions = post.Mentions.Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
|
||||
Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList()
|
||||
MediaAttachments = post.Media.Count > 0 ? post.Media.Select(Media).ToList() : Playable(post),
|
||||
Card = Card(post),
|
||||
Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)),
|
||||
Emojis = Emojis(post.Emojis),
|
||||
Privapub = Extension(post, accounts, viewerId == default ? default(bool?) : downvoted.Contains(post.ID)),
|
||||
EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new(),
|
||||
Pleroma = new PleromaStatus { EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new() },
|
||||
Mentions = post.Mentions.Where(m => !m.Silent).Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
|
||||
Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList(),
|
||||
Filtered = filters.Of(post)
|
||||
};
|
||||
return status;
|
||||
}
|
||||
|
||||
var localAuthors = all.Where(p => !p.IsFederatedCopy && p.GroupUserId != default).Select(p => p.GroupUserId).Distinct().ToList();
|
||||
var followsAuthor = viewerId == default || localAuthors.Count == 0
|
||||
? new HashSet<string>()
|
||||
: await FollowedLocalAuthors(viewerId, localAuthors, token);
|
||||
var quotedIds = nested ? new List<string>() : all.Where(p => p.QuoteState == QuoteState.Accepted && p.QuotedPostId != default).Select(p => p.QuotedPostId).Distinct().ToList();
|
||||
var quotedPosts = new List<PostEntity>();
|
||||
foreach (var quotedPost in quotedIds.Count == 0 ? new List<PostEntity>() : await _dbEntities.Posts.Match(p => quotedIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token))
|
||||
if (quotedPost.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(quotedPost, viewerId, token))
|
||||
quotedPosts.Add(quotedPost);
|
||||
var quotedStatuses = quotedPosts.Count == 0
|
||||
? new Dictionary<string, Status>()
|
||||
: (await Statuses(quotedPosts, viewerId, nested: true, token)).ToDictionary(q => q.Id);
|
||||
|
||||
void Quote(Status status, PostEntity post)
|
||||
{
|
||||
status.QuotesCount = post.QuotesCount;
|
||||
status.QuoteApproval = post.IsFederatedCopy ? Approval(post, viewerId) : LocalApproval(post, viewerId, followsAuthor.Contains(post.GroupUserId));
|
||||
status.InteractionPolicy = Policies(post);
|
||||
if (status.Privapub?.Event != default && participations.TryGetValue(post.ID, out var participation))
|
||||
status.Privapub.Event.Participation = participation.ToString().ToLowerInvariant();
|
||||
if (post.QuoteState == QuoteState.None)
|
||||
return;
|
||||
var state = post.QuoteState.ToString().ToLowerInvariant();
|
||||
var quoted = post.QuotedPostId != default && quotedStatuses.TryGetValue(post.QuotedPostId, out var shown) ? shown : default;
|
||||
status.Quote = nested
|
||||
? new QuoteEntity { State = state, QuotedStatusId = post.QuotedPostId }
|
||||
: new QuoteEntity { State = state, QuotedStatus = post.QuoteState == QuoteState.Accepted ? quoted : default, QuotedStatusId = post.QuotedPostId };
|
||||
if (!nested && post.QuoteState == QuoteState.Accepted && quoted != default)
|
||||
status.Content = WithoutQuoteFallback(status.Content);
|
||||
}
|
||||
|
||||
var mapped = new List<Status>();
|
||||
foreach (var post in posts)
|
||||
{
|
||||
if (hidden.Contains(post.ActorURI) || post.ReblogOfPostId != default && originals.TryGetValue(post.ReblogOfPostId, out var hiddenOriginal)
|
||||
if (!VisibilityPolicy.Shown(post) || hidden.Contains(post.ActorURI) || post.ReblogOfPostId != default && originals.TryGetValue(post.ReblogOfPostId, out var hiddenOriginal)
|
||||
&& hidden.Contains(hiddenOriginal.ActorURI))
|
||||
continue;
|
||||
var status = Map(post);
|
||||
if (status == default)
|
||||
continue;
|
||||
Quote(status, post);
|
||||
if (post.ReblogOfPostId != default)
|
||||
{
|
||||
if (!originals.TryGetValue(post.ReblogOfPostId, out var original) || Map(original) is not { } inner)
|
||||
continue;
|
||||
Quote(inner, original);
|
||||
status.Reblog = inner;
|
||||
status.Filtered = inner.Filtered;//a boost is filtered as what it boosts
|
||||
status.Url = inner.Url;//a boost has no page of its own; /grunts would answer JSON to a browser
|
||||
status.Content = string.Empty;
|
||||
status.Reblogged = reblogged.Contains(original.ID);
|
||||
}
|
||||
@@ -222,12 +358,268 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
|
||||
public static string AuthorOf(PostEntity post) => post.AuthorAccountId ?? post.GroupUserId;
|
||||
|
||||
static readonly AngleSharp.Html.Parser.HtmlParser Fragments = new();
|
||||
|
||||
public static string WithoutQuoteFallback(string html)
|
||||
{
|
||||
if (string.IsNullOrEmpty(html) || !html.Contains("quote-inline", StringComparison.Ordinal))
|
||||
return html;
|
||||
var document = Fragments.ParseDocument($"<body>{html}</body>");
|
||||
foreach (var fallback in document.QuerySelectorAll(".quote-inline").ToList())
|
||||
fallback.Remove();
|
||||
return document.Body!.InnerHtml;
|
||||
}
|
||||
|
||||
async Task<HashSet<string>> FollowedLocalAuthors(string viewerId, List<string> authorIds, CancellationToken token)
|
||||
{
|
||||
var uris = new Dictionary<string, string>();
|
||||
foreach (var avatar in await _dbEntities.Avatars.Match(a => authorIds.Contains(a.ID)).ExecuteAsync(token))
|
||||
uris[_localActors.FromAvatar(avatar).Uri] = avatar.ID;
|
||||
var targets = uris.Keys.ToList();
|
||||
return (await _dbEntities.Followings.Match(f => f.AvatarId == viewerId && targets.Contains(f.TargetActorURI) && f.State == FollowState.Accepted).ExecuteAsync(token))
|
||||
.Select(f => uris[f.TargetActorURI]).ToHashSet();
|
||||
}
|
||||
|
||||
static QuoteApprovalEntity LocalApproval(PostEntity post, string viewerId, bool viewerFollows)
|
||||
{
|
||||
var policy = ActivityPubRenderer.QuotableBy(post);
|
||||
var everyone = policy switch { QuotePolicies.Public => new List<string> { "public" }, QuotePolicies.Followers => new List<string> { "followers" }, _ => new List<string>() };
|
||||
var current = viewerId == default ? "unknown"
|
||||
: viewerId == post.GroupUserId && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted ? "automatic"
|
||||
: policy == QuotePolicies.Public || policy == QuotePolicies.Followers && viewerFollows ? "automatic"
|
||||
: "denied";
|
||||
return new QuoteApprovalEntity { Automatic = everyone, CurrentUser = current };
|
||||
}
|
||||
|
||||
// a remote post's canLike, canReply and canAnnounce, in GoToSocial's client terms; null when it states none
|
||||
static InteractionPolicyEntity Policies(PostEntity post)
|
||||
{
|
||||
if (!post.IsFederatedCopy || post.LikePolicy == default && post.ReplyPolicy == default && post.AnnouncePolicy == default)
|
||||
return default;
|
||||
var collections = post.ActorURI?.TrimEnd('/');
|
||||
string Named(string who) => Addressing.IsPublic(who) ? "public"
|
||||
: who == post.ActorURI ? "author"
|
||||
: who.EndsWith("/followers", StringComparison.Ordinal) && who.StartsWith(collections, StringComparison.Ordinal) ? "followers"
|
||||
: who.EndsWith("/following", StringComparison.Ordinal) && who.StartsWith(collections, StringComparison.Ordinal) ? "following"
|
||||
: who;
|
||||
InteractionRuleEntity Rule(InteractionRule rule) => rule == default
|
||||
? new InteractionRuleEntity { AutomaticApproval = new List<string> { "public" } }
|
||||
: new InteractionRuleEntity { AutomaticApproval = rule.Automatic.Select(Named).Distinct().ToList(), ManualApproval = rule.Manual.Select(Named).Distinct().ToList() };
|
||||
return new InteractionPolicyEntity { CanFavourite = Rule(post.LikePolicy), CanReply = Rule(post.ReplyPolicy), CanReblog = Rule(post.AnnouncePolicy) };
|
||||
}
|
||||
|
||||
static QuoteApprovalEntity Approval(PostEntity post, string viewerId)
|
||||
{
|
||||
if (!post.IsFederatedCopy || post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
|
||||
return new QuoteApprovalEntity();
|
||||
if (post.QuotePolicy is not { } policy)
|
||||
return new QuoteApprovalEntity { Automatic = new List<string> { "public" }, CurrentUser = viewerId == default ? "unknown" : "automatic" };
|
||||
static List<string> Named(IEnumerable<string> who) => who.Select(w => Addressing.IsPublic(w) ? "public" : w.EndsWith("/followers") ? "followers" : "unsupported_policy").Distinct().ToList();
|
||||
return new QuoteApprovalEntity
|
||||
{
|
||||
Automatic = Named(policy.Automatic),
|
||||
Manual = Named(policy.Manual),
|
||||
CurrentUser = viewerId == default ? "unknown" : policy.Automatic.Any(Addressing.IsPublic) ? "automatic" : policy.Manual.Any(Addressing.IsPublic) ? "manual" : "denied"
|
||||
};
|
||||
}
|
||||
|
||||
public static string Content(PostEntity post)
|
||||
{
|
||||
var content = post.ContentHtml ?? ActivityPubRenderer.Html(post.Text);
|
||||
return string.IsNullOrEmpty(post.Title) || post.IsFederatedCopy
|
||||
? content ?? string.Empty
|
||||
: $"<p><strong>{WebUtility.HtmlEncode(post.Title)}</strong></p>{content}";
|
||||
if (post.IsFederatedCopy && string.IsNullOrWhiteSpace(content) && !string.IsNullOrEmpty(post.Excerpt))
|
||||
content = $"<p>{WebUtility.HtmlEncode(post.Excerpt)}</p>";
|
||||
if (post.Event is { } happening && EventLine(happening) is { } line)
|
||||
content = $"<p>{WebUtility.HtmlEncode(line)}</p>{content}";
|
||||
var titled = !string.IsNullOrEmpty(post.Title) && (!post.IsFederatedCopy || post.ObjectType is not (null or "Note" or "Question"));
|
||||
return titled
|
||||
? $"<p><strong>{WebUtility.HtmlEncode(post.Title)}</strong></p>{content}"
|
||||
: content ?? string.Empty;
|
||||
}
|
||||
|
||||
public static string EventLine(EventDetails happening)
|
||||
{
|
||||
if (happening.Start is not { } start)
|
||||
return default;
|
||||
var zone = Zone(happening.FloatingTime ? default : happening.TimeZone);
|
||||
var local = zone == default ? start : TimeZoneInfo.ConvertTimeFromUtc(start, zone);
|
||||
var when = local.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
|
||||
if (happening.End is { } end && end > start)
|
||||
{
|
||||
var localEnd = zone == default ? end : TimeZoneInfo.ConvertTimeFromUtc(end, zone);
|
||||
when += " – " + localEnd.ToString(localEnd.Date == local.Date ? "HH:mm" : "yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
|
||||
}
|
||||
when += happening.FloatingTime ? " (local time)" : zone == default ? " UTC" : $" ({happening.TimeZone})";
|
||||
var where = happening.Places.Select(p => p.Name ?? p.Address).FirstOrDefault(p => p != default)
|
||||
?? (happening.OnlineURL != default ? "online" : default);
|
||||
return where == default ? when : $"{when} · {where}";
|
||||
}
|
||||
|
||||
static TimeZoneInfo Zone(string id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
return default;
|
||||
try
|
||||
{
|
||||
return TimeZoneInfo.FindSystemTimeZoneById(id);
|
||||
}
|
||||
catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
public PollEntity Poll(PostEntity post, string viewerId, IReadOnlyCollection<int> ownVotes)
|
||||
{
|
||||
if (post.Poll is not { } poll)
|
||||
return default;
|
||||
var expired = PollService.Ended(poll);
|
||||
var hidden = poll.HideTotals && !expired && post.GroupUserId != viewerId;
|
||||
return new PollEntity
|
||||
{
|
||||
Id = post.ID,
|
||||
ExpiresAt = poll.ExpiresAt is { } ends ? MastodonJson.Time(ends) : default,
|
||||
Expired = expired,
|
||||
Multiple = poll.Multiple,
|
||||
VotesCount = poll.Options.Sum(o => o.Votes),
|
||||
VotersCount = poll.Multiple ? poll.VotersCount : poll.VotersCount ?? poll.Options.Sum(o => o.Votes),
|
||||
Voted = viewerId == default ? default(bool?) : ownVotes?.Count > 0 || post.GroupUserId == viewerId,
|
||||
OwnVotes = viewerId == default ? default : ownVotes?.ToList() ?? new List<int>(),
|
||||
Options = poll.Options.Select(o => new PollOptionEntity { Title = o.Title, VotesCount = hidden ? default(int?) : o.Votes }).ToList(),
|
||||
Emojis = Emojis(post.Emojis.Where(e => poll.Options.Any(o => o.Title.Contains($":{e.Shortcode}:"))))
|
||||
};
|
||||
}
|
||||
|
||||
List<CustomEmojiEntity> Emojis(IEnumerable<CustomEmoji> emojis) =>
|
||||
emojis?.Select(e => new CustomEmojiEntity { Shortcode = e.Shortcode, Url = Proxied(e.URL), StaticUrl = Proxied(e.URL) }).ToList() ?? new();
|
||||
|
||||
PrivaPubStatus Extension(PostEntity post, Dictionary<string, Account> accounts, bool? downvoted) => new()
|
||||
{
|
||||
Wall = post.WallOwnerAccountId == default || post.WallOwnerAccountId == AuthorOf(post) ? default : accounts.GetValueOrDefault(post.WallOwnerAccountId),
|
||||
ObjectType = post.ObjectType,
|
||||
Locked = post.LockedAt.HasValue,
|
||||
Approval = post.Approval is ApprovalState.Pending or ApprovalState.Rejected ? post.Approval.ToString().ToLowerInvariant() : default,
|
||||
Title = post.IsFederatedCopy ? post.Title : default,
|
||||
Excerpt = post.Excerpt,
|
||||
Cover = Proxied(post.CoverURL),
|
||||
Source = post.Source == default ? default : new PrivaPubSource { Content = post.Source.Content, MediaType = post.Source.MediaType },
|
||||
Link = post.Link == default ? default : new PrivaPubLink
|
||||
{
|
||||
Url = post.Link.Href, Title = post.Link.Title, Description = post.Link.Description, Image = Proxied(post.Link.ImageURL), FromPublisher = post.Link.FromPublisher
|
||||
},
|
||||
Video = post.Video == default ? default : new PrivaPubVideo
|
||||
{
|
||||
Duration = post.Video.DurationSeconds,
|
||||
Variants = post.Video.Variants.Select(Variant).ToList(),
|
||||
IsLive = post.Video.IsLive,
|
||||
State = post.Video.State,
|
||||
Views = post.Video.Views,
|
||||
CommentsPolicy = post.Video.CommentsPolicy,
|
||||
DownloadEnabled = post.Video.DownloadEnabled,
|
||||
Licence = post.Video.Licence,
|
||||
Category = post.Video.Category,
|
||||
Channel = post.Video.ChannelURI,
|
||||
Captions = post.Video.Captions.Select(c => new PrivaPubCaption { Language = c.Language, Url = Proxied(c.Url) }).ToList()
|
||||
},
|
||||
Audio = post.Audio == default ? default : new PrivaPubAudio
|
||||
{
|
||||
Duration = post.Audio.DurationSeconds,
|
||||
Variants = post.Audio.Variants.Select(Variant).ToList(),
|
||||
Album = post.Audio.Album,
|
||||
Position = post.Audio.Position,
|
||||
Licence = post.Audio.Licence
|
||||
},
|
||||
Event = post.Event == default ? default : new PrivaPubEvent
|
||||
{
|
||||
Start = post.Event.Start is { } start ? MastodonJson.Time(start) : default,
|
||||
End = post.Event.End is { } end ? MastodonJson.Time(end) : default,
|
||||
TimeZone = post.Event.TimeZone,
|
||||
Floating = post.Event.FloatingTime,
|
||||
Places = post.Event.Places.Select(p => new PrivaPubPlace { Name = p.Name, Address = p.Address, Latitude = p.Latitude, Longitude = p.Longitude, Url = p.Url }).ToList(),
|
||||
OnlineUrl = post.Event.OnlineURL,
|
||||
JoinMode = post.Event.JoinMode,
|
||||
Participants = post.Event.ParticipantCount,
|
||||
Capacity = post.Event.Capacity,
|
||||
Status = post.Event.Status,
|
||||
Category = post.Event.Category
|
||||
},
|
||||
Place = post.Place == default ? default : new PrivaPubPlace
|
||||
{
|
||||
Name = post.Place.Name, Latitude = post.Place.Latitude, Longitude = post.Place.Longitude, Country = post.Place.Country
|
||||
},
|
||||
Votes = new PrivaPubVotes { Up = post.FavouritesCount, Down = post.DownvotesCount, Downvoted = downvoted },
|
||||
Flairs = post.Flairs?.Select(f => new PrivaPubFlair
|
||||
{
|
||||
Id = f.Id, Name = f.Name, Slug = f.Slug, Description = f.Description, Color = f.Color, TextColor = f.TextColor,
|
||||
BackgroundColor = f.BackgroundColor, BlurImages = f.BlurImages
|
||||
}).ToList() ?? new()
|
||||
};
|
||||
|
||||
PrivaPubVariant Variant(MediaVariant variant) => new()
|
||||
{
|
||||
Url = Proxied(variant.Url),
|
||||
MediaType = variant.MediaType,
|
||||
Width = variant.Width,
|
||||
Height = variant.Height,
|
||||
Fps = variant.Fps,
|
||||
Size = variant.Size,
|
||||
Streams = variant.Streams
|
||||
};
|
||||
|
||||
List<MediaAttachment> Playable(PostEntity post)
|
||||
{
|
||||
var (variant, kind, duration) = post.Video is { } video ? (BestVideo(video.Variants), "video", video.DurationSeconds)
|
||||
: post.Audio is { } audio ? (audio.Variants.FirstOrDefault(), "audio", audio.DurationSeconds)
|
||||
: default;
|
||||
if (variant == default)
|
||||
return new List<MediaAttachment>();
|
||||
return new List<MediaAttachment>
|
||||
{
|
||||
Media(new PostMedia
|
||||
{
|
||||
Id = new Guid(System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(variant.Url))),
|
||||
RemoteURL = variant.Url,
|
||||
ContentType = variant.MediaType ?? kind + "/*",
|
||||
RemotePreviewURL = post.CoverURL,
|
||||
Width = variant.Width,
|
||||
Height = variant.Height,
|
||||
DurationSeconds = duration,
|
||||
Description = post.Title
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
public static MediaVariant BestVideo(IReadOnlyCollection<MediaVariant> variants)
|
||||
{
|
||||
var playable = variants.Where(v => v.MediaType?.StartsWith("video/") == true && (v.Streams.Count == 0 || v.Streams.Contains("audio") && v.Streams.Contains("video"))).ToList();
|
||||
return playable.Where(v => v.Height <= 720).OrderByDescending(v => v.Height).FirstOrDefault()
|
||||
?? playable.OrderBy(v => v.Height ?? int.MaxValue).FirstOrDefault();
|
||||
}
|
||||
|
||||
PreviewCard Card(PostEntity post)
|
||||
{
|
||||
if (post.Link is { } link)
|
||||
return Card(link.Href, link.Title ?? post.Title, link.Description ?? (post.Media.Count == 0 ? post.Excerpt : default), link.ImageURL);
|
||||
if (post.IsFederatedCopy && post.ObjectType is "Article" or "Event" && post.Url != default && post.Media.Count == 0
|
||||
|| post.IsFederatedCopy && post.ObjectType is "Video" or "Audio" && post.Url != default && post.Media.Count == 0 && Playable(post).Count == 0)
|
||||
return Card(post.Url, post.Title, post.Excerpt, post.CoverURL);
|
||||
return default;
|
||||
}
|
||||
|
||||
public PreviewCard LinkCard(string url, string title, string description, string image) => Card(url, title, description, image);
|
||||
|
||||
PreviewCard Card(string url, string title, string description, string image)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
return default;
|
||||
return new PreviewCard
|
||||
{
|
||||
Url = url,
|
||||
Title = title ?? uri.Host,
|
||||
Description = description ?? string.Empty,
|
||||
ProviderName = uri.Host,
|
||||
ProviderUrl = uri.GetLeftPart(UriPartial.Authority),
|
||||
Image = Proxied(image)
|
||||
};
|
||||
}
|
||||
|
||||
public static string Visibility(PostVisibility visibility) => visibility switch
|
||||
@@ -250,29 +642,46 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
return new StatusMention { Id = account.ID, Username = account.UserName, Acct = $"{account.UserName}@{account.Domain}", Url = account.Url ?? account.ActorURI };
|
||||
}
|
||||
|
||||
static Dictionary<string, object> Meta(PostMedia media)
|
||||
{
|
||||
var original = new Dictionary<string, object>();
|
||||
if (media.Width > 0 && media.Height > 0)
|
||||
{
|
||||
original["width"] = media.Width;
|
||||
original["height"] = media.Height;
|
||||
original["size"] = $"{media.Width}x{media.Height}";
|
||||
original["aspect"] = (double)media.Width.Value / media.Height.Value;
|
||||
}
|
||||
if (media.DurationSeconds is { } seconds)
|
||||
original["duration"] = seconds;
|
||||
var meta = new Dictionary<string, object>();
|
||||
if (original.Count > 0)
|
||||
meta["original"] = original;
|
||||
if (media.DurationSeconds is { } length)
|
||||
meta["length"] = TimeSpan.FromSeconds(length).ToString(@"h\:mm\:ss\.ff", CultureInfo.InvariantCulture);
|
||||
if (FocalPoint.IsSound(media.Focus))
|
||||
meta["focus"] = new { x = media.Focus[0], y = media.Focus[1] };
|
||||
return meta;
|
||||
}
|
||||
|
||||
MediaAttachment Media(PostMedia media) => new()
|
||||
{
|
||||
Id = media.AttachmentId ?? media.Id.ToString("N"),
|
||||
Type = media.ContentType switch
|
||||
// a GIF is a gifv only once it is an mp4 (ours): a GIF itself is an image, which a gifv player can't play
|
||||
Type = media.Kind == "gifv" ? "gifv" : media.ContentType switch
|
||||
{
|
||||
{ } type when type.StartsWith("image/gif") => "gifv",
|
||||
{ } type when type.StartsWith("image/") => "image",
|
||||
{ } type when type.StartsWith("video/") => "video",
|
||||
{ } type when type.StartsWith("audio/") => "audio",
|
||||
_ => "unknown"
|
||||
},
|
||||
Url = media.URL ?? Proxied(media.RemoteURL),
|
||||
PreviewUrl = media.PreviewURL ?? media.URL ?? Proxied(media.RemoteURL),
|
||||
PreviewUrl = media.PreviewURL ?? Proxied(media.RemotePreviewURL)
|
||||
?? (media.ContentType?.StartsWith("image/") == true || media.ContentType == default ? media.URL ?? Proxied(media.RemoteURL) : default),
|
||||
RemoteUrl = media.RemoteURL,
|
||||
Description = media.Description,
|
||||
Blurhash = media.Blurhash,
|
||||
Meta = new
|
||||
{
|
||||
original = media.Width.HasValue && media.Height > 0
|
||||
? new { width = media.Width, height = media.Height, size = $"{media.Width}x{media.Height}", aspect = (double)media.Width / media.Height.Value }
|
||||
: default,
|
||||
focus = media.Focus is { Length: 2 } ? new { x = media.Focus[0], y = media.Focus[1] } : default
|
||||
}
|
||||
Meta = Meta(media)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.ClientModels.Admin;
|
||||
using PrivaPub.Extensions;
|
||||
using PrivaPub.Infrastructure.Backup;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Resources;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
namespace PrivaPub.Controllers.ClientToServer
|
||||
{
|
||||
// The server's backups from the administrator's page (owner decision 2026-10-07: it backs up and restores too). Only
|
||||
// an administrator, checked again against the database; a download and a restore ask for the password again, and a
|
||||
// restore for the server's host typed out. A restore asked for stops the service within seconds, and the next start
|
||||
// carries it out (ServerRestore).
|
||||
[ApiController,
|
||||
Route("clientapi/admin/backups"),
|
||||
Authorize(Policy = Policies.IsAdmin)]
|
||||
public class BackupController(Backups backups, TransferStore transfers, IPasswordHasher hasher, IStringLocalizer<GenericRes> localizer,
|
||||
ILogger<BackupController> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet, Route("")]
|
||||
public async Task<IActionResult> List(CancellationToken token)
|
||||
{
|
||||
if (await Administrator(token) == default)
|
||||
return Forbid();
|
||||
var deleted = await DB.Default.Find<RootUser>().Match(u => u.DeletedAt != null).Project(u => u.Include(x => x.DeletedAt)).ExecuteAsync(token);
|
||||
var code = ServerBackup.CodeMigration();
|
||||
var waiting = RestoreMarker.Read(backups.Root);
|
||||
var last = await DB.Default.Find<RestoreRecord>().Sort(r => r.RestoredAt, Order.Descending).ExecuteFirstAsync(token);
|
||||
ServerBackup.MakeDirectory(backups.Root);
|
||||
return Ok(new ViewBackups
|
||||
{
|
||||
Backups = [.. backups.List().Select(b => View(b, deleted.Count(u => u.DeletedAt > b.CreatedAt), code))],
|
||||
Running = (await MaintenanceLock.Current(token))?.What,
|
||||
Waiting = waiting == default ? default : new ViewRestoreWaiting
|
||||
{
|
||||
Backup = waiting.Backup,
|
||||
State = waiting.State,
|
||||
Attempts = waiting.Attempts,
|
||||
RequestedBy = waiting.RequestedBy,
|
||||
RequestedAt = waiting.RequestedAt,
|
||||
Error = waiting.Error
|
||||
},
|
||||
LastRestore = last == default ? default : View(last),
|
||||
Host = HostName,
|
||||
FreeBytes = new DriveInfo(backups.Root).AvailableFreeSpace
|
||||
});
|
||||
}
|
||||
|
||||
// a backup made now, while the page polls the list
|
||||
[HttpPost, Route("")]
|
||||
public async Task<IActionResult> Create(CancellationToken token)
|
||||
{
|
||||
var admin = await Administrator(token);
|
||||
if (admin == default)
|
||||
return Forbid();
|
||||
if (await MaintenanceLock.Current(token) is { } held)
|
||||
return Conflict(new WebResult().Invalidate(localizer["A {0} is running.", held.What]));
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var (made, error) = await backups.Create("manual", dbOnly: false, CancellationToken.None);
|
||||
if (made == default)
|
||||
logger.LogWarning("The backup {Admin} asked for was not made: {Error}", admin.UserName, error);
|
||||
else
|
||||
logger.LogInformation("Backup {Id} made for {Admin}", made.Id, admin.UserName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "The backup {Admin} asked for failed", admin.UserName);
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
[HttpDelete, Route("{id}")]
|
||||
public async Task<IActionResult> Delete(string id, CancellationToken token)
|
||||
{
|
||||
if (await Administrator(token) == default)
|
||||
return Forbid();
|
||||
if (RestoreMarker.Read(backups.Root) is { } waiting && (waiting.Backup == id || waiting.PreRestore == id))
|
||||
return Conflict(new WebResult().Invalidate(localizer["A restore waits for this backup."]));
|
||||
return backups.Delete(id) ? Ok() : NotFound();
|
||||
}
|
||||
|
||||
// a link to download it with, for the password
|
||||
[HttpPost, Route("{id}/ticket")]
|
||||
public async Task<IActionResult> Ticket(string id, BackupPasswordForm form, CancellationToken token)
|
||||
{
|
||||
var admin = await Administrator(token);
|
||||
if (admin == default)
|
||||
return Forbid();
|
||||
if (!PasswordHolds(admin, form.Password))
|
||||
return UnprocessableEntity(new WebResult().Invalidate(localizer["Wrong password."]));
|
||||
if (backups.Find(id) == default)
|
||||
return NotFound();
|
||||
var (ticket, expires) = transfers.Ticket(id);
|
||||
return Ok(new ViewBackupTicket { Url = $"/clientapi/admin/backups/download/{ticket}", ExpiresAt = expires, Bytes = BackupTar.Of(backups.Root, id).Length });
|
||||
}
|
||||
|
||||
// the backup as one tar, for a ticket: a plain link, so the browser downloads it to disk and resumes it
|
||||
[HttpGet, Route("download/{ticket}"), AllowAnonymous]
|
||||
public async Task Download(string ticket, CancellationToken token)
|
||||
{
|
||||
var id = transfers.Redeem(ticket);
|
||||
if (id == default || backups.Find(id) == default)
|
||||
{
|
||||
Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
HttpContext.Features.Get<IHttpResponseBodyFeature>()?.DisableBuffering();
|
||||
var tar = BackupTar.Of(backups.Root, id);
|
||||
var (from, to) = (0L, tar.Length - 1);
|
||||
Response.Headers.AcceptRanges = "bytes";
|
||||
Response.Headers.CacheControl = "no-store";
|
||||
Response.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment") { FileName = $"privapub-{id}.tar" }.ToString();
|
||||
var range = Request.GetTypedHeaders().Range;
|
||||
if (range is { Unit.Value: "bytes", Ranges.Count: 1 })
|
||||
{
|
||||
var asked = range.Ranges.First();
|
||||
var start = asked.From ?? tar.Length - asked.To.GetValueOrDefault();
|
||||
var end = asked.From.HasValue ? Math.Min(asked.To ?? tar.Length - 1, tar.Length - 1) : tar.Length - 1;
|
||||
if (start < 0 || start > end)
|
||||
{
|
||||
Response.StatusCode = StatusCodes.Status416RangeNotSatisfiable;
|
||||
Response.Headers.ContentRange = $"bytes */{tar.Length}";
|
||||
return;
|
||||
}
|
||||
(from, to) = (start, end);
|
||||
Response.StatusCode = StatusCodes.Status206PartialContent;
|
||||
Response.Headers.ContentRange = $"bytes {from}-{to}/{tar.Length}";
|
||||
}
|
||||
Response.ContentType = "application/x-tar";
|
||||
Response.ContentLength = to - from + 1;
|
||||
await tar.Write(Response.Body, from, to, token);
|
||||
}
|
||||
|
||||
[HttpPost, Route("uploads")]
|
||||
public async Task<IActionResult> StartUpload(CancellationToken token)
|
||||
{
|
||||
var admin = await Administrator(token);
|
||||
if (admin == default)
|
||||
return Forbid();
|
||||
return Ok(new ViewBackupUpload { Id = transfers.Start(admin.UserName), ChunkBytes = TransferStore.ChunkBytes });
|
||||
}
|
||||
|
||||
[HttpGet, Route("uploads/{id}")]
|
||||
public async Task<IActionResult> Upload(string id, CancellationToken token)
|
||||
{
|
||||
if (await Administrator(token) == default)
|
||||
return Forbid();
|
||||
var received = transfers.Received(id);
|
||||
return received < 0 ? NotFound() : Ok(new ViewBackupUpload { Id = id, Received = received, ChunkBytes = TransferStore.ChunkBytes });
|
||||
}
|
||||
|
||||
// a piece, at the offset already received: 409 with what was received when it isn't
|
||||
[HttpPut, Route("uploads/{id}"), RequestSizeLimit(TransferStore.ChunkBytes + 1024 * 1024)]
|
||||
public async Task<IActionResult> Append(string id, [FromQuery] long offset, CancellationToken token)
|
||||
{
|
||||
if (await Administrator(token) == default)
|
||||
return Forbid();
|
||||
var (received, taken) = await transfers.Append(id, offset, Request.Body, token);
|
||||
if (received < 0)
|
||||
return NotFound();
|
||||
var view = new ViewBackupUpload { Id = id, Received = received, ChunkBytes = TransferStore.ChunkBytes };
|
||||
return taken ? Ok(view) : Conflict(view);
|
||||
}
|
||||
|
||||
[HttpPost, Route("uploads/{id}/finish")]
|
||||
public async Task<IActionResult> FinishUpload(string id, CancellationToken token)
|
||||
{
|
||||
if (await Administrator(token) == default)
|
||||
return Forbid();
|
||||
if (await MaintenanceLock.Current(token) is { What: "restore" })
|
||||
return Conflict(new WebResult().Invalidate(localizer["A {0} is running.", "restore"]));
|
||||
var (backup, error) = await transfers.Finish(id, token);
|
||||
if (backup == default)
|
||||
return UnprocessableEntity(new WebResult().Invalidate(error));
|
||||
return Ok(View(backup, 0, ServerBackup.CodeMigration()));
|
||||
}
|
||||
|
||||
[HttpDelete, Route("uploads/{id}")]
|
||||
public async Task<IActionResult> CancelUpload(string id, CancellationToken token)
|
||||
{
|
||||
if (await Administrator(token) == default)
|
||||
return Forbid();
|
||||
return transfers.Cancel(id) ? Ok() : NotFound();
|
||||
}
|
||||
|
||||
// asked for with the password and the host typed out: the service stops within seconds and restores it as it starts
|
||||
[HttpPost, Route("{id}/restore")]
|
||||
public async Task<IActionResult> Restore(string id, RestoreBackupForm form, CancellationToken token)
|
||||
{
|
||||
var admin = await Administrator(token);
|
||||
if (admin == default)
|
||||
return Forbid();
|
||||
if (!PasswordHolds(admin, form.Password))
|
||||
return UnprocessableEntity(new WebResult().Invalidate(localizer["Wrong password."]));
|
||||
if (!string.Equals(form.Host?.Trim(), HostName, StringComparison.OrdinalIgnoreCase))
|
||||
return UnprocessableEntity(new WebResult().Invalidate(localizer["Type this server's host to restore it."]));
|
||||
var refused = await ServerRestore.Request(backups.Context(), id, admin.UserName, token);
|
||||
if (refused != default)
|
||||
return UnprocessableEntity(new WebResult().Invalidate(refused));
|
||||
logger.LogWarning("{Admin} asked to restore {Backup}", admin.UserName, id);
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
string HostName => Uri.TryCreate(backups.Host, UriKind.Absolute, out var host) ? host.Authority : backups.Host;
|
||||
|
||||
// the token says administrator; the database has the last word
|
||||
async Task<RootUser> Administrator(CancellationToken token)
|
||||
{
|
||||
var root = await DB.Default.Find<RootUser>().MatchID(User.GetUserId()).ExecuteFirstAsync(token);
|
||||
return root is { IsBanned: false, DeletedAt: null } && root.Policies.Contains(Policies.IsAdmin) ? root : default;
|
||||
}
|
||||
|
||||
bool PasswordHolds(RootUser root, string password) =>
|
||||
!string.IsNullOrEmpty(password) && !string.IsNullOrEmpty(root.HashedPassword) && hasher.Check(root.HashedPassword, password).verified;
|
||||
|
||||
static ViewBackup View(BackupInfo backup, int rootsDeletedSince, int code)
|
||||
{
|
||||
var manifest = backup.Manifest;
|
||||
return new ViewBackup
|
||||
{
|
||||
Id = backup.Id,
|
||||
Kind = backup.Kind,
|
||||
CreatedAt = backup.CreatedAt,
|
||||
Bytes = backup.Bytes,
|
||||
MediaBytes = manifest.Media.Bytes,
|
||||
MediaFiles = manifest.DbOnly ? manifest.Media.List.Count : manifest.Media.Files,
|
||||
MediaMissing = manifest.Media.Missing,
|
||||
DbOnly = manifest.DbOnly,
|
||||
Consistent = manifest.Consistent,
|
||||
Collections = manifest.Collections.Count,
|
||||
Documents = manifest.Collections.Sum(c => c.Count),
|
||||
AppCommit = manifest.AppCommit,
|
||||
MigrationNumber = manifest.MigrationNumber,
|
||||
RootsDeletedSince = rootsDeletedSince,
|
||||
NotRestorable = manifest.Format != ArchiveManifest.CurrentFormat ? "format"
|
||||
: manifest.MigrationNumber > code ? "newer"
|
||||
: default
|
||||
};
|
||||
}
|
||||
|
||||
static ViewRestore View(RestoreRecord record) => new()
|
||||
{
|
||||
Backup = record.Backup,
|
||||
BackupCreatedAt = record.BackupCreatedAt,
|
||||
PreRestore = record.PreRestore,
|
||||
RequestedBy = record.RequestedBy,
|
||||
RestoredAt = record.RestoredAt,
|
||||
Abandoned = record.Abandoned,
|
||||
Error = record.Error,
|
||||
Documents = record.Report.Documents,
|
||||
Collections = record.Report.Collections,
|
||||
MediaRestored = record.Report.MediaRestored,
|
||||
MediaMissing = record.Report.MediaMissing,
|
||||
RootsDeleted = record.Report.RootsDeleted,
|
||||
PersonasDeleted = record.Report.PersonasDeleted,
|
||||
GroupsDeleted = record.Report.GroupsDeleted,
|
||||
PostsDeleted = record.Report.PostsDeleted,
|
||||
RootsTombstoned = record.Report.RootsTombstoned,
|
||||
PersonasTombstoned = record.Report.PersonasTombstoned,
|
||||
GroupsTombstoned = record.Report.GroupsTombstoned,
|
||||
PostsGone = record.Report.PostsGone,
|
||||
MediaTrashed = record.Report.MediaTrashed,
|
||||
ProtectiveKept = record.Report.ProtectiveKept,
|
||||
SessionsEnded = record.Report.SessionsEnded
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,13 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
{
|
||||
readonly IDomainBlocks _domainBlocks;
|
||||
readonly IStringLocalizer _localizer;
|
||||
readonly Domain.Media.IMediaProxy _proxy;
|
||||
|
||||
public DomainBlockController(IDomainBlocks domainBlocks, IStringLocalizer<GenericRes> localizer)
|
||||
public DomainBlockController(IDomainBlocks domainBlocks, IStringLocalizer<GenericRes> localizer, Domain.Media.IMediaProxy proxy)
|
||||
{
|
||||
_domainBlocks = domainBlocks;
|
||||
_localizer = localizer;
|
||||
_proxy = proxy;
|
||||
}
|
||||
|
||||
[HttpGet, Route("/clientapi/admin/domainblocks/list")]
|
||||
@@ -48,6 +50,9 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
.Option(o => o.IsUpsert = true)
|
||||
.ExecuteAsync(token);
|
||||
await _domainBlocks.Reload(token);
|
||||
// what the media proxy holds of it goes with the block
|
||||
if (block.Severity == DomainBlockSeverity.Suspend || block.RejectMedia)
|
||||
_proxy.Purge(domain);
|
||||
return Ok(ToView(block));
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,18 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
public async Task<IActionResult> Approve(GroupMembershipForm form, CancellationToken token) =>
|
||||
!ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.ApproveMember(User.GetUserId(), form, token));
|
||||
|
||||
[HttpGet, Route("/clientapi/group/members")]
|
||||
public async Task<IActionResult> Members([FromQuery] string avatarId, [FromQuery] string groupId, CancellationToken token) =>
|
||||
Answer(await _groupUsersService.GetMembers(User.GetUserId(), avatarId, groupId, token));
|
||||
|
||||
[HttpPost, Route("/clientapi/group/reject")]
|
||||
public async Task<IActionResult> Reject(GroupMembershipForm form, CancellationToken token) =>
|
||||
!ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.RejectMember(User.GetUserId(), form, token));
|
||||
|
||||
[HttpPost, Route("/clientapi/group/remove")]
|
||||
public async Task<IActionResult> Remove(GroupMembershipForm form, CancellationToken token) =>
|
||||
!ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.RemoveMember(User.GetUserId(), form, token));
|
||||
|
||||
IActionResult Invalid() => BadRequest(new WebResult().Invalidate(_localizer["Invalid model."]));
|
||||
|
||||
IActionResult Answer(WebResult result) => result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.ClientModels.Admin;
|
||||
using PrivaPub.Extensions;
|
||||
using PrivaPub.Models.Social;
|
||||
|
||||
@@ -17,20 +18,22 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
[HttpGet, Route("/clientapi/moderator/reports")]
|
||||
public async Task<IActionResult> Reports([FromQuery] bool resolved, CancellationToken token) =>
|
||||
Ok((await DB.Default.Find<Report>().Match(r => r.IsResolved == resolved).Sort(r => r.ID, Order.Descending).Limit(100).ExecuteAsync(token))
|
||||
.Select(r => new
|
||||
.Select(r => new ViewReport
|
||||
{
|
||||
r.ID,
|
||||
ID = r.ID,
|
||||
Reporter = r.ReporterAvatarId != default ? "local" : r.ReporterActorURI,
|
||||
r.TargetAccountId,
|
||||
r.TargetActorURI,
|
||||
r.PostIds,
|
||||
r.ObjectURIs,
|
||||
r.Category,
|
||||
r.Comment,
|
||||
r.Forwarded,
|
||||
r.IsResolved,
|
||||
r.CreatedAt,
|
||||
r.ResolvedAt
|
||||
TargetAccountId = r.TargetAccountId,
|
||||
TargetActorURI = r.TargetActorURI,
|
||||
PostIds = r.PostIds,
|
||||
ObjectURIs = r.ObjectURIs,
|
||||
Category = r.Category,
|
||||
Comment = r.Comment,
|
||||
Forwarded = r.Forwarded,
|
||||
IsResolved = r.IsResolved,
|
||||
CreatedAt = r.CreatedAt,
|
||||
ResolvedAt = r.ResolvedAt,
|
||||
RemoteResolvedAt = r.RemoteResolvedAt,
|
||||
RemoteResolvedBy = r.RemoteResolvedBy
|
||||
}));
|
||||
|
||||
[HttpPost, Route("/clientapi/moderator/reports/{id}/resolve")]
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.ClientModels.User;
|
||||
using PrivaPub.Domain.Portability;
|
||||
using PrivaPub.Extensions;
|
||||
using PrivaPub.Infrastructure.Backup;
|
||||
using PrivaPub.Infrastructure.Jobs;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
namespace PrivaPub.Controllers.ClientToServer
|
||||
{
|
||||
// A persona's archive for its root (owner decision 2026-10-07): made in a job and downloaded through a link good for
|
||||
// ten minutes; another archive (its own, or Mastodon's) uploaded in pieces and imported into it in a job, the parts the
|
||||
// root picks, with progress, until it asks to stop. Only the persona's own root, never a banned one.
|
||||
[ApiController,
|
||||
Route("clientapi/persona"),
|
||||
Authorize(Policy = Policies.IsUser)]
|
||||
public class PersonaArchiveController(Backups backups, TransferStore transfers, IJobQueue jobs, Federation.Actors.ILocalActorService localActors)
|
||||
: ControllerBase
|
||||
{
|
||||
[HttpGet, Route("{avatarId}/archive")]
|
||||
public async Task<IActionResult> Status(string avatarId, CancellationToken token)
|
||||
{
|
||||
if (!await Owns(avatarId, token))
|
||||
return NotFound();
|
||||
await PersonaArchives.Forget(backups.Root, token);
|
||||
return Ok(PersonaArchives.View(await PersonaArchives.Of(avatarId, token)));
|
||||
}
|
||||
|
||||
[HttpPost, Route("{avatarId}/archive")]
|
||||
public async Task<IActionResult> Export(string avatarId, CancellationToken token)
|
||||
{
|
||||
if (!await Owns(avatarId, token))
|
||||
return NotFound();
|
||||
var state = await PersonaArchives.Of(avatarId, token);
|
||||
if (state.ExportState is "queued" or "running")
|
||||
return Conflict(new WebResult().Invalidate("The archive is being made."));
|
||||
state.ExportState = "queued";
|
||||
state.ExportAskedAt = DateTime.UtcNow;
|
||||
state.ExportError = default;
|
||||
await DB.Default.SaveAsync(state, token);
|
||||
await jobs.EnqueueMany([ExportArchiveJob.JobFor(avatarId, Host)], token);
|
||||
return Accepted(PersonaArchives.View(state));
|
||||
}
|
||||
|
||||
[HttpPost, Route("{avatarId}/archive/ticket")]
|
||||
public async Task<IActionResult> Ticket(string avatarId, CancellationToken token)
|
||||
{
|
||||
if (!await Owns(avatarId, token))
|
||||
return NotFound();
|
||||
var state = await PersonaArchives.Of(avatarId, token);
|
||||
var file = state.ExportFile == default ? default : Path.Combine(PersonaArchives.Folder(backups.Root, avatarId), state.ExportFile);
|
||||
if (state.ExportState != "ready" || file == default || !System.IO.File.Exists(file))
|
||||
return NotFound();
|
||||
var (ticket, expires) = transfers.Ticket($"persona/{avatarId}/{state.ExportFile}");
|
||||
return Ok(new ViewArchiveTicket { Url = $"/clientapi/persona/archive/download/{ticket}", ExpiresAt = expires, Bytes = new FileInfo(file).Length });
|
||||
}
|
||||
|
||||
// the archive itself, for a ticket: a plain link, so the browser saves it to disk (and resumes it)
|
||||
[HttpGet, Route("archive/download/{ticket}"), AllowAnonymous]
|
||||
public IActionResult Download(string ticket)
|
||||
{
|
||||
if (transfers.Redeem(ticket) is not { } what || what.Split('/') is not ["persona", var avatarId, var name] || name.Contains(".."))
|
||||
return NotFound();
|
||||
var file = Path.Combine(PersonaArchives.Folder(backups.Root, avatarId), name);
|
||||
if (!System.IO.File.Exists(file))
|
||||
return NotFound();
|
||||
Response.Headers.CacheControl = "no-store";
|
||||
return PhysicalFile(file, "application/zip", name, enableRangeProcessing: true);
|
||||
}
|
||||
|
||||
[HttpPost, Route("archive/uploads")]
|
||||
public IActionResult StartUpload() =>
|
||||
Ok(new ViewArchiveUpload { Id = transfers.Start(User.GetUserId()), ChunkBytes = TransferStore.ChunkBytes });
|
||||
|
||||
[HttpGet, Route("archive/uploads/{id}")]
|
||||
public IActionResult Upload(string id)
|
||||
{
|
||||
if (transfers.Owner(id) != User.GetUserId())
|
||||
return NotFound();
|
||||
return Ok(new ViewArchiveUpload { Id = id, Received = transfers.Received(id), ChunkBytes = TransferStore.ChunkBytes });
|
||||
}
|
||||
|
||||
[HttpPut, Route("archive/uploads/{id}"), RequestSizeLimit(TransferStore.ChunkBytes + 1024 * 1024)]
|
||||
public async Task<IActionResult> Append(string id, [FromQuery] long offset, CancellationToken token)
|
||||
{
|
||||
if (transfers.Owner(id) != User.GetUserId())
|
||||
return NotFound();
|
||||
var (received, taken) = await transfers.Append(id, offset, Request.Body, token);
|
||||
var view = new ViewArchiveUpload { Id = id, Received = received, ChunkBytes = TransferStore.ChunkBytes };
|
||||
return taken ? Ok(view) : Conflict(view);
|
||||
}
|
||||
|
||||
[HttpDelete, Route("archive/uploads/{id}")]
|
||||
public IActionResult CancelUpload(string id)
|
||||
{
|
||||
if (transfers.Owner(id) != User.GetUserId())
|
||||
return NotFound();
|
||||
transfers.Cancel(id);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// an uploaded archive imported into the persona: refused at once when it is not one, or holds what it must not
|
||||
[HttpPost, Route("{avatarId}/archive/import")]
|
||||
public async Task<IActionResult> Import(string avatarId, ImportPersonaArchiveForm form, CancellationToken token)
|
||||
{
|
||||
if (!await Owns(avatarId, token))
|
||||
return NotFound();
|
||||
var state = await PersonaArchives.Of(avatarId, token);
|
||||
if (state.ImportState is "queued" or "running")
|
||||
return Conflict(new WebResult().Invalidate("An import is running."));
|
||||
if (form.Parts.Count == 0 || form.Parts.Any(p => !PersonaArchives.Parts.Contains(p)))
|
||||
return UnprocessableEntity(new WebResult().Invalidate("Choose what to import."));
|
||||
if (transfers.Owner(form.UploadId) != User.GetUserId())
|
||||
return NotFound();
|
||||
var path = transfers.Take(form.UploadId, PersonaArchives.Folder(backups.Root, avatarId), $"import-{form.UploadId}.zip");
|
||||
if (path == default)
|
||||
return NotFound();
|
||||
var (archive, refused) = SafeArchive.Open(path);
|
||||
using (archive)
|
||||
if (archive?.Has("actor.json") != true)
|
||||
{
|
||||
System.IO.File.Delete(path);
|
||||
return UnprocessableEntity(new WebResult().Invalidate(refused ?? "the archive has no actor.json"));
|
||||
}
|
||||
state.ImportState = "queued";
|
||||
state.ImportAskedAt = DateTime.UtcNow;
|
||||
state.ImportEndedAt = default;
|
||||
state.ImportParts = [.. form.Parts.Distinct()];
|
||||
state.ImportFile = path;
|
||||
state.ImportCounts = [];
|
||||
(state.ImportDone, state.ImportTotal, state.ImportError, state.StopAsked) = (0, 0, default, false);
|
||||
await DB.Default.SaveAsync(state, token);
|
||||
await jobs.EnqueueMany([ImportArchiveJob.JobFor(avatarId, Host)], token);
|
||||
return Accepted(PersonaArchives.View(state));
|
||||
}
|
||||
|
||||
[HttpPost, Route("{avatarId}/archive/import/stop")]
|
||||
public async Task<IActionResult> Stop(string avatarId, CancellationToken token)
|
||||
{
|
||||
if (!await Owns(avatarId, token))
|
||||
return NotFound();
|
||||
await DB.Default.Update<PersonaArchive>().MatchID(avatarId).Match(a => a.ImportState == "queued" || a.ImportState == "running")
|
||||
.Modify(a => a.StopAsked, true).ExecuteAsync(token);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
string Host => new Uri(localActors.BaseAddress).Host;
|
||||
|
||||
// the root's own persona, and a root that may still act
|
||||
async Task<bool> Owns(string avatarId, CancellationToken token)
|
||||
{
|
||||
var rootId = User.GetUserId();
|
||||
if (string.IsNullOrEmpty(avatarId) || !await DB.Default.Find<RootToAvatar>().Match(r => r.RootId == rootId && r.AvatarId == avatarId).ExecuteAnyAsync(token))
|
||||
return false;
|
||||
var root = await DB.Default.Find<RootUser>().MatchID(rootId).ExecuteFirstAsync(token);
|
||||
return root is { IsBanned: false, DeletedAt: null }
|
||||
&& await DB.Default.Find<Avatar>().MatchID(avatarId).Match(a => !a.DeletionAt.HasValue).ExecuteAnyAsync(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
using PrivaPub.ClientModels.Group;
|
||||
@@ -34,6 +36,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
readonly ILocalActorService LocalActors;
|
||||
readonly AuthTokenManager AuthTokenManager;
|
||||
readonly IOptionsMonitor<AppConfiguration> AppConfiguration;
|
||||
readonly IOptionsMonitor<RegistrationOptions> Registrations;
|
||||
readonly ILogger<RootUserController> Logger;
|
||||
readonly IStringLocalizer Localizer;
|
||||
|
||||
@@ -43,9 +46,11 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
ILocalActorService localActors,
|
||||
AuthTokenManager authTokenManager,
|
||||
IOptionsMonitor<AppConfiguration> appConfiguration,
|
||||
IOptionsMonitor<RegistrationOptions> registrations,
|
||||
IStringLocalizer<GenericRes> localizer,
|
||||
ILogger<RootUserController> logger)
|
||||
{
|
||||
Registrations = registrations;
|
||||
UsersService = usersService;
|
||||
GroupUsersService = groupUsersService;
|
||||
AvatarUsersService = avatarUsersService;
|
||||
@@ -63,6 +68,8 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
{
|
||||
if (User.Identity?.IsAuthenticated ?? false) return Redirect("/");
|
||||
var result = new WebResult();
|
||||
if (!Registrations.CurrentValue.IsOpen)
|
||||
return StatusCode(StatusCodes.Status403Forbidden, result.Invalidate(Localizer[RegistrationOptions.ClosedMessage], StatusCodes.Status403Forbidden));
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
||||
try
|
||||
@@ -130,13 +137,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
return StatusCode(result.StatusCode, result);
|
||||
var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data;
|
||||
|
||||
var avatar = await AvatarUsersService.InsertAvatar(new InsertAvatarForm
|
||||
{
|
||||
RootId = user.ID,
|
||||
Name = string.IsNullOrWhiteSpace(signUpForm.AvatarName) ? avatarUserName : signUpForm.AvatarName.Trim(),
|
||||
UserName = avatarUserName,
|
||||
Biography = string.Empty
|
||||
});
|
||||
var avatar = await InsertPersona(user.ID, avatarUserName, signUpForm.AvatarName);
|
||||
if (!avatar.IsValid)
|
||||
return StatusCode(avatar.StatusCode, avatar);
|
||||
|
||||
@@ -185,13 +186,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
var personaProblem = await PersonaProblem(avatarUserName, user.UserName, token);
|
||||
if (personaProblem != default)
|
||||
return BadRequest(new WebResult().Invalidate(personaProblem));
|
||||
var inserted = await AvatarUsersService.InsertAvatar(new InsertAvatarForm
|
||||
{
|
||||
RootId = user.ID,
|
||||
Name = string.IsNullOrWhiteSpace(loginForm.AvatarName) ? avatarUserName : loginForm.AvatarName.Trim(),
|
||||
UserName = avatarUserName,
|
||||
Biography = string.Empty
|
||||
});
|
||||
var inserted = await InsertPersona(user.ID, avatarUserName, loginForm.AvatarName);
|
||||
if (!inserted.IsValid)
|
||||
return StatusCode(inserted.StatusCode, inserted);
|
||||
avatar = (ViewAvatar)inserted.Data;
|
||||
@@ -215,6 +210,77 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet, Route("/clientapi/user/setup"), AllowAnonymous]
|
||||
public async Task<IActionResult> Setup(CancellationToken token) =>
|
||||
Ok(new ViewServerSetup { IsPending = await ServerSetup.IsPending(token) });
|
||||
|
||||
// The server's first login, made while it has no administrator (owner decision 2026-10-16: first come, first served):
|
||||
// it is made the administrator, with its first persona, and setup closes for good. Sign-up stays closed to everyone
|
||||
// else (Registrations), so NodeInfo and the instance API don't change.
|
||||
[HttpPost, Route("/clientapi/user/setup"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
|
||||
public async Task<IActionResult> SetUp(SetupForm setupForm, CancellationToken token)
|
||||
{
|
||||
if (User.Identity?.IsAuthenticated ?? false) return Redirect("/");
|
||||
var result = new WebResult();
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
||||
var claimed = false;
|
||||
var adminId = default(string);
|
||||
try
|
||||
{
|
||||
if (!await ServerSetup.IsPending(token))
|
||||
return SetUpAlready();
|
||||
|
||||
var avatarUserName = setupForm.AvatarUserName?.Trim().ToLowerInvariant();
|
||||
var personaProblem = await PersonaProblem(avatarUserName, setupForm.UserName, token);
|
||||
if (personaProblem != default)
|
||||
return BadRequest(result.Invalidate(personaProblem));
|
||||
|
||||
claimed = await ServerSetup.Claim(token);
|
||||
if (!claimed)
|
||||
return SetUpAlready();
|
||||
|
||||
result = await UsersService.SignUpAsync(setupForm);
|
||||
if (!result.IsValid)
|
||||
{
|
||||
await ServerSetup.Release();
|
||||
return StatusCode(result.StatusCode, result);
|
||||
}
|
||||
var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data;
|
||||
user.SetAdmin(true);
|
||||
await DB.Default.SaveAsync(user, CancellationToken.None);
|
||||
adminId = user.ID;
|
||||
await ServerSetup.Claimed(adminId, CancellationToken.None);
|
||||
|
||||
// set up from here, whatever happens to the persona: it can be made in the client's settings
|
||||
var avatar = await InsertPersona(user.ID, avatarUserName, setupForm.AvatarName);
|
||||
if (!avatar.IsValid)
|
||||
return StatusCode(avatar.StatusCode, avatar);
|
||||
|
||||
return Ok(AuthTokenManager.GenerateToken(user, userSettings));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(SetUp)}()");
|
||||
if (claimed && adminId == default)
|
||||
await ServerSetup.Release();
|
||||
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
IActionResult SetUpAlready() =>
|
||||
StatusCode(StatusCodes.Status403Forbidden,
|
||||
new WebResult().Invalidate(Localizer["This server is already set up: joining it takes a group invitation."], StatusCodes.Status403Forbidden));
|
||||
|
||||
Task<WebResult> InsertPersona(string rootId, string avatarUserName, string avatarName) =>
|
||||
AvatarUsersService.InsertAvatar(new InsertAvatarForm
|
||||
{
|
||||
RootId = rootId,
|
||||
Name = string.IsNullOrWhiteSpace(avatarName) ? avatarUserName : avatarName.Trim(),
|
||||
UserName = avatarUserName,
|
||||
Biography = string.Empty
|
||||
});
|
||||
|
||||
async Task<string> PersonaProblem(string avatarUserName, string rootUserName, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(avatarUserName))
|
||||
@@ -222,7 +288,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
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 Localizer["The username '{0}' is already taken.", avatarUserName];
|
||||
return default;
|
||||
}
|
||||
|
||||
@@ -323,6 +389,15 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
// }
|
||||
//}
|
||||
|
||||
// what the login's media take, every persona's, and its quota when the server sets one
|
||||
[HttpGet, Route("/clientapi/user/storage"), Authorize(Policy = Policies.IsUser)]
|
||||
public async Task<IActionResult> GetStorage([FromServices] Microsoft.Extensions.Options.IOptionsMonitor<Domain.Media.MediaOptions> media, CancellationToken token)
|
||||
{
|
||||
var (bytes, files) = await Domain.Privacy.Counted.MediaOfRoot(User.GetUserId(), token);
|
||||
var quota = media.CurrentValue.QuotaBytesPerRoot;
|
||||
return Ok(new PrivaPub.ClientModels.User.ViewStorage { MediaBytes = bytes, MediaFiles = files, QuotaBytes = quota > 0 ? quota : null });
|
||||
}
|
||||
|
||||
[HttpGet, Route("/clientapi/user/settings"), Authorize(Policy = Policies.IsUser)]
|
||||
public async Task<IActionResult> GetUserSettings()
|
||||
{
|
||||
@@ -356,7 +431,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
if (!result.IsValid)
|
||||
return StatusCode(result.StatusCode, result);
|
||||
|
||||
return Ok(result);
|
||||
return Ok(result.Data);//the same sentence for everyone
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -409,36 +484,48 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
}
|
||||
}
|
||||
|
||||
//[HttpDelete, Route("delete"), Authorize(Policy = Policies.IsUser)]
|
||||
//public async Task<IActionResult> RemoveSelf()
|
||||
//{
|
||||
// var result = new WebResult();
|
||||
// try
|
||||
// {
|
||||
// //result = await UsersService.RemoveUserAsync(User.GetUserId());
|
||||
// if (!result.IsValid)
|
||||
// return StatusCode(result.StatusCode, result);
|
||||
|
||||
// await HttpContext.SignOutAsync();
|
||||
|
||||
// return Ok();
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// Logger.LogError(ex, $"{nameof(User)}.{nameof(RemoveSelf)}()");
|
||||
// return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
// }
|
||||
//}
|
||||
[HttpPost, Route("/clientapi/user/delete"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)]
|
||||
public async Task<IActionResult> RemoveSelf(RemoveSelfForm form)
|
||||
{
|
||||
var result = new WebResult();
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
||||
try
|
||||
{
|
||||
result = await UsersService.RemoveSelfAsync(User.GetUserId(), form.Password);
|
||||
return result.IsValid ? Ok() : StatusCode(result.StatusCode, result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(RemoveSelf)}()");
|
||||
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion User endpoints
|
||||
|
||||
#region Auth refresh
|
||||
|
||||
[HttpGet, Route("/clientapi/user/sniff/again"), Authorize(Policy = Policies.IsUser)]
|
||||
public async Task<IActionResult> SniffAgain()
|
||||
public async Task<IActionResult> SniffAgain([FromServices] DbEntities dbEntities, CancellationToken token)
|
||||
{
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
var user = await dbEntities.RootUsers.MatchID(User.GetUserId()).ExecuteFirstAsync(token);
|
||||
if (user is not { IsBanned: false, DeletedAt: null })
|
||||
return Unauthorized(result.Invalidate(Localizer["Unauthorized."], StatusCodes.Status401Unauthorized));
|
||||
result = await UsersService.GetUserSettingsAsync(user.ID);
|
||||
if (!result.IsValid)
|
||||
return StatusCode(result.StatusCode, result);
|
||||
|
||||
return Ok();
|
||||
return Ok(AuthTokenManager.GenerateToken(user, (ViewUserSettings)result.Data));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(SniffAgain)}()");
|
||||
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.Domain.Statistics;
|
||||
using PrivaPub.Infrastructure.Jobs;
|
||||
using PrivaPub.Infrastructure.Statistics;
|
||||
using PrivaPub.Models.Jobs;
|
||||
|
||||
using System.Globalization;
|
||||
|
||||
namespace PrivaPub.Controllers.ClientToServer
|
||||
{
|
||||
[ApiController,
|
||||
Route("clientapi/admin/statistics"),
|
||||
Authorize(Policy = Policies.IsAdmin)]
|
||||
public class StatisticsController : ControllerBase
|
||||
{
|
||||
readonly StatisticsQueries _queries;
|
||||
readonly IJobQueue _queue;
|
||||
readonly Microsoft.Extensions.Options.IOptionsMonitor<StatisticsOptions> _options;
|
||||
|
||||
public StatisticsController(StatisticsQueries queries, IJobQueue queue, Microsoft.Extensions.Options.IOptionsMonitor<StatisticsOptions> options)
|
||||
{
|
||||
_queries = queries;
|
||||
_queue = queue;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
[HttpGet, Route("/clientapi/admin/statistics/crawler")]
|
||||
public async Task<IActionResult> Crawler(CancellationToken token) =>
|
||||
Ok(await _queries.Crawler(_options.CurrentValue.Crawler.Enabled, _options.CurrentValue.Crawler.RevisitDays, token));
|
||||
|
||||
[HttpGet, Route("/clientapi/admin/statistics/overview")]
|
||||
public async Task<IActionResult> Overview([FromServices] Domain.Media.IMediaService media, [FromQuery] int days = 30, CancellationToken token = default)
|
||||
{
|
||||
var overview = await _queries.Overview(days, token);
|
||||
overview.Media.ProxyCacheBytes = Bytes(media.ProxyRoot);
|
||||
overview.Media.TrashBytes = Bytes(media.TrashRoot);
|
||||
return Ok(overview);
|
||||
}
|
||||
|
||||
static long Bytes(string root) =>
|
||||
Directory.Exists(root) ? new DirectoryInfo(root).EnumerateFiles("*", SearchOption.AllDirectories).Sum(f => f.Length) : 0;
|
||||
|
||||
[HttpGet, Route("/clientapi/admin/statistics/hosts")]
|
||||
public async Task<IActionResult> Hosts([FromQuery] int days = 30, [FromQuery] string sort = default, [FromQuery] string software = default,
|
||||
[FromQuery] int page = 1, [FromQuery] int limit = 50, CancellationToken token = default) =>
|
||||
Ok(await _queries.Hosts(days, sort, software, page, limit, token));
|
||||
|
||||
[HttpGet, Route("/clientapi/admin/statistics/hosts/{host}")]
|
||||
public async Task<IActionResult> Host(string host, [FromQuery] int days = 90, CancellationToken token = default) =>
|
||||
await _queries.Host(host, days, token) is { } found ? Ok(found) : NotFound();
|
||||
|
||||
[HttpGet, Route("/clientapi/admin/statistics/events")]
|
||||
public async Task<IActionResult> Events([FromQuery] string host = default, [FromQuery] string channel = default, [FromQuery] string outcome = default,
|
||||
[FromQuery] string reason = default, [FromQuery] DateTime? before = default, [FromQuery] int limit = 100, CancellationToken token = default) =>
|
||||
Ok(await _queries.Events(host, channel, outcome, reason, before, limit, token));
|
||||
|
||||
[HttpGet, Route("/clientapi/admin/statistics/server")]
|
||||
public async Task<IActionResult> Server([FromQuery] int days = 30, CancellationToken token = default) =>
|
||||
Ok(await _queries.Server(days, token));
|
||||
|
||||
[HttpPost, Route("/clientapi/admin/statistics/rollups/{day}")]
|
||||
public async Task<IActionResult> Rollup(string day, CancellationToken token)
|
||||
{
|
||||
if (!DateTime.TryParseExact(day, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed)
|
||||
|| parsed.Date >= DateTime.UtcNow.Date)
|
||||
return BadRequest();
|
||||
var job = RollupJob.For(parsed.Date, $"rollup|{day}|rerun|{DateTime.UtcNow.Ticks}");
|
||||
job.RunAt = DateTime.UtcNow;
|
||||
await _queue.EnqueueMany(new[] { job }, token);
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
[HttpPost, Route("/clientapi/admin/statistics/hosts/{host}/describe")]
|
||||
public async Task<IActionResult> Describe(string host, CancellationToken token)
|
||||
{
|
||||
host = Interactions.Host(host);
|
||||
if (host == Interactions.Unknown)
|
||||
return BadRequest();
|
||||
await _queue.Enqueue(JobKind.DescribeInstance, host, host, $"describe|{host}|admin|{DateTime.UtcNow.Ticks}", token);
|
||||
return Accepted();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,7 @@ namespace PrivaPub.Domain.Content
|
||||
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var local = await _localActors.FindByUserName(parts[0], token);
|
||||
return local is { IsFederated: true, IsCircle: false, Kind: not LocalActorKind.Application }
|
||||
return local is { IsFederated: true, IsCircle: false, IsServerActor: false }
|
||||
? new ResolvedMention(local.Uri, local.UserName, local.Domain, local.Uri, true, local.Id, local.Inbox)
|
||||
: default;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
using AngleSharp.Html.Parser;
|
||||
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Infrastructure.Statistics;
|
||||
using PrivaPub.Models.Statistics;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Infrastructure.Http;
|
||||
using PrivaPub.Infrastructure.Jobs;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Domain.Content
|
||||
{
|
||||
public class LinkPreview : Entity
|
||||
{
|
||||
public string Url { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Description { get; set; }
|
||||
public string ImageURL { get; set; }
|
||||
public string SiteName { get; set; }
|
||||
public bool Failed { get; set; }
|
||||
public DateTime FetchedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public interface ILinkPreviews
|
||||
{
|
||||
Task Wanted(PostEntity post, CancellationToken token);
|
||||
}
|
||||
|
||||
public class LinkPreviews : ILinkPreviews, IJobHandler
|
||||
{
|
||||
const int MaxJitterSeconds = 60;
|
||||
static readonly TimeSpan Freshness = TimeSpan.FromDays(7);
|
||||
static readonly HtmlParser Parser = new();
|
||||
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IFederationHttp _http;
|
||||
readonly IJobQueue _queue;
|
||||
readonly IOptionsMonitor<FederationOptions> _options;
|
||||
|
||||
readonly IInteractionLedger _ledger;
|
||||
|
||||
public LinkPreviews(DbEntities dbEntities, ILocalActorService localActors, IFederationHttp http, IJobQueue queue,
|
||||
IOptionsMonitor<FederationOptions> options, IInteractionLedger ledger = default)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
_http = http;
|
||||
_queue = queue;
|
||||
_options = options;
|
||||
_ledger = ledger;
|
||||
}
|
||||
|
||||
public JobKind Kind => JobKind.FetchPreview;
|
||||
public int Concurrency => 2;
|
||||
public int MaxAttempts => 1;
|
||||
public int PerHostLimit => 1;
|
||||
|
||||
public async Task Wanted(PostEntity post, CancellationToken token)
|
||||
{
|
||||
if (!_options.CurrentValue.FetchLinkPreviews || !Previewable(post) || post.Link == default && post.Media.Count > 0)
|
||||
return;
|
||||
var url = post.Link?.Href ?? FirstLink(post, _localActors.BaseAddress);
|
||||
if (url == default)
|
||||
return;
|
||||
await _queue.EnqueueMany(new[]
|
||||
{
|
||||
new Job
|
||||
{
|
||||
Kind = JobKind.FetchPreview, Payload = post.ID, Host = new Uri(url).Host.ToLowerInvariant(), DedupeKey = $"preview|{post.ID}",
|
||||
RunAt = DateTime.UtcNow.AddSeconds(RandomNumberGenerator.GetInt32(0, MaxJitterSeconds + 1))
|
||||
}
|
||||
}, token);
|
||||
}
|
||||
|
||||
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
|
||||
{
|
||||
var post = await _dbEntities.Posts.MatchID(job.Payload).ExecuteFirstAsync(token);
|
||||
if (!VisibilityPolicy.Shown(post) || !Previewable(post))
|
||||
return JobOutcome.Done;
|
||||
var url = post.Link?.Href ?? FirstLink(post, _localActors.BaseAddress);
|
||||
if (url == default)
|
||||
return JobOutcome.Done;
|
||||
|
||||
var preview = await DB.Default.Find<LinkPreview>().Match(p => p.Url == url).ExecuteFirstAsync(token);
|
||||
if (preview == default || preview.FetchedAt < DateTime.UtcNow - Freshness)
|
||||
preview = await Fetch(url, token);
|
||||
if (preview == default || preview.Failed)
|
||||
return JobOutcome.Done;
|
||||
|
||||
var link = post.Link ?? new PostLink { Href = url };
|
||||
link.Title ??= preview.Title;
|
||||
link.Description ??= preview.Description;
|
||||
link.ImageURL ??= preview.ImageURL;
|
||||
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.Link, link).ExecuteAsync(token);
|
||||
return JobOutcome.Done;
|
||||
}
|
||||
|
||||
async Task<LinkPreview> Fetch(string url, CancellationToken token)
|
||||
{
|
||||
var started = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
var (finalUri, html) = await _http.GetPage(url, token);
|
||||
var preview = html == default ? new LinkPreview { Url = url, Failed = true } : Read(url, finalUri, html);
|
||||
_ledger?.Record(new InteractionEvent
|
||||
{
|
||||
Channel = Interactions.Preview,
|
||||
Host = Interactions.HostOf(url),
|
||||
Outcome = html == default ? Interactions.Failed : Interactions.Ok,
|
||||
Reason = html == default ? default : preview.Title == default && preview.ImageURL == default ? "no-card" : "card",
|
||||
LatencyMs = (int)System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalMilliseconds,
|
||||
Bytes = html == default ? default : System.Text.Encoding.UTF8.GetByteCount(html),
|
||||
Redirects = finalUri == default || finalUri.AbsoluteUri == url ? 0 : 1
|
||||
});
|
||||
await DB.Default.Update<LinkPreview>()
|
||||
.Match(p => p.Url == url)
|
||||
.Modify(p => p.Title, preview.Title)
|
||||
.Modify(p => p.Description, preview.Description)
|
||||
.Modify(p => p.ImageURL, preview.ImageURL)
|
||||
.Modify(p => p.SiteName, preview.SiteName)
|
||||
.Modify(p => p.Failed, preview.Failed)
|
||||
.Modify(p => p.FetchedAt, DateTime.UtcNow)
|
||||
.Option(o => o.IsUpsert = true)
|
||||
.ExecuteAsync(token);
|
||||
return preview;
|
||||
}
|
||||
|
||||
//only public posts (owner decision 1), checked again when the job runs; never a boost or a post whose card is complete
|
||||
static bool Previewable(PostEntity post) => post.Visibility is (PostVisibility.Public or PostVisibility.Unlisted) && !post.IsLocalOnly
|
||||
&& post.ReblogOfPostId == default && post.Link?.Title == default;
|
||||
|
||||
public static LinkPreview Read(string url, Uri finalUri, string html)
|
||||
{
|
||||
var document = Parser.ParseDocument(html);
|
||||
string Meta(params string[] names) => names
|
||||
.Select(name => document.QuerySelector($"meta[property='{name}'], meta[name='{name}']")?.GetAttribute("content"))
|
||||
.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
|
||||
var image = Meta("og:image:secure_url", "og:image", "og:image:url", "twitter:image", "twitter:image:src");
|
||||
var imageUri = image != default && Uri.TryCreate(finalUri, image, out var resolved) && resolved.Scheme is "https" or "http" ? resolved.AbsoluteUri : default;
|
||||
var preview = new LinkPreview
|
||||
{
|
||||
Url = url,
|
||||
Title = ObjectShapes.Text(Meta("og:title", "twitter:title") ?? document.Title, 300),
|
||||
Description = ObjectShapes.Text(Meta("og:description", "twitter:description", "description"), 1000),
|
||||
SiteName = ObjectShapes.Text(Meta("og:site_name"), 200),
|
||||
ImageURL = imageUri
|
||||
};
|
||||
preview.Failed = preview.Title == default && preview.Description == default && preview.ImageURL == default;
|
||||
return preview;
|
||||
}
|
||||
|
||||
public static string FirstLink(PostEntity post, string ownBase)
|
||||
{
|
||||
if (string.IsNullOrEmpty(post.ContentHtml))
|
||||
return default;
|
||||
var document = Parser.ParseDocument(post.ContentHtml);
|
||||
return document.QuerySelectorAll("a[href]")
|
||||
.Where(a => !(a.ClassList.Contains("mention") || a.ClassList.Contains("hashtag") || a.GetAttribute("rel")?.Contains("tag") == true
|
||||
|| a.Closest(".quote-inline") != default))
|
||||
.Select(a => a.GetAttribute("href"))
|
||||
.FirstOrDefault(href => Uri.TryCreate(href, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http"
|
||||
&& href != post.Url && href != post.ObjectURI && !href.StartsWith(ownBase + "/", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace PrivaPub.Domain.Media
|
||||
{
|
||||
// A focal point as Mastodon's API takes it ("x,y", each within -1..1): finite numbers only, clamped to the frame. A NaN
|
||||
// once stored here made every status, timeline and Note holding the post fail to serialise, for every viewer.
|
||||
public static class FocalPoint
|
||||
{
|
||||
public static float[] Parse(string value)
|
||||
{
|
||||
if (value?.Split(',') is not [var x, var y]
|
||||
|| !float.TryParse(x, NumberStyles.Float, CultureInfo.InvariantCulture, out var fx) || !float.IsFinite(fx)
|
||||
|| !float.TryParse(y, NumberStyles.Float, CultureInfo.InvariantCulture, out var fy) || !float.IsFinite(fy))
|
||||
return default;
|
||||
return [Math.Clamp(fx, -1, 1), Math.Clamp(fy, -1, 1)];
|
||||
}
|
||||
|
||||
/// <summary>Whether a stored focal point can be written out: two finite numbers.</summary>
|
||||
public static bool IsSound(float[] focus) => focus is [var x, var y] && float.IsFinite(x) && float.IsFinite(y);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Media;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Domain.Media
|
||||
{
|
||||
// What the media directory holds against what the database says holds it, and, with fix, the two made to agree:
|
||||
// - a persona's current avatar or header with no row (they had none before) becomes a row of its own;
|
||||
// - media of a deleted post or of a deleted persona go to the trash;
|
||||
// - rows whose files are gone go to the trash;
|
||||
// - files nothing holds (deleted posts' media and replaced pictures, from before media were trashed) get a trashed row
|
||||
// each.
|
||||
// Trashed files leave what /media/files serves at once, and the janitor deletes them after its grace. Run it without
|
||||
// fix first; with fix it must run as the user that owns the media (www-data).
|
||||
public static class MediaAudit
|
||||
{
|
||||
public sealed record Report(int Files, int Held, int Adopted, int OfDeleted, int MissingFiles, int Unheld, IReadOnlyList<string> Examples, bool Fixed);
|
||||
|
||||
public static async Task<Report> Run(IMediaService media, bool fix, CancellationToken token)
|
||||
{
|
||||
var files = Served(media.Root);
|
||||
var rows = await DB.Default.Find<MediaAttachment>().Match(m => m.TrashedAt == null).ExecuteAsync(token);
|
||||
var held = rows.SelectMany(r => new[] { r.FilePath, r.PreviewPath }).Where(p => !string.IsNullOrEmpty(p)).ToHashSet();
|
||||
var examples = new List<string>();
|
||||
|
||||
// pictures personas show today, kept as rows from now on
|
||||
var prefix = media.Url(string.Empty);
|
||||
var adopted = 0;
|
||||
foreach (var avatar in await DB.Default.Find<Avatar>().Match(a => !a.DeletionAt.HasValue && (a.PictureURL != null || a.ThumbnailURL != null)).ExecuteAsync(token))
|
||||
foreach (var (url, kind) in new[] { (avatar.PictureURL, "avatar"), (avatar.ThumbnailURL, "header") })
|
||||
{
|
||||
if (url?.StartsWith(prefix, StringComparison.Ordinal) != true)
|
||||
continue;
|
||||
var relative = url[prefix.Length..];
|
||||
if (held.Contains(relative) || !files.Contains(relative))
|
||||
continue;
|
||||
adopted++;
|
||||
held.Add(relative);
|
||||
if (fix)
|
||||
await DB.Default.SaveAsync(new MediaAttachment
|
||||
{
|
||||
OwnerAvatarId = avatar.ID,
|
||||
ProfileOfAvatarId = avatar.ID,
|
||||
Kind = kind,
|
||||
ContentType = "image/jpeg",
|
||||
FilePath = relative,
|
||||
Size = new FileInfo(Path.Combine(media.Root, relative)).Length,
|
||||
AttachedAt = DateTime.UtcNow
|
||||
}, token);
|
||||
}
|
||||
|
||||
// held by something that is gone: a deleted post, a deleted persona
|
||||
var postIds = rows.Where(r => r.PostId != null).Select(r => r.PostId).Distinct().ToList();
|
||||
var livePosts = (await DB.Default.Find<PostEntity>().Match(p => postIds.Contains(p.ID) && !p.DeletedAt.HasValue).Project(p => p.Include(x => x.ID)).ExecuteAsync(token))
|
||||
.Select(p => p.ID).ToHashSet();
|
||||
var ownerIds = rows.Select(r => r.OwnerAvatarId).Where(id => id != null).Distinct().ToList();
|
||||
var liveOwners = (await DB.Default.Find<Avatar>().Match(a => ownerIds.Contains(a.ID) && !a.DeletionAt.HasValue).Project(a => a.Include(x => x.ID)).ExecuteAsync(token))
|
||||
.Select(a => a.ID).ToHashSet();
|
||||
var ofDeleted = rows.Where(r => (r.PostId != null && !livePosts.Contains(r.PostId)) || (r.OwnerAvatarId != null && !liveOwners.Contains(r.OwnerAvatarId))).ToList();
|
||||
var missing = rows.Except(ofDeleted).Where(r => !string.IsNullOrEmpty(r.FilePath) && !files.Contains(r.FilePath)).ToList();
|
||||
if (fix)
|
||||
foreach (var row in ofDeleted.Concat(missing))
|
||||
await media.Trash(m => m.ID == row.ID, ofDeleted.Contains(row) ? "audit: its holder is deleted" : "audit: its file is missing", token);
|
||||
foreach (var row in ofDeleted)
|
||||
held.Remove(row.FilePath);
|
||||
|
||||
// files nothing holds
|
||||
var unheld = files.Where(f => !held.Contains(f)).OrderBy(f => f, StringComparer.Ordinal).ToList();
|
||||
examples.AddRange(unheld.Take(10));
|
||||
if (fix)
|
||||
foreach (var relative in unheld)
|
||||
{
|
||||
var orphan = new MediaAttachment
|
||||
{
|
||||
Kind = "orphan",
|
||||
FilePath = relative,
|
||||
Size = new FileInfo(Path.Combine(media.Root, relative)).Length,
|
||||
TrashedAt = DateTime.UtcNow,
|
||||
TrashReason = "audit: nothing holds it"
|
||||
};
|
||||
await DB.Default.SaveAsync(orphan, token);
|
||||
media.Hide(orphan);
|
||||
}
|
||||
|
||||
return new Report(files.Count, rows.Count, adopted, ofDeleted.Count, missing.Count, unheld.Count, examples, fix);
|
||||
}
|
||||
|
||||
// every file /media/files serves: everything under the root
|
||||
static HashSet<string> Served(string root)
|
||||
{
|
||||
if (!Directory.Exists(root))
|
||||
return new HashSet<string>();
|
||||
return Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)
|
||||
.Select(f => Path.GetRelativePath(root, f).Replace('\\', '/'))
|
||||
.ToHashSet();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,13 @@ namespace PrivaPub.Domain.Media
|
||||
public long ProxyCacheBytes { get; set; } = 5L * 1024 * 1024 * 1024;
|
||||
public int MaxImageSide { get; set; } = 4096;
|
||||
public int PreviewSide { get; set; } = 640;
|
||||
public long MaxPixels { get; set; } = 40_000_000;//an image (or a GIF's frames together) may not decode to more
|
||||
public int MaxFrames { get; set; } = 500;
|
||||
public int MaxGifSide { get; set; } = 1280;//a GIF becomes an mp4 at most this wide
|
||||
public int Concurrency { get; set; } = 2;//uploads processed at once
|
||||
public long MaxVideoPixels { get; set; } = 2_304_000;//a larger video is made smaller (as Mastodon's video_matrix_limit)
|
||||
public double MaxFrameRate { get; set; } = 60;
|
||||
public int MaxSeconds { get; set; } = 3600;//audio or video may not last longer
|
||||
public long QuotaBytesPerRoot { get; set; }//what a login's media may take (0: no quota)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using PrivaPub.Models.Federation;
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using MongoDB.Entities;
|
||||
@@ -11,29 +13,77 @@ using System.Text;
|
||||
|
||||
namespace PrivaPub.Domain.Media
|
||||
{
|
||||
public enum ProxyOutcome
|
||||
{
|
||||
Cached,
|
||||
TooBig,
|
||||
Failed
|
||||
}
|
||||
|
||||
public interface IMediaProxy
|
||||
{
|
||||
string Wrap(string remoteUrl);
|
||||
string Verified(string signature, string encodedUrl);
|
||||
/// <summary>Whether a remote file's server is suspended or has its media rejected: nothing of it is proxied.</summary>
|
||||
bool Refuses(string url);
|
||||
(string Path, string ContentType) Cached(string url);
|
||||
/// <summary>Downloads a remote file into the cache, once however many ask at the same time.</summary>
|
||||
Task<(ProxyOutcome Outcome, string Path, string ContentType)> Download(string url, CancellationToken token);
|
||||
Task<(string Path, string ContentType)> Fetch(string signature, string encodedUrl, CancellationToken token);
|
||||
Task<HttpResponseMessage> Open(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token);
|
||||
/// <summary>Deletes what the cache holds of a domain and its subdomains; how many files.</summary>
|
||||
int Purge(string domain);
|
||||
/// <summary>Trims the cache to its size, oldest first.</summary>
|
||||
void Trim();
|
||||
}
|
||||
|
||||
// Remote media, fetched for clients so that they never contact a remote server (CLAUDE.md, media invariants):
|
||||
// - URLs are HMAC-signed, so only what PrivaPub showed is fetched;
|
||||
// - a download is shared by everyone asking for the same URL at once, streamed into a temporary file beside its place
|
||||
// and renamed there (a reader never sees half a file), and never held in memory;
|
||||
// - what is too big to cache is remembered for an hour (it is streamed instead), what failed for five minutes;
|
||||
// - the cache's size is counted as it grows, and trimmed as soon as it passes the cap;
|
||||
// - nothing of a suspended server, or of one whose media are rejected, is proxied, and blocking one purges its files.
|
||||
public class MediaProxy : IMediaProxy
|
||||
{
|
||||
static readonly TimeSpan TooBigFor = TimeSpan.FromHours(1);
|
||||
static readonly TimeSpan FailedFor = TimeSpan.FromMinutes(5);
|
||||
const int Downloads = 8;//remote files fetched at once, however many clients ask
|
||||
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IFederationHttp _http;
|
||||
readonly IMediaService _media;
|
||||
readonly IOptionsMonitor<MediaOptions> _options;
|
||||
readonly Federation.Moderation.IDomainBlocks _domainBlocks;
|
||||
// Lazy: GetOrAdd may run its factory more than once when asked at the same time, but only one Lazy is ever evaluated
|
||||
readonly ConcurrentDictionary<string, Lazy<Task<(ProxyOutcome, string, string)>>> _inFlight = new();
|
||||
readonly ConcurrentDictionary<string, (ProxyOutcome Outcome, DateTime Until)> _refused = new();
|
||||
readonly SemaphoreSlim _downloads = new(Downloads);
|
||||
readonly object _keyLock = new();
|
||||
byte[] _key;
|
||||
long _bytes = -1;//what the cache holds, counted once from the disk and then as it changes
|
||||
int _trimming;
|
||||
|
||||
public MediaProxy(ILocalActorService localActors, IFederationHttp http, IMediaService media, IOptionsMonitor<MediaOptions> options)
|
||||
public MediaProxy(ILocalActorService localActors, IFederationHttp http, IMediaService media, IOptionsMonitor<MediaOptions> options,
|
||||
Federation.Moderation.IDomainBlocks domainBlocks = default)
|
||||
{
|
||||
_localActors = localActors;
|
||||
_http = http;
|
||||
_media = media;
|
||||
_options = options;
|
||||
_domainBlocks = domainBlocks;
|
||||
}
|
||||
|
||||
byte[] Key => _key ??= LoadKey();
|
||||
byte[] Key
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_key != default)
|
||||
return _key;
|
||||
lock (_keyLock)
|
||||
return _key ??= LoadKey();
|
||||
}
|
||||
}
|
||||
|
||||
public string Wrap(string remoteUrl)
|
||||
{
|
||||
@@ -43,7 +93,7 @@ namespace PrivaPub.Domain.Media
|
||||
return $"{_localActors.BaseAddress}/media/proxy/{Sign(remoteUrl)}/{encoded}";
|
||||
}
|
||||
|
||||
public async Task<(string Path, string ContentType)> Fetch(string signature, string encodedUrl, CancellationToken token)
|
||||
public string Verified(string signature, string encodedUrl)
|
||||
{
|
||||
string url;
|
||||
try
|
||||
@@ -54,38 +104,206 @@ namespace PrivaPub.Domain.Media
|
||||
{
|
||||
return default;
|
||||
}
|
||||
if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(signature ?? string.Empty), Encoding.ASCII.GetBytes(Sign(url))))
|
||||
return default;
|
||||
return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(signature ?? string.Empty), Encoding.ASCII.GetBytes(Sign(url))) ? url : default;
|
||||
}
|
||||
|
||||
var name = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(url)));
|
||||
var directory = System.IO.Path.Combine(_media.ProxyRoot, name[..2]);
|
||||
var path = System.IO.Path.Combine(directory, name);
|
||||
var typePath = path + ".type";
|
||||
if (File.Exists(path) && File.Exists(typePath))
|
||||
public bool Refuses(string url)
|
||||
{
|
||||
if (_domainBlocks == default || !Uri.TryCreate(url, UriKind.Absolute, out var target))
|
||||
return false;
|
||||
var block = _domainBlocks.Find(target.Host);
|
||||
return block is { Severity: DomainBlockSeverity.Suspend } or { RejectMedia: true };
|
||||
}
|
||||
|
||||
public (string Path, string ContentType) Cached(string url)
|
||||
{
|
||||
var (path, typePath) = CachePaths(url);
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path) || !File.Exists(typePath))
|
||||
return default;
|
||||
File.SetLastWriteTimeUtc(path, DateTime.UtcNow);
|
||||
return (path, await File.ReadAllTextAsync(typePath, token));
|
||||
return (path, File.ReadLines(typePath).FirstOrDefault());
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return default;//trimmed meanwhile
|
||||
}
|
||||
}
|
||||
|
||||
var (bytes, contentType) = await _http.GetMedia(url, _options.CurrentValue.MaxProxiedBytes, token);
|
||||
if (bytes == default)
|
||||
public Task<HttpResponseMessage> Open(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token) =>
|
||||
_http.OpenMedia(url, range, token);
|
||||
|
||||
public async Task<(string Path, string ContentType)> Fetch(string signature, string encodedUrl, CancellationToken token)
|
||||
{
|
||||
var url = Verified(signature, encodedUrl);
|
||||
if (url == default || Refuses(url))
|
||||
return default;
|
||||
Directory.CreateDirectory(directory);
|
||||
await File.WriteAllBytesAsync(path, bytes, token);
|
||||
await File.WriteAllTextAsync(typePath, contentType, token);
|
||||
return (path, contentType);
|
||||
var (outcome, path, contentType) = await Download(url, token);
|
||||
return outcome == ProxyOutcome.Cached ? (path, contentType) : default;
|
||||
}
|
||||
|
||||
public async Task<(ProxyOutcome Outcome, string Path, string ContentType)> Download(string url, CancellationToken token)
|
||||
{
|
||||
if (Cached(url) is { Path: not null } cached)
|
||||
return (ProxyOutcome.Cached, cached.Path, cached.ContentType);
|
||||
if (_refused.TryGetValue(url, out var refused))
|
||||
{
|
||||
if (refused.Until > DateTime.UtcNow)
|
||||
return (refused.Outcome, default, default);
|
||||
_refused.TryRemove(url, out _);
|
||||
}
|
||||
// shared by everyone asking for it now; the download itself isn't cancelled when one of them leaves
|
||||
var shared = _inFlight.GetOrAdd(url, key => new Lazy<Task<(ProxyOutcome, string, string)>>(() => DownloadOnce(key)));
|
||||
try
|
||||
{
|
||||
return await shared.Value.WaitAsync(token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (shared.Value.IsCompleted)
|
||||
_inFlight.TryRemove(new KeyValuePair<string, Lazy<Task<(ProxyOutcome, string, string)>>>(url, shared));
|
||||
}
|
||||
}
|
||||
|
||||
async Task<(ProxyOutcome, string, string)> DownloadOnce(string url)
|
||||
{
|
||||
await Task.Yield();
|
||||
if (!await _downloads.WaitAsync(TimeSpan.FromSeconds(30)))
|
||||
return (ProxyOutcome.Failed, default, default);//too busy: the client tries again
|
||||
// cached by a download that ended just before this one began
|
||||
if (Cached(url) is { Path: not null } done)
|
||||
{
|
||||
_downloads.Release();
|
||||
return (ProxyOutcome.Cached, done.Path, done.ContentType);
|
||||
}
|
||||
var (path, typePath) = CachePaths(url);
|
||||
var part = $"{path}.{Guid.NewGuid():N}.part";
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!);
|
||||
string contentType, refusal;
|
||||
await using (var file = new FileStream(part, FileMode.CreateNew, FileAccess.Write, FileShare.None, 64 * 1024, useAsync: true))
|
||||
(contentType, refusal) = await _http.DownloadMedia(url, _options.CurrentValue.MaxProxiedBytes, file, CancellationToken.None);
|
||||
if (contentType == default)
|
||||
{
|
||||
var outcome = refusal == "too-large" ? ProxyOutcome.TooBig : ProxyOutcome.Failed;
|
||||
_refused[url] = (outcome, DateTime.UtcNow + (outcome == ProxyOutcome.TooBig ? TooBigFor : FailedFor));
|
||||
return (outcome, default, default);
|
||||
}
|
||||
await File.WriteAllTextAsync(typePath, contentType + "\n" + new Uri(url).Host);
|
||||
var size = new FileInfo(part).Length;
|
||||
File.Move(part, path, overwrite: true);
|
||||
Grew(size);
|
||||
return (ProxyOutcome.Cached, path, contentType);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_downloads.Release();
|
||||
if (File.Exists(part))
|
||||
File.Delete(part);
|
||||
}
|
||||
}
|
||||
|
||||
void Grew(long bytes)
|
||||
{
|
||||
if (Interlocked.Read(ref _bytes) < 0)
|
||||
Interlocked.CompareExchange(ref _bytes, Measure(), -1);
|
||||
if (Interlocked.Add(ref _bytes, bytes) > _options.CurrentValue.ProxyCacheBytes)
|
||||
_ = Task.Run(Trim);
|
||||
}
|
||||
|
||||
public void Trim()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _trimming, 1) == 1)
|
||||
return;
|
||||
try
|
||||
{
|
||||
Interlocked.Exchange(ref _bytes, TrimDirectory(_media.ProxyRoot, _options.CurrentValue.ProxyCacheBytes));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _trimming, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public int Purge(string domain)
|
||||
{
|
||||
var directory = new DirectoryInfo(_media.ProxyRoot);
|
||||
if (string.IsNullOrEmpty(domain) || !directory.Exists)
|
||||
return 0;
|
||||
var purged = 0;
|
||||
foreach (var type in directory.EnumerateFiles("*.type", SearchOption.AllDirectories))
|
||||
{
|
||||
try
|
||||
{
|
||||
var host = File.ReadLines(type.FullName).Skip(1).FirstOrDefault();
|
||||
if (host == default || !(host.Equals(domain, StringComparison.OrdinalIgnoreCase) || host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
var file = new FileInfo(type.FullName[..^".type".Length]);
|
||||
if (file.Exists)
|
||||
file.Delete();
|
||||
type.Delete();
|
||||
purged++;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
}
|
||||
Interlocked.Exchange(ref _bytes, -1);
|
||||
return purged;
|
||||
}
|
||||
|
||||
long Measure()
|
||||
{
|
||||
var directory = new DirectoryInfo(_media.ProxyRoot);
|
||||
return directory.Exists ? directory.EnumerateFiles("*", SearchOption.AllDirectories).Where(f => f.Extension is not (".type" or ".part")).Sum(f => f.Length) : 0;
|
||||
}
|
||||
|
||||
// deletes the oldest files until the cache is within its cap; what it holds afterwards
|
||||
public static long TrimDirectory(string root, long cap)
|
||||
{
|
||||
var directory = new DirectoryInfo(root);
|
||||
if (!directory.Exists)
|
||||
return 0;
|
||||
var files = directory.EnumerateFiles("*", SearchOption.AllDirectories).Where(f => f.Extension is not (".type" or ".part")).OrderBy(f => f.LastWriteTimeUtc).ToList();
|
||||
var total = files.Sum(f => f.Length);
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (total <= cap)
|
||||
break;
|
||||
try
|
||||
{
|
||||
total -= file.Length;
|
||||
file.Delete();
|
||||
var type = new FileInfo(file.FullName + ".type");
|
||||
if (type.Exists)
|
||||
type.Delete();
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
(string Path, string TypePath) CachePaths(string url)
|
||||
{
|
||||
var name = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(url)));
|
||||
var path = System.IO.Path.Combine(_media.ProxyRoot, name[..2], name);
|
||||
return (path, path + ".type");
|
||||
}
|
||||
|
||||
string Sign(string url) => Base64Url(HMACSHA256.HashData(Key, Encoding.UTF8.GetBytes(url))[..16]);
|
||||
|
||||
// the oldest key, so that two first uses at once agree on one
|
||||
static byte[] LoadKey()
|
||||
{
|
||||
var secret = DB.Default.Find<MediaSecret>().ExecuteFirstAsync().GetAwaiter().GetResult();
|
||||
var secret = DB.Default.Find<MediaSecret>().Sort(m => m.ID, Order.Ascending).ExecuteFirstAsync().GetAwaiter().GetResult();
|
||||
if (secret == default)
|
||||
{
|
||||
secret = new MediaSecret { Key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) };
|
||||
DB.Default.SaveAsync(secret).GetAwaiter().GetResult();
|
||||
secret = DB.Default.Find<MediaSecret>().ExecuteFirstAsync().GetAwaiter().GetResult();
|
||||
DB.Default.SaveAsync(new MediaSecret { Key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) }).GetAwaiter().GetResult();
|
||||
secret = DB.Default.Find<MediaSecret>().Sort(m => m.ID, Order.Ascending).ExecuteFirstAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
return Convert.FromBase64String(secret.Key);
|
||||
}
|
||||
@@ -101,8 +319,11 @@ namespace PrivaPub.Domain.Media
|
||||
|
||||
public class MediaJanitor : BackgroundService
|
||||
{
|
||||
static readonly TimeSpan FirstPass = TimeSpan.FromMinutes(5);
|
||||
static readonly TimeSpan Interval = TimeSpan.FromHours(1);
|
||||
static readonly TimeSpan UnattachedLifetime = TimeSpan.FromDays(1);
|
||||
// a trashed file waits this long before it is deleted, already out of what /media/files serves
|
||||
public static readonly TimeSpan TrashGrace = TimeSpan.FromDays(1);
|
||||
|
||||
readonly IMediaService _media;
|
||||
readonly IOptionsMonitor<MediaOptions> _options;
|
||||
@@ -117,15 +338,14 @@ namespace PrivaPub.Domain.Media
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var wait = FirstPass;
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(Interval, stoppingToken);
|
||||
var cutoff = DateTime.UtcNow - UnattachedLifetime;
|
||||
foreach (var stale in await DB.Default.Find<MediaAttachment>().Match(m => m.PostId == null && m.CreatedAt < cutoff).Limit(500).ExecuteAsync(stoppingToken))
|
||||
await _media.Delete(stale);
|
||||
TrimProxyCache();
|
||||
await Task.Delay(wait, stoppingToken);
|
||||
wait = Interval;
|
||||
await Sweep(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -138,23 +358,38 @@ namespace PrivaPub.Domain.Media
|
||||
}
|
||||
}
|
||||
|
||||
void TrimProxyCache()
|
||||
// one pass:
|
||||
// - uploads never posted for a day (and not waiting for a scheduled post, nor a profile picture) go to the trash;
|
||||
// - trashed files still served (a crash between the mark and the move) are moved out;
|
||||
// - trashed files past their grace are deleted, with their rows, unless a backup or a restore is running (one may be
|
||||
// reading the trash);
|
||||
// - the proxy cache is trimmed to its size, oldest first
|
||||
public async Task Sweep(CancellationToken token)
|
||||
{
|
||||
var directory = new DirectoryInfo(_media.ProxyRoot);
|
||||
if (!directory.Exists)
|
||||
return;
|
||||
var files = directory.EnumerateFiles("*", SearchOption.AllDirectories).Where(f => f.Extension != ".type").OrderBy(f => f.LastWriteTimeUtc).ToList();
|
||||
var total = files.Sum(f => f.Length);
|
||||
foreach (var file in files)
|
||||
var cutoff = DateTime.UtcNow - UnattachedLifetime;
|
||||
var unattached = await _media.Trash(m => m.PostId == null && m.ScheduledStatusId == null && m.ProfileOfAvatarId == null && m.CreatedAt < cutoff,
|
||||
"never posted", token);
|
||||
|
||||
var trashed = await DB.Default.Find<MediaAttachment>().Match(m => m.TrashedAt != null).Limit(2000).ExecuteAsync(token);
|
||||
var purgeBefore = DateTime.UtcNow - TrashGrace;
|
||||
var maintenance = await Infrastructure.Backup.MaintenanceLock.Current(token) != default;
|
||||
var purged = 0;
|
||||
foreach (var row in trashed)
|
||||
{
|
||||
if (total <= _options.CurrentValue.ProxyCacheBytes)
|
||||
break;
|
||||
total -= file.Length;
|
||||
file.Delete();
|
||||
var type = new FileInfo(file.FullName + ".type");
|
||||
if (type.Exists)
|
||||
type.Delete();
|
||||
if (row.TrashedAt < purgeBefore && !maintenance)
|
||||
{
|
||||
await _media.Purge(row, token);
|
||||
purged++;
|
||||
}
|
||||
else
|
||||
_media.Hide(row);
|
||||
}
|
||||
if (unattached > 0 || purged > 0)
|
||||
_logger.LogInformation("{Service}: {Unattached} uploads never posted trashed, {Purged} trashed files deleted",
|
||||
nameof(MediaJanitor), unattached, purged);
|
||||
TrimProxyCache();
|
||||
}
|
||||
|
||||
void TrimProxyCache() => MediaProxy.TrimDirectory(_media.ProxyRoot, _options.CurrentValue.ProxyCacheBytes);
|
||||
}
|
||||
}
|
||||
@@ -22,19 +22,46 @@ namespace PrivaPub.Domain.Media
|
||||
|
||||
public sealed record ProcessedImage(byte[] Bytes, string Extension, string ContentType, int Width, int Height, byte[] Preview, string Blurhash);
|
||||
|
||||
// what libvips makes of an upload's header alone, before anything is decoded
|
||||
public sealed record ImageHeader(string Loader, int Width, int PageHeight, int Pages)
|
||||
{
|
||||
public bool Animated => Pages > 1 && Loader?.StartsWith("gifload", StringComparison.Ordinal) == true;
|
||||
public long Pixels => (long)Width * PageHeight * (Animated ? Pages : 1);
|
||||
}
|
||||
|
||||
public interface IMediaService
|
||||
{
|
||||
string Root { get; }
|
||||
string ProxyRoot { get; }
|
||||
string Url(string relativePath);
|
||||
Task<MediaOutcome> Upload(LocalActor owner, IFormFile file, string description, string focus, CancellationToken token);
|
||||
Task<string> ProfileImage(IFormFile file, int width, int height, CancellationToken token);
|
||||
Task Delete(MediaAttachment attachment);
|
||||
/// <summary>Where trashed files wait for the janitor: the sibling of Root, on the same disk (a move is a rename) and
|
||||
/// outside what /media/files serves. (Not a dot-prefixed folder inside Root: the file provider only hides a file whose
|
||||
/// own name starts with a dot.)</summary>
|
||||
string TrashRoot { get; }
|
||||
/// <summary>Audio and video waiting for their processing: beside the media root, never served.</summary>
|
||||
string IncomingRoot { get; }
|
||||
/// <summary>An upload; with <paramref name="later"/>, audio and video are only stored, "pending" for ProcessMedia.</summary>
|
||||
Task<MediaOutcome> Upload(LocalActor owner, IFormFile file, string description, string focus, bool later, CancellationToken token);
|
||||
/// <summary>Processes a pending upload's audio or video; false when it can't be (the row then says why).</summary>
|
||||
Task<bool> ProcessPending(MediaAttachment pending, CancellationToken token);
|
||||
/// <summary>A persona's new avatar or header ("avatar" or "header"), cropped to its size, as a row of its own.</summary>
|
||||
Task<MediaOutcome> ProfileImage(string avatarId, string kind, IFormFile file, int width, int height, CancellationToken token);
|
||||
/// <summary>Trashes the media <paramref name="which"/> matches (and that isn't trashed yet): each row is marked in one
|
||||
/// conditional update, so a row attached meanwhile is left alone, and its files stop being served at once.</summary>
|
||||
Task<long> Trash(System.Linq.Expressions.Expression<Func<MediaAttachment, bool>> which, string reason, CancellationToken token);
|
||||
/// <summary>Moves a trashed row's files out of what is served, if they are still there (a crash between the mark and the move).</summary>
|
||||
void Hide(MediaAttachment trashed);
|
||||
/// <summary>Deletes a trashed row's files and the row.</summary>
|
||||
Task Purge(MediaAttachment trashed, CancellationToken token);
|
||||
}
|
||||
|
||||
public class MediaService : IMediaService
|
||||
{
|
||||
static readonly string[] ImageTypes = { "image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "image/heif", "image/avif" };
|
||||
// HEIC and HEIF are not among them: the libvips bundled here decodes AVIF but has no HEVC decoder
|
||||
static readonly string[] ImageTypes = { "image/jpeg", "image/png", "image/gif", "image/webp", "image/avif" };
|
||||
// the only loaders an upload ever reaches: everything else libvips could read (SVG, PDF, TIFF, ImageMagick, ...) is
|
||||
// blocked, whatever type the upload claims
|
||||
static readonly string[] Loaders = { "VipsForeignLoadJpeg", "VipsForeignLoadPng", "VipsForeignLoadNsgif", "VipsForeignLoadWebp", "VipsForeignLoadHeif" };
|
||||
static readonly Dictionary<string, string> AvTypes = new()
|
||||
{
|
||||
["video/mp4"] = "mp4", ["video/quicktime"] = "mp4", ["video/webm"] = "webm",
|
||||
@@ -42,8 +69,27 @@ namespace PrivaPub.Domain.Media
|
||||
["audio/mp4"] = "m4a", ["audio/x-m4a"] = "m4a", ["audio/flac"] = "flac", ["audio/webm"] = "webm"
|
||||
};
|
||||
|
||||
// what /media/files says each file is: ASP.NET's map, which has no entry for FLAC
|
||||
public static Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider ContentTypes { get; } = new()
|
||||
{
|
||||
Mappings = { [".flac"] = "audio/flac" }
|
||||
};
|
||||
|
||||
// what an upload may be, as the instance API advertises it
|
||||
public static IReadOnlyList<string> SupportedTypes => ImageTypes.Concat(AvTypes.Keys).ToList();
|
||||
|
||||
static MediaService()
|
||||
{
|
||||
NetVips.NetVips.BlockUntrusted = true;
|
||||
Operation.Block("VipsForeignLoad", true);
|
||||
foreach (var loader in Loaders)
|
||||
Operation.Block(loader, false);
|
||||
Cache.Max = 0;//an upload is decoded once: nothing to keep between operations
|
||||
}
|
||||
|
||||
readonly IOptionsMonitor<MediaOptions> _options;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly SemaphoreSlim _processing;
|
||||
readonly IWebHostEnvironment _environment;
|
||||
readonly ILogger<MediaService> _logger;
|
||||
|
||||
@@ -51,6 +97,7 @@ namespace PrivaPub.Domain.Media
|
||||
{
|
||||
_options = options;
|
||||
_localActors = localActors;
|
||||
_processing = new SemaphoreSlim(Math.Max(1, options.CurrentValue.Concurrency));
|
||||
_environment = environment;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -59,54 +106,88 @@ namespace PrivaPub.Domain.Media
|
||||
|
||||
public string ProxyRoot => Root.TrimEnd(Path.DirectorySeparatorChar) + "-proxy";
|
||||
|
||||
public string TrashRoot => Root.TrimEnd(Path.DirectorySeparatorChar) + "-trash";
|
||||
|
||||
public string IncomingRoot => Root.TrimEnd(Path.DirectorySeparatorChar) + "-incoming";
|
||||
|
||||
string Incoming(string id) => Path.Combine(IncomingRoot, id + ".in");
|
||||
|
||||
public string Url(string relativePath) => relativePath == default ? default : $"{_localActors.BaseAddress}/media/files/{relativePath.Replace('\\', '/')}";
|
||||
|
||||
public async Task<MediaOutcome> Upload(LocalActor owner, IFormFile file, string description, string focus, CancellationToken token)
|
||||
public async Task<MediaOutcome> Upload(LocalActor owner, IFormFile file, string description, string focus, bool later, CancellationToken token)
|
||||
{
|
||||
if (file == default || file.Length == 0)
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File can't be blank");
|
||||
var options = _options.CurrentValue;
|
||||
var contentType = file.ContentType?.Split(';')[0].Trim().ToLowerInvariant();
|
||||
if (await OverQuota(owner.Id, file.Length, token) is { } full)
|
||||
return full;
|
||||
|
||||
var attachment = new MediaAttachment
|
||||
{
|
||||
OwnerAvatarId = owner.Id,
|
||||
Description = Clean(description, 1500),
|
||||
Focus = Focus(focus)
|
||||
Focus = FocalPoint.Parse(focus)
|
||||
};
|
||||
if (ImageTypes.Contains(contentType))
|
||||
{
|
||||
if (file.Length > options.MaxImageBytes)
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File is too big");
|
||||
await using var stream = file.OpenReadStream();
|
||||
using var buffer = new MemoryStream();
|
||||
await stream.CopyToAsync(buffer, token);
|
||||
ProcessedImage processed;
|
||||
var bytes = await Read(file, token);
|
||||
if (Refusal(bytes, options) is { } refused)
|
||||
return refused;
|
||||
await _processing.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
processed = ProcessImage(buffer.ToArray(), options.MaxImageSide, options.PreviewSide, contentType == "image/gif");
|
||||
var header = Inspect(bytes);
|
||||
if (header.Animated)
|
||||
{
|
||||
var animated = await ProcessAnimation(bytes, header, attachment, token);
|
||||
if (!animated.Ok)
|
||||
return animated;
|
||||
}
|
||||
else
|
||||
{
|
||||
var processed = ProcessImage(bytes, options.MaxImageSide, options.PreviewSide);
|
||||
attachment.Kind = "image";
|
||||
attachment.ContentType = processed.ContentType;
|
||||
attachment.FilePath = await Save(processed.Bytes, processed.Extension, token);
|
||||
attachment.PreviewPath = await Save(processed.Preview, "jpg", token);
|
||||
attachment.Width = processed.Width;
|
||||
attachment.Height = processed.Height;
|
||||
attachment.Blurhash = processed.Blurhash;
|
||||
attachment.Size = processed.Bytes.Length;
|
||||
}
|
||||
}
|
||||
catch (VipsException ex)
|
||||
{
|
||||
_logger.LogInformation("Refused an image upload: {Error}", ex.Message);
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file is not a readable image");
|
||||
}
|
||||
attachment.Kind = contentType == "image/gif" ? "gifv" : "image";
|
||||
attachment.ContentType = processed.ContentType;
|
||||
attachment.FilePath = await Save(processed.Bytes, processed.Extension, token);
|
||||
attachment.PreviewPath = await Save(processed.Preview, "jpg", token);
|
||||
attachment.Width = processed.Width;
|
||||
attachment.Height = processed.Height;
|
||||
attachment.Blurhash = processed.Blurhash;
|
||||
attachment.Size = processed.Bytes.Length;
|
||||
finally
|
||||
{
|
||||
_processing.Release();
|
||||
}
|
||||
}
|
||||
else if (contentType != default && AvTypes.TryGetValue(contentType, out var extension))
|
||||
else if (contentType != default && AvTypes.ContainsKey(contentType))
|
||||
{
|
||||
if (file.Length > options.MaxVideoBytes)
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File is too big");
|
||||
var outcome = await ProcessAv(file, extension, contentType, attachment, token);
|
||||
if (!outcome.Ok)
|
||||
return outcome;
|
||||
// stored as it came, outside what is served, then processed: by a job when asked later (v2), here otherwise (v1)
|
||||
attachment.ID = (string)attachment.GenerateNewID();
|
||||
attachment.Kind = contentType.StartsWith("video/", StringComparison.Ordinal) ? "video" : "audio";
|
||||
attachment.ContentType = contentType;
|
||||
Directory.CreateDirectory(IncomingRoot);
|
||||
await using (var stored = File.Create(Incoming(attachment.ID)))
|
||||
await file.CopyToAsync(stored, token);
|
||||
if (later)
|
||||
attachment.ProcessingState = "pending";
|
||||
else
|
||||
{
|
||||
var outcome = await ProcessIncoming(attachment, token);
|
||||
if (!outcome.Ok)
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
else
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File type is not supported");
|
||||
@@ -115,68 +196,205 @@ namespace PrivaPub.Domain.Media
|
||||
return new MediaOutcome(attachment);
|
||||
}
|
||||
|
||||
public async Task<string> ProfileImage(IFormFile file, int width, int height, CancellationToken token)
|
||||
public async Task<MediaOutcome> ProfileImage(string avatarId, string kind, IFormFile file, int width, int height, CancellationToken token)
|
||||
{
|
||||
if (file == default || file.Length == 0 || file.Length > _options.CurrentValue.MaxImageBytes)
|
||||
return default;
|
||||
await using var stream = file.OpenReadStream();
|
||||
using var buffer = new MemoryStream();
|
||||
await stream.CopyToAsync(buffer, token);
|
||||
var contentType = file?.ContentType?.Split(';')[0].Trim().ToLowerInvariant();
|
||||
if (file == default || file.Length == 0)
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File can't be blank");
|
||||
if (!ImageTypes.Contains(contentType))
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File type is not supported");
|
||||
if (file.Length > _options.CurrentValue.MaxImageBytes)
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File is too big");
|
||||
if (await OverQuota(avatarId, file.Length, token) is { } full)
|
||||
return full;
|
||||
var input = await Read(file, token);
|
||||
if (Refusal(input, _options.CurrentValue) is { } refused)
|
||||
return refused;
|
||||
byte[] bytes;
|
||||
int outWidth, outHeight;
|
||||
await _processing.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
using var image = Image.ThumbnailBuffer(buffer.ToArray(), width, height: height, crop: Enums.Interesting.Centre, size: Enums.Size.Down);
|
||||
using var image = Image.ThumbnailBuffer(input, width, height: height, crop: Enums.Interesting.Centre, size: Enums.Size.Down);
|
||||
using var flat = Flatten(image);
|
||||
return Url(await Save(flat.WriteToBuffer(".jpg[Q=85,keep=none]"), "jpg", token));
|
||||
bytes = flat.WriteToBuffer(".jpg[Q=85,keep=none]");
|
||||
(outWidth, outHeight) = (flat.Width, flat.Height);
|
||||
}
|
||||
catch (VipsException ex)
|
||||
{
|
||||
_logger.LogInformation("Refused a profile picture: {Error}", ex.Message);
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file is not a readable image");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_processing.Release();
|
||||
}
|
||||
var attachment = new MediaAttachment
|
||||
{
|
||||
OwnerAvatarId = avatarId,
|
||||
ProfileOfAvatarId = avatarId,
|
||||
Kind = kind,
|
||||
ContentType = "image/jpeg",
|
||||
FilePath = await Save(bytes, "jpg", token),
|
||||
Size = bytes.Length,
|
||||
Width = outWidth,
|
||||
Height = outHeight,
|
||||
AttachedAt = DateTime.UtcNow
|
||||
};
|
||||
await DB.Default.SaveAsync(attachment, token);
|
||||
return new MediaOutcome(attachment);
|
||||
}
|
||||
|
||||
// a login over its quota (Media:QuotaBytesPerRoot; none when 0) uploads nothing more
|
||||
async Task<MediaOutcome> OverQuota(string avatarId, long incoming, CancellationToken token)
|
||||
{
|
||||
var quota = _options.CurrentValue.QuotaBytesPerRoot;
|
||||
if (quota <= 0)
|
||||
return default;
|
||||
var rootId = (await DB.Default.Find<Models.User.RootToAvatar>().Match(r => r.AvatarId == avatarId).ExecuteFirstAsync(token))?.RootId;
|
||||
if (rootId == default)
|
||||
return default;
|
||||
var (bytes, _) = await Privacy.Counted.MediaOfRoot(rootId, token);
|
||||
return bytes + incoming > quota ? MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Your storage is full") : default;
|
||||
}
|
||||
|
||||
public async Task<long> Trash(System.Linq.Expressions.Expression<Func<MediaAttachment, bool>> which, string reason, CancellationToken token)
|
||||
{
|
||||
var trashed = 0L;
|
||||
foreach (var candidate in await DB.Default.Find<MediaAttachment>().Match(which).Match(m => m.TrashedAt == null).ExecuteAsync(token))
|
||||
{
|
||||
var marked = await DB.Default.Update<MediaAttachment>()
|
||||
.Match(which).Match(m => m.ID == candidate.ID && m.TrashedAt == null)
|
||||
.Modify(m => m.TrashedAt, DateTime.UtcNow)
|
||||
.Modify(m => m.TrashReason, reason)
|
||||
.ExecuteAsync(token);
|
||||
if (marked.ModifiedCount == 0)
|
||||
continue;
|
||||
Hide(candidate);
|
||||
trashed++;
|
||||
}
|
||||
return trashed;
|
||||
}
|
||||
|
||||
public void Hide(MediaAttachment trashed)
|
||||
{
|
||||
foreach (var relative in new[] { trashed.FilePath, trashed.PreviewPath }.Where(p => !string.IsNullOrEmpty(p)))
|
||||
{
|
||||
var served = Path.Combine(Root, relative);
|
||||
if (!File.Exists(served))
|
||||
continue;
|
||||
var hidden = Path.Combine(TrashRoot, relative);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(hidden)!);
|
||||
File.Move(served, hidden, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Purge(MediaAttachment trashed, CancellationToken token)
|
||||
{
|
||||
foreach (var relative in new[] { trashed.FilePath, trashed.PreviewPath }.Where(p => !string.IsNullOrEmpty(p)))
|
||||
foreach (var full in new[] { Path.Combine(TrashRoot, relative), Path.Combine(Root, relative) })
|
||||
if (File.Exists(full))
|
||||
File.Delete(full);
|
||||
if (File.Exists(Incoming(trashed.ID)))
|
||||
File.Delete(Incoming(trashed.ID));
|
||||
await DB.Default.DeleteAsync<MediaAttachment>(m => m.ID == trashed.ID && m.TrashedAt != null);
|
||||
}
|
||||
|
||||
// reads only the header: which loader takes the bytes and how big they would decode (throws VipsException when none)
|
||||
public static ImageHeader Inspect(byte[] input)
|
||||
{
|
||||
using var image = Image.NewFromBuffer(input, access: Enums.Access.Sequential);
|
||||
var pages = image.Contains("n-pages") ? Math.Max(1, (int)image.Get("n-pages")) : 1;
|
||||
var pageHeight = image.Contains("page-height") ? (int)image.Get("page-height") : image.Height;
|
||||
return new ImageHeader(image.Contains("vips-loader") ? (string)image.Get("vips-loader") : default, image.Width, pageHeight, pages);
|
||||
}
|
||||
|
||||
// an upload refused before anything is decoded: no loader takes it, or it would decode to too many pixels or frames
|
||||
static MediaOutcome Refusal(byte[] input, MediaOptions options)
|
||||
{
|
||||
ImageHeader header;
|
||||
try
|
||||
{
|
||||
header = Inspect(input);
|
||||
}
|
||||
catch (VipsException)
|
||||
{
|
||||
return default;
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file is not a readable image");
|
||||
}
|
||||
if (header.Pixels > options.MaxPixels || header.Pages > options.MaxFrames)
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The image is too large");
|
||||
return default;
|
||||
}
|
||||
|
||||
public Task Delete(MediaAttachment attachment)
|
||||
// a still image: shrunk on load to the largest side allowed, turned by its orientation, its colours brought into sRGB
|
||||
// (thumbnail does all three), then written without its metadata, with a preview and a blurhash
|
||||
public static ProcessedImage ProcessImage(byte[] input, int maxSide, int previewSide)
|
||||
{
|
||||
foreach (var path in new[] { attachment.FilePath, attachment.PreviewPath }.Where(p => p != default))
|
||||
{
|
||||
var full = Path.Combine(Root, path);
|
||||
if (File.Exists(full))
|
||||
File.Delete(full);
|
||||
}
|
||||
return DB.Default.DeleteAsync<MediaAttachment>(attachment.ID);
|
||||
}
|
||||
// rendered into memory once (it is read three times below, and a shrunk-on-load image can be read only in order)
|
||||
using var shrunk = Image.ThumbnailBuffer(input, maxSide, height: maxSide, size: Enums.Size.Down);
|
||||
using var resized = shrunk.CopyMemory();
|
||||
var keepsAlpha = resized.HasAlpha();
|
||||
var bytes = keepsAlpha ? resized.WriteToBuffer(".png[keep=none]") : resized.WriteToBuffer(".jpg[Q=88,keep=none]");
|
||||
var (extension, contentType) = keepsAlpha ? ("png", "image/png") : ("jpg", "image/jpeg");
|
||||
|
||||
public static ProcessedImage ProcessImage(byte[] input, int maxSide, int previewSide, bool animated)
|
||||
{
|
||||
using var loaded = animated ? Image.NewFromBuffer(input, kwargs: new VOption { { "n", -1 } }) : Image.NewFromBuffer(input);
|
||||
using var rotated = animated ? loaded.Copy() : loaded.Autorot();
|
||||
var pageHeight = animated && rotated.Contains("page-height") ? (int)rotated.Get("page-height") : rotated.Height;
|
||||
var longest = Math.Max(rotated.Width, pageHeight);
|
||||
|
||||
byte[] bytes;
|
||||
string extension, contentType;
|
||||
int width, height;
|
||||
if (animated)
|
||||
{
|
||||
bytes = rotated.WriteToBuffer(".gif");
|
||||
(extension, contentType, width, height) = ("gif", "image/gif", rotated.Width, pageHeight);
|
||||
}
|
||||
else
|
||||
{
|
||||
using var resized = longest > maxSide ? rotated.ThumbnailImage(maxSide, height: maxSide, size: Enums.Size.Down) : rotated.Copy();
|
||||
var keepsAlpha = resized.HasAlpha();
|
||||
bytes = keepsAlpha ? resized.WriteToBuffer(".png[keep=none]") : resized.WriteToBuffer(".jpg[Q=88,keep=none]");
|
||||
(extension, contentType, width, height) = keepsAlpha ? ("png", "image/png", resized.Width, resized.Height) : ("jpg", "image/jpeg", resized.Width, resized.Height);
|
||||
}
|
||||
|
||||
using var firstFrame = animated ? rotated.Crop(0, 0, rotated.Width, pageHeight) : rotated.Copy();
|
||||
using var preview = firstFrame.ThumbnailImage(previewSide, height: previewSide, size: Enums.Size.Down);
|
||||
using var preview = resized.ThumbnailImage(previewSide, height: previewSide, size: Enums.Size.Down);
|
||||
using var previewFlat = Flatten(preview);
|
||||
var previewBytes = previewFlat.WriteToBuffer(".jpg[Q=80,keep=none]");
|
||||
|
||||
using var tiny = firstFrame.ThumbnailImage(32, height: 32);
|
||||
using var tiny = resized.ThumbnailImage(32, height: 32);
|
||||
using var tinyFlat = Flatten(tiny);
|
||||
var pixels = tinyFlat.WriteToMemory();
|
||||
return new ProcessedImage(bytes, extension, contentType, width, height, previewBytes, Blurhash.Encode(pixels, tinyFlat.Width, tinyFlat.Height));
|
||||
return new ProcessedImage(bytes, extension, contentType, resized.Width, resized.Height, previewBytes, Blurhash.Encode(pixels, tinyFlat.Width, tinyFlat.Height));
|
||||
}
|
||||
|
||||
// an animated GIF becomes what Mastodon makes of one: a silent, looping H.264 mp4 (a gifv) at most MaxGifSide wide,
|
||||
// its first frame the poster
|
||||
async Task<MediaOutcome> ProcessAnimation(byte[] input, ImageHeader header, MediaAttachment attachment, CancellationToken token)
|
||||
{
|
||||
var options = _options.CurrentValue;
|
||||
var temp = Path.Combine(Path.GetTempPath(), $"privapub-{Guid.NewGuid():N}");
|
||||
var gif = temp + ".gif";
|
||||
var mp4 = temp + ".mp4";
|
||||
try
|
||||
{
|
||||
await File.WriteAllBytesAsync(gif, input, token);
|
||||
var width = Math.Min(header.Width, options.MaxGifSide) / 2 * 2;
|
||||
var height = Math.Max(2, (int)Math.Round(header.PageHeight * (double)width / header.Width / 2) * 2);
|
||||
await FFMpegArguments.FromFileInput(gif, true, o => o.ForceFormat("gif").WithCustomArgument("-protocol_whitelist file"))
|
||||
.OutputToFile(mp4, true, o => o.WithCustomArgument(
|
||||
$"-an -map_metadata -1 -map_chapters -1 -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p -movflags +faststart -vf scale={width}:{height}"))
|
||||
.CancellableThrough(token)
|
||||
.ProcessAsynchronously();
|
||||
var poster = ProcessImage(input, options.PreviewSide, options.PreviewSide);
|
||||
attachment.Kind = "gifv";
|
||||
attachment.ContentType = "video/mp4";
|
||||
attachment.Width = width;
|
||||
attachment.Height = height;
|
||||
attachment.Size = new FileInfo(mp4).Length;
|
||||
attachment.Blurhash = poster.Blurhash;
|
||||
attachment.PreviewPath = await Save(poster.Preview, "jpg", token);
|
||||
attachment.FilePath = SaveFile(mp4, "mp4");
|
||||
return new MediaOutcome(attachment);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation(ex, "Refused a GIF upload");
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file could not be processed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var path in new[] { gif, mp4 })
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<byte[]> Read(IFormFile file, CancellationToken token)
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
using var buffer = new MemoryStream((int)Math.Min(file.Length, int.MaxValue));
|
||||
await stream.CopyToAsync(buffer, token);
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
static Image Flatten(Image image)
|
||||
@@ -190,37 +408,102 @@ namespace PrivaPub.Domain.Media
|
||||
return bands.Format == Enums.BandFormat.Uchar ? bands.Copy() : bands.Cast(Enums.BandFormat.Uchar);
|
||||
}
|
||||
|
||||
async Task<MediaOutcome> ProcessAv(IFormFile file, string extension, string contentType, MediaAttachment attachment, CancellationToken token)
|
||||
public async Task<bool> ProcessPending(MediaAttachment pending, CancellationToken token)
|
||||
{
|
||||
var outcome = await ProcessIncoming(pending, token);
|
||||
if (outcome.Ok)
|
||||
{
|
||||
await DB.Default.Update<MediaAttachment>().Match(m => m.ID == pending.ID && m.TrashedAt == null)
|
||||
.Modify(m => m.Kind, pending.Kind)
|
||||
.Modify(m => m.ContentType, pending.ContentType)
|
||||
.Modify(m => m.FilePath, pending.FilePath)
|
||||
.Modify(m => m.PreviewPath, pending.PreviewPath)
|
||||
.Modify(m => m.Size, pending.Size)
|
||||
.Modify(m => m.Width, pending.Width)
|
||||
.Modify(m => m.Height, pending.Height)
|
||||
.Modify(m => m.Blurhash, pending.Blurhash)
|
||||
.Modify(m => m.DurationSeconds, pending.DurationSeconds)
|
||||
.Modify(m => m.ProcessingState, null)
|
||||
.ExecuteAsync(token);
|
||||
return true;
|
||||
}
|
||||
await DB.Default.Update<MediaAttachment>().MatchID(pending.ID)
|
||||
.Modify(m => m.ProcessingState, "failed").Modify(m => m.ProcessingError, outcome.Error).ExecuteAsync(token);
|
||||
return false;
|
||||
}
|
||||
|
||||
// the stored upload, made playable: probed, then remuxed without its metadata when browsers play it as it is (H.264,
|
||||
// VP8, VP9 or AV1 within MaxVideoPixels and MaxFrameRate), or else transcoded to H.264 that fits; a frame becomes the
|
||||
// poster. ffmpeg only ever reads the local file (no protocol but file, its format forced from the probe). Nothing is
|
||||
// saved until everything succeeded, and every temporary file goes, the stored upload too.
|
||||
async Task<MediaOutcome> ProcessIncoming(MediaAttachment attachment, CancellationToken token)
|
||||
{
|
||||
var options = _options.CurrentValue;
|
||||
var input = Incoming(attachment.ID);
|
||||
var temp = Path.Combine(Path.GetTempPath(), $"privapub-{Guid.NewGuid():N}");
|
||||
var input = temp + ".in";
|
||||
var output = temp + "." + extension;
|
||||
var frame = temp + ".png";
|
||||
var output = default(string);
|
||||
await _processing.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
await using (var target = File.Create(input))
|
||||
await file.CopyToAsync(target, token);
|
||||
var probe = await FFProbe.AnalyseAsync(input, cancellationToken: token);
|
||||
var isVideo = contentType.StartsWith("video/") && probe.PrimaryVideoStream != default;
|
||||
await FFMpegArguments.FromFileInput(input)
|
||||
.OutputToFile(output, true, o => o.WithCustomArgument("-map 0 -map_metadata -1 -map_chapters -1 -c copy" + (extension is "mp4" or "m4a" ? " -movflags +faststart" : string.Empty)))
|
||||
var probe = await FFProbe.AnalyseAsync(input, default, token, "-protocol_whitelist file");
|
||||
if (probe.Duration > TimeSpan.FromSeconds(options.MaxSeconds))
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file is too long");
|
||||
var video = attachment.ContentType.StartsWith("video/", StringComparison.Ordinal) ? probe.PrimaryVideoStream : default;
|
||||
var audio = probe.PrimaryAudioStream;
|
||||
if (video == default && audio == default)
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file could not be processed");
|
||||
var extension = AvTypes[attachment.ContentType];
|
||||
var format = probe.Format.FormatName.Split(',')[0];
|
||||
string arguments;
|
||||
if (video == default)
|
||||
arguments = "-map 0:a:0 -vn -dn -sn -map_metadata -1 -map_chapters -1 -c copy" + (extension == "m4a" ? " -movflags +faststart" : string.Empty);
|
||||
else if (video.CodecName is "h264" or "vp8" or "vp9" or "av1" && (long)video.Width * video.Height <= options.MaxVideoPixels
|
||||
&& video.FrameRate <= options.MaxFrameRate + 0.5)
|
||||
arguments = "-map 0:v:0 -map 0:a:0? -dn -sn -map_metadata -1 -map_chapters -1 -c copy" + (extension == "mp4" ? " -movflags +faststart" : string.Empty);
|
||||
else
|
||||
{
|
||||
var scale = Math.Min(1, Math.Sqrt(options.MaxVideoPixels / (double)((long)video.Width * video.Height)));
|
||||
var width = Math.Max(2, (int)(video.Width * scale) / 2 * 2);
|
||||
var height = Math.Max(2, (int)(video.Height * scale) / 2 * 2);
|
||||
extension = "mp4";
|
||||
arguments = $"-map 0:v:0 -map 0:a:0? -dn -sn -map_metadata -1 -map_chapters -1 -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p "
|
||||
+ $"-vf scale={width}:{height} -fpsmax {options.MaxFrameRate.ToString(CultureInfo.InvariantCulture)} -c:a aac -b:a 128k -movflags +faststart";
|
||||
}
|
||||
output = temp + "." + extension;
|
||||
await FFMpegArguments.FromFileInput(input, true, o => o.ForceFormat(format).WithCustomArgument("-protocol_whitelist file"))
|
||||
.OutputToFile(output, true, o => o.WithCustomArgument(arguments))
|
||||
.CancellableThrough(token)
|
||||
.ProcessAsynchronously();
|
||||
|
||||
attachment.Kind = isVideo ? "video" : "audio";
|
||||
attachment.ContentType = extension switch { "mp4" => "video/mp4", "webm" => isVideo ? "video/webm" : "audio/webm", "m4a" => "audio/mp4", "mp3" => "audio/mpeg", _ => contentType };
|
||||
attachment.FilePath = await Save(await File.ReadAllBytesAsync(output, token), extension, token);
|
||||
attachment.Size = new FileInfo(output).Length;
|
||||
if (isVideo)
|
||||
var made = await FFProbe.AnalyseAsync(output, default, token, "-protocol_whitelist file");
|
||||
byte[] poster = default;
|
||||
if (video != default)
|
||||
{
|
||||
attachment.Width = probe.PrimaryVideoStream.Width;
|
||||
attachment.Height = probe.PrimaryVideoStream.Height;
|
||||
var frame = temp + ".png";
|
||||
await FFMpeg.SnapshotAsync(input, frame, captureTime: TimeSpan.FromSeconds(Math.Min(1, probe.Duration.TotalSeconds / 2)));
|
||||
var still = ProcessImage(await File.ReadAllBytesAsync(frame, token), 640, _options.CurrentValue.PreviewSide, false);
|
||||
attachment.PreviewPath = await Save(still.Preview, "jpg", token);
|
||||
attachment.Width = made.PrimaryVideoStream?.Width;
|
||||
attachment.Height = made.PrimaryVideoStream?.Height;
|
||||
await FFMpeg.SnapshotAsync(output, frame, captureTime: TimeSpan.FromSeconds(Math.Min(1, made.Duration.TotalSeconds / 2)));
|
||||
var still = ProcessImage(await File.ReadAllBytesAsync(frame, token), 640, options.PreviewSide);
|
||||
poster = still.Preview;
|
||||
attachment.Blurhash = still.Blurhash;
|
||||
File.Delete(frame);
|
||||
}
|
||||
attachment.Kind = video != default ? "video" : "audio";
|
||||
attachment.ContentType = extension switch
|
||||
{
|
||||
"mp4" => "video/mp4",
|
||||
"webm" => video != default ? "video/webm" : "audio/webm",
|
||||
"m4a" => "audio/mp4",
|
||||
"mp3" => "audio/mpeg",
|
||||
"ogg" => "audio/ogg",
|
||||
"wav" => "audio/wav",
|
||||
"flac" => "audio/flac",
|
||||
_ => attachment.ContentType
|
||||
};
|
||||
attachment.DurationSeconds = Math.Round(made.Duration.TotalSeconds, 2);
|
||||
attachment.Size = new FileInfo(output).Length;
|
||||
attachment.FilePath = SaveFile(output, extension);
|
||||
if (poster != default)
|
||||
attachment.PreviewPath = await Save(poster, "jpg", token);
|
||||
return new MediaOutcome(attachment);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
@@ -230,34 +513,38 @@ namespace PrivaPub.Domain.Media
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var path in new[] { input, output })
|
||||
_processing.Release();
|
||||
foreach (var path in new[] { input, output, frame }.Where(p => p != default))
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
async Task<string> Save(byte[] bytes, string extension, CancellationToken token)
|
||||
string SaveFile(string source, string extension)
|
||||
{
|
||||
var (relative, full) = NewPath(extension);
|
||||
File.Move(source, full);
|
||||
return relative;
|
||||
}
|
||||
|
||||
(string Relative, string Full) NewPath(string extension)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var relative = Path.Combine(now.ToString("yyyy", CultureInfo.InvariantCulture), now.ToString("MM", CultureInfo.InvariantCulture),
|
||||
$"{Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16))}.{extension}");
|
||||
var full = Path.Combine(Root, relative);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
|
||||
return (relative.Replace('\\', '/'), full);
|
||||
}
|
||||
|
||||
async Task<string> Save(byte[] bytes, string extension, CancellationToken token)
|
||||
{
|
||||
var (relative, full) = NewPath(extension);
|
||||
await File.WriteAllBytesAsync(full, bytes, token);
|
||||
return relative.Replace('\\', '/');
|
||||
return relative;
|
||||
}
|
||||
|
||||
static string Clean(string value, int max) =>
|
||||
string.IsNullOrWhiteSpace(value) ? default : value.Trim().Length <= max ? value.Trim() : value.Trim()[..max];
|
||||
|
||||
static float[] Focus(string focus)
|
||||
{
|
||||
var parts = focus?.Split(',');
|
||||
if (parts is not { Length: 2 }
|
||||
|| !float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x)
|
||||
|| !float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y))
|
||||
return default;
|
||||
return new[] { Math.Clamp(x, -1, 1), Math.Clamp(y, -1, 1) };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Infrastructure.Jobs;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Models.Media;
|
||||
|
||||
namespace PrivaPub.Domain.Media
|
||||
{
|
||||
// Audio and video sent to /api/v2/media, processed off the request (its lease renewed while ffmpeg runs): playable, or
|
||||
// marked failed with why. One at a time, since a transcode takes the machine's cores.
|
||||
public class ProcessMediaJob : IJobHandler
|
||||
{
|
||||
readonly IMediaService _media;
|
||||
readonly ILogger<ProcessMediaJob> _logger;
|
||||
|
||||
public ProcessMediaJob(IMediaService media, ILogger<ProcessMediaJob> logger)
|
||||
{
|
||||
_media = media;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public JobKind Kind => JobKind.ProcessMedia;
|
||||
public int Concurrency => 1;
|
||||
public int MaxAttempts => 3;
|
||||
public int PerHostLimit => 1;
|
||||
|
||||
public static Job JobFor(MediaAttachment pending, string host) => new()
|
||||
{
|
||||
Kind = JobKind.ProcessMedia,
|
||||
Payload = pending.ID,
|
||||
Host = host,
|
||||
DedupeKey = $"media|{pending.ID}"
|
||||
};
|
||||
|
||||
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
|
||||
{
|
||||
var pending = await DB.Default.Find<MediaAttachment>().Match(m => m.ID == job.Payload && m.ProcessingState == "pending" && m.TrashedAt == null).ExecuteFirstAsync(token);
|
||||
if (pending == default)
|
||||
return JobOutcome.Done;//processed already, or trashed meanwhile
|
||||
if (!await _media.ProcessPending(pending, token))
|
||||
_logger.LogInformation("Upload {Id} could not be processed", pending.ID);
|
||||
return JobOutcome.Done;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Domain.Media;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Infrastructure.Backup;
|
||||
using PrivaPub.Infrastructure.Jobs;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Models.Media;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Domain.Portability
|
||||
{
|
||||
// A persona's archive, in Mastodon's account-archive layout so other servers' importers read it:
|
||||
// - actor.json (as other servers see the persona: its public key, never the private one), avatar and header;
|
||||
// - outbox.json: every post of its own as a Create (any visibility but circles' and located posts, addressed as it was
|
||||
// delivered) and its boosts as Announces; the media its posts hold under media_attachments/files/, which the
|
||||
// attachments' urls name;
|
||||
// - likes.json, bookmarks.json, and the CSV files Mastodon exports: follows, lists, blocks, mutes, blocked servers,
|
||||
// bookmarks;
|
||||
// - privapub/: what only PrivaPub has: filters, followed hashtags, the notification policy, pinned posts, scheduled and
|
||||
// located posts, and archive.json saying whose it is.
|
||||
// Nothing of its root, its siblings, its keys or anyone's tokens. Written whole to a .partial file, then renamed; kept a
|
||||
// week (PersonaArchives.Forget).
|
||||
public class ExportArchiveJob(ILocalActorService localActors, IMediaService media, Backups backups, ILogger<ExportArchiveJob> logger) : IJobHandler
|
||||
{
|
||||
public const int Format = 1;
|
||||
static readonly JsonSerializerOptions Options = new() { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
|
||||
static readonly JsonWriterOptions Writer = new() { Indented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
|
||||
const string MediaFolder = "media_attachments/files/";
|
||||
|
||||
public JobKind Kind => JobKind.ExportArchive;
|
||||
public int Concurrency => 1;
|
||||
public int MaxAttempts => 2;
|
||||
public int PerHostLimit => 1;
|
||||
|
||||
public static Job JobFor(string avatarId, string host) => new()
|
||||
{
|
||||
Kind = JobKind.ExportArchive,
|
||||
Payload = avatarId,
|
||||
Host = host,
|
||||
DedupeKey = $"export|{avatarId}|{DateTime.UtcNow.Ticks}"
|
||||
};
|
||||
|
||||
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
|
||||
{
|
||||
var avatarId = job.Payload;
|
||||
var actor = await localActors.FindById(LocalActorKind.Person, avatarId, token);
|
||||
if (actor == default)
|
||||
return JobOutcome.Done;
|
||||
await DB.Default.Update<PersonaArchive>().MatchID(avatarId).Modify(a => a.ExportState, "running").Modify(a => a.ExportError, null).ExecuteAsync(token);
|
||||
var folder = PersonaArchives.Folder(backups.Root, avatarId);
|
||||
ServerBackup.MakeDirectory(Path.GetDirectoryName(folder)!);
|
||||
ServerBackup.MakeDirectory(folder);
|
||||
var name = $"{actor.UserName}-{DateTime.UtcNow:yyyyMMdd-HHmmss}.zip";
|
||||
var partial = Path.Combine(folder, name + ServerBackup.PartialSuffix);
|
||||
try
|
||||
{
|
||||
await using (var file = new FileStream(partial, FileMode.Create, FileAccess.Write))
|
||||
using (var zip = new ZipArchive(file, ZipArchiveMode.Create))
|
||||
await Write(zip, actor, token);
|
||||
var final = Path.Combine(folder, name);
|
||||
File.Move(partial, final);
|
||||
foreach (var old in Directory.EnumerateFiles(folder, "*.zip").Where(f => f != final))
|
||||
File.Delete(old);
|
||||
await DB.Default.Update<PersonaArchive>().MatchID(avatarId)
|
||||
.Modify(a => a.ExportState, "ready").Modify(a => a.ExportReadyAt, DateTime.UtcNow)
|
||||
.Modify(a => a.ExportFile, name).Modify(a => a.ExportBytes, new FileInfo(final).Length).ExecuteAsync(token);
|
||||
return JobOutcome.Done;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(ex, "The archive of {Persona} could not be made", actor.Handle);
|
||||
if (File.Exists(partial))
|
||||
File.Delete(partial);
|
||||
await DB.Default.Update<PersonaArchive>().MatchID(avatarId).Modify(a => a.ExportState, "failed")
|
||||
.Modify(a => a.ExportError, ex.Message).ExecuteAsync(CancellationToken.None);
|
||||
return JobOutcome.Done;
|
||||
}
|
||||
}
|
||||
|
||||
async Task Write(ZipArchive zip, LocalActor actor, CancellationToken token)
|
||||
{
|
||||
var avatar = await DB.Default.Find<Avatar>().MatchID(actor.Id).ExecuteFirstAsync(token);
|
||||
var files = new SortedSet<string>(StringComparer.Ordinal);
|
||||
var prefix = media.Url(string.Empty);
|
||||
|
||||
var document = ActivityPubRenderer.Actor(actor);
|
||||
foreach (var (field, kind) in new[] { ("icon", "avatar"), ("image", "header") })
|
||||
if (document[field] is JsonObject image && image["url"]?.GetValue<string>() is { } url && url.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
var relative = url[prefix.Length..];
|
||||
var local = Path.Combine(media.Root, relative);
|
||||
if (!File.Exists(local))
|
||||
continue;
|
||||
var entry = kind + Path.GetExtension(relative);
|
||||
await Copy(zip, local, entry, token);
|
||||
image["url"] = entry;
|
||||
}
|
||||
await Text(zip, "actor.json", document.ToJsonString(Options), token);
|
||||
|
||||
var counts = await Outbox(zip, actor, prefix, files, token);
|
||||
foreach (var relative in files)
|
||||
{
|
||||
var local = Path.Combine(media.Root, relative);
|
||||
if (File.Exists(local))
|
||||
await Copy(zip, local, MediaFolder + relative, token);
|
||||
}
|
||||
|
||||
var handles = new Handles(localActors);
|
||||
await Collection(zip, "likes.json", await Uris(DB.Default.Find<Favourite, string>().Match(f => f.AccountId == actor.Id).Project(f => f.PostId), token), token);
|
||||
var bookmarks = await Uris(DB.Default.Find<Bookmark, string>().Match(b => b.AvatarId == actor.Id).Project(b => b.PostId), token);
|
||||
await Collection(zip, "bookmarks.json", bookmarks, token);
|
||||
await Text(zip, "bookmarks.csv", string.Concat(bookmarks.Select(b => b + "\n")), token);
|
||||
|
||||
var follows = await DB.Default.Find<Following>().Match(f => f.AvatarId == actor.Id).ExecuteAsync(token);
|
||||
var csv = new StringBuilder("Account address,Show boosts,Notify on new posts,Languages\n");
|
||||
foreach (var follow in follows)
|
||||
csv.Append($"{Csv(await handles.Of(follow.TargetActorURI, token))},{(follow.ShowReblogs ? "true" : "false")},false,\n");
|
||||
await Text(zip, "following_accounts.csv", csv.ToString(), token);
|
||||
|
||||
csv.Clear();
|
||||
var lists = await DB.Default.Find<PersonaList>().Match(l => l.AvatarId == actor.Id).ExecuteAsync(token);
|
||||
foreach (var list in lists)
|
||||
foreach (var member in await DB.Default.Find<PersonaListMember>().Match(m => m.ListId == list.ID).ExecuteAsync(token))
|
||||
if (follows.FirstOrDefault(f => f.TargetAccountId == member.AccountId) is { } followed)
|
||||
csv.Append($"{Csv(list.Title)},{Csv(await handles.Of(followed.TargetActorURI, token))}\n");
|
||||
await Text(zip, "lists.csv", csv.ToString(), token);
|
||||
|
||||
csv.Clear();
|
||||
foreach (var block in await DB.Default.Find<Block>().Match(b => b.AvatarId == actor.Id).ExecuteAsync(token))
|
||||
csv.Append(Csv(await handles.Of(block.TargetActorURI, token)) + "\n");
|
||||
await Text(zip, "blocked_accounts.csv", csv.ToString(), token);
|
||||
|
||||
csv.Clear().Append("Account address,Hide notifications\n");
|
||||
foreach (var mute in await DB.Default.Find<Mute>().Match(m => m.AvatarId == actor.Id).ExecuteAsync(token))
|
||||
csv.Append($"{Csv(await handles.Of(mute.TargetActorURI, token))},{(mute.HideNotifications ? "true" : "false")}\n");
|
||||
await Text(zip, "muted_accounts.csv", csv.ToString(), token);
|
||||
|
||||
csv.Clear();
|
||||
foreach (var domain in await DB.Default.Find<AccountDomainBlock, string>().Match(d => d.AvatarId == actor.Id).Project(d => d.Domain).ExecuteAsync(token))
|
||||
csv.Append(domain + "\n");
|
||||
await Text(zip, "blocked_domains.csv", csv.ToString(), token);
|
||||
|
||||
await PrivaPubParts(zip, actor, avatar, prefix, counts, token);
|
||||
}
|
||||
|
||||
// the outbox, a post at a time, newest first as Mastodon writes it: what was published and boosted
|
||||
async Task<(int Posts, int Boosts, int Located)> Outbox(ZipArchive zip, LocalActor actor, string prefix, ISet<string> files, CancellationToken token)
|
||||
{
|
||||
var entry = zip.CreateEntry("outbox.json", CompressionLevel.Optimal);
|
||||
await using var stream = entry.Open();
|
||||
await using var writer = new Utf8JsonWriter(stream, Writer);
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("@context", "https://www.w3.org/ns/activitystreams");
|
||||
writer.WriteString("id", "outbox.json");
|
||||
writer.WriteString("type", "OrderedCollection");
|
||||
writer.WritePropertyName("orderedItems");
|
||||
writer.WriteStartArray();
|
||||
var (posts, boosts) = (0, 0);
|
||||
var located = 0;
|
||||
var before = default(string);
|
||||
while (true)
|
||||
{
|
||||
var find = DB.Default.Find<PostEntity>().Match(p => p.GroupUserId == actor.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue);
|
||||
if (before != default)
|
||||
find = find.Match(f => f.Lt(p => p.ID, before));
|
||||
var batch = await find.Sort(p => p.ID, Order.Descending).Limit(200).ExecuteAsync(token);
|
||||
if (batch.Count == 0)
|
||||
break;
|
||||
before = batch[^1].ID;
|
||||
var originals = (await DB.Default.Find<PostEntity>().Match(p => batch.Select(b => b.ReblogOfPostId).Contains(p.ID)).ExecuteAsync(token))
|
||||
.ToDictionary(p => p.ID);
|
||||
foreach (var post in batch)
|
||||
{
|
||||
if (post.Visibility == PostVisibility.LocalGeo)
|
||||
{
|
||||
located++;
|
||||
continue;
|
||||
}
|
||||
if (post.Visibility == PostVisibility.Circle)
|
||||
continue;
|
||||
if (post.ReblogOfPostId != default)
|
||||
{
|
||||
if (!originals.TryGetValue(post.ReblogOfPostId, out var original) || string.IsNullOrEmpty(original.ObjectURI))
|
||||
continue;
|
||||
new JsonObject
|
||||
{
|
||||
["id"] = actor.ActivityUri($"announce-{post.ID}"),
|
||||
["type"] = "Announce",
|
||||
["actor"] = actor.Uri,
|
||||
["published"] = ActivityPubRenderer.Timestamp(post.CreationDate),
|
||||
["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"),
|
||||
["cc"] = new JsonArray(actor.Followers),
|
||||
["object"] = original.ObjectURI
|
||||
}.WriteTo(writer);
|
||||
boosts++;
|
||||
continue;
|
||||
}
|
||||
var group = post.GroupId == default ? default : await localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
||||
var note = ActivityPubRenderer.Note(post, actor, group, post.InReplyToURI);
|
||||
if (post.Visibility is PostVisibility.Direct or PostVisibility.FollowersOnly && (post.To.Count > 0 || post.Cc.Count > 0))
|
||||
{
|
||||
note["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray());
|
||||
note["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray());
|
||||
}
|
||||
if (note["attachment"] is JsonArray attachments)
|
||||
foreach (var attachment in attachments.OfType<JsonObject>())
|
||||
if (attachment["url"]?.GetValue<string>() is { } url && url.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
var relative = url[prefix.Length..];
|
||||
files.Add(relative);
|
||||
attachment["url"] = "/" + MediaFolder + relative;
|
||||
}
|
||||
var create = ActivityPubRenderer.Create(actor, note, $"create-{post.ID}");
|
||||
create["to"] = note["to"]?.DeepClone();
|
||||
create["cc"] = note["cc"]?.DeepClone();
|
||||
create.WriteTo(writer);
|
||||
posts++;
|
||||
}
|
||||
await writer.FlushAsync(token);
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
writer.WriteNumber("totalItems", posts + boosts);
|
||||
writer.WriteEndObject();
|
||||
return (posts, boosts, located);
|
||||
}
|
||||
|
||||
async Task PrivaPubParts(ZipArchive zip, LocalActor actor, Avatar avatar, string prefix, (int Posts, int Boosts, int Located) counts, CancellationToken token)
|
||||
{
|
||||
var filters = await DB.Default.Find<PersonaFilter>().Match(f => f.AvatarId == actor.Id).ExecuteAsync(token);
|
||||
var filtered = await UriMap(filters.SelectMany(f => f.Statuses.Select(s => s.PostId)), token);
|
||||
await Json(zip, "privapub/filters.json", filters.Select(f => new
|
||||
{
|
||||
title = f.Title,
|
||||
context = f.Context,
|
||||
expiresAt = f.ExpiresAt,
|
||||
action = f.Action,
|
||||
keywords = f.Keywords.Select(k => new { keyword = k.Keyword, wholeWord = k.WholeWord }),
|
||||
statuses = f.Statuses.Select(s => filtered.GetValueOrDefault(s.PostId)).Where(u => u != default)
|
||||
}), token);
|
||||
await Json(zip, "privapub/followed_tags.json",
|
||||
await DB.Default.Find<FollowedTag, string>().Match(t => t.AvatarId == actor.Id).Project(t => t.Name).ExecuteAsync(token), token);
|
||||
var policy = await NotificationPolicies.Of(actor.Id, token);
|
||||
await Json(zip, "privapub/notification_policy.json", new
|
||||
{
|
||||
forNotFollowing = policy.ForNotFollowing.ToString(),
|
||||
forNotFollowers = policy.ForNotFollowers.ToString(),
|
||||
forNewAccounts = policy.ForNewAccounts.ToString(),
|
||||
forPrivateMentions = policy.ForPrivateMentions.ToString(),
|
||||
forLimitedAccounts = policy.ForLimitedAccounts.ToString()
|
||||
}, token);
|
||||
await Json(zip, "privapub/pins.json",
|
||||
await Uris(DB.Default.Find<Pin, string>().Match(p => p.AvatarId == actor.Id).Project(p => p.PostId), token), token);
|
||||
|
||||
var scheduled = await DB.Default.Find<ScheduledStatus>().Match(s => s.AvatarId == actor.Id).ExecuteAsync(token);
|
||||
var scheduledMedia = await MediaFiles(scheduled.SelectMany(s => s.Params.MediaIds), token);
|
||||
foreach (var file in scheduledMedia.Values)
|
||||
await CopyMedia(zip, file.Path, token);
|
||||
await Json(zip, "privapub/scheduled.json", scheduled.Select(s => new
|
||||
{
|
||||
scheduledAt = s.ScheduledAt,
|
||||
text = s.Params.Text,
|
||||
spoilerText = s.Params.SpoilerText,
|
||||
sensitive = s.Params.Sensitive,
|
||||
visibility = s.Params.Visibility,
|
||||
language = s.Params.Language,
|
||||
poll = s.Params.Poll,
|
||||
media = s.Params.MediaIds.Where(scheduledMedia.ContainsKey).Select(id => new { url = MediaFolder + scheduledMedia[id].Path, description = scheduledMedia[id].Description })
|
||||
}), token);
|
||||
|
||||
var located = await DB.Default.Find<PostEntity>().Match(p => p.GroupUserId == actor.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue
|
||||
&& p.Visibility == PostVisibility.LocalGeo).ExecuteAsync(token);
|
||||
foreach (var post in located)
|
||||
foreach (var item in post.Media.Where(m => m.URL?.StartsWith(prefix, StringComparison.Ordinal) == true))
|
||||
await CopyMedia(zip, item.URL[prefix.Length..], token);
|
||||
await Json(zip, "privapub/located.json", located.Select(p => new
|
||||
{
|
||||
id = p.ObjectURI,
|
||||
published = p.CreationDate,
|
||||
title = p.Title,
|
||||
text = p.Text,
|
||||
spoilerText = p.SpoilerText,
|
||||
latitude = p.Geo?.Coordinates[1],
|
||||
longitude = p.Geo?.Coordinates[0],
|
||||
rangeKm = p.RangeKm,
|
||||
media = p.Media.Where(m => m.URL?.StartsWith(prefix, StringComparison.Ordinal) == true)
|
||||
.Select(m => new { url = MediaFolder + m.URL[prefix.Length..], description = m.Description })
|
||||
}), token);
|
||||
|
||||
await Json(zip, "privapub/archive.json", new
|
||||
{
|
||||
format = Format,
|
||||
software = "privapub",
|
||||
actor = actor.Uri,
|
||||
username = avatar?.UserName ?? actor.UserName,
|
||||
exportedAt = DateTime.UtcNow,
|
||||
posts = counts.Posts,
|
||||
boosts = counts.Boosts,
|
||||
located = counts.Located,
|
||||
scheduled = scheduled.Count
|
||||
}, token);
|
||||
}
|
||||
|
||||
async Task CopyMedia(ZipArchive zip, string relative, CancellationToken token)
|
||||
{
|
||||
var local = Path.Combine(media.Root, relative);
|
||||
if (File.Exists(local) && zip.GetEntry(MediaFolder + relative) == default)
|
||||
await Copy(zip, local, MediaFolder + relative, token);
|
||||
}
|
||||
|
||||
static async Task<Dictionary<string, (string Path, string Description)>> MediaFiles(IEnumerable<string> ids, CancellationToken token)
|
||||
{
|
||||
var wanted = ids.Distinct().ToList();
|
||||
if (wanted.Count == 0)
|
||||
return [];
|
||||
return (await DB.Default.Find<MediaAttachment>().Match(m => wanted.Contains(m.ID) && m.TrashedAt == null).ExecuteAsync(token))
|
||||
.ToDictionary(m => m.ID, m => (m.FilePath, m.Description));
|
||||
}
|
||||
|
||||
static async Task<Dictionary<string, string>> UriMap(IEnumerable<string> postIds, CancellationToken token)
|
||||
{
|
||||
var ids = postIds.Where(id => id != default).Distinct().ToList();
|
||||
if (ids.Count == 0)
|
||||
return [];
|
||||
return (await DB.Default.Find<PostEntity>().Match(p => ids.Contains(p.ID)).Project(p => new PostEntity { ID = p.ID, ObjectURI = p.ObjectURI }).ExecuteAsync(token))
|
||||
.Where(p => !string.IsNullOrEmpty(p.ObjectURI)).ToDictionary(p => p.ID, p => p.ObjectURI);
|
||||
}
|
||||
|
||||
static async Task<List<string>> Uris<T>(Find<T, string> postIds, CancellationToken token) where T : IEntity
|
||||
{
|
||||
var ids = await postIds.ExecuteAsync(token);
|
||||
var map = await UriMap(ids, token);
|
||||
return ids.Where(map.ContainsKey).Select(id => map[id]).ToList();
|
||||
}
|
||||
|
||||
static async Task Collection(ZipArchive zip, string name, List<string> items, CancellationToken token) =>
|
||||
await Text(zip, name, new JsonObject
|
||||
{
|
||||
["@context"] = "https://www.w3.org/ns/activitystreams",
|
||||
["id"] = name,
|
||||
["type"] = "OrderedCollection",
|
||||
["totalItems"] = items.Count,
|
||||
["orderedItems"] = new JsonArray(items.Select(i => (JsonNode)i).ToArray())
|
||||
}.ToJsonString(Options), token);
|
||||
|
||||
static Task Json<T>(ZipArchive zip, string name, T value, CancellationToken token) =>
|
||||
Text(zip, name, JsonSerializer.Serialize(value, Options), token);
|
||||
|
||||
static async Task Text(ZipArchive zip, string name, string text, CancellationToken token)
|
||||
{
|
||||
await using var stream = zip.CreateEntry(name, CompressionLevel.Optimal).Open();
|
||||
await stream.WriteAsync(Encoding.UTF8.GetBytes(text), token);
|
||||
}
|
||||
|
||||
static async Task Copy(ZipArchive zip, string path, string name, CancellationToken token)
|
||||
{
|
||||
await using var source = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.SequentialScan);
|
||||
await using var target = zip.CreateEntry(name, CompressionLevel.NoCompression).Open();
|
||||
await source.CopyToAsync(target, token);
|
||||
}
|
||||
|
||||
// a CSV cell, quoted when it must be
|
||||
static string Csv(string value) =>
|
||||
value.IndexOfAny([',', '"', '\n', '\r']) < 0 ? value : "\"" + value.Replace("\"", "\"\"") + "\"";
|
||||
|
||||
// an account as Mastodon's CSV files name it: user@host, or its address when the handle is not known
|
||||
sealed class Handles(ILocalActorService localActors)
|
||||
{
|
||||
readonly Dictionary<string, string> _known = new(StringComparer.Ordinal);
|
||||
|
||||
public async Task<string> Of(string actorUri, CancellationToken token)
|
||||
{
|
||||
if (_known.TryGetValue(actorUri, out var handle))
|
||||
return handle;
|
||||
if (await localActors.FindByUri(actorUri, token) is { } local)
|
||||
handle = local.Handle;
|
||||
else if (await DB.Default.Find<ForeignAvatar>().Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token) is { UserName.Length: > 0 } remote
|
||||
&& Uri.TryCreate(actorUri, UriKind.Absolute, out var parsed))
|
||||
handle = $"{remote.UserName}@{(string.IsNullOrEmpty(remote.Domain) ? parsed.Authority : remote.Domain)}";
|
||||
else
|
||||
handle = actorUri;
|
||||
return _known[actorUri] = handle;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,943 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Controllers;
|
||||
using PrivaPub.Domain.Media;
|
||||
using PrivaPub.Domain.Relationships;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Inbox;
|
||||
using PrivaPub.Federation.Moderation;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Infrastructure.Backup;
|
||||
using PrivaPub.Infrastructure.Ids;
|
||||
using PrivaPub.Infrastructure.Jobs;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Models.Media;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Domain.Portability
|
||||
{
|
||||
// An archive restored into one of a root's personas (owner decision 2026-10-07): PrivaPub's own, or Mastodon's account
|
||||
// archive. Only the parts asked for, one at a time, with progress, until asked to stop.
|
||||
// Posts: only the archive actor's own Creates (boosts, direct and circle posts are counted, not imported); delivered to
|
||||
// no one, put in no home, notifying nobody, they show on the profile, the outbox, hashtags and search. From the same
|
||||
// actor (a persona's own archive, back home) a post keeps its id and address, and one already here (even deleted) or
|
||||
// whose address is a tombstone is left alone; from another actor it gets an id of its date and its address here, with
|
||||
// the original in ImportedFromURI, so importing twice changes nothing. Replies and self-quotes inside the archive point
|
||||
// at their copies; polls are closed, with no votes; mentions stay links; the HTML is sanitized as any remote post's;
|
||||
// media go through MediaService, its checks and the root's quota.
|
||||
// The rest: follows asked again (not on a blocked server), blocks, mutes, blocked servers, lists (with only accounts
|
||||
// followed), bookmarks (read by signed fetch), filters, followed hashtags, the notification policy, pins (not
|
||||
// announced), the profile; and only when asked, located and scheduled posts (PrivaPub's archives) and likes, which tell
|
||||
// their authors. Followers are never imported: they choose.
|
||||
public partial class ImportArchiveJob(IServiceScopeFactory scopes, ILocalActorService localActors, IMediaService media, IDomainBlocks domainBlocks,
|
||||
IJobQueue jobs, ILogger<ImportArchiveJob> logger) : IJobHandler
|
||||
{
|
||||
static readonly HashSet<string> Public = ["https://www.w3.org/ns/activitystreams#Public", "as:Public", "Public"];
|
||||
static readonly HashSet<string> Postable = ["Note", "Article", "Page", "Question"];
|
||||
const int MaxMedia = 4;
|
||||
|
||||
public JobKind Kind => JobKind.ImportArchive;
|
||||
public int Concurrency => 1;
|
||||
public int MaxAttempts => 1;
|
||||
public int PerHostLimit => 1;
|
||||
|
||||
public static Job JobFor(string avatarId, string host) => new()
|
||||
{
|
||||
Kind = JobKind.ImportArchive,
|
||||
Payload = avatarId,
|
||||
Host = host,
|
||||
DedupeKey = $"import|{avatarId}|{DateTime.UtcNow.Ticks}"
|
||||
};
|
||||
|
||||
sealed class Run(PersonaArchive state, LocalActor me, SafeArchive archive, string archiveActor, string archiveFollowers, bool sameActor,
|
||||
IServiceProvider services)
|
||||
{
|
||||
public PersonaArchive State { get; } = state;
|
||||
public string ArchiveFollowers { get; } = archiveFollowers;
|
||||
public LocalActor Me { get; } = me;
|
||||
public SafeArchive Archive { get; } = archive;
|
||||
public string ArchiveActor { get; } = archiveActor;
|
||||
public bool SameActor { get; } = sameActor;
|
||||
public IServiceProvider Services { get; } = services;
|
||||
public Dictionary<string, (string Id, string Uri)> Posts { get; } = new(StringComparer.Ordinal);//archive object id → here
|
||||
public Dictionary<string, string> Parents { get; } = new(StringComparer.Ordinal);//archive object id → what it answers
|
||||
public DateTime LastSaved { get; set; }
|
||||
|
||||
public void Count(string what, int by = 1) => State.ImportCounts[what] = State.ImportCounts.GetValueOrDefault(what) + by;
|
||||
}
|
||||
|
||||
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
|
||||
{
|
||||
var state = await DB.Default.Find<PersonaArchive>().MatchID(job.Payload).ExecuteFirstAsync(token);
|
||||
if (state is not { ImportState: "queued" or "running" })
|
||||
return JobOutcome.Done;
|
||||
var me = await localActors.FindById(LocalActorKind.Person, job.Payload, token);
|
||||
var path = state.ImportFile;
|
||||
try
|
||||
{
|
||||
if (me == default || !await RootMayImport(me.Id, token))
|
||||
{
|
||||
await End(state, "failed", "the persona or its login can no longer import", token);
|
||||
return JobOutcome.Done;
|
||||
}
|
||||
var (archive, refused) = path == default ? (default, "no archive") : SafeArchive.Open(path);
|
||||
if (archive == default)
|
||||
{
|
||||
await End(state, "failed", refused, token);
|
||||
return JobOutcome.Done;
|
||||
}
|
||||
using (archive)
|
||||
{
|
||||
var actor = archive.Json("actor.json") as JsonObject;
|
||||
var actorId = Text(actor?["id"]);
|
||||
if (actorId == default)
|
||||
{
|
||||
await End(state, "failed", "the archive has no actor.json", token);
|
||||
return JobOutcome.Done;
|
||||
}
|
||||
using var scope = scopes.CreateScope();
|
||||
var run = new Run(state, me, archive, actorId, Text(actor["followers"]) ?? actorId.TrimEnd('/') + "/followers", actorId == me.Uri,
|
||||
scope.ServiceProvider);
|
||||
state.ImportState = "running";
|
||||
state.ImportFrom = actorId;
|
||||
state.ImportSameActor = run.SameActor;
|
||||
state.ImportCounts = [];
|
||||
await Save(run, token, force: true);
|
||||
|
||||
var parts = state.ImportParts.ToHashSet(StringComparer.Ordinal);
|
||||
var privapub = Text((archive.Json("privapub/archive.json") as JsonObject)?["software"]) == "privapub";
|
||||
if (parts.Contains("profile") && !await Stopped(run, token))
|
||||
await Profile(run, actor, token);
|
||||
if (parts.Contains("posts") && !await Stopped(run, token))
|
||||
await Posts(run, token);
|
||||
else
|
||||
await KnownPosts(run, token);
|
||||
if (parts.Contains("located") && privapub && !await Stopped(run, token))
|
||||
await Located(run, token);
|
||||
if (parts.Contains("scheduled") && privapub && !await Stopped(run, token))
|
||||
await Scheduled(run, token);
|
||||
if (parts.Contains("tags") && !await Stopped(run, token))
|
||||
await Tags(run, token);
|
||||
if (parts.Contains("notificationpolicy") && !await Stopped(run, token))
|
||||
await Policy(run, token);
|
||||
if (parts.Contains("filters") && !await Stopped(run, token))
|
||||
await Filters(run, token);
|
||||
if (parts.Contains("pins") && !await Stopped(run, token))
|
||||
await Pins(run, token);
|
||||
if (parts.Contains("domainblocks") && !await Stopped(run, token))
|
||||
await DomainBlocks(run, token);
|
||||
if (parts.Contains("blocks") && !await Stopped(run, token))
|
||||
await Accounts(run, "blocked_accounts.csv", "blocks", Block, token);
|
||||
if (parts.Contains("mutes") && !await Stopped(run, token))
|
||||
await Accounts(run, "muted_accounts.csv", "mutes", Mute, token);
|
||||
if (parts.Contains("follows") && !await Stopped(run, token))
|
||||
await Accounts(run, "following_accounts.csv", "follows", Follow, token);
|
||||
if (parts.Contains("lists") && !await Stopped(run, token))
|
||||
await Lists(run, token);
|
||||
if (parts.Contains("bookmarks") && !await Stopped(run, token))
|
||||
await Bookmarks(run, token);
|
||||
if (parts.Contains("likes") && !await Stopped(run, token))
|
||||
await Likes(run, token);
|
||||
await End(state, await Stopped(run, token) ? "stopped" : "done", default, token);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(ex, "The import into {Persona} failed", me?.Handle ?? job.Payload);
|
||||
await End(state, "failed", ex.Message, CancellationToken.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (path != default && File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
return JobOutcome.Done;
|
||||
}
|
||||
|
||||
static async Task<bool> RootMayImport(string avatarId, CancellationToken token)
|
||||
{
|
||||
var link = await DB.Default.Find<RootToAvatar>().Match(r => r.AvatarId == avatarId).ExecuteFirstAsync(token);
|
||||
var root = link == default ? default : await DB.Default.Find<RootUser>().MatchID(link.RootId).ExecuteFirstAsync(token);
|
||||
return root is { IsBanned: false, DeletedAt: null };
|
||||
}
|
||||
|
||||
static async Task End(PersonaArchive state, string how, string error, CancellationToken token) =>
|
||||
await DB.Default.Update<PersonaArchive>().MatchID(state.ID)
|
||||
.Modify(a => a.ImportState, how).Modify(a => a.ImportError, error).Modify(a => a.ImportEndedAt, DateTime.UtcNow)
|
||||
.Modify(a => a.ImportCounts, state.ImportCounts).Modify(a => a.ImportDone, state.ImportDone).Modify(a => a.ImportTotal, state.ImportTotal)
|
||||
.Modify(a => a.ImportFile, null).Modify(a => a.StopAsked, false)
|
||||
.ExecuteAsync(token);
|
||||
|
||||
// progress written at most every two seconds; the answer to whether the root asked to stop
|
||||
static async Task Save(Run run, CancellationToken token, bool force = false)
|
||||
{
|
||||
if (!force && DateTime.UtcNow - run.LastSaved < TimeSpan.FromSeconds(2))
|
||||
return;
|
||||
run.LastSaved = DateTime.UtcNow;
|
||||
await DB.Default.Update<PersonaArchive>().MatchID(run.State.ID)
|
||||
.Modify(a => a.ImportState, run.State.ImportState).Modify(a => a.ImportFrom, run.State.ImportFrom)
|
||||
.Modify(a => a.ImportSameActor, run.State.ImportSameActor).Modify(a => a.ImportCounts, run.State.ImportCounts)
|
||||
.Modify(a => a.ImportDone, run.State.ImportDone).Modify(a => a.ImportTotal, run.State.ImportTotal)
|
||||
.ExecuteAsync(token);
|
||||
}
|
||||
|
||||
static async Task<bool> Stopped(Run run, CancellationToken token)
|
||||
{
|
||||
await Save(run, token);
|
||||
return await DB.Default.Find<PersonaArchive>().MatchID(run.State.ID).Match(a => a.StopAsked).ExecuteAnyAsync(token);
|
||||
}
|
||||
|
||||
// ---- posts
|
||||
|
||||
// what an outbox item is to this import: the object to import, or why not
|
||||
(JsonObject Note, string Skipped) Importable(Run run, JsonObject item)
|
||||
{
|
||||
var type = Text(item["type"]);
|
||||
if (type == "Announce")
|
||||
return (default, "boosts skipped");
|
||||
if (type != "Create" || item["object"] is not JsonObject note || !Postable.Contains(Text(note["type"]) ?? string.Empty))
|
||||
return (default, "not a post");
|
||||
if (Text(item["actor"]) != run.ArchiveActor || Id(note["attributedTo"]) is { } author && author != run.ArchiveActor)
|
||||
return (default, "not the archive's own");
|
||||
if (Text(note["id"]) == default)
|
||||
return (default, "not a post");
|
||||
return Visibility(run, note) == default ? (default, "direct or circle posts skipped") : (note, default);
|
||||
}
|
||||
|
||||
PostVisibility? Visibility(Run run, JsonObject note)
|
||||
{
|
||||
var to = Strings(note["to"]);
|
||||
var cc = Strings(note["cc"]);
|
||||
if (to.Any(Public.Contains))
|
||||
return PostVisibility.Public;
|
||||
if (cc.Any(Public.Contains))
|
||||
return PostVisibility.Unlisted;
|
||||
if (to.Concat(cc).Contains(run.ArchiveFollowers) && Text(note["audience"]) == default)
|
||||
return PostVisibility.FollowersOnly;
|
||||
return default;
|
||||
}
|
||||
|
||||
// what is already here of the archive's posts, for pins, filters and bookmarks when posts are not imported again
|
||||
async Task KnownPosts(Run run, CancellationToken token)
|
||||
{
|
||||
await foreach (var item in run.Archive.Outbox(token))
|
||||
{
|
||||
if (Importable(run, item).Note is not { } note)
|
||||
continue;
|
||||
var original = Text(note["id"]);
|
||||
var here = run.SameActor
|
||||
? await DB.Default.Find<PostEntity>().Match(p => p.ObjectURI == original && p.GroupUserId == run.Me.Id).ExecuteFirstAsync(token)
|
||||
: await DB.Default.Find<PostEntity>().Match(p => p.GroupUserId == run.Me.Id && p.ImportedFromURI == original).ExecuteFirstAsync(token);
|
||||
if (here is { DeletedAt: null })
|
||||
run.Posts[original] = (here.ID, here.ObjectURI);
|
||||
}
|
||||
}
|
||||
|
||||
async Task Posts(Run run, CancellationToken token)
|
||||
{
|
||||
// first the ids each post will have here, so a reply or a quote anywhere in the archive finds its copy
|
||||
var planned = new Dictionary<string, (string Id, string Uri, bool New)>(StringComparer.Ordinal);
|
||||
var total = 0L;
|
||||
await foreach (var item in run.Archive.Outbox(token))
|
||||
{
|
||||
total++;
|
||||
if (Importable(run, item).Note is not { } note)
|
||||
continue;
|
||||
var original = Text(note["id"]);
|
||||
var published = Date(note["published"]) ?? DateTime.UtcNow;
|
||||
if (Id(note["inReplyTo"]) is { } answers)
|
||||
run.Parents[original] = answers;
|
||||
if (run.SameActor)
|
||||
{
|
||||
var id = original.TrimEnd('/').Split('/')[^1];
|
||||
if (!IsObjectId(id) || original != run.Me.PostUri(id))
|
||||
id = PrivacyIds.At(Clamp(published));
|
||||
var here = await DB.Default.Find<PostEntity>().Match(p => p.ID == id || p.ObjectURI == original).ExecuteFirstAsync(token);
|
||||
var tombstone = await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == original).ExecuteAnyAsync(token);
|
||||
if (here != default || tombstone)
|
||||
{
|
||||
if (here is { DeletedAt: null, GroupUserId: var owner } && owner == run.Me.Id)
|
||||
run.Posts[original] = (here.ID, here.ObjectURI);
|
||||
run.Count(here is { DeletedAt: not null } || tombstone ? "posts deleted here" : "posts already here");
|
||||
continue;
|
||||
}
|
||||
planned[original] = (id, original, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
var here = await DB.Default.Find<PostEntity>().Match(p => p.GroupUserId == run.Me.Id && p.ImportedFromURI == original).ExecuteFirstAsync(token);
|
||||
if (here != default)
|
||||
{
|
||||
if (here.DeletedAt == default)
|
||||
run.Posts[original] = (here.ID, here.ObjectURI);
|
||||
run.Count("posts already here");
|
||||
continue;
|
||||
}
|
||||
var id = PrivacyIds.At(Clamp(published));
|
||||
planned[original] = (id, run.Me.PostUri(id), true);
|
||||
}
|
||||
}
|
||||
foreach (var (original, (id, uri, _)) in planned)
|
||||
run.Posts[original] = (id, uri);
|
||||
run.State.ImportTotal = total;
|
||||
run.State.ImportDone = 0;
|
||||
|
||||
await foreach (var item in run.Archive.Outbox(token))
|
||||
{
|
||||
run.State.ImportDone++;
|
||||
if (run.State.ImportDone % 50 == 0 && await Stopped(run, token))
|
||||
return;
|
||||
var (note, skipped) = Importable(run, item);
|
||||
if (note == default)
|
||||
{
|
||||
run.Count(skipped);
|
||||
continue;
|
||||
}
|
||||
var original = Text(note["id"]);
|
||||
if (!planned.TryGetValue(original, out var target))
|
||||
continue;
|
||||
await Post(run, note, original, target.Id, target.Uri, Visibility(run, note).Value, token);
|
||||
run.Count("posts");
|
||||
}
|
||||
}
|
||||
|
||||
async Task Post(Run run, JsonObject note, string original, string id, string uri, PostVisibility visibility, CancellationToken token)
|
||||
{
|
||||
var me = run.Me;
|
||||
var summary = Clean(Text(note["summary"]));
|
||||
var content = Text(note["content"]) ?? (note["contentMap"] as JsonObject)?.Select(c => Text(c.Value)).FirstOrDefault();
|
||||
var source = note["source"] as JsonObject;
|
||||
var post = new PostEntity
|
||||
{
|
||||
ID = id,
|
||||
GroupUserId = me.Id,
|
||||
AuthorAccountId = me.Id,
|
||||
ActorURI = me.Uri,
|
||||
Visibility = visibility,
|
||||
Title = Clean(Text(note["name"])),
|
||||
SpoilerText = summary,
|
||||
HasContentWarning = summary != default || note["sensitive"]?.GetValueKind() == System.Text.Json.JsonValueKind.True,
|
||||
ContentHtml = content == default ? default : ContentSanitizer.Html(content),
|
||||
Text = Text(source?["mediaType"]) is "text/plain" or "text/markdown" ? Text(source["content"]) : default,
|
||||
ContentFormat = ContentFormat.Html,
|
||||
Language = (note["contentMap"] as JsonObject)?.Select(c => c.Key).FirstOrDefault(),
|
||||
Tags = (note["tag"] as JsonArray ?? [])
|
||||
.OfType<JsonObject>().Where(t => Text(t["type"]) == "Hashtag").Select(t => TagsController.Normalise(Text(t["name"])))
|
||||
.Where(t => t.Length > 0).Distinct().ToList(),
|
||||
ObjectURI = uri,
|
||||
Url = me.PostHtmlUrl(id),
|
||||
ActivityURI = me.ActivityUri("create-" + id),
|
||||
ImportedFromURI = run.SameActor ? default : original,
|
||||
CreationDate = Clamp(Date(note["published"]) ?? DateTime.UtcNow),
|
||||
LocalQuotePolicy = me.Settings.QuotePolicy ?? QuotePolicies.Public
|
||||
};
|
||||
if (Id(note["inReplyTo"]) is { } inReplyTo)
|
||||
{
|
||||
if (run.Posts.TryGetValue(inReplyTo, out var parent))
|
||||
(post.AnsweringToPostId, post.InReplyToURI, post.InReplyToAccountId) = (parent.Id, parent.Uri, me.Id);
|
||||
else
|
||||
{
|
||||
post.InReplyToURI = inReplyTo;
|
||||
if (await DB.Default.Find<PostEntity>().Match(p => p.ObjectURI == inReplyTo).ExecuteFirstAsync(token) is { } held)
|
||||
(post.AnsweringToPostId, post.InReplyToAccountId) = (held.ID, held.AuthorAccountId ?? held.GroupUserId);
|
||||
}
|
||||
}
|
||||
foreach (var key in new[] { "quote", "quoteUrl", "quoteUri", "_misskey_quote" })
|
||||
if (Text(note[key]) is { } quoted && run.Posts.TryGetValue(quoted, out var copy))
|
||||
{
|
||||
(post.QuotedPostId, post.QuoteURI, post.QuoteState, post.QuoteByConsent) = (copy.Id, copy.Uri, QuoteState.Accepted, true);
|
||||
break;
|
||||
}
|
||||
if (Text(note["type"]) == "Question")
|
||||
post.Poll = Poll(note);
|
||||
// the conversation (FEP-7888) of its thread's first post, when the thread starts in the archive
|
||||
if (visibility is PostVisibility.Public or PostVisibility.Unlisted && Root(run, original) is { } root && run.Posts.TryGetValue(root, out var first))
|
||||
post.ContextURI = first.Uri + "/context";
|
||||
post.Media = await Media(run, note["attachment"] as JsonArray, id, token);
|
||||
var rendered = ActivityPubRenderer.Note(post, me, default, post.InReplyToURI);
|
||||
post.To = Strings(rendered["to"]);
|
||||
post.Cc = Strings(rendered["cc"]);
|
||||
await DB.Default.SaveAsync(post, token);
|
||||
if (post.AnsweringToPostId != default)
|
||||
await DB.Default.Update<PostEntity>().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
|
||||
}
|
||||
|
||||
// the first post of an archive post's thread, when the thread starts in the archive
|
||||
static string Root(Run run, string original)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
while (run.Parents.TryGetValue(original, out var parent) && seen.Add(original))
|
||||
{
|
||||
if (!run.Posts.ContainsKey(parent))
|
||||
return default;
|
||||
original = parent;
|
||||
}
|
||||
return original;
|
||||
}
|
||||
|
||||
static PostPoll Poll(JsonObject note)
|
||||
{
|
||||
var multiple = note["anyOf"] is JsonArray;
|
||||
var options = (note["anyOf"] ?? note["oneOf"]) as JsonArray ?? [];
|
||||
var ends = Date(note["endTime"]) ?? Date(note["closed"]) ?? DateTime.UtcNow;
|
||||
var now = DateTime.UtcNow;
|
||||
return new PostPoll
|
||||
{
|
||||
Options = options.OfType<JsonObject>().Select(o => new PollOption { Title = Text(o["name"]) }).Where(o => o.Title != default).ToList(),
|
||||
Multiple = multiple,
|
||||
ExpiresAt = ends < now ? ends : now,
|
||||
ClosedAt = ends < now ? ends : now,
|
||||
VotersCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
// the attachments the archive holds, uploaded as the persona's own (checked, processed, counted against its quota)
|
||||
async Task<List<PostMedia>> Media(Run run, JsonArray attachments, string postId, CancellationToken token)
|
||||
{
|
||||
var made = new List<PostMedia>();
|
||||
foreach (var attachment in (attachments ?? []).OfType<JsonObject>().Take(MaxMedia))
|
||||
{
|
||||
var url = Text(attachment["url"]) ?? Text((attachment["url"] as JsonArray)?.OfType<JsonObject>().FirstOrDefault()?["href"]);
|
||||
var file = url?.TrimStart('/');
|
||||
if (file == default || file.Contains("://") || !run.Archive.Has(file))
|
||||
{
|
||||
run.Count("media not in the archive");
|
||||
continue;
|
||||
}
|
||||
var focus = attachment["focalPoint"] is JsonArray { Count: 2 } point ? $"{point[0]},{point[1]}" : default;
|
||||
var outcome = await Upload(run, file, Text(attachment["mediaType"]), Clean(Text(attachment["name"])), focus, token);
|
||||
if (outcome?.Ok != true)
|
||||
{
|
||||
run.Count("media refused");
|
||||
continue;
|
||||
}
|
||||
var row = outcome.Attachment;
|
||||
await DB.Default.Update<MediaAttachment>().MatchID(row.ID).Modify(m => m.PostId, postId).Modify(m => m.AttachedAt, DateTime.UtcNow).ExecuteAsync(token);
|
||||
made.Add(new PostMedia
|
||||
{
|
||||
AttachmentId = row.ID,
|
||||
ContentType = row.ContentType,
|
||||
Kind = row.Kind,
|
||||
DurationSeconds = row.DurationSeconds,
|
||||
URL = media.Url(row.FilePath),
|
||||
PreviewURL = media.Url(row.PreviewPath ?? row.FilePath),
|
||||
Description = row.Description,
|
||||
Blurhash = row.Blurhash,
|
||||
Width = row.Width,
|
||||
Height = row.Height,
|
||||
Focus = row.Focus
|
||||
});
|
||||
run.Count("media");
|
||||
}
|
||||
return made;
|
||||
}
|
||||
|
||||
// a file of the archive given to MediaService as an upload would be
|
||||
async Task<MediaOutcome> Upload(Run run, string file, string contentType, string description, string focus, CancellationToken token)
|
||||
{
|
||||
var temporary = Path.Combine(Path.GetTempPath(), $"privapub-import-{Guid.NewGuid():N}{Path.GetExtension(file)}");
|
||||
try
|
||||
{
|
||||
await using (var source = run.Archive.Read(file))
|
||||
await using (var target = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write))
|
||||
await source.CopyToAsync(target, token);
|
||||
await using var stream = new FileStream(temporary, FileMode.Open, FileAccess.Read);
|
||||
var form = new FormFile(stream, 0, stream.Length, "file", Path.GetFileName(file))
|
||||
{
|
||||
Headers = new HeaderDictionary(),
|
||||
ContentType = contentType ?? (MediaService.ContentTypes.TryGetContentType(file, out var guessed) ? guessed : "application/octet-stream")
|
||||
};
|
||||
return await media.Upload(run.Me, form, description, focus, later: false, token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- PrivaPub's own parts
|
||||
|
||||
async Task Located(Run run, CancellationToken token)
|
||||
{
|
||||
foreach (var located in (run.Archive.Json("privapub/located.json") as JsonArray ?? []).OfType<JsonObject>())
|
||||
{
|
||||
var original = Text(located["id"]);
|
||||
if (original != default && await DB.Default.Find<PostEntity>().Match(p => p.GroupUserId == run.Me.Id
|
||||
&& (p.ObjectURI == original || p.ImportedFromURI == original)).ExecuteAnyAsync(token))
|
||||
{
|
||||
run.Count("located posts already here");
|
||||
continue;
|
||||
}
|
||||
if (located["latitude"]?.GetValue<double>() is not { } latitude || located["longitude"]?.GetValue<double>() is not { } longitude)
|
||||
continue;
|
||||
var id = PrivacyIds.At(Clamp(Date(located["published"]) ?? DateTime.UtcNow));
|
||||
var post = new PostEntity
|
||||
{
|
||||
ID = id,
|
||||
GroupUserId = run.Me.Id,
|
||||
AuthorAccountId = run.Me.Id,
|
||||
ActorURI = run.Me.Uri,
|
||||
Visibility = PostVisibility.LocalGeo,
|
||||
IsLocalOnly = true,
|
||||
Title = Clean(Text(located["title"])),
|
||||
SpoilerText = Clean(Text(located["spoilerText"])),
|
||||
HasContentWarning = Clean(Text(located["spoilerText"])) != default,
|
||||
Text = Text(located["text"]),
|
||||
ContentHtml = ActivityPubRenderer.Html(Text(located["text"]) ?? string.Empty),
|
||||
ContentFormat = ContentFormat.Markdown,
|
||||
Geo = new MongoDB.Entities.Coordinates2D(Math.Round(longitude, 2), Math.Round(latitude, 2)),
|
||||
RangeKm = (float)Math.Clamp(located["rangeKm"]?.GetValue<double>() ?? 5, 1, 50),
|
||||
ObjectURI = run.Me.PostUri(id),
|
||||
Url = run.Me.PostHtmlUrl(id),
|
||||
ImportedFromURI = original,
|
||||
CreationDate = Clamp(Date(located["published"]) ?? DateTime.UtcNow)
|
||||
};
|
||||
var files = (located["media"] as JsonArray ?? []).OfType<JsonObject>()
|
||||
.Select(m => new JsonObject { ["url"] = Text(m["url"]), ["name"] = Text(m["description"]) }).ToArray();
|
||||
post.Media = await Media(run, new JsonArray(files), id, token);
|
||||
await DB.Default.SaveAsync(post, token);
|
||||
run.Count("located posts");
|
||||
}
|
||||
}
|
||||
|
||||
async Task Scheduled(Run run, CancellationToken token)
|
||||
{
|
||||
var made = new List<ScheduledStatus>();
|
||||
foreach (var scheduled in (run.Archive.Json("privapub/scheduled.json") as JsonArray ?? []).OfType<JsonObject>())
|
||||
{
|
||||
var at = Date(scheduled["scheduledAt"]);
|
||||
if (at is not { } when || when < DateTime.UtcNow.AddMinutes(5) || await ScheduledStatuses.Refusal(run.Me.Id, when, default, token) != default)
|
||||
{
|
||||
run.Count("scheduled posts past or refused");
|
||||
continue;
|
||||
}
|
||||
var status = new ScheduledStatus
|
||||
{
|
||||
AvatarId = run.Me.Id,
|
||||
ScheduledAt = when,
|
||||
Params = new ScheduledParams
|
||||
{
|
||||
Text = Text(scheduled["text"]),
|
||||
SpoilerText = Text(scheduled["spoilerText"]),
|
||||
Sensitive = scheduled["sensitive"]?.GetValueKind() == System.Text.Json.JsonValueKind.True,
|
||||
Visibility = Text(scheduled["visibility"]) is { } visibility && visibility is "public" or "unlisted" or "private" or "direct" ? visibility : "public",
|
||||
Language = Text(scheduled["language"])
|
||||
}
|
||||
};
|
||||
status.ID = (string)status.GenerateNewID();
|
||||
foreach (var file in (scheduled["media"] as JsonArray ?? []).OfType<JsonObject>().Take(MaxMedia))
|
||||
{
|
||||
var path = Text(file["url"])?.TrimStart('/');
|
||||
if (path == default || !run.Archive.Has(path))
|
||||
continue;
|
||||
var outcome = await Upload(run, path, default, Clean(Text(file["description"])), default, token);
|
||||
if (outcome?.Ok != true)
|
||||
continue;
|
||||
await DB.Default.Update<MediaAttachment>().MatchID(outcome.Attachment.ID).Modify(m => m.ScheduledStatusId, status.ID).ExecuteAsync(token);
|
||||
status.Params.MediaIds.Add(outcome.Attachment.ID);
|
||||
}
|
||||
await DB.Default.SaveAsync(status, token);
|
||||
made.Add(status);
|
||||
run.Count("scheduled posts");
|
||||
}
|
||||
if (made.Count > 0)
|
||||
await jobs.EnqueueMany(made.Select(s => ScheduledStatuses.JobFor(s, new Uri(localActors.BaseAddress).Host)), token);
|
||||
}
|
||||
|
||||
async Task Tags(Run run, CancellationToken token)
|
||||
{
|
||||
var mine = (await DB.Default.Find<FollowedTag, string>().Match(t => t.AvatarId == run.Me.Id).Project(t => t.Name).ExecuteAsync(token)).ToHashSet();
|
||||
foreach (var name in (run.Archive.Json("privapub/followed_tags.json") as JsonArray ?? []).Select(t => TagsController.Normalise(Text(t))))
|
||||
{
|
||||
if (name.Length == 0 || mine.Contains(name) || mine.Count >= 500)
|
||||
continue;
|
||||
await DB.Default.SaveAsync(new FollowedTag { AvatarId = run.Me.Id, Name = name }, token);
|
||||
mine.Add(name);
|
||||
run.Count("followed hashtags");
|
||||
}
|
||||
}
|
||||
|
||||
async Task Policy(Run run, CancellationToken token)
|
||||
{
|
||||
if (run.Archive.Json("privapub/notification_policy.json") is not JsonObject json)
|
||||
return;
|
||||
var policy = await NotificationPolicies.Of(run.Me.Id, token);
|
||||
NotificationAction Action(string field, NotificationAction kept) => Enum.TryParse<NotificationAction>(Text(json[field]), out var action) ? action : kept;
|
||||
policy.ForNotFollowing = Action("forNotFollowing", policy.ForNotFollowing);
|
||||
policy.ForNotFollowers = Action("forNotFollowers", policy.ForNotFollowers);
|
||||
policy.ForNewAccounts = Action("forNewAccounts", policy.ForNewAccounts);
|
||||
policy.ForPrivateMentions = Action("forPrivateMentions", policy.ForPrivateMentions);
|
||||
policy.ForLimitedAccounts = Action("forLimitedAccounts", policy.ForLimitedAccounts);
|
||||
await NotificationPolicies.Save(policy, token);
|
||||
run.Count("notification policy");
|
||||
}
|
||||
|
||||
async Task Filters(Run run, CancellationToken token)
|
||||
{
|
||||
var titles = (await DB.Default.Find<PersonaFilter, string>().Match(f => f.AvatarId == run.Me.Id).Project(f => f.Title).ExecuteAsync(token)).ToHashSet();
|
||||
foreach (var filter in (run.Archive.Json("privapub/filters.json") as JsonArray ?? []).OfType<JsonObject>())
|
||||
{
|
||||
var title = Clean(Text(filter["title"]));
|
||||
if (title == default || titles.Contains(title))
|
||||
continue;
|
||||
var statuses = new List<FilterStatus>();
|
||||
foreach (var uri in Strings(filter["statuses"]))
|
||||
if (await Held(run, uri, token) is { } post)
|
||||
statuses.Add(new FilterStatus { Id = MongoDB.Bson.ObjectId.GenerateNewId().ToString(), PostId = post.ID });
|
||||
await DB.Default.SaveAsync(new PersonaFilter
|
||||
{
|
||||
AvatarId = run.Me.Id,
|
||||
Title = title,
|
||||
Context = Strings(filter["context"]).Where(c => c is "home" or "notifications" or "public" or "thread" or "account").ToList(),
|
||||
ExpiresAt = Date(filter["expiresAt"]),
|
||||
Action = Text(filter["action"]) is { } action && action is "warn" or "hide" or "blur" ? action : "warn",
|
||||
Keywords = (filter["keywords"] as JsonArray ?? []).OfType<JsonObject>().Where(k => Clean(Text(k["keyword"])) != default)
|
||||
.Select(k => new FilterKeyword { Id = MongoDB.Bson.ObjectId.GenerateNewId().ToString(), Keyword = Text(k["keyword"]).Trim(),
|
||||
WholeWord = k["wholeWord"]?.GetValueKind() == System.Text.Json.JsonValueKind.True }).ToList(),
|
||||
Statuses = statuses
|
||||
}, token);
|
||||
titles.Add(title);
|
||||
run.Count("filters");
|
||||
}
|
||||
}
|
||||
|
||||
// pinned again without announcing it (an Add would tell every follower's server)
|
||||
async Task Pins(Run run, CancellationToken token)
|
||||
{
|
||||
var pinned = (await DB.Default.Find<Pin, string>().Match(p => p.AvatarId == run.Me.Id).Project(p => p.PostId).ExecuteAsync(token)).ToHashSet();
|
||||
foreach (var uri in Strings(run.Archive.Json("privapub/pins.json")))
|
||||
{
|
||||
if (pinned.Count >= 5)
|
||||
break;
|
||||
if (!run.Posts.TryGetValue(uri, out var copy) || pinned.Contains(copy.Id))
|
||||
continue;
|
||||
await DB.Default.SaveAsync(new Pin { AvatarId = run.Me.Id, PostId = copy.Id }, token);
|
||||
pinned.Add(copy.Id);
|
||||
run.Count("pins");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- relationships
|
||||
|
||||
async Task DomainBlocks(Run run, CancellationToken token)
|
||||
{
|
||||
var relationships = run.Services.GetRequiredService<IRelationshipService>();
|
||||
var mine = (await DB.Default.Find<AccountDomainBlock, string>().Match(d => d.AvatarId == run.Me.Id).Project(d => d.Domain).ExecuteAsync(token)).ToHashSet();
|
||||
foreach (var line in run.Archive.Lines("blocked_domains.csv"))
|
||||
{
|
||||
var domain = Cells(line).FirstOrDefault()?.Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(domain) || domain == "#domain" || Uri.CheckHostName(domain) != UriHostNameType.Dns || mine.Contains(domain))
|
||||
continue;
|
||||
await relationships.BlockDomain(run.Me, domain, token);
|
||||
mine.Add(domain);
|
||||
run.Count("blocked servers");
|
||||
}
|
||||
}
|
||||
|
||||
// one CSV of accounts, each resolved (WebFinger or its address) and acted on; a header line is skipped
|
||||
async Task Accounts(Run run, string file, string what, Func<Run, (LocalActor Local, ForeignAvatar Remote), string[], CancellationToken, Task<bool>> act,
|
||||
CancellationToken token)
|
||||
{
|
||||
var follows = run.Services.GetRequiredService<IFollowService>();
|
||||
var lines = run.Archive.Lines(file);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (await Stopped(run, token))
|
||||
return;
|
||||
var cells = Cells(line);
|
||||
var address = cells.FirstOrDefault()?.Trim();
|
||||
if (string.IsNullOrEmpty(address) || address.Equals("Account address", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
var target = await follows.Resolve(address, token);
|
||||
if (target.Local == default && target.Remote == default)
|
||||
{
|
||||
run.Count($"{what} not found");
|
||||
continue;
|
||||
}
|
||||
if (target.Local?.Id == run.Me.Id)
|
||||
continue;
|
||||
run.Count(await act(run, target, cells, token) ? what : $"{what} skipped");
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<bool> Block(Run run, (LocalActor Local, ForeignAvatar Remote) target, string[] cells, CancellationToken token)
|
||||
{
|
||||
var (uri, id) = target.Local != default ? (target.Local.Uri, target.Local.Id) : (target.Remote.ActorURI, target.Remote.ID);
|
||||
if (await DB.Default.Find<Block>().Match(b => b.AvatarId == run.Me.Id && b.TargetActorURI == uri).ExecuteAnyAsync(token))
|
||||
return false;
|
||||
await run.Services.GetRequiredService<IRelationshipService>().Block(run.Me, uri, id, token);
|
||||
return true;
|
||||
}
|
||||
|
||||
static async Task<bool> Mute(Run run, (LocalActor Local, ForeignAvatar Remote) target, string[] cells, CancellationToken token)
|
||||
{
|
||||
var (uri, id) = target.Local != default ? (target.Local.Uri, target.Local.Id) : (target.Remote.ActorURI, target.Remote.ID);
|
||||
var hide = cells.Length < 2 || !cells[1].Trim().Equals("false", StringComparison.OrdinalIgnoreCase);
|
||||
await run.Services.GetRequiredService<IRelationshipService>().Mute(run.Me, uri, id, hide, default, token);
|
||||
return true;
|
||||
}
|
||||
|
||||
async Task<bool> Follow(Run run, (LocalActor Local, ForeignAvatar Remote) target, string[] cells, CancellationToken token)
|
||||
{
|
||||
var uri = target.Local?.Uri ?? target.Remote.ActorURI;
|
||||
var host = new Uri(uri).Host;
|
||||
if (domainBlocks.IsSuspended(host)
|
||||
|| await DB.Default.Find<AccountDomainBlock>().Match(d => d.AvatarId == run.Me.Id && d.Domain == host).ExecuteAnyAsync(token)
|
||||
|| await DB.Default.Find<Following>().Match(f => f.AvatarId == run.Me.Id && f.TargetActorURI == uri).ExecuteAnyAsync(token))
|
||||
return false;
|
||||
var boosts = cells.Length < 2 || !cells[1].Trim().Equals("false", StringComparison.OrdinalIgnoreCase);
|
||||
return await run.Services.GetRequiredService<IFollowService>().FollowAs(run.Me, uri, boosts, token) != default;
|
||||
}
|
||||
|
||||
// lists by title, with only the accounts the persona follows
|
||||
async Task Lists(Run run, CancellationToken token)
|
||||
{
|
||||
var follows = run.Services.GetRequiredService<IFollowService>();
|
||||
var lists = (await DB.Default.Find<PersonaList>().Match(l => l.AvatarId == run.Me.Id).ExecuteAsync(token)).ToDictionary(l => l.Title, StringComparer.Ordinal);
|
||||
foreach (var line in run.Archive.Lines("lists.csv"))
|
||||
{
|
||||
var cells = Cells(line);
|
||||
if (cells.Length < 2 || Clean(cells[0]) is not { } title)
|
||||
continue;
|
||||
if (!lists.TryGetValue(title, out var list))
|
||||
{
|
||||
if (lists.Count >= 50)
|
||||
continue;
|
||||
list = new PersonaList { AvatarId = run.Me.Id, Title = title, RepliesPolicy = "list" };
|
||||
await DB.Default.SaveAsync(list, token);
|
||||
lists[title] = list;
|
||||
run.Count("lists");
|
||||
}
|
||||
var target = await follows.Resolve(cells[1].Trim(), token);
|
||||
var uri = target.Local?.Uri ?? target.Remote?.ActorURI;
|
||||
var following = uri == default ? default
|
||||
: await DB.Default.Find<Following>().Match(f => f.AvatarId == run.Me.Id && f.TargetActorURI == uri && f.State == FollowState.Accepted).ExecuteFirstAsync(token);
|
||||
if (following == default)
|
||||
{
|
||||
run.Count("list members not followed");
|
||||
continue;
|
||||
}
|
||||
if (await DB.Default.Find<PersonaListMember>().Match(m => m.ListId == list.ID && m.AccountId == following.TargetAccountId).ExecuteAnyAsync(token))
|
||||
continue;
|
||||
await DB.Default.SaveAsync(new PersonaListMember { ListId = list.ID, AvatarId = run.Me.Id, AccountId = following.TargetAccountId }, token);
|
||||
run.Count("list members");
|
||||
}
|
||||
}
|
||||
|
||||
async Task Bookmarks(Run run, CancellationToken token)
|
||||
{
|
||||
var uris = Strings(run.Archive.Json("bookmarks.json")?["orderedItems"]);
|
||||
if (uris.Count == 0)
|
||||
uris = run.Archive.Lines("bookmarks.csv").Select(l => Cells(l).FirstOrDefault()?.Trim()).Where(u => u?.StartsWith("http") == true).ToList();
|
||||
foreach (var uri in uris.Distinct())
|
||||
{
|
||||
if (await Stopped(run, token))
|
||||
return;
|
||||
var post = await Fetched(run, uri, token);
|
||||
if (post == default)
|
||||
{
|
||||
run.Count("bookmarks not found");
|
||||
continue;
|
||||
}
|
||||
if (await DB.Default.Find<Bookmark>().Match(b => b.AvatarId == run.Me.Id && b.PostId == post.ID).ExecuteAnyAsync(token))
|
||||
continue;
|
||||
await DB.Default.SaveAsync(new Bookmark { AvatarId = run.Me.Id, PostId = post.ID }, token);
|
||||
run.Count("bookmarks");
|
||||
}
|
||||
}
|
||||
|
||||
// asked for on purpose: each like tells the post's author
|
||||
async Task Likes(Run run, CancellationToken token)
|
||||
{
|
||||
var statuses = run.Services.GetRequiredService<IStatusService>();
|
||||
foreach (var uri in Strings(run.Archive.Json("likes.json")?["orderedItems"]).Distinct())
|
||||
{
|
||||
if (await Stopped(run, token))
|
||||
return;
|
||||
var post = await Fetched(run, uri, token);
|
||||
if (post == default)
|
||||
{
|
||||
run.Count("likes not found");
|
||||
continue;
|
||||
}
|
||||
run.Count((await statuses.Favourite(run.Me, post.ID, true, token)).Ok ? "likes" : "likes not found");
|
||||
}
|
||||
}
|
||||
|
||||
// a post here by its address, or read from its server (signed) and kept
|
||||
static async Task<PostEntity> Fetched(Run run, string uri, CancellationToken token)
|
||||
{
|
||||
if (await Held(run, uri, token) is { } held)
|
||||
return held;
|
||||
return await run.Services.GetRequiredService<IRemotePosts>().StoreContext(uri, 0, token);
|
||||
}
|
||||
|
||||
static async Task<PostEntity> Held(Run run, string uri, CancellationToken token)
|
||||
{
|
||||
if (uri == default)
|
||||
return default;
|
||||
if (run.Posts.TryGetValue(uri, out var copy))
|
||||
return await DB.Default.Find<PostEntity>().MatchID(copy.Id).ExecuteFirstAsync(token);
|
||||
return await DB.Default.Find<PostEntity>().Match(p => (p.ObjectURI == uri || p.Url == uri) && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
}
|
||||
|
||||
// ---- the profile: name, words, fields, flags and pictures, saved as Settings saves them (and announced the same way)
|
||||
|
||||
async Task Profile(Run run, JsonObject actor, CancellationToken token)
|
||||
{
|
||||
var avatar = await DB.Default.Find<Avatar>().MatchID(run.Me.Id).ExecuteFirstAsync(token);
|
||||
if (Clean(Text(actor["name"])) is { } name)
|
||||
avatar.Name = name;
|
||||
if (Text(actor["summary"]) is { } summary)
|
||||
avatar.Biography = Plain(summary);
|
||||
var fields = (actor["attachment"] as JsonArray ?? []).OfType<JsonObject>().Where(f => Text(f["type"]) == "PropertyValue" && Clean(Text(f["name"])) != default)
|
||||
.Take(4).ToDictionary(f => Text(f["name"]).Trim(), f => Plain(Text(f["value"]) ?? string.Empty));
|
||||
if (fields.Count > 0)
|
||||
avatar.Fields = fields;
|
||||
if (actor["manuallyApprovesFollowers"]?.GetValueKind() is { } locked and (System.Text.Json.JsonValueKind.True or System.Text.Json.JsonValueKind.False))
|
||||
avatar.Settings.IsLocked = locked == System.Text.Json.JsonValueKind.True;
|
||||
if (actor["discoverable"]?.GetValueKind() is { } discoverable and (System.Text.Json.JsonValueKind.True or System.Text.Json.JsonValueKind.False))
|
||||
avatar.Settings.IsDiscoverable = discoverable == System.Text.Json.JsonValueKind.True;
|
||||
if (actor["indexable"]?.GetValueKind() is { } indexable and (System.Text.Json.JsonValueKind.True or System.Text.Json.JsonValueKind.False))
|
||||
avatar.Settings.IsIndexable = indexable == System.Text.Json.JsonValueKind.True;
|
||||
foreach (var (field, kind, width, height) in new[] { ("icon", "avatar", 400, 400), ("image", "header", 1500, 500) })
|
||||
{
|
||||
var file = (Text((actor[field] as JsonObject)?["url"]) ?? Text(actor[field]))?.TrimStart('/');
|
||||
if (file == default || file.Contains("://") || !run.Archive.Has(file))
|
||||
continue;
|
||||
var outcome = await Picture(run, file, kind, width, height, token);
|
||||
if (outcome?.Ok != true)
|
||||
{
|
||||
run.Count("pictures refused");
|
||||
continue;
|
||||
}
|
||||
if (kind == "avatar")
|
||||
avatar.PictureURL = media.Url(outcome.Attachment.FilePath);
|
||||
else
|
||||
avatar.ThumbnailURL = media.Url(outcome.Attachment.FilePath);
|
||||
await media.Trash(m => m.ProfileOfAvatarId == avatar.ID && m.Kind == kind && m.ID != outcome.Attachment.ID, "replaced", token);
|
||||
}
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
await DB.Default.SaveAsync(avatar, token);
|
||||
await run.Services.GetRequiredService<IOutboxPublisher>().PublishProfile(localActors.FromAvatar(avatar), token);
|
||||
run.Count("profile");
|
||||
}
|
||||
|
||||
async Task<MediaOutcome> Picture(Run run, string file, string kind, int width, int height, CancellationToken token)
|
||||
{
|
||||
var temporary = Path.Combine(Path.GetTempPath(), $"privapub-import-{Guid.NewGuid():N}{Path.GetExtension(file)}");
|
||||
try
|
||||
{
|
||||
await using (var source = run.Archive.Read(file))
|
||||
await using (var target = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write))
|
||||
await source.CopyToAsync(target, token);
|
||||
await using var stream = new FileStream(temporary, FileMode.Open, FileAccess.Read);
|
||||
var form = new FormFile(stream, 0, stream.Length, kind, Path.GetFileName(file))
|
||||
{
|
||||
Headers = new HeaderDictionary(),
|
||||
ContentType = MediaService.ContentTypes.TryGetContentType(file, out var guessed) ? guessed : "application/octet-stream"
|
||||
};
|
||||
return await media.ProfileImage(run.Me.Id, kind, form, width, height, token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers
|
||||
|
||||
static string Text(JsonNode node) => node is JsonValue value && value.TryGetValue<string>(out var text) ? text : default;
|
||||
|
||||
static string Id(JsonNode node) => node switch
|
||||
{
|
||||
JsonValue => Text(node),
|
||||
JsonObject json => Text(json["id"]),
|
||||
JsonArray list => list.Select(Id).FirstOrDefault(i => i != default),
|
||||
_ => default
|
||||
};
|
||||
|
||||
static List<string> Strings(JsonNode node) => node switch
|
||||
{
|
||||
JsonArray list => list.Select(Id).Where(i => i != default).ToList(),
|
||||
JsonValue => Text(node) is { } one ? [one] : [],
|
||||
JsonObject json when Text(json["type"]) is "OrderedCollection" or "Collection" => Strings(json["orderedItems"] ?? json["items"]),
|
||||
_ => []
|
||||
};
|
||||
|
||||
static DateTime? Date(JsonNode node) =>
|
||||
DateTime.TryParse(Text(node), System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var date) ? date : default;
|
||||
|
||||
static DateTime Clamp(DateTime when) => when > DateTime.UtcNow ? DateTime.UtcNow : when;
|
||||
|
||||
static string Clean(string text) => string.IsNullOrWhiteSpace(text) ? default : text.Trim();
|
||||
|
||||
static bool IsObjectId(string id) => id is { Length: 24 } && id.All(char.IsAsciiHexDigitLower);
|
||||
|
||||
// a CSV line's cells (quotes as Mastodon writes them)
|
||||
static string[] Cells(string line)
|
||||
{
|
||||
var cells = new List<string>();
|
||||
var cell = new System.Text.StringBuilder();
|
||||
var quoted = false;
|
||||
for (var i = 0; i < line.Length; i++)
|
||||
{
|
||||
var c = line[i];
|
||||
if (quoted)
|
||||
{
|
||||
if (c == '"' && i + 1 < line.Length && line[i + 1] == '"')
|
||||
{
|
||||
cell.Append('"');
|
||||
i++;
|
||||
}
|
||||
else if (c == '"')
|
||||
quoted = false;
|
||||
else
|
||||
cell.Append(c);
|
||||
}
|
||||
else if (c == '"')
|
||||
quoted = true;
|
||||
else if (c == ',')
|
||||
{
|
||||
cells.Add(cell.ToString());
|
||||
cell.Clear();
|
||||
}
|
||||
else
|
||||
cell.Append(c);
|
||||
}
|
||||
cells.Add(cell.ToString());
|
||||
return [.. cells];
|
||||
}
|
||||
|
||||
// HTML to the plain words a persona writes in its profile
|
||||
static string Plain(string html)
|
||||
{
|
||||
var text = Breaks().Replace(html, "\n");
|
||||
text = Tags().Replace(text, string.Empty);
|
||||
return WebUtility.HtmlDecode(text).Trim();
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"<br\s*/?>|</p>\s*<p[^>]*>", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex Breaks();
|
||||
|
||||
[GeneratedRegex(@"<[^>]+>")]
|
||||
private static partial Regex Tags();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.ClientModels.User;
|
||||
|
||||
namespace PrivaPub.Domain.Portability
|
||||
{
|
||||
// A persona's archive, made and restored in jobs (owner decision 2026-10-07): where each stands, one row per persona
|
||||
// (its ID is the persona's). The files live in <backups>/.personas/<avatarId>/: the export, kept a week, and the upload
|
||||
// being imported.
|
||||
public class PersonaArchive : Entity
|
||||
{
|
||||
public string ExportState { get; set; } = "none";
|
||||
public DateTime? ExportAskedAt { get; set; }
|
||||
public DateTime? ExportReadyAt { get; set; }
|
||||
public string ExportFile { get; set; }
|
||||
public long ExportBytes { get; set; }
|
||||
public string ExportError { get; set; }
|
||||
|
||||
public string ImportState { get; set; } = "none";
|
||||
public DateTime? ImportAskedAt { get; set; }
|
||||
public DateTime? ImportEndedAt { get; set; }
|
||||
public string ImportFile { get; set; }
|
||||
public List<string> ImportParts { get; set; } = [];
|
||||
public long ImportDone { get; set; }
|
||||
public long ImportTotal { get; set; }
|
||||
public Dictionary<string, int> ImportCounts { get; set; } = [];
|
||||
public string ImportError { get; set; }
|
||||
public string ImportFrom { get; set; }
|
||||
public bool ImportSameActor { get; set; }
|
||||
public bool StopAsked { get; set; }
|
||||
}
|
||||
|
||||
public static class PersonaArchives
|
||||
{
|
||||
public static readonly TimeSpan Kept = TimeSpan.FromDays(7);
|
||||
|
||||
/// <summary>Every part an import can take; the last three only when asked (they act, or tell others).</summary>
|
||||
public static readonly string[] Parts = ["posts", "profile", "follows", "blocks", "mutes", "domainblocks", "lists", "bookmarks", "filters", "tags",
|
||||
"notificationpolicy", "pins", "located", "scheduled", "likes"];
|
||||
|
||||
public static string Folder(string backupsRoot, string avatarId) => Path.Combine(backupsRoot, ".personas", avatarId);
|
||||
|
||||
public static async Task<PersonaArchive> Of(string avatarId, CancellationToken token) =>
|
||||
await DB.Default.Find<PersonaArchive>().MatchID(avatarId).ExecuteFirstAsync(token) ?? new PersonaArchive { ID = avatarId };
|
||||
|
||||
public static ViewPersonaArchive View(PersonaArchive archive) => new()
|
||||
{
|
||||
ExportState = archive.ExportState,
|
||||
ExportAskedAt = archive.ExportAskedAt,
|
||||
ExportReadyAt = archive.ExportReadyAt,
|
||||
ExportExpiresAt = archive.ExportReadyAt + Kept,
|
||||
ExportBytes = archive.ExportBytes,
|
||||
ExportError = archive.ExportError,
|
||||
ImportState = archive.ImportState,
|
||||
ImportAskedAt = archive.ImportAskedAt,
|
||||
ImportEndedAt = archive.ImportEndedAt,
|
||||
ImportParts = archive.ImportParts,
|
||||
ImportDone = archive.ImportDone,
|
||||
ImportTotal = archive.ImportTotal,
|
||||
ImportCounts = archive.ImportCounts,
|
||||
ImportError = archive.ImportError,
|
||||
ImportFrom = archive.ImportFrom,
|
||||
ImportSameActor = archive.ImportSameActor
|
||||
};
|
||||
|
||||
/// <summary>Exports older than a week go, as do imports' files left behind.</summary>
|
||||
public static async Task Forget(string backupsRoot, CancellationToken token)
|
||||
{
|
||||
var folder = Path.Combine(backupsRoot, ".personas");
|
||||
if (!Directory.Exists(folder))
|
||||
return;
|
||||
var before = DateTime.UtcNow - Kept;
|
||||
foreach (var file in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories).Where(f => File.GetLastWriteTimeUtc(f) < before))
|
||||
File.Delete(file);
|
||||
await DB.Default.Update<PersonaArchive>().Match(a => a.ExportState == "ready" && a.ExportReadyAt < before)
|
||||
.Modify(a => a.ExportState, "none").Modify(a => a.ExportFile, null).ExecuteAsync(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Domain.Portability
|
||||
{
|
||||
// An archive someone uploads, opened only if nothing in it can harm the server: no link, no path leaving it (.. or
|
||||
// absolute), no name twice, at most MaxEntries files and MaxBytes in all (and what the disk has), and no file
|
||||
// compressed more than MaxRatio to one. Every file is read through a stream that stops at its stated size, and the
|
||||
// outbox one item at a time, none larger than MaxItemBytes, so a large archive never sits in memory.
|
||||
public sealed class SafeArchive : IDisposable
|
||||
{
|
||||
public const int MaxEntries = 200_000;
|
||||
public const long MaxBytes = 64L * 1024 * 1024 * 1024;
|
||||
public const int MaxRatio = 100;
|
||||
public const int MaxItemBytes = 1024 * 1024;
|
||||
public const int MaxJsonBytes = 64 * 1024 * 1024;
|
||||
|
||||
readonly ZipArchive _zip;
|
||||
readonly Dictionary<string, ZipArchiveEntry> _entries;
|
||||
readonly string _root;//the folder the archive's files sit in, when it wraps them in one
|
||||
|
||||
SafeArchive(ZipArchive zip, Dictionary<string, ZipArchiveEntry> entries, string root)
|
||||
{
|
||||
_zip = zip;
|
||||
_entries = entries;
|
||||
_root = root;
|
||||
}
|
||||
|
||||
public static (SafeArchive Archive, string Error) Open(string path)
|
||||
{
|
||||
ZipArchive zip;
|
||||
try
|
||||
{
|
||||
zip = ZipFile.OpenRead(path);
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
return (default, "not a zip archive");
|
||||
}
|
||||
var entries = new Dictionary<string, ZipArchiveEntry>(StringComparer.Ordinal);
|
||||
var total = 0L;
|
||||
var free = new DriveInfo(Path.GetFullPath(path)).AvailableFreeSpace;
|
||||
string Refuse(string why)
|
||||
{
|
||||
zip.Dispose();
|
||||
return why;
|
||||
}
|
||||
if (zip.Entries.Count > MaxEntries)
|
||||
return (default, Refuse("too many files"));
|
||||
foreach (var entry in zip.Entries)
|
||||
{
|
||||
var name = entry.FullName;
|
||||
if (name.Contains('\\') || name.StartsWith('/') || name.Contains(':') || name.Split('/').Any(part => part is ".." or "."))
|
||||
return (default, Refuse($"{name}: a path that leaves the archive"));
|
||||
if (((entry.ExternalAttributes >> 16) & 0xF000) == 0xA000)
|
||||
return (default, Refuse($"{name}: a link"));
|
||||
if (name.EndsWith('/'))
|
||||
continue;
|
||||
if (!entries.TryAdd(name, entry))
|
||||
return (default, Refuse($"{name}: twice"));
|
||||
total += entry.Length;
|
||||
if (total > MaxBytes || total > free)
|
||||
return (default, Refuse("larger than the server can take"));
|
||||
if (entry.Length > 1024 * 1024 && entry.Length > (long)MaxRatio * Math.Max(1, entry.CompressedLength))
|
||||
return (default, Refuse($"{name}: compressed more than {MaxRatio} to one"));
|
||||
}
|
||||
// some archivers wrap everything in one folder
|
||||
var root = string.Empty;
|
||||
if (!entries.ContainsKey("actor.json") && entries.Keys.FirstOrDefault(k => k.EndsWith("/actor.json", StringComparison.Ordinal)) is { } nested
|
||||
&& nested.Count(c => c == '/') == 1)
|
||||
root = nested[..(nested.IndexOf('/') + 1)];
|
||||
return (new SafeArchive(zip, entries, root), default);
|
||||
}
|
||||
|
||||
public bool Has(string name) => _entries.ContainsKey(_root + name);
|
||||
|
||||
public long Length(string name) => _entries.TryGetValue(_root + name, out var entry) ? entry.Length : -1;
|
||||
|
||||
/// <summary>A file's contents, never more than its stated size.</summary>
|
||||
public Stream Read(string name) =>
|
||||
_entries.TryGetValue(_root + name, out var entry) ? new Bounded(entry.Open(), entry.Length) : default;
|
||||
|
||||
/// <summary>A JSON file, or null when it is missing, too large or not JSON.</summary>
|
||||
public JsonNode Json(string name)
|
||||
{
|
||||
if (!_entries.TryGetValue(_root + name, out var entry) || entry.Length > MaxJsonBytes)
|
||||
return default;
|
||||
try
|
||||
{
|
||||
using var stream = Read(name);
|
||||
return JsonNode.Parse(stream);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The lines of a text file (a CSV), none when it is missing or too large.</summary>
|
||||
public List<string> Lines(string name)
|
||||
{
|
||||
if (!_entries.TryGetValue(_root + name, out var entry) || entry.Length > MaxJsonBytes)
|
||||
return [];
|
||||
using var reader = new StreamReader(Read(name));
|
||||
var lines = new List<string>();
|
||||
while (reader.ReadLine() is { } line)
|
||||
if (line.Length > 0)
|
||||
lines.Add(line);
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// <summary>outbox.json's orderedItems, one object at a time; an item larger than MaxItemBytes stops it.</summary>
|
||||
public async IAsyncEnumerable<JsonObject> Outbox([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token)
|
||||
{
|
||||
await using var stream = Read("outbox.json");
|
||||
if (stream == default)
|
||||
yield break;
|
||||
var buffer = new byte[64 * 1024];
|
||||
var filled = 0;
|
||||
var final = false;
|
||||
var state = new JsonReaderState();
|
||||
var phase = Phase.Seeking;
|
||||
while (phase != Phase.Done)
|
||||
{
|
||||
if (!final)
|
||||
{
|
||||
if (filled == buffer.Length)
|
||||
{
|
||||
if (buffer.Length > MaxItemBytes + 64 * 1024)
|
||||
throw new InvalidDataException($"an outbox item larger than {MaxItemBytes / 1024} KiB");
|
||||
Array.Resize(ref buffer, buffer.Length * 2);
|
||||
}
|
||||
var read = await stream.ReadAsync(buffer.AsMemory(filled), token);
|
||||
filled += read;
|
||||
final = read == 0;
|
||||
}
|
||||
var (items, consumed, next, after) = Scan(buffer.AsSpan(0, filled), final, state, phase);
|
||||
foreach (var item in items)
|
||||
yield return item;
|
||||
(state, phase) = (next, after);
|
||||
Buffer.BlockCopy(buffer, consumed, buffer, 0, filled - consumed);
|
||||
filled -= consumed;
|
||||
if (final && consumed == 0 && items.Count == 0)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
enum Phase { Seeking, Array, Done }
|
||||
|
||||
static (List<JsonObject> Items, int Consumed, JsonReaderState State, Phase Phase) Scan(ReadOnlySpan<byte> data, bool final, JsonReaderState state, Phase phase)
|
||||
{
|
||||
var items = new List<JsonObject>();
|
||||
var reader = new Utf8JsonReader(data, final, state);
|
||||
while (phase != Phase.Done)
|
||||
{
|
||||
var mark = reader;
|
||||
if (!reader.Read())
|
||||
{
|
||||
reader = mark;
|
||||
break;
|
||||
}
|
||||
if (phase == Phase.Seeking)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.PropertyName && reader.CurrentDepth == 1 && reader.ValueTextEquals("orderedItems"))
|
||||
{
|
||||
var before = reader;
|
||||
if (!reader.Read())
|
||||
{
|
||||
reader = mark;
|
||||
break;
|
||||
}
|
||||
if (reader.TokenType == JsonTokenType.StartArray)
|
||||
phase = Phase.Array;
|
||||
else
|
||||
{
|
||||
reader = before;
|
||||
if (!reader.TrySkip())
|
||||
{
|
||||
reader = mark;
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (reader.TokenType is JsonTokenType.StartObject or JsonTokenType.StartArray && reader.CurrentDepth >= 1 && !reader.TrySkip())
|
||||
{
|
||||
reader = mark;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (reader.TokenType == JsonTokenType.EndArray)
|
||||
{
|
||||
phase = Phase.Done;
|
||||
break;
|
||||
}
|
||||
if (reader.TokenType != JsonTokenType.StartObject)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.StartArray && !reader.TrySkip())
|
||||
{
|
||||
reader = mark;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
var start = (int)reader.TokenStartIndex;
|
||||
if (!reader.TrySkip())
|
||||
{
|
||||
reader = mark;
|
||||
break;
|
||||
}
|
||||
var length = (int)reader.BytesConsumed - start;
|
||||
if (length > MaxItemBytes)
|
||||
throw new InvalidDataException($"an outbox item larger than {MaxItemBytes / 1024} KiB");
|
||||
if (JsonNode.Parse(data.Slice(start, length)) is JsonObject item)
|
||||
items.Add(item);
|
||||
}
|
||||
return (items, (int)reader.BytesConsumed, reader.CurrentState, phase);
|
||||
}
|
||||
|
||||
public void Dispose() => _zip.Dispose();
|
||||
|
||||
// a stream that ends at the size the archive states, whatever the compressed data says
|
||||
sealed class Bounded(Stream inner, long length) : Stream
|
||||
{
|
||||
long _left = length;
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count));
|
||||
|
||||
public override int Read(Span<byte> buffer)
|
||||
{
|
||||
if (_left <= 0)
|
||||
return 0;
|
||||
var read = inner.Read(buffer[..(int)Math.Min(buffer.Length, _left)]);
|
||||
_left -= read;
|
||||
return read;
|
||||
}
|
||||
|
||||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken token = default)
|
||||
{
|
||||
if (_left <= 0)
|
||||
return 0;
|
||||
var read = await inner.ReadAsync(buffer[..(int)Math.Min(buffer.Length, _left)], token);
|
||||
_left -= read;
|
||||
return read;
|
||||
}
|
||||
|
||||
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken token) =>
|
||||
ReadAsync(buffer.AsMemory(offset, count), token).AsTask();
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => length;
|
||||
public override long Position { get => length - _left; set => throw new NotSupportedException(); }
|
||||
public override void Flush() { }
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
inner.Dispose();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
using System.Linq.Expressions;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Domain.Privacy
|
||||
{
|
||||
// What the numbers count, in one place, so a persona's statuses_count, its outbox's totalItems, NodeInfo and the
|
||||
// instance API agree (they used to count four different things).
|
||||
public static class Counted
|
||||
{
|
||||
// Mastodon's statuses_count: every post that is neither deleted nor a direct message, reblogs included. Circle and
|
||||
// located posts count too (owner decision 2026-10-04: "a good balance for the fediverse to work").
|
||||
public static readonly Expression<Func<PostEntity, bool>> Post = p => !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct;
|
||||
|
||||
// a persona's own posts, or every post in a group, its remote members' included
|
||||
public static Expression<Func<PostEntity, bool>> By(LocalActor actor)
|
||||
{
|
||||
var id = actor.Id;
|
||||
return actor.Kind == LocalActorKind.Group
|
||||
? p => p.GroupId == id && !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct
|
||||
: p => p.GroupUserId == id && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct;
|
||||
}
|
||||
|
||||
// replies_count counts only public and unlisted replies, as Mastodon does: a count must not tell anyone that a
|
||||
// followers-only, direct or circle reply exists
|
||||
public static bool Reply(PostEntity reply) => reply.Visibility is PostVisibility.Public or PostVisibility.Unlisted;
|
||||
|
||||
public static async Task<long> PostsBy(LocalActor actor, CancellationToken token) =>
|
||||
await DB.Default.CountAsync(By(actor), token);
|
||||
|
||||
// Personas that do not count as this server's users, and are not found in search: those of a banned or deleted root.
|
||||
public static async Task<List<string>> BarredPersonas(CancellationToken token)
|
||||
{
|
||||
var roots = (await DB.Default.Find<RootUser>().Match(u => u.IsBanned || u.DeletedAt != null).Project(u => u.Include(x => x.ID)).ExecuteAsync(token))
|
||||
.Select(u => u.ID).ToList();
|
||||
return roots.Count == 0
|
||||
? new List<string>()
|
||||
: (await DB.Default.Find<RootToAvatar>().Match(ra => roots.Contains(ra.RootId)).ExecuteAsync(token)).Select(ra => ra.AvatarId).ToList();
|
||||
}
|
||||
|
||||
public static async Task<long> Users(CancellationToken token)
|
||||
{
|
||||
var barred = await BarredPersonas(token);
|
||||
return await DB.Default.CountAsync<Avatar>(a => !a.DeletionAt.HasValue && !barred.Contains(a.ID), token);
|
||||
}
|
||||
|
||||
// users who posted within the last days, as NodeInfo's activeMonth and activeHalfyear and the instance API mean it
|
||||
public static async Task<long> ActiveUsers(int days, CancellationToken token)
|
||||
{
|
||||
var barred = await BarredPersonas(token);
|
||||
var since = DateTime.UtcNow.AddDays(-days);
|
||||
var authors = await DB.Default.Find<PostEntity, string>()
|
||||
.Match(p => !p.IsFederatedCopy && p.CreationDate >= since && !barred.Contains(p.GroupUserId))
|
||||
.Project(p => p.GroupUserId)
|
||||
.ExecuteAsync(token);
|
||||
var distinct = authors.Where(a => !string.IsNullOrEmpty(a)).Distinct().ToList();
|
||||
return distinct.Count == 0 ? 0 : await DB.Default.CountAsync<Avatar>(a => distinct.Contains(a.ID) && !a.DeletionAt.HasValue, token);
|
||||
}
|
||||
|
||||
// the media kept (not trashed) that which holds: their bytes and how many
|
||||
public static async Task<(long Bytes, long Files)> Media(Expression<Func<Models.Media.MediaAttachment, bool>> which, CancellationToken token)
|
||||
{
|
||||
var totals = await DB.Default.Fluent<Models.Media.MediaAttachment>()
|
||||
.Match(m => m.TrashedAt == null)
|
||||
.Match(which)
|
||||
.Group(m => true, g => new { Bytes = g.Sum(m => m.Size), Files = g.LongCount() })
|
||||
.FirstOrDefaultAsync(token);
|
||||
return totals == default ? (0, 0) : (totals.Bytes, totals.Files);
|
||||
}
|
||||
|
||||
// a login's media: every persona's uploads and pictures
|
||||
public static async Task<(long Bytes, long Files)> MediaOfRoot(string rootId, CancellationToken token)
|
||||
{
|
||||
var personas = (await DB.Default.Find<Models.User.RootToAvatar>().Match(r => r.RootId == rootId).ExecuteAsync(token)).Select(r => r.AvatarId).ToList();
|
||||
return await Media(m => personas.Contains(m.OwnerAvatarId), token);
|
||||
}
|
||||
|
||||
public static async Task<long> LocalPosts(CancellationToken token)
|
||||
{
|
||||
var barred = await BarredPersonas(token);
|
||||
return await DB.Default.CountAsync<PostEntity>(f => f.Where(p => !p.IsFederatedCopy && !barred.Contains(p.GroupUserId)) & f.Where(Post), token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
using System.Linq.Expressions;
|
||||
|
||||
@@ -11,14 +14,20 @@ namespace PrivaPub.Domain.Privacy
|
||||
{
|
||||
public static class VisibilityPolicy
|
||||
{
|
||||
public static readonly Expression<Func<Post, bool>> IsPublic = p =>
|
||||
!p.DeletedAt.HasValue && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted);
|
||||
//neither deleted nor kept back after its remote author deleted their account (owner decision, 2026-10-03)
|
||||
public static readonly Expression<Func<Post, bool>> IsShown = p => !p.DeletedAt.HasValue && !p.AuthorGone;
|
||||
|
||||
public static readonly Expression<Func<Post, bool>> IsPublic = p =>
|
||||
!p.DeletedAt.HasValue && !p.AuthorGone && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted);
|
||||
|
||||
static readonly Func<Post, bool> IsShownCompiled = IsShown.Compile();
|
||||
static readonly Func<Post, bool> IsPublicCompiled = IsPublic.Compile();
|
||||
|
||||
public static bool Shown(Post post) => post != default && IsShownCompiled(post);
|
||||
|
||||
public static async Task<bool> CanSee(Post post, string viewerAvatarId, CancellationToken token)
|
||||
{
|
||||
if (post == default || post.DeletedAt.HasValue)
|
||||
if (!Shown(post))
|
||||
return false;
|
||||
if (IsPublicCompiled(post))
|
||||
return true;
|
||||
@@ -35,8 +44,45 @@ namespace PrivaPub.Domain.Privacy
|
||||
PostVisibility.Circle => !string.IsNullOrEmpty(post.GroupId) && await DB.Default.Find<GroupEntity>()
|
||||
.Match(g => g.ID == post.GroupId && g.Members.Any(m => !m.IsForeign && m.AvatarId == viewerAvatarId))
|
||||
.ExecuteAnyAsync(token),
|
||||
PostVisibility.FollowersOnly => await FollowsAuthor(viewerAvatarId, post, token),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
// A remote account may see a post, and so like, react to, vote on or downvote it, when the post was for it: anyone for
|
||||
// public and unlisted, an addressed account for a DM, a follower or an addressed account for followers-only (our own
|
||||
// followers-only posts are addressed to the followers collection, never to each follower), a member for a circle.
|
||||
// Accounts only: the instance-actor alias of SignedFetchAuthorizer.MayRead is for refetches, never for interactions.
|
||||
public static async Task<bool> RemoteCanSee(Post post, ForeignAvatar actor, CancellationToken token)
|
||||
{
|
||||
if (!Shown(post) || actor == default || string.IsNullOrEmpty(actor.ActorURI))
|
||||
return false;
|
||||
if (IsPublicCompiled(post))
|
||||
return true;
|
||||
var addressed = post.To.Contains(actor.ActorURI) || post.Cc.Contains(actor.ActorURI) || post.Mentions.Any(m => m.ActorURI == actor.ActorURI);
|
||||
return post.Visibility switch
|
||||
{
|
||||
PostVisibility.Direct => addressed,
|
||||
//a remote author's followers live on other servers too; we know only who it addressed
|
||||
PostVisibility.FollowersOnly => addressed || !post.IsFederatedCopy && !string.IsNullOrEmpty(post.GroupUserId) && await DB.Default.Find<Follower>()
|
||||
.Match(f => f.LocalActorId == post.GroupUserId && f.LocalActorKind == LocalActorKind.Person && f.IsAccepted && f.ActorURI == actor.ActorURI)
|
||||
.ExecuteAnyAsync(token),
|
||||
PostVisibility.Circle => !string.IsNullOrEmpty(post.GroupId) && await DB.Default.Find<GroupEntity>()
|
||||
.Match(g => g.ID == post.GroupId && g.Members.Any(m => m.IsForeign && m.AvatarId == actor.ActorURI))
|
||||
.ExecuteAnyAsync(token),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
static async Task<bool> FollowsAuthor(string viewerAvatarId, Post post, CancellationToken token)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(post.ActorURI))
|
||||
return await DB.Default.Find<Following>()
|
||||
.Match(f => f.AvatarId == viewerAvatarId && f.TargetActorURI == post.ActorURI && f.State == FollowState.Accepted)
|
||||
.ExecuteAnyAsync(token);
|
||||
return !post.IsFederatedCopy && !string.IsNullOrEmpty(post.GroupUserId) && await DB.Default.Find<Following>()
|
||||
.Match(f => f.AvatarId == viewerAvatarId && f.TargetIsLocal && f.TargetAccountId == post.GroupUserId && f.State == FollowState.Accepted)
|
||||
.ExecuteAnyAsync(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
@@ -17,6 +18,7 @@ namespace PrivaPub.Domain.Relationships
|
||||
{
|
||||
Task Block(LocalActor me, string targetActorUri, string targetAccountId, CancellationToken token);
|
||||
Task Unblock(LocalActor me, string targetActorUri, CancellationToken token);
|
||||
Task RemoveFollower(LocalActor me, string followerActorUri, string followerAccountId, CancellationToken token);
|
||||
Task Mute(LocalActor me, string targetActorUri, string targetAccountId, bool hideNotifications, TimeSpan? duration, CancellationToken token);
|
||||
Task Unmute(LocalActor me, string targetActorUri, CancellationToken token);
|
||||
Task BlockDomain(LocalActor me, string domain, CancellationToken token);
|
||||
@@ -38,39 +40,79 @@ namespace PrivaPub.Domain.Relationships
|
||||
|
||||
public async Task Block(LocalActor me, string targetActorUri, string targetAccountId, CancellationToken token)
|
||||
{
|
||||
var block = new Block { AvatarId = me.Id, TargetAccountId = targetAccountId, TargetActorURI = targetActorUri };
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(new Block { AvatarId = me.Id, TargetAccountId = targetAccountId, TargetActorURI = targetActorUri }, token);
|
||||
await DB.Default.SaveAsync(block, token);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
block = await DB.Default.Find<Block>().Match(b => b.AvatarId == me.Id && b.TargetActorURI == targetActorUri).ExecuteFirstAsync(token);
|
||||
}
|
||||
|
||||
await _follows.UnfollowAs(me, targetActorUri, token);
|
||||
var follower = await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id && f.ActorURI == targetActorUri).ExecuteFirstAsync(token);
|
||||
if (follower != default)
|
||||
{
|
||||
await DB.Default.DeleteAsync<Follower>(follower.ID);
|
||||
if (!targetActorUri.StartsWith(me.BaseAddress + "/", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(follower.FollowActivityURI))
|
||||
{
|
||||
var reject = new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||
["id"] = me.ActivityUri($"reject-{follower.ID}-{DateTime.UtcNow.Ticks}"),
|
||||
["type"] = "Reject",
|
||||
["actor"] = me.Uri,
|
||||
["object"] = new JsonObject { ["id"] = follower.FollowActivityURI, ["type"] = "Follow", ["actor"] = targetActorUri, ["object"] = me.Uri }
|
||||
};
|
||||
await _delivery.Enqueue(me, new[] { follower.InboxURL }, reject, token);
|
||||
}
|
||||
else
|
||||
await DB.Default.DeleteAsync<Following>(f => f.TargetActorURI == me.Uri && f.AvatarId == targetAccountId);
|
||||
}
|
||||
await RemoveFollower(me, targetActorUri, targetAccountId, token);
|
||||
await Forget(me, targetAccountId, token);
|
||||
if (block != default)
|
||||
await Tell(me, targetActorUri, BlockActivity(me, block), token);
|
||||
}
|
||||
|
||||
public async Task Unblock(LocalActor me, string targetActorUri, CancellationToken token) =>
|
||||
await DB.Default.DeleteAsync<Block>(b => b.AvatarId == me.Id && b.TargetActorURI == targetActorUri);
|
||||
public async Task RemoveFollower(LocalActor me, string followerActorUri, string followerAccountId, CancellationToken token)
|
||||
{
|
||||
var follower = await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id && f.ActorURI == followerActorUri).ExecuteFirstAsync(token);
|
||||
if (follower == default)
|
||||
return;
|
||||
await DB.Default.DeleteAsync<Follower>(follower.ID);
|
||||
if (!followerActorUri.StartsWith(me.BaseAddress + "/", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(follower.FollowActivityURI))
|
||||
{
|
||||
var reject = new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||
["id"] = me.ActivityUri($"reject-{follower.ID}-{DateTime.UtcNow.Ticks}"),
|
||||
["type"] = "Reject",
|
||||
["actor"] = me.Uri,
|
||||
["object"] = new JsonObject { ["id"] = follower.FollowActivityURI, ["type"] = "Follow", ["actor"] = followerActorUri, ["object"] = me.Uri }
|
||||
};
|
||||
await _delivery.Enqueue(me, new[] { follower.InboxURL }, reject, token);
|
||||
}
|
||||
else
|
||||
await DB.Default.DeleteAsync<Following>(f => f.TargetActorURI == me.Uri && f.AvatarId == followerAccountId);
|
||||
}
|
||||
|
||||
public async Task Unblock(LocalActor me, string targetActorUri, CancellationToken token)
|
||||
{
|
||||
var block = await DB.Default.Find<Block>().Match(b => b.AvatarId == me.Id && b.TargetActorURI == targetActorUri).ExecuteFirstAsync(token);
|
||||
if (block == default)
|
||||
return;
|
||||
await DB.Default.DeleteAsync<Block>(block.ID);
|
||||
await Tell(me, targetActorUri, new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||
["id"] = me.ActivityUri($"undo-block-{block.ID}"),
|
||||
["type"] = "Undo",
|
||||
["actor"] = me.Uri,
|
||||
["object"] = BlockActivity(me, block)
|
||||
}, token);
|
||||
}
|
||||
|
||||
static JsonObject BlockActivity(LocalActor me, Block block) => new()
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||
["id"] = me.ActivityUri($"block-{block.ID}"),
|
||||
["type"] = "Block",
|
||||
["actor"] = me.Uri,
|
||||
["object"] = block.TargetActorURI,
|
||||
["to"] = new JsonArray(block.TargetActorURI)
|
||||
};
|
||||
|
||||
async Task Tell(LocalActor me, string targetActorUri, JsonObject activity, CancellationToken token)
|
||||
{
|
||||
if (targetActorUri.StartsWith(me.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
var target = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == targetActorUri).ExecuteFirstAsync(token);
|
||||
if (!string.IsNullOrEmpty(target?.InboxURL))
|
||||
await _delivery.Enqueue(me, new[] { target.InboxURL }, activity, token);
|
||||
}
|
||||
|
||||
public async Task Mute(LocalActor me, string targetActorUri, string targetAccountId, bool hideNotifications, TimeSpan? duration, CancellationToken token)
|
||||
{
|
||||
@@ -100,11 +142,14 @@ namespace PrivaPub.Domain.Relationships
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
}
|
||||
foreach (var following in await _dbEntities.Followings.Match(f => f.AvatarId == me.Id && !f.TargetIsLocal).ExecuteAsync(token))
|
||||
if (Hidden.HostMatches(following.TargetActorURI, domain))
|
||||
var followings = await _dbEntities.Followings.Match(f => f.AvatarId == me.Id && !f.TargetIsLocal).ExecuteAsync(token);
|
||||
var followers = await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id).ExecuteAsync(token);
|
||||
var handles = await Hidden.HandleDomains(followings.Select(f => f.TargetActorURI).Concat(followers.Select(f => f.ActorURI)).ToList(), token);
|
||||
foreach (var following in followings)
|
||||
if (Hidden.Blocks(domain, following.TargetActorURI, handles.GetValueOrDefault(following.TargetActorURI)))
|
||||
await _follows.UnfollowAs(me, following.TargetActorURI, token);
|
||||
foreach (var follower in await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id).ExecuteAsync(token))
|
||||
if (Hidden.HostMatches(follower.ActorURI, domain))
|
||||
foreach (var follower in followers)
|
||||
if (Hidden.Blocks(domain, follower.ActorURI, handles.GetValueOrDefault(follower.ActorURI)))
|
||||
await DB.Default.DeleteAsync<Follower>(follower.ID);
|
||||
}
|
||||
|
||||
@@ -126,8 +171,23 @@ namespace PrivaPub.Domain.Relationships
|
||||
public static class Hidden
|
||||
{
|
||||
public static bool HostMatches(string actorUri, string domain) =>
|
||||
Uri.TryCreate(actorUri, UriKind.Absolute, out var uri)
|
||||
&& (uri.Host.Equals(domain, StringComparison.OrdinalIgnoreCase) || uri.Host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase));
|
||||
Uri.TryCreate(actorUri, UriKind.Absolute, out var uri) && IsOn(uri.Host, domain);
|
||||
|
||||
static bool IsOn(string host, string domain) =>
|
||||
host.Equals(domain, StringComparison.OrdinalIgnoreCase) || host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// a server blocked by a persona hides an account whose actor is on it, or whose handle is (its domain apart from its
|
||||
// actor's host, FEP-2c59: the servers Mastodon lists as blocked are its handles' domains)
|
||||
public static bool Blocks(string domain, string actorUri, string handleDomain) =>
|
||||
HostMatches(actorUri, domain) || (!string.IsNullOrEmpty(handleDomain) && IsOn(handleDomain.Split(':')[0], domain));
|
||||
|
||||
// the domains of these accounts' handles, for those known
|
||||
public static async Task<Dictionary<string, string>> HandleDomains(List<string> actorUris, CancellationToken token) =>
|
||||
actorUris.Count == 0
|
||||
? []
|
||||
: (await DB.Default.Find<ForeignAvatar>().Match(a => actorUris.Contains(a.ActorURI))
|
||||
.Project(a => new ForeignAvatar { ActorURI = a.ActorURI, Domain = a.Domain }).ExecuteAsync(token))
|
||||
.GroupBy(a => a.ActorURI).ToDictionary(g => g.Key, g => g.First().Domain);
|
||||
|
||||
public static async Task<HashSet<string>> AuthorsHiddenFrom(string avatarId, IEnumerable<string> actorUris, bool forNotifications, CancellationToken token)
|
||||
{
|
||||
@@ -138,12 +198,18 @@ namespace PrivaPub.Domain.Relationships
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var block in await DB.Default.Find<Block>().Match(b => b.AvatarId == avatarId && uris.Contains(b.TargetActorURI)).ExecuteAsync(token))
|
||||
hidden.Add(block.TargetActorURI);
|
||||
//an account that blocked the persona is hidden from it too, as Mastodon hides it from the blocked account's home
|
||||
foreach (var blocker in await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == avatarId && uris.Contains(b.ActorURI)).ExecuteAsync(token))
|
||||
hidden.Add(blocker.ActorURI);
|
||||
foreach (var mute in await DB.Default.Find<Mute>().Match(m => m.AvatarId == avatarId && uris.Contains(m.TargetActorURI)).ExecuteAsync(token))
|
||||
if ((mute.ExpiresAt == null || mute.ExpiresAt > now) && (!forNotifications || mute.HideNotifications))
|
||||
hidden.Add(mute.TargetActorURI);
|
||||
var domains = await DB.Default.Find<AccountDomainBlock>().Match(b => b.AvatarId == avatarId).ExecuteAsync(token);
|
||||
if (domains.Count == 0)
|
||||
return hidden;
|
||||
var handles = await HandleDomains(uris, token);
|
||||
foreach (var uri in uris)
|
||||
if (domains.Any(d => HostMatches(uri, d.Domain)))
|
||||
if (domains.Any(d => Blocks(d.Domain, uri, handles.GetValueOrDefault(uri))))
|
||||
hidden.Add(uri);
|
||||
return hidden;
|
||||
}
|
||||
@@ -157,11 +223,17 @@ namespace PrivaPub.Domain.Relationships
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var block in await DB.Default.Find<Block>().Match(b => ids.Contains(b.AvatarId) && b.TargetActorURI == actorUri).ExecuteAsync(token))
|
||||
hiding.Add(block.AvatarId);
|
||||
foreach (var blocked in await DB.Default.Find<BlockedBy>().Match(b => ids.Contains(b.AvatarId) && b.ActorURI == actorUri).ExecuteAsync(token))
|
||||
hiding.Add(blocked.AvatarId);
|
||||
foreach (var mute in await DB.Default.Find<Mute>().Match(m => ids.Contains(m.AvatarId) && m.TargetActorURI == actorUri).ExecuteAsync(token))
|
||||
if (mute.ExpiresAt == null || mute.ExpiresAt > now)
|
||||
hiding.Add(mute.AvatarId);
|
||||
foreach (var block in await DB.Default.Find<AccountDomainBlock>().Match(b => ids.Contains(b.AvatarId)).ExecuteAsync(token))
|
||||
if (HostMatches(actorUri, block.Domain))
|
||||
var domains = await DB.Default.Find<AccountDomainBlock>().Match(b => ids.Contains(b.AvatarId)).ExecuteAsync(token);
|
||||
if (domains.Count == 0)
|
||||
return hiding;
|
||||
var handle = (await HandleDomains([actorUri], token)).GetValueOrDefault(actorUri);
|
||||
foreach (var block in domains)
|
||||
if (Blocks(block.Domain, actorUri, handle))
|
||||
hiding.Add(block.AvatarId);
|
||||
return hiding;
|
||||
}
|
||||
|
||||
Loaded 100 of 449 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user