Compare commits

...
6 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 5f56681c01 Everything on, phase 1: geolocation fetches itself, the deploy signs in as @thepra, the crawler is on, sign-up by invitation
Build / Build (push) Successful in 5m1s
Deploy / privapub.thepra.dev (push) Successful in 5m39s
Owner decisions (2026-10-04, recorded in docs/ROADMAP.md): production runs everything that is built, and nothing waits
on a person running a command.

- Geolocation updates itself. GeoUpdater, a hosted service, checks daily whether each DB-IP Lite database was built this
  month. If not, it fetches this month's, or last month's early in the month. It installs a file only once it opens as
  the right kind of database, then swaps it in atomically, and the locator reloads at once. Lookups now run under the
  lock, so a reload can no longer dispose a reader mid-lookup. The systemd timer, its script and their setup.sh lines
  are gone: the root step they needed never happened, and none is needed now. /stargazing names the database in use.
- The admin CLI runs after the app is built, with every service and nothing started.
  - `create-root <login> [--admin]` takes the password on stdin; it is how the first login is made while sign-up is
    closed.
  - `smoke <persona>` keeps the root `deploy-smoke` and an undiscoverable persona, and gives the root a new password
    on every run.
- The deploy signs in as @thepra. It runs the CLI, gets a token through the real OAuth flow (tools/smoke/oauth.sh,
  moved out of the pasture's privapub_token, which now uses it), checks the signed-in API and that @thepra is
  undiscoverable, then revokes the token. PRIVAPUB_SMOKE_TOKEN is gone.
- The deploy also fails when:
  - NodeInfo and the instance API disagree about registrations;
  - /stargazing does not say the crawler is on;
  - the geolocation databases are missing or more than 40 days old.
- The crawler is on in production, seeded with ten large servers of different kinds. FEDERATION.md now describes it
  and how to opt out.
- One registrations switch (Registrations:Mode, default Invitations; Open in tests and the pasture). It is read by
  open sign-up (403 when closed), NodeInfo `openRegistrations`, and v1 and v2 of the instance API, so they can no longer
  disagree. Before, NodeInfo said open and the instance API said closed. Group invitations always work, so
  invites_enabled is true.
- A persona edit through /clientapi no longer resets what the Mastodon API set (discoverable, locked, quote policy…):
  the theme is merged into the settings instead of replacing them.

650 tests pass. The deploy's smoke step was rehearsed against the pasture's PrivaPub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-04 02:37:38 +02:00
thepraandClaude Opus 5.5 cd5eb25948 docs: v1.17.1 deployed and verified; what the T/M track still needs from the owner
Build / Build (push) Successful in 4m8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 15:35:14 +02:00
thepraandClaude Opus 5.5 ab526ed291 T12, T13: Sharkey and Akkoma in the pasture, and two bugs Akkoma found
Build / Build (push) Successful in 4m51s
Deploy / privapub.thepra.dev (push) Successful in 5m2s
Sharkey 2025.4.7 is the Misskey peer under another name, image, database, Redis db and
home. Its scenario is Misskey's plus edits both ways and the FEP-e232 quote tag: 40 checks
pass.

Akkoma 3.20.1 publishes no image, so tools/pasture/images/akkoma installs its OTP
release, pinned by checksum, and appends Caddy's CA to the CA bundles the release ships.
Its scenario has 44 checks, which pass three runs in a row:
- follows, posts, CW, followers-only posts and the published time;
- replies, likes, boosts and their undos;
- EmojiReact both ways and withdrawn;
- DMs, polls, quotes, media, edits with history and deletes, both ways;
- unfollow, block, unblock and statistics.

The two bugs, both fixed:
- Followers-only posts arrived as DMs on Pleroma and Akkoma. They call a post private
  only if an address in `to` contains "/followers" or its cc is not empty. Ours is
  /groupies, and a post mentioning nobody had an empty cc. The followers collection is now
  named in cc as well, which tells nobody anything new.
- Akkoma's open polls showed as ended and refused votes. Akkoma carries an open poll's
  end in `closed` and sends no `endTime`. A `closed` in the future is now read as the end.

Neither of these is a PrivaPub bug:
- Akkoma's Linkify never takes @user@host.test for a mention, so its DM addresses alice
  with to[].
- Its API never reports a remote blocker as blocked_by, so the block is read from its
  database.

Also in this commit:
- The rate limits are configuration (RateLimits: AccountsPerMinute, InboxBurst,
  InboxPerTenSeconds), with the old values as defaults. The pasture raises the accounts
  limit, which back-to-back runs from one address had hit.
- A new Lemmy never sends what it queued for a server before it started that server's
  send worker, so the scenario waits for the worker before its first follow.

All six peers in one clean pass: GoToSocial 54 (+1 expected), Mastodon 49 (+2), Misskey
35, Sharkey 40, Akkoma 44, and Lemmy 20 (+3) once the worker wait was added. 642 tests
pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 15:24:08 +02:00
thepraandClaude Opus 5.5 aa7e43a61e T14: Lemmy 1.0 in the pasture
peers/lemmy.sh runs the Lemmy 1.0.0-beta.2 backend on the shared Postgres. It trusts
Caddy's CA through SSL_CERT_FILE and reaches the pasture through
DANGER_FEDERATION_ALLOW_LOCAL_IP. Lemmy 0.19 cannot join: its rustls trusts only its
bundled roots. LEMMY_LOG sets RUST_LOG.

scenarios/lemmy.sh drives Lemmy through its v4 API. 20 checks pass, three runs in a row:
- communities both ways;
- a titled thread each way, and alice's mention of a Lemmy community becoming a thread
  there;
- the Lemmy community's announce reaching alice's home;
- comments both ways and alice's like as an upvote;
- private messages both ways;
- statistics.

Two expected failures: votes, which Lemmy sends only through the community as
Announce{Like|Dislike}, and a moderator's removal. Both belong to P7.

What it showed, in docs/INTEROP.md:
- Lemmy 1.0 keeps its user's thread in a remote community pending until the community
  announces it back. It clears the flag before answering that echo 400 ("Object is not
  remote"). Leaving the author's server out of the Announce, as tried here, left every
  such thread pending, so GroupDistributor now says why the echo stays.
- Every bare Announce{object} is answered 400, as Lemmy answers its own compatibility
  Announce(Page).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 14:24:15 +02:00
thepraandClaude Opus 5.5 e29da1b47a T12: Misskey 2026.10 in the pasture
peers/misskey.sh runs Misskey on the shared Postgres and Redis. Its config is generated with
the pasture's private networks allowed and a setup password, it trusts Caddy's CA through
NODE_EXTRA_CA_CERTS, and the first account it makes is the scenario's user. A new Misskey
federates with nobody, so the setup also turns federation on.

scenarios/misskey.sh adds 35 checks, all passing, as listed in docs/INTEROP.md. They cover
posts, CW, MFM source, replies, unicode reactions both ways and their withdrawal, likes as
reactions, legacy quotes both ways, polls, specified notes, media, deletes, unfollow,
block and statistics. MISSKEY_NAME and MISSKEY_IMAGE are there so Sharkey can reuse the peer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 13:29:17 +02:00
thepraandClaude Opus 5.5 d4e9acdb79 T10: what GoToSocial had not yet been asked, and quick edits that were lost
The GoToSocial scenario gains 17 checks:
- alice's reply threads under gtsuser's post;
- gtsuser's edit arrives with edited_at and two history entries;
- unlike and unboost both ways;
- images with alt text both ways, theirs through our proxy;
- gtsuser follows a PrivaPub community and its announce brings the post;
- gtsuser's request to join a circle waits for the owner and is approved, and the
  circle post is never served unsigned;
- a locked persona holds gtsuser's follow, rejects it, then authorizes it;
- the deploy's Mastodon smoke check passes, signed in.

54 checks passed, three runs in a row. The circle post reaching gtsuser is an expected
failure: GoToSocial keeps no post addressed only to a collection it does not know.

It found a bug. An edit made within the second the post was published carries GoToSocial's
whole-second updated == published, and IsEdit wanted strictly newer, so the edit was taken
as a refresh and lost. A first edit now also counts when it is no older and the text,
warning or title actually changed. A bare refresh still never makes a revision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 12:54:01 +02:00
59 changed files with 1800 additions and 179 deletions

No files matched your search

+35 -4
View File
@@ -90,8 +90,6 @@ jobs:
fi
- name: Verify what is being served
env:
SMOKE_TOKEN: ${{ secrets.PRIVAPUB_SMOKE_TOKEN }}
run: |
served=$(curl -fsS "$PUBLIC_URL/build.json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('commit',''))")
[ "$served" = "$BUILD_COMMIT" ] || { echo "::error::served build is '$served', expected '$BUILD_COMMIT'"; exit 1; }
@@ -106,6 +104,39 @@ jobs:
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' \
--data '{"type":"Follow","actor":"https://example.org/users/x","object":"'"$PUBLIC_URL"'/peasants/privapub"}' "$PUBLIC_URL/human-centipede")
[ "$code" = "401" ] || { echo "::error::an unsigned inbox POST answered $code"; exit 1; }
tools/smoke/mastodon-api.sh "$PUBLIC_URL" "$SMOKE_TOKEN"
[ -n "$SMOKE_TOKEN" ] || echo "::warning::PRIVAPUB_SMOKE_TOKEN is not set, so the signed-in API was not checked"
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"
# @thepra is the deploy's own persona (owner decision, 2026-10-04): the server's CLI makes or keeps it, undiscoverable,
# and gives its root a new password on every deploy, so no secret is stored and nobody has to create anything.
- name: Sign in as @thepra and check the signed-in API
run: |
creds=$(cd "$WEB_ROOT" && ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin smoke thepra | grep -E '^deploy-smoke [^ ]+$' | tail -1)
[ -n "$creds" ] || { echo "::error::the smoke persona could not be prepared"; exit 1; }
read -r login password <<< "$creds"
echo "::add-mask::$password"
read -r token cid cs <<< "$(tools/smoke/oauth.sh "$PUBLIC_URL" "$login" "$password" thepra read)"
echo "::add-mask::$token"
tools/smoke/mastodon-api.sh "$PUBLIC_URL" "$token"
discoverable=$(curl -fsS -H 'Accept: application/activity+json' "$PUBLIC_URL/peasants/thepra" | python3 -c "import json,sys; print(json.load(sys.stdin).get('discoverable'))")
[ "$discoverable" = "False" ] || { echo "::error::@thepra is discoverable"; exit 1; }
curl -fsS -o /dev/null -X POST "$PUBLIC_URL/oauth/revoke" --data-urlencode "token=$token" \
--data-urlencode "client_id=$cid" --data-urlencode "client_secret=$cs"
echo "::notice::signed in as @thepra"
# 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 /'
+46 -7
View File
@@ -136,18 +136,27 @@ curl-able. Outbound fetches only go to https DNS names resolving to public addre
`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):
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. Sign-up is closed in production (invitations only), so the first login is made here; 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 smoke <persona> # the deploy's: prints "deploy-smoke <new password>"
```
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).
3. **A remote document is believed only from its own address.** `RemoteActorService.FetchObject` requires the
@@ -281,13 +290,15 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
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** (`RemoteEdits.IsEdit`; plain and community-wrapped
- **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).
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`).
@@ -409,6 +420,27 @@ tools/pasture/run.sh down # removes e
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. 49 checks; circle posts are an expected failure (see `docs/INTEROP.md`, Mastodon).
- **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`. 44 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. 20 checks; relayed votes and a moderator's removal are expected failures (P7).
- **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
@@ -423,10 +455,17 @@ the owner's GoToSocial account.**
`deploy.yml`: tests, self-contained linux-x64 publish, snapshot and `mongodump` to `/var/backups/privapub.thepra.dev`,
stop → rsync → start, a `127.0.0.1:6970/build.json` health loop with rollback, then public checks (actor, NodeInfo,
Swagger 404, inbox junk 400, unsigned 401) and `tools/smoke/mastodon-api.sh` (app registration, client credentials,
discovery, instance, public timeline; pass a persona token as a second argument to check the signed-in side).
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;
- **@thepra signs in**: `PrivaPub admin smoke thepra` gives the root `deploy-smoke` a new password, `tools/smoke/oauth.sh`
runs the real OAuth flow (the pasture's `privapub_token` uses the same script), the signed-in API is checked, the
persona must be undiscoverable, and the token is revoked;
- 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
+16
View File
@@ -162,6 +162,22 @@ The page's OpenGraph and Twitter tags give the title, description and image; the
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.
## 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.
+2
View File
@@ -65,6 +65,8 @@ namespace PrivaPub.Tests.Domain
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"));
// Pleroma reads an empty cc without a "/followers" address as a direct message
Assert.Contains(sent[0]["object"]!["cc"]!.AsArray(), n => n!.GetValue<string>() == alice.Followers);
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());
@@ -190,7 +190,7 @@ namespace PrivaPub.Tests.Federation
}
[Fact]
public void Reads_polls_from_mastodon_misskey_and_closed_ones()
public void Reads_polls_from_mastodon_misskey_akkoma_and_closed_ones()
{
var mastodon = Parse("""
{ "id": "https://m.example/q/1", "type": "Question", "attributedTo": "https://m.example/u", "content": "?", "endTime": "2026-10-02T00:00:00Z", "votersCount": 7,
@@ -204,6 +204,10 @@ namespace PrivaPub.Tests.Federation
{ "id": "https://g.example/q/1", "type": "Question", "attributedTo": "https://g.example/u", "content": "?", "closed": "2026-09-30T12:00:00Z",
"oneOf": [{ "type": "Note", "name": "p" }, { "type": "Note", "name": "q" }] }
""");
var akkoma = Parse("""
{ "id": "https://a.example/objects/1", "type": "Question", "attributedTo": "https://a.example/users/u", "content": "?", "closed": "2099-01-01T00:00:00.000000Z",
"oneOf": [{ "type": "Note", "name": "p" }, { "type": "Note", "name": "q" }] }
""");
Assert.False(mastodon.Poll.Multiple);
Assert.Equal(new[] { 4, 3 }, mastodon.Poll.Options.Select(o => o.Votes));
@@ -212,6 +216,8 @@ namespace PrivaPub.Tests.Federation
Assert.Equal(new[] { 2, 5 }, misskey.Poll.Options.Select(o => o.Votes));
Assert.Null(misskey.Poll.VotersCount);
Assert.Equal(new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), closed.Poll.ClosedAt);
Assert.Null(akkoma.Poll.ClosedAt);
Assert.Equal(new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc), akkoma.Poll.ExpiresAt);
}
[Fact]
@@ -0,0 +1,38 @@
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
namespace PrivaPub.Tests.Federation
{
public class RemoteEditsTests
{
static readonly DateTime Published = new(2026, 10, 3, 10, 40, 1, DateTimeKind.Utc);
static Post Stored(DateTime? editedAt = default) => new() { CreationDate = Published, EditedAt = editedAt, ContentHtml = "<p>before</p>" };
[Fact]
public void A_newer_update_is_an_edit_and_an_update_without_a_date_is_a_refresh()
{
Assert.True(RemoteEdits.IsEdit(new NoteDocument { Updated = Published.AddSeconds(1), ContentHtml = "<p>before</p>" }, Stored()));
Assert.False(RemoteEdits.IsEdit(new NoteDocument { ContentHtml = "<p>after</p>" }, Stored()));
Assert.False(RemoteEdits.IsEdit(new NoteDocument { Updated = Published.AddSeconds(-1), ContentHtml = "<p>after</p>" }, Stored()));
}
[Fact]
public void An_edit_in_the_second_the_post_was_published_counts_when_the_text_changed()
{
Assert.True(RemoteEdits.IsEdit(new NoteDocument { Updated = Published, ContentHtml = "<p>after</p>" }, Stored()));
Assert.True(RemoteEdits.IsEdit(new NoteDocument { Updated = Published, ContentHtml = "<p>before</p>", SpoilerText = "now with a warning" }, Stored()));
Assert.False(RemoteEdits.IsEdit(new NoteDocument { Updated = Published, ContentHtml = "<p>before</p>" }, Stored()));
}
[Fact]
public void After_a_first_edit_only_a_newer_one_counts()
{
var edited = Published.AddMinutes(1);
Assert.False(RemoteEdits.IsEdit(new NoteDocument { Updated = edited, ContentHtml = "<p>again</p>" }, Stored(edited)));
Assert.True(RemoteEdits.IsEdit(new NoteDocument { Updated = edited.AddSeconds(1), ContentHtml = "<p>again</p>" }, Stored(edited)));
}
}
}
@@ -154,6 +154,34 @@ namespace PrivaPub.Tests.Http
Assert.DoesNotContain(persona.Root.UserName, update.ToJsonString());
}
[Fact]
public async Task A_theme_change_here_keeps_what_the_mastodon_api_set()
{
var persona = await _host.Persona(await _host.SignUp("keep"), "keep");
await DB.Default.Update<Avatar>().MatchID(persona.Id)
.Modify(a => a.Settings.IsDiscoverable, false)
.Modify(a => a.Settings.IsLocked, true)
.Modify(a => a.Settings.QuotePolicy, QuotePolicies.Nobody)
.ExecuteAsync(TestContext.Current.CancellationToken);
using var client = _host.As(persona.Root.Jwt);
var response = await client.PostJson("/clientapi/avatar/private/update", new
{
avatarId = persona.Id,
name = "Kept",
biography = "same settings",
settings = new { isDefault = false, darkThemeIndexColour = 100, themeIsDarkMode = true }
});
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var settings = (await DB.Default.Find<Avatar>().MatchID(persona.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken)).Settings;
Assert.True(settings.ThemeIsDarkMode);
Assert.Equal(100, settings.DarkThemeIndexColour);
Assert.False(settings.IsDiscoverable);
Assert.True(settings.IsLocked);
Assert.Equal(QuotePolicies.Nobody, settings.QuotePolicy);
}
[Fact]
public async Task Updating_another_roots_persona_is_refused()
{
+4 -2
View File
@@ -28,7 +28,8 @@ namespace PrivaPub.Tests.Http
Assert.Equal("4.2.0 (compatible; PrivaPub)", v1.Body.Text("version"));
Assert.Equal(InstanceController.Version, v1.Body.Text("version"));
Assert.Equal(PrivaPubHost.Host, v1.Body.Text("uri"));
Assert.False(v1.Body.Flag("registrations"));
Assert.True(v1.Body.Flag("registrations"));
Assert.True(v1.Body.Flag("invites_enabled"));
Assert.True(v1.Body["stats"].Number("user_count") >= 0);
Assert.Equal(5000, v1.Body["configuration"]!["statuses"].Number("max_characters"));
Assert.Equal(4, v1.Body["configuration"]!["polls"].Number("max_options"));
@@ -41,7 +42,8 @@ namespace PrivaPub.Tests.Http
Assert.Equal(InstanceController.Version, v2.Body.Text("version"));
Assert.Equal(PrivaPubHost.Host, v2.Body.Text("domain"));
Assert.Equal($"wss://{PrivaPubHost.Host}", v2.Body["configuration"]!["urls"].Text("streaming"));
Assert.False(v2.Body["registrations"].Flag("enabled"));
Assert.True(v2.Body["registrations"].Flag("enabled"));
Assert.True((await anonymous.Get("/nodeinfo/2.1")).Ok().Body.Flag("openRegistrations"));
Assert.False(v2.Body["configuration"]!["translation"].Flag("enabled"));
Assert.Equal(7, v2.Body["api_versions"].Number("mastodon"));
+2 -1
View File
@@ -60,7 +60,8 @@ namespace PrivaPub.Tests.Http
Assert.Equal(new[] { MastodonHelpers.Public }, Strings(unlistedNote["cc"]));
var quietNote = creates[quiet.Text("uri")]["object"];
Assert.Equal(new[] { followers }, Strings(quietNote["to"]));
Assert.Empty(quietNote["cc"]!.AsArray());
// named again in cc: Pleroma and Akkoma read an empty cc without a "/followers" address as a direct message
Assert.Equal(new[] { followers }, Strings(quietNote["cc"]));
var whisper = Assert.Single(await carol.Delivered(since));
Assert.Equal(direct.Text("uri"), whisper["object"].Text("id"));
Assert.Equal(new[] { carol.Id }, Strings(whisper["object"]!["to"]));
+50
View File
@@ -0,0 +1,50 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using PrivaPub.Infrastructure;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
namespace PrivaPub.Tests.Http
{
[Trait("Category", "Integration")]
public sealed class RegistrationsTests : IAsyncLifetime
{
WebApplicationFactory<Program> _closed;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var shared = await PrivaPubHost.Shared();
_closed = shared.WithWebHostBuilder(builder => builder.UseSetting("Registrations:Mode", nameof(RegistrationMode.Invitations)));
}
public async ValueTask DisposeAsync()
{
if (_closed != default)
await _closed.DisposeAsync();
}
[Fact]
public async Task Invitations_only_refuses_sign_up_and_every_document_says_closed()
{
using var client = _closed.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = new Uri(PrivaPubHost.Base + "/"), AllowAutoRedirect = false });
client.DefaultRequestHeaders.Add(PrivaPubHost.ClientHeader, $"10.9.{Random.Shared.Next(256)}.{Random.Shared.Next(1, 255)}");
var signUp = await client.PostJson("/clientapi/user/signup", new { userName = $"closed{Guid.NewGuid():N}"[..20], password = "Closed-Door-Pass-1" });
var nodeInfo = (await client.Get("/nodeinfo/2.1")).Ok().Body;
var v1 = (await client.Get("/api/v1/instance")).Ok().Body;
var v2 = (await client.Get("/api/v2/instance")).Ok().Body;
Assert.Equal(HttpStatusCode.Forbidden, signUp.StatusCode);
Assert.Contains("invitation", await signUp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
Assert.False(nodeInfo.Flag("openRegistrations"));
Assert.False(v1.Flag("registrations"));
Assert.True(v1.Flag("invites_enabled"));
Assert.False(v2["registrations"].Flag("enabled"));
Assert.Equal(RegistrationOptions.ClosedMessage, v2["registrations"].Text("message"));
}
}
}
@@ -1,3 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Entities;
using PrivaPub.ClientModels;
@@ -37,8 +39,8 @@ namespace PrivaPub.Tests.Infrastructure
{
var root = await _host.SignUp("promote");
Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName.ToUpperInvariant() }));
Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName }));
Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName.ToUpperInvariant() }, _host.Services));
Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName }, _host.Services));
Assert.Equal(new[] { Policies.IsAdmin, Policies.IsModerator, Policies.IsUser }.Order(), await StoredPolicies(root));
}
@@ -47,10 +49,10 @@ namespace PrivaPub.Tests.Infrastructure
public async Task Demote_leaves_a_plain_user()
{
var root = await _host.SignUp("demote");
Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName }));
Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName }, _host.Services));
Assert.Equal(0, await AdminCommands.Run(new[] { "demote", root.UserName }));
Assert.Equal(0, await AdminCommands.Run(new[] { "demote", root.UserName }));
Assert.Equal(0, await AdminCommands.Run(new[] { "demote", root.UserName }, _host.Services));
Assert.Equal(0, await AdminCommands.Run(new[] { "demote", root.UserName }, _host.Services));
Assert.Equal(new[] { Policies.IsUser }, await StoredPolicies(root));
}
@@ -58,8 +60,8 @@ namespace PrivaPub.Tests.Infrastructure
[Fact]
public async Task An_unknown_root_exits_1()
{
Assert.Equal(1, await AdminCommands.Run(new[] { "promote", $"nobody{Guid.NewGuid():N}" }));
Assert.Equal(1, await AdminCommands.Run(new[] { "demote", $"nobody{Guid.NewGuid():N}" }));
Assert.Equal(1, await AdminCommands.Run(new[] { "promote", $"nobody{Guid.NewGuid():N}" }, _host.Services));
Assert.Equal(1, await AdminCommands.Run(new[] { "demote", $"nobody{Guid.NewGuid():N}" }, _host.Services));
}
public static TheoryData<string[]> Misuses() => new()
@@ -75,7 +77,7 @@ namespace PrivaPub.Tests.Infrastructure
[MemberData(nameof(Misuses))]
public async Task Misuse_exits_2(string[] args)
{
Assert.Equal(2, await AdminCommands.Run(args));
Assert.Equal(2, await AdminCommands.Run(args, _host.Services));
}
[Fact]
@@ -84,7 +86,7 @@ namespace PrivaPub.Tests.Infrastructure
var root = await _host.SignUp("policies");
Assert.Equal(HttpStatusCode.Forbidden, await AdminRoute(root));
Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName }));
Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName }, _host.Services));
Assert.Equal(HttpStatusCode.OK, await AdminRoute(root));
using (var client = _host.As(root.Jwt))
{
@@ -92,8 +94,54 @@ namespace PrivaPub.Tests.Infrastructure
Assert.Contains(Policies.IsAdmin, refreshed["policies"]!.AsArray().Select(p => p!.GetValue<string>()));
}
Assert.Equal(0, await AdminCommands.Run(new[] { "demote", root.UserName }));
Assert.Equal(0, await AdminCommands.Run(new[] { "demote", root.UserName }, _host.Services));
Assert.Equal(HttpStatusCode.Forbidden, await AdminRoute(root));
}
async Task<HttpStatusCode> LogIn(string login, string password)
{
using var client = _host.Client();
return (await client.PostJson("/clientapi/user/login", new { userName = login, password })).StatusCode;
}
[Fact]
public async Task Create_root_makes_a_login_from_a_password_on_standard_input()
{
using var scope = _host.Services.CreateScope();
var login = $"made{Guid.NewGuid():N}"[..20];
var output = new StringWriter();
Assert.Equal(0, await AdminCommands.Run(new[] { "create-root", login, "--admin" }, scope.ServiceProvider, new StringReader("Typed-On-Stdin-1\n"), output));
Assert.Equal(1, await AdminCommands.Run(new[] { "create-root", login }, scope.ServiceProvider, new StringReader("Typed-On-Stdin-2\n"), new StringWriter()));
Assert.Equal(1, await AdminCommands.Run(new[] { "create-root", $"weak{Guid.NewGuid():N}"[..20] }, scope.ServiceProvider, new StringReader("short\n"), new StringWriter()));
Assert.Equal(2, await AdminCommands.Run(new[] { "create-root", login, "--root" }, scope.ServiceProvider, new StringReader("Typed-On-Stdin-3\n"), new StringWriter()));
Assert.Equal(HttpStatusCode.OK, await LogIn(login, "Typed-On-Stdin-1"));
var stored = await DB.Default.Find<RootUser>().Match(u => u.UserName == login).ExecuteFirstAsync(TestContext.Current.CancellationToken);
Assert.Contains(Policies.IsAdmin, stored.Policies);
}
[Fact]
public async Task Smoke_keeps_one_undiscoverable_persona_and_a_new_password_each_run()
{
using var scope = _host.Services.CreateScope();
var persona = $"smoke{Guid.NewGuid():N}"[..20];
var first = new StringWriter();
var second = new StringWriter();
Assert.Equal(0, await AdminCommands.Run(new[] { "smoke", persona }, scope.ServiceProvider, output: first));
Assert.Equal(0, await AdminCommands.Run(new[] { "smoke", persona }, scope.ServiceProvider, output: second));
var (login, oldPassword) = (first.ToString().Trim().Split(' ')[0], first.ToString().Trim().Split(' ')[1]);
var newPassword = second.ToString().Trim().Split(' ')[1];
Assert.Equal(AdminCommands.SmokeLogin, login);
Assert.NotEqual(oldPassword, newPassword);
Assert.Equal(HttpStatusCode.OK, await LogIn(login, newPassword));
Assert.NotEqual(HttpStatusCode.OK, await LogIn(login, oldPassword));
var avatars = await DB.Default.Find<Avatar>().Match(a => a.UserName == persona).ExecuteAsync(TestContext.Current.CancellationToken);
var avatar = Assert.Single(avatars);
Assert.False(avatar.Settings.IsDiscoverable);
Assert.False(avatar.Settings.IsIndexable);
}
}
}
@@ -0,0 +1,125 @@
using MaxMind.Db;
using Microsoft.Extensions.Logging.Abstractions;
using PrivaPub.Infrastructure.Geo;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Tests.Support;
using System.IO.Compression;
namespace PrivaPub.Tests.Infrastructure
{
public sealed class GeoUpdaterTests : IAsyncLifetime
{
static readonly DateTime Now = new(2026, 10, 4, 12, 0, 0, DateTimeKind.Utc);
static readonly DateTime LastMonth = new(2026, 9, 2, 0, 0, 0, DateTimeKind.Utc);
readonly string _directory = Path.Combine(Path.GetTempPath(), $"privapub-geo-{Guid.NewGuid():N}");
Peer _peer;
GeoUpdater _updater;
DbIpLocator _locator;
public async ValueTask InitializeAsync()
{
_peer = await Peer.Start();
var options = new StaticOptions<StatisticsOptions>(new StatisticsOptions
{
GeoDirectory = _directory,
Geo = new GeoOptions { DownloadBase = $"{_peer.A}/free" }
});
_locator = new DbIpLocator(options, NullLogger<DbIpLocator>.Instance);
_updater = new GeoUpdater(new Clients(), options, _locator, NullLogger<GeoUpdater>.Instance);
}
public async ValueTask DisposeAsync()
{
_locator?.Dispose();
await _peer.DisposeAsync();
if (Directory.Exists(_directory))
Directory.Delete(_directory, recursive: true);
}
sealed class Clients : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new();
}
static byte[] Gzip(byte[] bytes)
{
using var output = new MemoryStream();
using (var gzip = new GZipStream(output, CompressionLevel.Fastest))
gzip.Write(bytes);
return output.ToArray();
}
void Publish(string kind, DateTime month, byte[] database) =>
_peer.ServeFile($"/free/dbip-{kind}-lite-{month:yyyy-MM}.mmdb.gz", Gzip(database), "application/gzip");
static byte[] City(DateTime built) => MiniMmdb.Build("DBIP-City-Lite", built);
static byte[] Asn(DateTime built) => MiniMmdb.Build("DBIP-ASN-Lite (compat=GeoLite2-ASN)", built);
DateTime Built(string kind)
{
using var reader = new Reader(Path.Combine(_directory, GeoUpdater.FileOf(kind)));
return reader.Metadata.BuildDate;
}
[Fact]
public async Task Missing_databases_are_fetched_checked_and_read_at_once()
{
Publish("city", Now, City(Now));
Publish("asn", Now, Asn(Now));
Assert.Equal(2, await _updater.Update(Now, TestContext.Current.CancellationToken));
Assert.Equal(Now.Date, Built("city").Date);
Assert.Equal(Now.Date, Built("asn").Date);
Assert.Equal("DB-IP Lite 2026-10", _locator.Source);
Assert.Empty(Directory.GetFiles(_directory).Where(f => f.EndsWith(".part") || f.EndsWith(".new")));
}
[Fact]
public async Task Early_in_the_month_last_months_database_is_used_and_not_fetched_again()
{
Publish("city", LastMonth, City(LastMonth));
Publish("asn", LastMonth, Asn(LastMonth));
Assert.Equal(2, await _updater.Update(Now, TestContext.Current.CancellationToken));
var asked = _peer.Requests.Count;
Assert.Equal(0, await _updater.Update(Now, TestContext.Current.CancellationToken));
Assert.Equal(LastMonth.Date, Built("city").Date);
Assert.DoesNotContain(_peer.Requests.Skip(asked), r => r.Path.Contains("2026-09"));
}
[Fact]
public async Task A_current_database_asks_nothing()
{
Publish("city", Now, City(Now));
Publish("asn", Now, Asn(Now));
await _updater.Update(Now, TestContext.Current.CancellationToken);
var asked = _peer.Requests.Count;
Assert.Equal(0, await _updater.Update(Now.AddDays(1), TestContext.Current.CancellationToken));
Assert.Equal(asked, _peer.Requests.Count);
}
[Fact]
public async Task A_broken_or_wrong_database_is_refused_and_the_old_one_kept()
{
Publish("city", LastMonth, City(LastMonth));
Publish("asn", LastMonth, Asn(LastMonth));
await _updater.Update(LastMonth.AddDays(1), TestContext.Current.CancellationToken);
Publish("city", Now, "not a database at all"u8.ToArray());
Publish("asn", Now, City(Now));
Assert.Equal(0, await _updater.Update(Now, TestContext.Current.CancellationToken));
Assert.Equal(LastMonth.Date, Built("city").Date);
Assert.Equal(LastMonth.Date, Built("asn").Date);
Assert.Empty(Directory.GetFiles(_directory).Where(f => f.EndsWith(".part") || f.EndsWith(".new")));
}
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ namespace PrivaPub.Tests.Support.Host
public static async Task<Root> Admin(this PrivaPubHost host)
{
var root = await host.SignUp("admin");
Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName }));
Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName }, host.Services));
return await host.LogIn(root);
}
@@ -73,6 +73,8 @@ namespace PrivaPub.Tests.Support.Host
["Federation:AllowPrivateNetworks"] = "true",
["Federation:AllowPlainHttp"] = "true",
["Federation:FetchLinkPreviews"] = "false",
["Registrations:Mode"] = "Open",
["Statistics:Geo:AutoUpdate"] = "false",
["Media:Root"] = _mediaRoot,
["Logging:LogLevel:Default"] = "Warning",
["Serilog:MinimumLevel:Default"] = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_LOGS") == "1" ? "Information" : "Fatal"
+79
View File
@@ -0,0 +1,79 @@
using System.Text;
namespace PrivaPub.Tests.Support
{
// The smallest MaxMind DB the reader accepts (https://maxmind.github.io/MaxMind-DB/): an IPv4 search tree of one node
// whose two records both mean "no data", an empty data section, and the metadata map, which is all a test of the updater
// needs to tell a city database from an ASN one and to read its build date.
public static class MiniMmdb
{
public static byte[] Build(string databaseType, DateTime built)
{
using var output = new MemoryStream();
const uint nodeCount = 1;
for (var record = 0; record < 2; record++)
output.Write(new byte[] { 0, 0, (byte)nodeCount });//24-bit records equal to node_count: not found
output.Write(new byte[16]);//data section separator; the data section itself is empty
output.Write(new byte[] { 0xAB, 0xCD, 0xEF });
output.Write(Encoding.ASCII.GetBytes("MaxMind.com"));
Map(output,
("binary_format_major_version", w => UInt16(w, 2)),
("binary_format_minor_version", w => UInt16(w, 0)),
("build_epoch", w => UInt64(w, (ulong)new DateTimeOffset(built).ToUnixTimeSeconds())),
("database_type", w => Text(w, databaseType)),
("description", w => Map(w, ("en", x => Text(x, "a test database")))),
("ip_version", w => UInt16(w, 4)),
("languages", w => Array(w, x => Text(x, "en"))),
("node_count", w => UInt32(w, nodeCount)),
("record_size", w => UInt16(w, 24)));
return output.ToArray();
}
// type in the top three bits (0 and an extra byte for the extended types), size below 29 in the low five, or 29 and the
// rest in one more byte (names longer than 28 characters need it)
static void Control(Stream output, int type, int size)
{
var low = size < 29 ? size : 29;
output.WriteByte((byte)((type <= 7 ? type : 0) << 5 | low));
if (type > 7)
output.WriteByte((byte)(type - 7));
if (size >= 29)
output.WriteByte((byte)(size - 29));
}
static void Text(Stream output, string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
Control(output, 2, bytes.Length);
output.Write(bytes);
}
static void Unsigned(Stream output, int type, ulong value)
{
var bytes = BitConverter.GetBytes(value).Reverse().SkipWhile(b => b == 0).ToArray();
Control(output, type, bytes.Length);
output.Write(bytes);
}
static void UInt16(Stream output, ushort value) => Unsigned(output, 5, value);
static void UInt32(Stream output, uint value) => Unsigned(output, 6, value);
static void UInt64(Stream output, ulong value) => Unsigned(output, 9, value);
static void Map(Stream output, params (string Key, Action<Stream> Value)[] entries)
{
Control(output, 7, entries.Length);
foreach (var (key, value) in entries)
{
Text(output, key);
value(output);
}
}
static void Array(Stream output, params Action<Stream>[] items)
{
Control(output, 11, items.Length);
foreach (var item in items)
item(output);
}
}
}
@@ -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,12 +20,16 @@ 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;
string Domain => new Uri(_localActors.BaseAddress).Authority;
static object Statuses => new { max_characters = 5000, max_media_attachments = 4, characters_reserved_per_url = 23 };
@@ -58,9 +64,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
stats = new { user_count = users, status_count = statuses, domain_count = domains },
thumbnail = $"{_localActors.BaseAddress}/media/missing-header.png",
languages = new[] { "en" },
registrations = false,
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>()
@@ -88,7 +94,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
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>()
});
@@ -34,6 +34,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 +44,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 +66,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
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using MongoDB.Entities;
@@ -12,6 +13,7 @@ using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Actors;
using PrivaPub.Infrastructure;
namespace PrivaPub.Federation.Controllers
{
@@ -20,9 +22,11 @@ namespace PrivaPub.Federation.Controllers
{
readonly ILocalActorService _localActors;
readonly DbEntities _dbEntities;
readonly IOptionsMonitor<RegistrationOptions> _registrations;
public WellKnownController(ILocalActorService localActors, DbEntities dbEntities)
public WellKnownController(ILocalActorService localActors, DbEntities dbEntities, IOptionsMonitor<RegistrationOptions> registrations)
{
_registrations = registrations;
_localActors = localActors;
_dbEntities = dbEntities;
}
@@ -102,7 +106,7 @@ namespace PrivaPub.Federation.Controllers
["software"] = software,
["protocols"] = new JsonArray("activitypub"),
["services"] = new JsonObject { ["inbound"] = new JsonArray(), ["outbound"] = new JsonArray() },
["openRegistrations"] = true,
["openRegistrations"] = _registrations.CurrentValue.IsOpen,
["usage"] = new JsonObject
{
["users"] = new JsonObject { ["total"] = users },
+8 -1
View File
@@ -15,8 +15,15 @@ namespace PrivaPub.Federation.Inbox
{
public const int MaxRevisions = 20;
//newer than what we hold; or, for a first edit, no older and actually different, because GoToSocial's timestamps are
//whole seconds and an edit made in the second the post was published carries updated == published
public static bool IsEdit(NoteDocument note, PostEntity post) =>
note.Updated is { } updated && updated > (post.EditedAt ?? post.CreationDate);
note.Updated is { } updated
&& (updated > (post.EditedAt ?? post.CreationDate)
|| post.EditedAt == default && updated >= post.CreationDate && Changed(note, post));
static bool Changed(NoteDocument note, PostEntity post) =>
note.ContentHtml != post.ContentHtml || note.SpoilerText != post.SpoilerText || note.Title != post.Title || note.Sensitive != post.HasContentWarning;
public static async Task<bool> Apply(PostEntity post, NoteDocument note, string activityId, ILocalActorService localActors, IObjectRecords records,
IQuoteService quotes, CancellationToken token)
+8 -1
View File
@@ -137,11 +137,18 @@ namespace PrivaPub.Federation.Objects
JsonValue value when value.TryGetValue<string>(out var at) => Moment(at),
_ => default
};
var expiresAt = Moment(Value(note, "endTime"));
// Pleroma and Akkoma put an open poll's end in `closed`, often without `endTime`: it is closed once that has passed
if (closedAt > DateTime.UtcNow)
{
expiresAt ??= closedAt;
closedAt = default;
}
return new PostPoll
{
Options = options,
Multiple = multiple,
ExpiresAt = Moment(Value(note, "endTime")),
ExpiresAt = expiresAt,
ClosedAt = closedAt,
VotersCount = Int(note, "votersCount")
};
@@ -40,6 +40,8 @@ namespace PrivaPub.Federation.Outbox
["audience"] = group.Uri,
["object"] = embedded
};
// Every follower, the author's own server included: Lemmy 1.0 keeps its user's post pending until the community
// announces it back, and clears that before it answers the echo 400 ("Object is not remote").
await _delivery.EnqueueToFollowers(group, announce, token);
if (isNewPost && !string.IsNullOrEmpty(objectUri))
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, objectUri, $"announce-object-{key}"), token);
@@ -138,7 +138,9 @@ namespace PrivaPub.Federation.Rendering
{
PostVisibility.Circle when group != default => (new JsonArray(group.Uri, group.Flock), new JsonArray(mentions)),
PostVisibility.Unlisted => (new JsonArray(author.Followers), new JsonArray(mentions.Prepend(Public).ToArray())),
PostVisibility.FollowersOnly => (new JsonArray(author.Followers), new JsonArray(mentions)),
// Pleroma and Akkoma call a post private only if a `to` contains "/followers" or its cc is not empty, and ours
// are /groupies: with an empty cc every followers-only post would be a DM there. Naming them again says nothing new.
PostVisibility.FollowersOnly => (new JsonArray(author.Followers), new JsonArray(mentions.Append(author.Followers).ToArray())),
PostVisibility.Direct => (new JsonArray(mentions), new JsonArray()),
_ => (new JsonArray(Public), new JsonArray(mentions.Prepend(author.Followers).ToArray()))
};
+112 -5
View File
@@ -1,22 +1,49 @@
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.User;
using PrivaPub.ClientModels.User.Avatar;
using PrivaPub.Models.User;
using PrivaPub.Services;
using PrivaPub.Services.ClientToServer.Private;
using PrivaPub.StaticServices;
using System.ComponentModel.DataAnnotations;
using System.Security.Cryptography;
namespace PrivaPub.Infrastructure.Cli
{
// `PrivaPub admin ...` runs with the whole server built but not started: no Kestrel, no hosted services.
public static class AdminCommands
{
const string Usage = "usage: PrivaPub admin promote|demote <root username>";
public const string SmokeLogin = "deploy-smoke";
public static async Task<int> Run(string[] args)
const string Usage = """
usage: PrivaPub admin promote|demote <root>
PrivaPub admin create-root <login> [--admin] the password is read from standard input
PrivaPub admin smoke <persona> prints "<login> <password>" for the deploy's signed-in check
""";
public static async Task<int> Run(string[] args, IServiceProvider services, TextReader input = default, TextWriter output = default)
{
if (args is not [("promote" or "demote") and var verb, var userName])
input ??= Console.In;
output ??= Console.Out;
switch (args)
{
case [("promote" or "demote") and var verb, var userName]:
return await Promote(verb == "promote", userName, output);
case ["create-root", var login, .. var flags] when flags.All(f => f == "--admin"):
return await CreateRoot(services, login, input.ReadLine(), flags.Contains("--admin"), output);
case ["smoke", var persona]:
return await Smoke(services, persona, output);
default:
Console.Error.WriteLine(Usage);
return 2;
}
}
static async Task<int> Promote(bool promote, string userName, TextWriter output)
{
userName = userName.ToLowerInvariant();
var user = await DB.Default.Find<RootUser>().Match(u => u.UserName == userName).ExecuteFirstAsync();
if (user == default)
@@ -26,14 +53,94 @@ namespace PrivaPub.Infrastructure.Cli
}
user.Policies.RemoveAll(p => p is Policies.IsAdmin or Policies.IsModerator);
if (verb == "promote")
if (promote)
user.Policies.AddRange(new[] { Policies.IsAdmin, Policies.IsModerator });
if (!user.Policies.Contains(Policies.IsUser))
user.Policies.Add(Policies.IsUser);
user.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(user);
Console.WriteLine($"{userName}: {string.Join(", ", user.Policies)}");
output.WriteLine($"{userName}: {string.Join(", ", user.Policies)}");
return 0;
}
// With sign-up closed this is how the first root is made; group invitations make the rest.
static async Task<int> CreateRoot(IServiceProvider services, string login, string password, bool admin, TextWriter output)
{
var form = new LoginForm { UserName = login, Password = password?.Trim() };
var problems = new List<ValidationResult>();
if (!Validator.TryValidateObject(form, new ValidationContext(form), problems, validateAllProperties: true))
{
Console.Error.WriteLine(string.Join(Environment.NewLine, problems.Select(p => p.ErrorMessage)));
return 1;
}
var created = await services.GetRequiredService<IRootUsersService>().SignUpAsync(form);
if (!created.IsValid)
{
Console.Error.WriteLine(created.ErrorMessage);
return 1;
}
return admin ? await Promote(true, login, output) : await Report(login, output);
}
static Task<int> Report(string login, TextWriter output)
{
output.WriteLine($"{login.ToLowerInvariant()}: created");
return Task.FromResult(0);
}
// The deploy's signed-in smoke check: a root nobody signs in to by hand, owning one undiscoverable persona. Every run
// sets a new password and prints it, so no secret is kept anywhere and nothing waits on a person.
static async Task<int> Smoke(IServiceProvider services, string personaName, TextWriter output)
{
personaName = personaName.ToLowerInvariant();
var password = "Smoke-x" + Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(24));
var root = await DB.Default.Find<RootUser>().Match(u => u.UserName == SmokeLogin).ExecuteFirstAsync();
if (root == default)
{
var created = await services.GetRequiredService<IRootUsersService>().SignUpAsync(new LoginForm { UserName = SmokeLogin, Password = password });
if (!created.IsValid)
{
Console.Error.WriteLine(created.ErrorMessage);
return 1;
}
root = await DB.Default.Find<RootUser>().Match(u => u.UserName == SmokeLogin).ExecuteFirstAsync();
}
else if (root.DeletedAt.HasValue || root.IsBanned)
{
Console.Error.WriteLine($"the root '{SmokeLogin}' is deleted or banned");
return 1;
}
else
await DB.Default.Update<RootUser>().MatchID(root.ID)
.Modify(u => u.HashedPassword, services.GetRequiredService<IPasswordHasher>().Hash(password))
.Modify(u => u.UpdatedAt, DateTime.UtcNow)
.ExecuteAsync();
var owned = (await DB.Default.Find<RootToAvatar>().Match(ra => ra.RootId == root.ID).ExecuteAsync()).Select(ra => ra.AvatarId).ToList();
var persona = await DB.Default.Find<Avatar>().Match(a => owned.Contains(a.ID) && a.UserName == personaName && !a.DeletionAt.HasValue).ExecuteFirstAsync();
if (persona == default)
{
var inserted = await services.GetRequiredService<IPrivateAvatarUsersService>().InsertAvatar(new InsertAvatarForm
{
RootId = root.ID,
UserName = personaName,
Name = personaName,
Biography = "The deploy signs in here to check that the Mastodon API works for a signed-in persona."
});
if (!inserted.IsValid)
{
Console.Error.WriteLine(inserted.ErrorMessage);
return 1;
}
persona = await DB.Default.Find<Avatar>().MatchID(((ViewAvatar)inserted.Data).Id).ExecuteFirstAsync();
}
await DB.Default.Update<Avatar>().MatchID(persona.ID)
.Modify(a => a.Settings.IsDiscoverable, false)
.Modify(a => a.Settings.IsIndexable, false)
.ExecuteAsync();
output.WriteLine($"{SmokeLogin} {password}");
return 0;
}
}
+19 -12
View File
@@ -25,8 +25,8 @@ namespace PrivaPub.Infrastructure.Geo
public sealed class DbIpLocator : IGeoLocator, IDisposable
{
public const string CityFile = "dbip-city-lite.mmdb";
public const string AsnFile = "dbip-asn-lite.mmdb";
public static readonly string CityFile = GeoUpdater.FileOf("city");
public static readonly string AsnFile = GeoUpdater.FileOf("asn");
static readonly TimeSpan CheckInterval = TimeSpan.FromMinutes(10);
readonly IOptionsMonitor<StatisticsOptions> _options;
@@ -60,18 +60,17 @@ namespace PrivaPub.Infrastructure.Geo
if (address == default || !IpRangeGuard.IsPublic(address))
return default;
Refresh();
Reader city, asn;
lock (_lock)
{
city = _city;
asn = _asn;
}
if (city == default && asn == default)
return default;
Dictionary<string, object> place, network;
try
{
var place = city?.Find<Dictionary<string, object>>(address);
var network = asn?.Find<Dictionary<string, object>>(address);
// under the lock, so a reload cannot dispose a reader in the middle of a lookup; lookups are rare
lock (_lock)
{
if (_city == default && _asn == default)
return default;
place = _city?.Find<Dictionary<string, object>>(address);
network = _asn?.Find<Dictionary<string, object>>(address);
}
var location = Section(place, "location");
return new GeoFix(
Text(Section(place, "country"), "iso_code"),
@@ -88,6 +87,14 @@ namespace PrivaPub.Infrastructure.Geo
}
}
// Looks at the files again now instead of within the next ten minutes: GeoUpdater has just replaced one.
public void Reload()
{
lock (_lock)
_checkedAt = DateTime.MinValue;
Refresh();
}
void Refresh()
{
if (DateTime.UtcNow - _checkedAt < CheckInterval)
+163
View File
@@ -0,0 +1,163 @@
using MaxMind.Db;
using Microsoft.Extensions.Options;
using PrivaPub.Infrastructure.Statistics;
using System.IO.Compression;
using System.Net;
namespace PrivaPub.Infrastructure.Geo
{
// Keeps the DB-IP Lite databases current by itself, so a deploy needs no timer and no root: once a day it checks
// whether each database was built this month and, if not, fetches this month's (or, early in the month, last month's),
// checks it opens as the right kind of database and swaps it in. This and SMTP are the server's only traffic that is not
// federation, which is why it has its own client instead of IFederationHttp.
public sealed class GeoUpdater : BackgroundService
{
public const string ClientName = "geo";
static readonly string[] Kinds = { "city", "asn" };
static readonly TimeSpan FirstWait = TimeSpan.FromSeconds(30);
static readonly TimeSpan Interval = TimeSpan.FromHours(24);
readonly IHttpClientFactory _http;
readonly IOptionsMonitor<StatisticsOptions> _options;
readonly IGeoLocator _locator;
readonly ILogger<GeoUpdater> _logger;
public GeoUpdater(IHttpClientFactory http, IOptionsMonitor<StatisticsOptions> options, IGeoLocator locator, ILogger<GeoUpdater> logger)
{
_http = http;
_options = options;
_locator = locator;
_logger = logger;
}
public static string FileOf(string kind) => $"dbip-{kind}-lite.mmdb";
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await Task.Delay(FirstWait, stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
if (_options.CurrentValue.Geo.AutoUpdate)
await Update(DateTime.UtcNow, stoppingToken);
await Task.Delay(Interval + TimeSpan.FromMinutes(Random.Shared.Next(60)), stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
}
}
// The number of databases replaced. A failure keeps the database already there and is tried again the next day.
public async Task<int> Update(DateTime now, CancellationToken token)
{
var options = _options.CurrentValue;
var replaced = 0;
try
{
Directory.CreateDirectory(options.GeoDirectory);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogWarning(ex, "Cannot create {Directory}; servers are not located", options.GeoDirectory);
return 0;
}
foreach (var kind in Kinds)
{
var path = Path.Combine(options.GeoDirectory, FileOf(kind));
var built = BuiltMonth(path);
foreach (var month in new[] { Month(now), Month(now).AddMonths(-1) })
{
if (built >= month)
break;
try
{
if (await Install(kind, month, path, options, token))
{
replaced++;
_logger.LogInformation("Installed DB-IP Lite {Kind} {Month:yyyy-MM}", kind, month);
break;
}
}
catch (Exception ex) when (ex is HttpRequestException or IOException or InvalidDatabaseException or InvalidDataException
or TaskCanceledException && !token.IsCancellationRequested)
{
_logger.LogWarning(ex, "Could not install DB-IP Lite {Kind} {Month:yyyy-MM}", kind, month);
break;
}
}
}
if (replaced > 0 && _locator is DbIpLocator locator)
locator.Reload();
return replaced;
}
static DateTime Month(DateTime at) => new(at.Year, at.Month, 1, 0, 0, 0, DateTimeKind.Utc);
static DateTime BuiltMonth(string path)
{
if (!File.Exists(path))
return DateTime.MinValue;
try
{
using var reader = new Reader(path, FileAccessMode.MemoryMapped);
return Month(reader.Metadata.BuildDate);
}
catch (Exception ex) when (ex is InvalidDatabaseException or IOException)
{
return DateTime.MinValue;
}
}
// False when that month is not published (yet); throws when it is published but cannot be used.
async Task<bool> Install(string kind, DateTime month, string path, StatisticsOptions options, CancellationToken token)
{
var url = $"{options.Geo.DownloadBase.TrimEnd('/')}/dbip-{kind}-lite-{month:yyyy-MM}.mmdb.gz";
var packed = path + ".gz.part";
var unpacked = path + ".new";
try
{
using (var response = await _http.CreateClient(ClientName).GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token))
{
if (response.StatusCode == HttpStatusCode.NotFound)
return false;
response.EnsureSuccessStatusCode();
await using var source = await response.Content.ReadAsStreamAsync(token);
await using var target = File.Create(packed);
await CopyCapped(source, target, options.Geo.MaxDownloadBytes, token);
}
await using (var source = new GZipStream(File.OpenRead(packed), CompressionMode.Decompress))
await using (var target = File.Create(unpacked))
await CopyCapped(source, target, options.Geo.MaxDatabaseBytes, token);
using (var reader = new Reader(unpacked, FileAccessMode.MemoryMapped))
if (!reader.Metadata.DatabaseType.Contains(kind, StringComparison.OrdinalIgnoreCase))
throw new InvalidDatabaseException($"{url} is a {reader.Metadata.DatabaseType} database, not {kind}");
File.Move(unpacked, path, overwrite: true);
return true;
}
finally
{
File.Delete(packed);
File.Delete(unpacked);
}
}
static async Task CopyCapped(Stream source, Stream target, long limit, CancellationToken token)
{
var buffer = new byte[81920];
long total = 0;
int read;
while ((read = await source.ReadAsync(buffer, token)) > 0)
{
total += read;
if (total > limit)
throw new InvalidDataException($"more than {limit} bytes");
await target.WriteAsync(buffer.AsMemory(0, read), token);
}
}
}
}
+15 -5
View File
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Signing;
@@ -9,13 +10,22 @@ using System.Threading.RateLimiting;
namespace PrivaPub.Infrastructure
{
public class RateLimitOptions
{
public int AccountsPerMinute { get; set; } = 10;//sign-ups, sign-ins and recoveries per client address
public int InboxBurst { get; set; } = 300;//deliveries a sending origin may make at once
public int InboxPerTenSeconds { get; set; } = 50;//and the rate it earns them back
}
public static class RateLimiting
{
public const string Accounts = "accounts";
public const string Inbox = "inbox";
public static IServiceCollection PrivaPubRateLimiting(this IServiceCollection service) =>
service.AddRateLimiter(options =>
static RateLimitOptions Limits(HttpContext context) => context.RequestServices.GetRequiredService<IOptions<RateLimitOptions>>().Value;
public static IServiceCollection PrivaPubRateLimiting(this IServiceCollection service, IConfiguration configuration) =>
service.Configure<RateLimitOptions>(configuration.GetSection("RateLimits")).AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = (context, _) =>
@@ -39,13 +49,13 @@ namespace PrivaPub.Infrastructure
};
options.AddPolicy(Accounts, context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
_ => new FixedWindowRateLimiterOptions { PermitLimit = Limits(context).AccountsPerMinute, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
options.AddPolicy(Inbox, context => RateLimitPartition.GetTokenBucketLimiter(
SenderOrigin(context.Request) ?? "unsigned:" + context.Connection.RemoteIpAddress,
_ => new TokenBucketRateLimiterOptions
{
TokenLimit = 300,
TokensPerPeriod = 50,
TokenLimit = Limits(context).InboxBurst,
TokensPerPeriod = Limits(context).InboxPerTenSeconds,
ReplenishmentPeriod = TimeSpan.FromSeconds(10),
QueueLimit = 0
}));
@@ -0,0 +1,19 @@
namespace PrivaPub.Infrastructure
{
public enum RegistrationMode
{
Invitations,
Open
}
// Who may make a root login. NodeInfo, both instance API versions and the sign-up endpoint read this one switch, so they
// cannot disagree. A group invitation always creates an account, whatever the mode.
public class RegistrationOptions
{
public RegistrationMode Mode { get; set; } = RegistrationMode.Invitations;//owner decision (2026-10-04): invitations only
public bool IsOpen => Mode == RegistrationMode.Open;
public const string ClosedMessage = "Sign-up is by invitation only: ask someone here for a group invitation.";
}
}
@@ -6,9 +6,19 @@ namespace PrivaPub.Infrastructure.Statistics
public string GeoDirectory { get; set; } = "/var/lib/privapub/geo";
public int PublicCityMinUsers { get; set; } = 10;
public CrawlerOptions Crawler { get; set; } = new();
public GeoOptions Geo { get; set; } = new();
}
//owner decision: off by default; when on, one server a minute, each at most weekly, at most MaxHosts servers
//owner decision (2026-10-04): the server keeps its DB-IP Lite databases current by itself
public class GeoOptions
{
public bool AutoUpdate { get; set; } = true;
public string DownloadBase { get; set; } = "https://download.db-ip.com/free";
public long MaxDownloadBytes { get; set; } = 300L * 1024 * 1024;
public long MaxDatabaseBytes { get; set; } = 1024L * 1024 * 1024;
}
//owner decision: off by default (on in production since 2026-10-04); when on, one server a minute, each at most weekly, at most MaxHosts servers
public class CrawlerOptions
{
public bool Enabled { get; set; }
+14 -3
View File
@@ -41,7 +41,8 @@ namespace PrivaPub.Middleware
{
return service
.Configure<MongoSettings>(configuration.GetSection(nameof(MongoSettings)))
.Configure<AppConfiguration>(configuration.GetSection(nameof(AppConfiguration)));
.Configure<AppConfiguration>(configuration.GetSection(nameof(AppConfiguration)))
.Configure<PrivaPub.Infrastructure.RegistrationOptions>(configuration.GetSection("Registrations"));
}
public static IServiceCollection PrivaPubWorkersConfiguration(this IServiceCollection service)
{
@@ -107,8 +108,16 @@ namespace PrivaPub.Middleware
.AddSingleton<IJobHandler, DeliveryJobHandler>()
.AddHostedService<JobWorker>();
}
public static IServiceCollection PrivaPubStatisticsConfiguration(this IServiceCollection service, IConfiguration configuration) =>
service
public static IServiceCollection PrivaPubStatisticsConfiguration(this IServiceCollection service, IConfiguration configuration)
{
// not federation: the DB-IP download only (GeoUpdater), plain HTTPS to a fixed host
service.AddHttpClient(GeoUpdater.ClientName, (provider, client) =>
{
var baseAddress = provider.GetRequiredService<IOptionsMonitor<AppConfiguration>>().CurrentValue.BackendBaseAddress?.TrimEnd('/');
client.Timeout = TimeSpan.FromMinutes(15);
client.DefaultRequestHeaders.UserAgent.ParseAdd($"PrivaPub/{BuildInfo.Ref} (+{baseAddress}/)");
});
return service
.Configure<StatisticsOptions>(configuration.GetSection("Statistics"))
.AddSingleton<InteractionSalts>()
.AddSingleton<InteractionLedger>()
@@ -116,10 +125,12 @@ namespace PrivaPub.Middleware
.AddHostedService(services => services.GetRequiredService<InteractionLedger>())
.AddSingleton<IJobHandler, RollupJob>()
.AddSingleton<IGeoLocator, DbIpLocator>()
.AddHostedService<GeoUpdater>()
.AddSingleton<Domain.Statistics.StatisticsQueries>()
.AddSingleton<IJobHandler, Federation.Crawler.CrawlPlanner>()
.AddSingleton<IJobHandler, Federation.Crawler.InstanceCrawler>()
.AddHostedService<StatisticsSchedule>();
}
public static IServiceCollection PrivaPubAuthServicesConfiguration(this IServiceCollection service, IConfiguration configuration)
{
+10 -7
View File
@@ -59,7 +59,7 @@ try
.PrivaPubFederationConfiguration(builder.Configuration)
.PrivaPubStatisticsConfiguration(builder.Configuration)
.PrivaPubCORSConfiguration()
.PrivaPubRateLimiting()
.PrivaPubRateLimiting(builder.Configuration)
.PrivaPubOAuth(builder.Environment)
.AddScoped<PrivaPub.Api.Mastodon.Mappers.MastodonMapper>()
.AddScoped<PrivaPub.Api.Mastodon.Mappers.AccountSearch>()
@@ -87,12 +87,6 @@ try
EntityMaps.Warm();
await DB.Default.MigrateAsync<Program>();
await Indexes.Create();
if (args is ["admin", ..])
{
Environment.ExitCode = await AdminCommands.Run(args[1..]);
return;
}
}
catch (Exception ex)
{
@@ -111,6 +105,15 @@ try
throw;
}
// Commands get every service but start nothing: no Kestrel, no hosted services, no media directory. The deploy runs
// them as its own user, which can read the configuration and reach the private mongod but owns no www-data directory.
if (args is ["admin", ..])
{
using var scope = app.Services.CreateScope();
Environment.ExitCode = await AdminCommands.Run(args[1..], scope.ServiceProvider);
return;
}
try
{
var localizationService = app.Services.GetService<RequestLocalizationOptionsService>();
@@ -73,7 +73,7 @@ namespace PrivaPub.Services.ClientToServer.Private
if (!await _localActors.TryReserveUserName(userName, LocalActorKind.Person, newAvatar.ID, default))
return result.Invalidate(_localizer["The username '{0}' is already take.", userName]);
if (form.Settings is { IsDefault: false })
newAvatar.Settings = ToSettings(form.Settings);
Apply(newAvatar.Settings, form.Settings);
var actor = _localActors.FromAvatar(newAvatar);
newAvatar.Url = actor.Uri;
@@ -116,7 +116,7 @@ namespace PrivaPub.Services.ClientToServer.Private
avatar.Fields = form.Fields ?? new();
avatar.PersonalNote = form.PersonalNote;
if (form.Settings != default)
avatar.Settings = ToSettings(form.Settings);
Apply(avatar.Settings ??= new(), form.Settings);
avatar.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(avatar);
await _outbox.PublishProfile(_localActors.FromAvatar(avatar), default);
@@ -158,18 +158,20 @@ namespace PrivaPub.Services.ClientToServer.Private
.Match(ru => !ru.DeletedAt.HasValue && !ru.IsBanned)
.ExecuteAnyAsync();
static AvatarSettings ToSettings(ViewAvatarSettings settings) => new()
// The client API carries only the theme; the rest (discoverable, locked, quote policy…) is set through the Mastodon API
// and must survive an edit made here.
static void Apply(AvatarSettings target, ViewAvatarSettings settings)
{
IsDefault = settings.IsDefault,
DarkThemeIndexColour = settings.DarkThemeIndexColour,
IconsThemeIndexColour = settings.IconsThemeIndexColour,
LanguageCode = settings.LanguageCode,
LightThemeIndexColour = settings.LightThemeIndexColour,
PreferSystemTheming = settings.PreferSystemTheming,
ThemeIsDarkGray = settings.ThemeIsDarkGray,
ThemeIsDarkMode = settings.ThemeIsDarkMode,
ThemeIsLightGray = settings.ThemeIsLightGray
};
target.IsDefault = settings.IsDefault;
target.DarkThemeIndexColour = settings.DarkThemeIndexColour;
target.IconsThemeIndexColour = settings.IconsThemeIndexColour;
target.LanguageCode = settings.LanguageCode;
target.LightThemeIndexColour = settings.LightThemeIndexColour;
target.PreferSystemTheming = settings.PreferSystemTheming;
target.ThemeIsDarkGray = settings.ThemeIsDarkGray;
target.ThemeIsDarkMode = settings.ThemeIsDarkMode;
target.ThemeIsLightGray = settings.ThemeIsLightGray;
}
ViewAvatar ToView(Avatar avatar) => new()
{
+5 -1
View File
@@ -133,14 +133,18 @@ namespace PrivaPub.Web.Pages
readonly Microsoft.Extensions.Options.IOptionsMonitor<Infrastructure.Statistics.StatisticsOptions> _options;
readonly Microsoft.Extensions.Options.IOptionsMonitor<Models.AppConfiguration> _app;
readonly Infrastructure.Geo.IGeoLocator _geo;
public StargazingModel(Microsoft.Extensions.Options.IOptionsMonitor<Infrastructure.Statistics.StatisticsOptions> options,
Microsoft.Extensions.Options.IOptionsMonitor<Models.AppConfiguration> app)
Microsoft.Extensions.Options.IOptionsMonitor<Models.AppConfiguration> app, Infrastructure.Geo.IGeoLocator geo)
{
_options = options;
_app = app;
_geo = geo;
}
public bool CrawlerEnabled => _options.CurrentValue.Crawler.Enabled;
public string GeoSource => _geo.Source;
public string UserAgent => Federation.Crawler.Stargazer.UserAgent(_app.CurrentValue.BackendBaseAddress);
public void OnGet() => Harden();
+9
View File
@@ -15,6 +15,15 @@
<p>A server we exchange activities with is described once a week, from its public NodeInfo and, when it has one, its
Mastodon instance API. Its location comes from the address we reached, looked up in an offline database: only the
country is shown publicly for small servers, and only the CDN for servers behind one.</p>
@if (Model.GeoSource is { } geo)
{
<p>Locations come from <a href="https://db-ip.com" rel="nofollow noopener">@geo</a>, licensed under
<a href="https://creativecommons.org/licenses/by/4.0/" rel="nofollow noopener">CC BY 4.0</a>.</p>
}
else
{
<p>No location database is loaded yet, so servers are not located.</p>
}
</article>
<article>
<h2>The crawler</h2>
+20
View File
@@ -2,6 +2,26 @@
"Media": {
"Root": "/var/lib/privapub/media"
},
"Registrations": {
"Mode": "Invitations"
},
"Statistics": {
"Crawler": {
"Enabled": true,
"Seeds": [
"mastodon.social",
"fosstodon.org",
"mas.to",
"lemmy.world",
"lemmy.ml",
"misskey.io",
"pixelfed.social",
"framatube.org",
"piefed.social",
"bookwyrm.social"
]
}
},
"MongoSettings": {
"Database": "PrivaPub",
"LogsDatabase": "logs",
-23
View File
@@ -1,23 +0,0 @@
#!/usr/bin/env bash
# Fetches the month's DB-IP Lite city and ASN databases (CC BY 4.0, https://db-ip.com) into the directory PrivaPub reads
# them from (Statistics:GeoDirectory). The app swaps to new files on its own; a failed download leaves the old ones.
set -euo pipefail
dir="${GEO_DIR:-/var/lib/privapub/geo}"
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
this=$(date -u +%Y-%m)
last=$(date -u -d "$(date -u +%Y-%m-15) -1 month" +%Y-%m)
for kind in city asn; do
got=""
for month in "$this" "$last"; do
if curl -fsS --max-time 900 -o "$tmp/$kind.gz" "https://download.db-ip.com/free/dbip-$kind-lite-$month.mmdb.gz"; then
got=$month; break
fi
done
[ -n "$got" ] || { echo "no $kind database for $this or $last" >&2; exit 1; }
gunzip -t "$tmp/$kind.gz"
gunzip -c "$tmp/$kind.gz" > "$tmp/dbip-$kind-lite.mmdb"
[ "$(stat -c %s "$tmp/dbip-$kind-lite.mmdb")" -gt 1000000 ] || { echo "the $kind database is too small" >&2; exit 1; }
install -m 640 "$tmp/dbip-$kind-lite.mmdb" "$dir/.dbip-$kind-lite.mmdb.new"
mv -f "$dir/.dbip-$kind-lite.mmdb.new" "$dir/dbip-$kind-lite.mmdb"
echo "$kind: DB-IP Lite $got"
done
+1 -6
View File
@@ -12,7 +12,7 @@ ACME=/root/.acme.sh/acme.sh
echo "== directories"
install -d -o "$RUNNER" -g www-data -m 755 /var/www/$HOST
install -d -o "$RUNNER" -g "$RUNNER" -m 750 /var/backups/$HOST
install -d -o www-data -g www-data -m 750 /var/lib/privapub /var/lib/privapub/mongo /var/lib/privapub/geo
install -d -o www-data -g www-data -m 750 /var/lib/privapub /var/lib/privapub/mongo
echo "== sudoers"
SUDOERS=/etc/sudoers.d/$RUNNER
@@ -23,15 +23,10 @@ visudo -cf "$SUDOERS"
echo "== units"
install -m 644 "$SRC/systemd/privapub-mongod.service" /etc/systemd/system/privapub-mongod.service
install -m 644 "$SRC/systemd/$UNIT.service" /etc/systemd/system/$UNIT.service
install -m 755 "$SRC/max/geo-update.sh" /usr/local/bin/privapub-geo-update
install -m 644 "$SRC/systemd/privapub-geo.service" /etc/systemd/system/privapub-geo.service
install -m 644 "$SRC/systemd/privapub-geo.timer" /etc/systemd/system/privapub-geo.timer
systemctl daemon-reload
systemctl enable --now privapub-mongod >/dev/null
systemctl enable $UNIT >/dev/null
systemctl enable --now privapub-geo.timer >/dev/null
systemctl is-active privapub-mongod
[ -f /var/lib/privapub/geo/dbip-city-lite.mmdb ] || systemctl start privapub-geo.service || echo "geolocation databases not fetched yet; the timer retries"
echo "== nginx snippet and bootstrap vhost"
install -m 644 "$SRC/nginx/privapub-headers.conf" /etc/nginx/snippets/privapub-headers.conf
-15
View File
@@ -1,15 +0,0 @@
[Unit]
Description=PrivaPub: fetch the DB-IP Lite geolocation databases
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=www-data
Group=www-data
ExecStart=/usr/local/bin/privapub-geo-update
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/privapub/geo
-10
View File
@@ -1,10 +0,0 @@
[Unit]
Description=PrivaPub: refresh the geolocation databases monthly
[Timer]
OnCalendar=*-*-03 04:00:00
RandomizedDelaySec=6h
Persistent=true
[Install]
WantedBy=timers.target
+82 -1
View File
@@ -310,6 +310,26 @@ Firefish is dead (its site has answered 410 since February 2025).
| Per-attachment `sensitive`; `isCat` and other actor extras; enforce `requireSignin…` and `makeNotes…Before` on our public pages | P2 | own `privapub.*` |
| Quotes: when we send one, send every key (`quote`, `_misskey_quote`, `quoteUrl`, `quoteUri`, a FEP-e232 tag, the `RE:` fallback) | P2 | — |
**Pasture evidence (2026-10-03, Misskey 2026.10.0, `tools/pasture/scenarios/misskey.sh`):** 35 checks pass:
- discovery and follows both ways;
- posts, CW and the MFM `source` kept;
- replies threading both ways;
- 👍 and 🎉 reactions both ways, and their withdrawal;
- a like counting as a reaction;
- legacy quotes both ways (`_misskey_quote` in, `renoteId` out);
- polls and votes both ways;
- `specified` notes as DMs both ways;
- images with alt text (`comment`) both ways;
- deletes both ways, unfollow and block;
- statistics.
A new Misskey 2026 starts with `federation: none`.
**Pasture evidence (2026-10-03, Sharkey 2025.4.7, `tools/pasture/scenarios/sharkey.sh`):** 40 checks pass, the whole
Misskey scenario under Sharkey's name and then what only Sharkey does:
- its edits arrive as edits, and ours reach it;
- its quote carries the FEP-e232 `Link` tag and is understood.
### Pleroma 2.10.2 and Akkoma 3.20.1
**Emits**
@@ -321,6 +341,7 @@ Firefish is dead (its site has answered 410 since February 2025).
- **Edit history:** `formerRepresentations`, an OrderedCollection of earlier versions.
- **Reactions:** `EmojiReact{content: ":name:", tag:[Emoji]}`, several per user; separate from Like.
- **Polls:** `votersCount` (Pleroma 2.10.1, Akkoma 3.20). A vote is a `Note{name, inReplyTo, to:[], cc:[owner]}`.
An open poll's end is in `closed`, and Akkoma sends no `endTime` (pasture, 3.20.1).
- **Pleroma only:**
- `ChatMessage`, sent only to actors with `capabilities.acceptsChatMessages`;
- `Listen{Audio}`;
@@ -333,6 +354,8 @@ Firefish is dead (its site has answered 410 since February 2025).
- **Pleroma's inbox guard answers 400 for unknown activity types:** `Move`, `QuoteRequest` and `Bite` are not on its
list (develop, 2026-09-30). Treat that 4xx as final.
- **Signatures (Akkoma):** `host` must be signed and match; signatures up to 2 h old and up to 40 min in the future.
- **Visibility is guessed from addresses:** a post is private only if an address in `to` contains `/followers` or its
`cc` is not empty; otherwise it is direct. Our followers-only posts therefore name `/groupies` in `cc` too.
- **Activity ids** must be at least 8 bytes.
- **ObjectAgePolicy** (default in both) delists anything older than 7 days, so `published` must be accurate.
- **Quotes:** Pleroma's InlineQuotePolicy rewrites incoming quotes into "RT: url" text. **Neither reads FEP-044f
@@ -349,6 +372,35 @@ Firefish is dead (its site has answered 410 since February 2025).
| `ChatMessage` in as a direct message (also needed for Lemmy, Mbin and PieFed); advertise `acceptsChatMessages` only once it is answered | P1 (in), P3 (out) | `visibility: direct`, Conversations |
| `Listen`, `vcard:bday`, `backgroundUrl` | P3 | own |
**Pasture evidence (2026-10-03, Akkoma 3.20.1, `tools/pasture/scenarios/akkoma.sh`):** 44 checks pass, three runs in a
row. Akkoma publishes no image, so `tools/pasture/images/akkoma` installs its OTP release, pinned by checksum. Covered:
- discovery and follows both ways;
- posts, CW and followers-only posts;
- the `published` time kept;
- replies both ways and their notification;
- likes and boosts both ways with their undos;
- `EmojiReact` both ways and its withdrawal;
- DMs both ways and off public timelines;
- polls and votes both ways;
- quotes both ways (`quote_id` out of its API, `quoteUri` in ours);
- images with alt text both ways;
- edits with history, and deletes, both ways;
- unfollow, block and unblock;
- statistics.
It found two bugs, both fixed:
- **Followers-only posts arrived as DMs.** Akkoma, like Pleroma, calls a post private only if an address in `to` contains
`/followers` or its `cc` is not empty. Ours is `/groupies` and a post mentioning nobody had an empty `cc`. Followers-only
posts now name the followers collection in `cc` as well, which tells nobody anything new.
- **Open polls were shown as ended, and votes refused.** An open Akkoma poll carries its end in `closed`, with no
`endTime`. A `closed` in the future is now read as the end.
Seen along the way:
- Its Linkify never takes `@user@host.test` for a mention, whatever `validate_tld` says, so in the pasture Akkoma
addresses us with Pleroma's `to[]`. Real top-level domains are unaffected.
- It records our `Block` (`user_relationships`) but never reports a remote blocker as `blocked_by`.
- Its streamer crashes rendering a new DM conversation (`ConversationView`, a nil `last_status`); delivery is unaffected.
### Lemmy: 0.19.20 live (lemmy.ml); 1.0.0-beta.2 (2026-09-25) in beta since May
join-lemmy.org's federation page is out of date. Current Lemmy neither sends nor reads `stickied` or `commentsEnabled`
@@ -412,7 +464,7 @@ on a Page: pins live in `featured`, locks in `Lock`.
| Link posts: keep `Link.href`, the thumbnail `image` and alt text; build the card (Lemmy sends no title or description for the link) | P1 | `Status.card` |
| `ChatMessage` in and out (out only to Lemmy < 1.0 and Mbin; `Note` to everyone else) | P1 | `visibility: direct` |
| Outbound shape for Lemmy: both `to` and `cc`; the community in `to`; Public in the object, Create and Announce; votes and comments sent to the community inbox | P1 | — |
| Communities we host: announce to every follower instance including the author's; pick `Announce(object)` per peer by NodeInfo (as PieFed does) | P1 | — |
| Communities we host: pick `Announce(object)` per peer by NodeInfo (as PieFed does). Announcing to every follower instance, the author's included, is done and needed (pasture evidence below) | P1 | — |
| Flags from a `Service`-typed reporter actor with `to: [community]`; the reporter stays anonymous | P2 | — |
| `Warn` → `moderation_warning` notification; `Resolve{Flag}` | P2 | AccountWarning |
| Remote communities: `description`, `language[]`, private (`locked`), `discoverable`; post tags | P2 | `Account.locked`, own `privapub.flairs[]` |
@@ -420,6 +472,35 @@ on a Page: pins live in `featured`, locks in `Lock`.
| `Feed` actors | P2 | group-like account |
| Read 1.0 `context`, grouped by root post; cross-post detection by URL | P3 | — |
**Pasture evidence (2026-10-03, Lemmy 1.0.0-beta.2, `tools/pasture/scenarios/lemmy.sh`):** 20 checks pass and 3 are
expected failures:
- communities both ways: Lemmy follows ours and alice follows Lemmy's, each Accept arriving;
- a Lemmy thread in our community arrives with its title, and our titled community post reaches Lemmy;
- the Lemmy community's Announce brings its thread to alice's home;
- alice's post mentioning a Lemmy community lands in it, titled from its first line (Lemmy repeats that line in the
body);
- comments both ways, and alice's like counted as an upvote;
- private messages both ways: 1.0 takes our single-recipient direct `Note`, and sends its own as `Note`s;
- statistics.
What it showed:
- **1.0 keeps its user's thread in a remote community `federation_pending` until the community announces it back**, and
clears the flag *before* answering that echo 400 (`Object is not remote`). Without the echo the thread stays pending,
so `GroupDistributor` sends the `Announce{Create}` to the author's own server on purpose.
- **Every bare `Announce{object}` is answered 400** (`Failed to parse object`: Lemmy dereferences it expecting an
activity), as Lemmy answers the compatibility `Announce(Page)` it sends itself. Both 400s show up as dead deliveries
in the statistics.
- **Votes travel only to the community**, which relays them as `Announce{Like}` and `Announce{Dislike}`. They are
dropped as `unsupported` until P7 (expected failures), as is a moderator's removal.
- Lemmy logs no refused activity at `warn`; the reason is in the 400's body, which our delivery does not keep. The
scenario's API notes: `sort` values are lowercase (`new`), private messages and mentions are in
`account/notification/list`, and `resolve_object` takes both `!community@host` and `@user@host`.
- **1.0 sends nothing it queued for a server before it started that server's send worker.** A worker starts, up to a
minute after the server is first seen, at the newest activity and skips everything older. On a clean pasture its
first Follow of our community was lost, so the scenario waits for the worker (`federation_queue_state`). On the
public network the same applies to the first thing a Lemmy sends to a PrivaPub it has just discovered.
- 0.19 cannot join the pasture: its rustls trusts only its bundled roots, never Caddy's CA.
### PieFed 1.7.17 and Mbin 1.10.1
- **PieFed sends Lemmy's set plus:**
+37 -7
View File
@@ -17,7 +17,23 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
- daily rollups, every touched server described and located, the admin statistics API, the opt-in crawler and `/stargazing`;
- the pasture as plugins: GoToSocial 37/37, Mastodon 49 plus 2 expected failures.
Still open: T10 (GoToSocial gaps), T12 Misskey/Sharkey, T13 Akkoma, T14 Lemmy, and installing the geolocation timer on Max.
T10 and T12–T14: v1.17.1, deployed and verified 2026-10-03. The pasture runs all six peers in one pass:
- GoToSocial 54 plus 1 expected failure;
- Mastodon 49 plus 2;
- Misskey 35, Sharkey 40, Akkoma 44;
- Lemmy 20 plus 3.
The expected failures are circle posts on GoToSocial and Mastodon, inbound Block, and Lemmy's relayed votes and
removals (P7). It found three bugs, all fixed:
- quick GoToSocial edits were lost;
- followers-only posts arrived as DMs on Pleroma and Akkoma;
- Akkoma's open polls showed as ended and refused votes.
Both open items were closed without a root step (owner decisions, 2026-10-04): the server fetches its geolocation
databases itself, and the deploy makes and signs in as @thepra.
- [ ] Everything on in production (owner decisions 2026-10-04): v1.18.0 self-updating geolocation, @thepra, the crawler
on, sign-up by invitation, one registrations switch; then signed audiences with circles for everyone and SecureMode on,
account privacy, and one answer everywhere (the mismatch sweep).
- [ ] P7 Threads, communities, moderation, the social graph
- [ ] P8 Signatures, discovery, the long tail
@@ -146,10 +162,24 @@ and circles (see Owner decisions).
| Local side in statistics | **Only the kind of local actor** (person, group, application), **and only on public and unlisted traffic.** DMs, followers-only and circle traffic are one "private" class, never broken out per server in public. Circles are never named, whether as a kind or as a reason. Fetches of our own documents are counted per day, never per server. |
| Reading-driven traffic | **Counted per day, never logged per event:** the media proxy, lookups a client asks for, and the client API per endpoint group (admin only). Client app names are not recorded. |
| Describing servers | **Every server we exchange activities with is described weekly**, from its NodeInfo (including the user counts it publishes) and its Mastodon instance API, never its contact account. These requests are unsigned, because they are not ActivityPub documents. Never on read. |
| Server locations | **City and network (ASN) from the offline DB-IP Lite databases** (CC BY 4.0, attributed), downloaded monthly outside the app. The location comes from the address we connected to; an inbound sender's address is never recorded, and no address is stored. In public: city and network only for servers reporting at least 10 users and not behind a CDN; the country otherwise; only the CDN's name for CDN-fronted servers. The admin sees everything. |
| Crawler | **Off by default** (`Statistics:Crawler:Enabled`). When on, it identifies as `PrivaPub-Stargazer/<version> (+https://privapub.thepra.dev/stargazing)`, where `/stargazing` explains it and how to opt out. It honours robots.txt (an unreachable robots.txt means "keep out") and domain blocks. It visits one server a minute, each at most weekly, and at most 5000 servers. It reads only robots.txt, NodeInfo, the instance API and the peers list, never accounts, posts or directories. Crawled servers stay marked as crawled. |
| Server locations | **City and network (ASN) from the offline DB-IP Lite databases** (CC BY 4.0, attributed), downloaded monthly (by the server itself since 2026-10-04). The location comes from the address we connected to; an inbound sender's address is never recorded, and no address is stored. In public: city and network only for servers reporting at least 10 users and not behind a CDN; the country otherwise; only the CDN's name for CDN-fronted servers. The admin sees everything. |
| Crawler | **Off by default** (`Statistics:Crawler:Enabled`); **on in production since 2026-10-04**, see below. When on, it identifies as `PrivaPub-Stargazer/<version> (+https://privapub.thepra.dev/stargazing)`, where `/stargazing` explains it and how to opt out. It honours robots.txt (an unreachable robots.txt means "keep out") and domain blocks. It visits one server a minute, each at most weekly, and at most 5000 servers. It reads only robots.txt, NodeInfo, the instance API and the peers list, never accounts, posts or directories. Crawled servers stay marked as crawled. |
| A remote account deletes itself | **Its posts are kept but hidden everywhere** (`Post.AuthorGone`): from timelines, profiles, search and lookups by id. Its follows and timeline rows go, as before. |
| Signed-in smoke check in production | **An undiscoverable persona**, whose read-only token is the Gitea secret `PRIVAPUB_SMOKE_TOKEN`; the deploy checks `verify_credentials`, home and notifications with it. The owner creates both. |
| Signed-in smoke check in production | **An undiscoverable persona**, the deploy checks `verify_credentials`, home and notifications with it. *Superseded 2026-10-04: the deploy makes and keeps the persona itself, below.* |
### Owner decisions on running everything in production (2026-10-04)
| Question | Decision |
|---|---|
| What runs in production | **Everything that is built is on and checked by the deploy**, and nothing waits on a person running a command. |
| Geolocation | **The server fetches DB-IP Lite itself** (`GeoUpdater`): it checks daily, installs a new month's databases once they are published, refuses a file that does not open as the right kind of database, and keeps the old one when anything fails. No timer and no root step. The deploy fails if the databases are missing or more than 40 days old. |
| Crawler | **On in production**, seeded with a handful of large servers of different kinds (`appsettings.Production.json`); the deploy fails if `/stargazing` does not say it is on. |
| Sign-up | **Closed: invitations only.** A group invitation creates an account; open sign-up answers 403. NodeInfo, `/api/v1/instance` and `/api/v2/instance` read the same switch (`Registrations:Mode`), and the deploy fails if they disagree. The first login on a server is made with `PrivaPub admin create-root`. |
| Signed-in smoke check | **`@thepra`, undiscoverable, made and kept by the deploy**: `PrivaPub admin smoke thepra` creates or keeps the root `deploy-smoke` and the persona and gives the root a new password on every deploy; the deploy signs in through the real OAuth flow, checks the signed-in API and revokes its token. No secret is stored. |
| Signed fetches (SecureMode) | **On in production** once the pasture passes with it on (Phase 2 of the 2026-10-04 plan). |
| Circles on Mastodon and GoToSocial | **Each member's copy names that member** in `cc`; a member's refetch names the member, an instance actor's refetch the members on its server. Nothing new is revealed to anyone outside the circle. |
| What circles and located posts reveal | **Unchanged**: circles still answer WebFinger, and circle and located posts still count in a persona's post count, "a good balance for the fediverse to work". |
| Public `/stargazing` statistics | **Later**, as decided on 2026-10-03; the crawler and the admin API keep collecting meanwhile. |
## Libraries (researched; no maintained .NET ActivityPub library exists, so Letterbook and Iceshrimp.NET both wrote their own)
@@ -551,9 +581,9 @@ statistics gain value with every day recorded.
| T1–T4 | Tests stop sharing state they don't own. CI runs every test against a throwaway mongod. The whole server runs under test (`WebApplicationFactory`). Nothing answers 500. | v1.15.1 |
| M1–M6 | The interaction ledger: inbox answers, handler verdicts, delivery attempts, outbound requests, served and client traffic. Provenance fixes (fetched records, stored extensions, group-wrapped Update/Delete). | v1.16.0 |
| T5–T8 | Coverage sweep over HTTP: OAuth, `/clientapi`, the Mastodon API, federation GETs, inbox gaps, jobs, migrations, pages. | v1.17.0 |
| M7–M10 | Daily rollups (`InstanceDay`, `ServerDay`). Every touched server described weekly (NodeInfo usage, instance API, snapshots). Geolocation (DB-IP Lite city and ASN). Admin statistics API under `/clientapi/admin/statistics`. | v1.18.0 |
| T9–T14 | The pasture as plugins. GoToSocial gaps, then Mastodon, Misskey/Sharkey, Akkoma and Lemmy 1.0, each run also checking that peer's statistics. | v1.18.x |
| M11 | The opt-in crawler (`PrivaPub-Stargazer`) and the `/stargazing` explainer. | v1.19.0 |
| M7–M10 | Daily rollups (`InstanceDay`, `ServerDay`). Every touched server described weekly (NodeInfo usage, instance API, snapshots). Geolocation (DB-IP Lite city and ASN). Admin statistics API under `/clientapi/admin/statistics`. | v1.17.0 (planned v1.18.0) |
| T9–T14 | The pasture as plugins. GoToSocial gaps, then Mastodon, Misskey/Sharkey, Akkoma and Lemmy 1.0, each run also checking that peer's statistics. | v1.17.0, v1.17.1 |
| M11 | The opt-in crawler (`PrivaPub-Stargazer`) and the `/stargazing` explainer. | v1.17.0 (planned v1.19.0) |
Later, and not part of these steps: the public `/stargazing` statistics. It is anonymous, cached and rate-limited, and
covers per-server software, self-published counts, location as projected by the rule above, availability buckets and
+20
View File
@@ -17,3 +17,23 @@ mastodon.test {
tls internal
reverse_proxy pasture-mastodon:3000
}
misskey.test {
tls internal
reverse_proxy pasture-misskey:3000
}
sharkey.test {
tls internal
reverse_proxy pasture-sharkey:3000
}
lemmy.test {
tls internal
reverse_proxy pasture-lemmy:8536
}
akkoma.test {
tls internal
reverse_proxy pasture-akkoma:4000
}
+3
View File
@@ -25,6 +25,9 @@
"AcceptAnyCertificate": true
},
"Media": { "Root": "/tmp/privapub-media" },
"RateLimits": { "AccountsPerMinute": 1000 },
"Registrations": { "Mode": "Open" },
"Statistics": { "Geo": { "AutoUpdate": false } },
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:80", "Protocols": "Http1AndHttp2" } } },
"Serilog": {
"MinimumLevel": { "Default": "Information", "Override": { "Microsoft": "Warning", "System": "Warning" } },
+16
View File
@@ -0,0 +1,16 @@
# Akkoma publishes no image, so this is its OTP release on the Ubuntu it is built for. The "stable" zip moves; the
# checksum pins 3.20.1 and fails the build when stable has moved on.
FROM docker.io/library/ubuntu:22.04
ARG AKKOMA_ZIP=https://akkoma-updates.s3-website.fr-par.scw.cloud/stable/akkoma-amd64.zip
ARG AKKOMA_SHA256=a06d1077dc43357923ffbebbee509f1faa2d66ae6078828ad35f9ad34ec6f96c
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl unzip libncurses5 libmagic1 libmagic-mgc file postgresql-client \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL "$AKKOMA_ZIP" -o /tmp/akkoma.zip \
&& echo "$AKKOMA_SHA256 /tmp/akkoma.zip" | sha256sum -c - \
&& unzip -q /tmp/akkoma.zip -d /tmp && mv /tmp/release /opt/akkoma && rm /tmp/akkoma.zip \
&& mkdir -p /var/lib/akkoma/uploads /var/lib/akkoma/static /etc/akkoma
ENV AKKOMA_CONFIG_PATH=/etc/akkoma/config.exs PLEROMA_CONFIG_PATH=/etc/akkoma/config.exs LANG=C.UTF-8 ELIXIR_ERL_OPTIONS=+fnu
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
+19
View File
@@ -0,0 +1,19 @@
#!/bin/sh
# Trusts the pasture's CA, writes the config on first start, migrates, and runs Akkoma in the foreground.
set -e
cp /pasture/ca/root.crt /usr/local/share/ca-certificates/pasture.crt && update-ca-certificates >/dev/null
# Mint and hackney read the bundles shipped in the release (CAStore, certifi), never the system's
for bundle in /opt/akkoma/lib/castore-*/priv/cacerts.pem /opt/akkoma/lib/certifi-*/priv/cacerts.pem; do
grep -q "pasture" "$bundle" || { echo "# pasture"; cat /pasture/ca/root.crt; } >> "$bundle"
done
if [ ! -f /etc/akkoma/config.exs ]; then
# pleroma_ctl passes its arguments on unquoted, so no value may contain a space
env -u AKKOMA_CONFIG_PATH -u PLEROMA_CONFIG_PATH /opt/akkoma/bin/pleroma_ctl instance gen --force --output /etc/akkoma/config.exs --output-psql /tmp/setup.psql \
--domain akkoma.test --instance-name PastureAkkoma --admin-email admin@akkoma.test --notify-email admin@akkoma.test \
--dbhost postgres --dbname akkoma --dbuser pasture --dbpass pasture --rum N --indexable N --db-configurable N \
--uploads-dir /var/lib/akkoma/uploads --static-dir /var/lib/akkoma/static --listen-ip 0.0.0.0 --listen-port 4000 \
--media-url https://akkoma.test/media --strip-uploads-metadata N --read-uploads-description Y --anonymize-uploads N \
</dev/null
fi
/opt/akkoma/bin/pleroma_ctl migrate
exec /opt/akkoma/bin/pleroma start
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Drives PrivaPub and each peer (started by run.sh) through each other's federation, as their users would, then checks
# that PrivaPub's statistics saw it all without naming anyone.
# usage: tools/pasture/interop.sh [peer...] peers: gts (default)
# usage: tools/pasture/interop.sh [peer...] peers: gts (default), mastodon, misskey, sharkey, akkoma, lemmy, crawler
set -uo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$here/lib/interop.sh"
+12 -27
View File
@@ -14,6 +14,14 @@ site() { curl -k --resolve "$1:6443:127.0.0.1" "${@:2}"; }
# fetches one of PrivaPub's own https URIs (ids, scribbles) from the workstation, through Caddy
pfetch() { curl -sk --connect-to privapub.test:443:127.0.0.1:6443 "$@"; }
# make_png <path>: an 8x8 red PNG, for uploads
make_png() { python3 -c "
import struct,zlib
w=h=8
raw=b''.join(b'\x00'+bytes([200,60,60])*w for _ in range(h))
png=b'\x89PNG\r\n\x1a\n'+b''.join(struct.pack('>I',len(c))+t+c+struct.pack('>I',zlib.crc32(t+c)&0xffffffff) for t,c in [(b'IHDR',struct.pack('>IIBBBBB',w,h,8,2,0,0,0)),(b'IDAT',zlib.compress(raw)),(b'IEND',b'')])
open('$1','wb').write(png)"; }
ROOT_USER=pastureroot; ROOT_PASS='Pasture-Pass-1!'
privapub_root() {
local root
@@ -22,37 +30,14 @@ privapub_root() {
echo "$root" | j "print(d['token'])"
}
# privapub_token <persona>: creates the persona under the pasture root if needed and returns a Mastodon token for it.
# privapub_token <persona>: creates the persona under the pasture root if needed and returns a Mastodon token for it,
# through the same OAuth flow the deploy's smoke check uses (tools/smoke/oauth.sh).
privapub_token() {
local persona=$1 jwt cid cs q xt form code
local persona=$1 jwt
jwt=$(privapub_root)
curl -s -o /dev/null -X POST $P/clientapi/avatar/private/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
-d "{\"userName\":\"$persona\",\"name\":\"$persona of PrivaPub\",\"biography\":\"testing federation\"}"
local app; app=$(curl -s -X POST $P/api/v1/apps -d 'client_name=pasture&redirect_uris=urn:ietf:wg:oauth:2.0:oob&scopes=read+write+follow')
cid=$(echo "$app" | j "print(d['client_id'])"); cs=$(echo "$app" | j "print(d['client_secret'])")
q="client_id=$cid&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=read+write+follow"
local jar="$work/jar-$persona"
xt=$(curl -s -c "$jar" -b "$jar" "$P/oauth/login?returnUrl=/oauth/authorize?$q" | grep -o 'name="__RequestVerificationToken" type="hidden" value="[^"]*"' | sed 's/.*value="//;s/"//')
curl -s -o /dev/null -c "$jar" -b "$jar" -X POST $P/oauth/login --data-urlencode "returnUrl=/oauth/authorize?$q" --data-urlencode "__RequestVerificationToken=$xt" \
--data-urlencode "userName=$ROOT_USER" --data-urlencode "password=$ROOT_PASS"
curl -s -c "$jar" -b "$jar" "$P/oauth/authorize?$q&signed_in=1" > "$work/choose-$persona.html"
form=$(python3 - "$work/choose-$persona.html" "$persona" <<'PY'
import re,sys,urllib.parse,html
s=open(sys.argv[1]).read()
pairs=[(k,html.unescape(v)) for k,v in re.findall(r'<input type="hidden" name="([^"]*)" value="([^"]*)"',s)]
blocks=re.findall(r'<label[^>]*>(.*?)</label>',s,re.S)
avatar=None
for b in blocks:
if '@'+sys.argv[2]+'@' in b or '@'+sys.argv[2]+'<' in b or '>'+sys.argv[2]+'<' in b:
m=re.search(r'name="avatarId" value="([^"]*)"',b)
if m: avatar=m.group(1)
if avatar is None:
avatar=re.findall(r'name="avatarId" value="([^"]*)"',s)[0]
print(urllib.parse.urlencode(pairs+[("avatarId",avatar),("decision","allow")]))
PY
)
code=$(curl -s -c "$jar" -b "$jar" -X POST $P/oauth/authorize --data "$form" | grep -o '<code>[^<]*</code>' | sed 's/<[^>]*>//g')
curl -s -X POST $P/oauth/token -d "grant_type=authorization_code&code=$code&client_id=$cid&client_secret=$cs&redirect_uri=urn:ietf:wg:oauth:2.0:oob" | j "print(d['access_token'])"
"$here/../smoke/oauth.sh" "$P" "$ROOT_USER" "$ROOT_PASS" "$persona" "read write follow" | cut -d' ' -f1
}
# stats_check <host> <software>: the admin statistics name the peer's software and count traffic both ways.
+2
View File
@@ -2,6 +2,8 @@
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"; repo="$(cd "$here/../.." && pwd)"
net=privapub-pasture; publish="$here/.publish"; ca="$here/.ca"
site() { curl -k --resolve "$1:6443:127.0.0.1" "${@:2}"; }
wait_http() { # url tries
for _ in $(seq 1 "${2:-60}"); do curl -fsk -o /dev/null "$1" && return 0; sleep 2; done
echo "timed out waiting for $1" >&2; return 1
+29
View File
@@ -0,0 +1,29 @@
# Akkoma 3.20.1: our own image (images/akkoma: the OTP release, pinned by checksum) on the shared Postgres. Its config
# is generated on first start; the scenario's user is made with pleroma_ctl and signs in with the password grant.
AKKOMA_IMAGE=${AKKOMA_IMAGE:-localhost/pasture-akkoma:3.20.1}
. "$here/peers/shared.sh"
akkoma_up() {
shared_postgres_up
podman image exists "$AKKOMA_IMAGE" || podman build -q -t "$AKKOMA_IMAGE" "$here/images/akkoma" >/dev/null
podman exec pasture-postgres sh -c "psql -U pasture -tc \"select 1 from pg_database where datname='akkoma'\" | grep -q 1 || createdb -U pasture akkoma"
podman exec pasture-postgres psql -U pasture -d akkoma -qc 'create extension if not exists citext; create extension if not exists pg_trgm; create extension if not exists "uuid-ossp";' >/dev/null
mkdir -p "$here/.state/akkoma"
podman run -d --replace --name pasture-akkoma --network $net \
-v "$here/.state/akkoma:/etc/akkoma:z" -v "$ca:/pasture/ca:z,ro" "$AKKOMA_IMAGE" >/dev/null
for _ in $(seq 1 120); do
site akkoma.test -s -o /dev/null -w '%{http_code}' https://akkoma.test:6443/api/v1/instance 2>/dev/null | grep -q 200 && break
sleep 3
done
podman exec pasture-akkoma /opt/akkoma/bin/pleroma_ctl user new akuser akuser@akkoma.test --password Akkoma-Pasture-Pass-1 \
--name akuser --admin --assume-yes >/dev/null 2>&1 || true
local app cid cs
app=$(site akkoma.test -s -X POST https://akkoma.test:6443/api/v1/apps -d 'client_name=pasture&redirect_uris=urn:ietf:wg:oauth:2.0:oob&scopes=read write follow push admin')
cid=$(echo "$app" | python3 -c "import sys,json; print(json.load(sys.stdin)['client_id'])" 2>/dev/null)
cs=$(echo "$app" | python3 -c "import sys,json; print(json.load(sys.stdin)['client_secret'])" 2>/dev/null)
site akkoma.test -s -X POST https://akkoma.test:6443/oauth/token --data-urlencode grant_type=password --data-urlencode username=akuser \
--data-urlencode password=Akkoma-Pasture-Pass-1 --data-urlencode "client_id=$cid" --data-urlencode "client_secret=$cs" \
--data-urlencode 'scope=read write follow push admin' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])" > "$here/.state/akkoma.token" 2>/dev/null || true
echo "akkoma: https://akkoma.test:6443"
}
+36
View File
@@ -0,0 +1,36 @@
# Lemmy 1.0 (beta): the backend alone (no UI, no pict-rs) on the shared Postgres. 0.19's rustls ignores every CA but its
# bundled ones, so only 1.0 can trust Caddy's; DANGER_FEDERATION_ALLOW_LOCAL_IP lets it reach the pasture's addresses.
LEMMY_IMAGE=${LEMMY_IMAGE:-docker.io/dessalines/lemmy:1.0.0-beta.2}
. "$here/peers/shared.sh"
lemmy_up() {
shared_postgres_up
podman exec pasture-postgres sh -c "psql -U pasture -tc \"select 1 from pg_database where datname='lemmy'\" | grep -q 1 || createdb -U pasture lemmy"
mkdir -p "$here/.state/lemmy"
cat > "$here/.state/lemmy/config.hjson" <<HJSON
{
database: { connection: "postgres://pasture:pasture@postgres:5432/lemmy" }
hostname: "lemmy.test"
bind: "0.0.0.0"
port: 8536
tls_enabled: true
setup: {
admin_username: "lemmyuser"
admin_password: "Lemmy-Pasture-Pass-1"
site_name: "Pasture Lemmy"
admin_email: "lemmyuser@lemmy.test"
}
}
HJSON
podman run -d --replace --name pasture-lemmy --network $net -e LEMMY_CONFIG_LOCATION=/config/config.hjson \
-e DANGER_FEDERATION_ALLOW_LOCAL_IP=1 -e SSL_CERT_FILE=/pasture/ca/bundle.pem -e RUST_LOG="${LEMMY_LOG:-warn}" \
-v "$here/.state/lemmy:/config:z,ro" -v "$ca:/pasture/ca:z,ro" $LEMMY_IMAGE >/dev/null
for _ in $(seq 1 90); do
site lemmy.test -s -o /dev/null -w '%{http_code}' https://lemmy.test:6443/api/v4/site 2>/dev/null | grep -q 200 && break
sleep 2
done
site lemmy.test -s -X POST https://lemmy.test:6443/api/v4/account/auth/login -H 'Content-Type: application/json' \
-d '{"username_or_email":"lemmyuser","password":"Lemmy-Pasture-Pass-1"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['jwt'])" > "$here/.state/lemmy.token" 2>/dev/null || true
echo "lemmy: https://lemmy.test:6443"
}
+48
View File
@@ -0,0 +1,48 @@
# Misskey: one container on the shared Postgres (database misskey) and Redis (db 2). The first account, made with the
# setup password, is the scenario's user; its token is kept in .state/misskey.token.
MISSKEY_IMAGE=${MISSKEY_IMAGE:-docker.io/misskey/misskey:2026.10.0}
MISSKEY_NAME=${MISSKEY_NAME:-misskey}
. "$here/peers/shared.sh"
misskey_up() {
shared_postgres_up
shared_redis_up
podman exec pasture-postgres sh -c "psql -U pasture -tc \"select 1 from pg_database where datname='$MISSKEY_NAME'\" | grep -q 1 || createdb -U pasture $MISSKEY_NAME"
mkdir -p "$here/.state/$MISSKEY_NAME"
cat > "$here/.state/$MISSKEY_NAME/default.yml" <<YML
url: https://$MISSKEY_NAME.test/
port: 3000
db:
host: postgres
port: 5432
db: $MISSKEY_NAME
user: pasture
pass: pasture
dbReplications: false
redis:
host: redis
port: 6379
db: ${MISSKEY_REDIS_DB:-2}
fulltextSearch:
provider: sqlLike
id: 'aidx'
setupPassword: pasture-setup
allowedPrivateNetworks:
- '10.0.0.0/8'
- '172.16.0.0/12'
- '192.168.0.0/16'
YML
podman run -d --replace --name pasture-$MISSKEY_NAME --network $net -e NODE_EXTRA_CA_CERTS=/pasture/ca/root.crt \
-v "$here/.state/$MISSKEY_NAME:${MISSKEY_HOME:-/misskey}/.config:z,ro" -v "$ca:/pasture/ca:z,ro" $MISSKEY_IMAGE >/dev/null
for _ in $(seq 1 120); do
site "$MISSKEY_NAME.test" -s -o /dev/null -w '%{http_code}' -X POST "https://$MISSKEY_NAME.test:6443/api/meta" -H 'Content-Type: application/json' -d '{}' 2>/dev/null | grep -q 200 && break
sleep 3
done
site "$MISSKEY_NAME.test" -s -X POST "https://$MISSKEY_NAME.test:6443/api/admin/accounts/create" -H 'Content-Type: application/json' \
-d '{"username":"mkuser","password":"Misskey-Pasture-Pass-1","setupPassword":"pasture-setup"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['token'])" > "$here/.state/$MISSKEY_NAME.token" 2>/dev/null || true
# a new Misskey federates with nobody (federation: none) until its admin says otherwise
site "$MISSKEY_NAME.test" -s -o /dev/null -X POST "https://$MISSKEY_NAME.test:6443/api/admin/update-meta" -H 'Content-Type: application/json' \
-d "{\"i\":\"$(cat "$here/.state/$MISSKEY_NAME.token")\",\"federation\":\"all\"}"
echo "$MISSKEY_NAME: https://$MISSKEY_NAME.test:6443"
}
+8
View File
@@ -0,0 +1,8 @@
# Sharkey: the Misskey peer under its own name, image, database, Redis db and home. Its scenario is Misskey's plus what
# only Sharkey sends.
. "$here/peers/misskey.sh"
sharkey_up() {
MISSKEY_NAME=sharkey MISSKEY_IMAGE=${SHARKEY_IMAGE:-registry.activitypub.software/transfem-org/sharkey:2025.4.7} \
MISSKEY_REDIS_DB=3 MISSKEY_HOME=/sharkey misskey_up
}
+1 -1
View File
@@ -2,7 +2,7 @@
# A private test fediverse on one podman network: PrivaPub (privapub.test) and the peers asked for, behind one Caddy
# whose internal CA every side is told to accept (its root is copied to .ca/root.crt). From the workstation: PrivaPub's
# API at http://127.0.0.1:6971, every site at https://<name>.test:6443 (curl --resolve <name>.test:6443:127.0.0.1 -k).
# usage: tools/pasture/run.sh up [peer...] | down | logs <name> | ps peers: gts (default)
# usage: tools/pasture/run.sh up [peer...] | down | logs <name> | ps peers: gts (default), mastodon, misskey, sharkey, akkoma, lemmy
set -euo pipefail
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/pasture.sh"
+142
View File
@@ -0,0 +1,142 @@
# Akkoma 3.20: discovery, follows, posts, CW, replies both ways, likes and boosts with their undos, emoji reactions
# both ways, DMs, polls, quotes both ways, edits with their history, deletes, media with alt text, the published time
# kept, blocks, statistics. Akkoma speaks the Mastodon API, with reactions under /api/v1/pleroma.
AK=https://akkoma.test:6443
acurl() { curl -sk --resolve akkoma.test:6443:127.0.0.1 "$@"; }
KT=$(cat "$here/.state/akkoma.token" 2>/dev/null)
KH="Authorization: Bearer $KT"
# what Akkoma holds of an account, newest first; and the one status whose uri is given
a_statuses() { acurl -H "$KH" "$AK/api/v1/accounts/$1/statuses?limit=40"; }
a_status_by_uri() { a_statuses "$1" | j "print(json.dumps(next((s for s in d if s['uri']=='$2'), None)))"; }
# a PrivaPub status in alice_akkoma's home whose text has the given words
p_home_id() { curl -s -H "$PH" "$P/api/v1/timelines/home?limit=40" | j "print(next((s['id'] for s in d if '$1' in s['content']), ''))"; }
echo "akkoma"
[ -n "$KT" ] && ok "Akkoma token for akuser" || { ko "Akkoma token"; return 1; }
PT=$(privapub_token alice_akkoma)
PH="Authorization: Bearer $PT"
[ -n "$PT" ] && ok "PrivaPub token for alice_akkoma" || { ko "PrivaPub token for alice_akkoma"; return 1; }
alice_self=$(curl -s -H "$PH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])")
echo " discovery"
alice_on_a=$(acurl -H "$KH" "$AK/api/v2/search?q=@alice_akkoma@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
[ -n "$alice_on_a" ] && ok "Akkoma resolves @alice_akkoma@privapub.test" || ko "Akkoma cannot resolve alice_akkoma"
ak_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=akuser@akkoma.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
[ -n "$ak_on_p" ] && ok "PrivaPub resolves @akuser@akkoma.test" || ko "PrivaPub cannot resolve akuser"
echo " follows"
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/accounts/$alice_on_a/follow"
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/accounts/relationships?id[]=$alice_on_a" | j "print(d[0][\"following\"])")" = "True" ]' && ok "akuser follows alice_akkoma (Accept arrived)" || ko "Akkoma's follow not accepted"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$ak_on_p/follow"
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$ak_on_p" | j "print(d[0][\"following\"])")" = "True" ]' && ok "alice_akkoma follows akuser (Accept arrived)" || ko "PrivaPub's follow not accepted"
echo " posts"
p_post=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=Hello Akkoma from PrivaPub&visibility=public')
p_post_id=$(echo "$p_post" | j "print(d['id'])"); p_post_uri=$(echo "$p_post" | j "print(d['uri'])")
until_true 30 '[ "$(a_status_by_uri "$alice_on_a" "$p_post_uri")" != "null" ]' && ok "alice_akkoma's post reaches Akkoma" || ko "post missing on Akkoma"
p_post_on_a=$(a_status_by_uri "$alice_on_a" "$p_post_uri" | j "print(d['id'])")
a_post=$(acurl -X POST -H "$KH" "$AK/api/v1/statuses" -d 'status=Hello PrivaPub from Akkoma&visibility=public')
a_post_id=$(echo "$a_post" | j "print(d['id'])")
until_true 30 '[ -n "$(p_home_id "Hello PrivaPub from Akkoma")" ]' && ok "akuser's post reaches alice_akkoma's home" || ko "Akkoma's post missing on PrivaPub"
a_post_on_p=$(p_home_id "Hello PrivaPub from Akkoma")
a_created=$(echo "$a_post" | j "print(d['created_at'][:19])")
[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$a_post_on_p" | j "print(d['created_at'][:19])")" = "$a_created" ] \
&& ok "akuser's post keeps its published time" || ko "published time changed on PrivaPub"
cw_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=behind a warning&spoiler_text=spoilers&visibility=public' | j "print(d['uri'])")
until_true 30 '[ "$(a_status_by_uri "$alice_on_a" "$cw_uri" | j "print(d and d[\"spoiler_text\"]==\"spoilers\" and d[\"sensitive\"])")" = "True" ]' && ok "a content warning survives to Akkoma" || ko "content warning lost on Akkoma"
fo_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=only for followers&visibility=private' | j "print(d['uri'])")
until_true 30 '[ "$(a_status_by_uri "$alice_on_a" "$fo_uri" | j "print(d and d[\"visibility\"])")" = "private" ]' && ok "a followers-only post reaches Akkoma as private" || ko "followers-only post missing or widened on Akkoma"
echo " replies"
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses" -d "status=@alice_akkoma@privapub.test replying from Akkoma&in_reply_to_id=$p_post_on_a&visibility=public"
until_true 30 'curl -s -H "$PH" "$P/api/v1/notifications" | j "print(any(n[\"type\"]==\"mention\" for n in d))" | grep -q True' && ok "akuser's reply notifies alice_akkoma" || ko "reply did not notify"
until_true 15 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id/context" | j "print(len(d[\"descendants\"]))")" -ge 1 ]' && ok "the reply threads under alice_akkoma's post" || ko "reply not threaded on PrivaPub"
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d "status=@akuser@akkoma.test replying from PrivaPub&in_reply_to_id=$a_post_on_p&visibility=public"
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post_id/context" | j "print(len(d[\"descendants\"]))")" -ge 1 ]' && ok "alice_akkoma's reply threads under akuser's post" || ko "outbound reply not threaded on Akkoma"
echo " likes, boosts and reactions"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$a_post_on_p/favourite"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$a_post_on_p/reblog"
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post_id" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "1 1" ]' && ok "alice_akkoma's like and boost count on Akkoma" || ko "like or boost not counted on Akkoma"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$a_post_on_p/unfavourite"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$a_post_on_p/unreblog"
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post_id" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "0 0" ]' && ok "alice_akkoma's unlike and unboost reach Akkoma" || ko "undo of like or boost not applied on Akkoma"
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses/$p_post_on_a/favourite"
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses/$p_post_on_a/reblog"
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "1 1" ]' && ok "akuser's like and boost count on PrivaPub" || ko "like or boost not counted on PrivaPub"
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses/$p_post_on_a/unfavourite"
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses/$p_post_on_a/unreblog"
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "0 0" ]' && ok "akuser's unlike and unboost reach PrivaPub" || ko "undo of like or boost not applied on PrivaPub"
acurl -o /dev/null -X PUT -H "$KH" "$AK/api/v1/pleroma/statuses/$p_post_on_a/reactions/%F0%9F%8E%89"
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id" | j "print(any(r[\"name\"]==\"🎉\" and r[\"count\"]==1 for r in d.get(\"emoji_reactions\") or []))")" = "True" ]' \
&& ok "akuser's 🎉 (EmojiReact) arrives as a reaction" || ko "Akkoma's reaction missing on PrivaPub"
acurl -o /dev/null -X DELETE -H "$KH" "$AK/api/v1/pleroma/statuses/$p_post_on_a/reactions/%F0%9F%8E%89"
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id" | j "print(len(d.get(\"emoji_reactions\") or []))")" = "0" ]' && ok "akuser's withdrawn reaction is gone" || ko "withdrawn reaction still on PrivaPub"
curl -s -o /dev/null -X PUT -H "$PH" "$P/api/v1/pleroma/statuses/$a_post_on_p/reactions/%F0%9F%91%8D"
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post_id" | j "print(any(r[\"name\"]==\"👍\" and r[\"count\"]==1 for r in (d.get(\"pleroma\") or {}).get(\"emoji_reactions\") or d.get(\"emoji_reactions\") or []))")" = "True" ]' \
&& ok "alice_akkoma's 👍 is a reaction on Akkoma" || ko "PrivaPub's reaction missing on Akkoma"
curl -s -o /dev/null -X DELETE -H "$PH" "$P/api/v1/pleroma/statuses/$a_post_on_p/reactions/%F0%9F%91%8D"
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post_id" | j "print(len((d.get(\"pleroma\") or {}).get(\"emoji_reactions\") or d.get(\"emoji_reactions\") or []))")" = "0" ]' && ok "alice_akkoma's withdrawn reaction is gone from Akkoma" || ko "withdrawn reaction still on Akkoma"
echo " direct messages"
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d 'status=@akuser@akkoma.test a secret for Akkoma&visibility=direct'
until_true 30 'acurl -H "$KH" "$AK/api/v1/conversations" | grep -q "a secret for Akkoma"' && ok "alice_akkoma's DM reaches akuser" || ko "DM missing on Akkoma"
# Akkoma's Linkify never takes @user@host.test for a mention, so its DM names alice with Pleroma's to[] instead
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses" -d 'status=a secret for PrivaPub&visibility=direct&to[]=alice_akkoma@privapub.test'
until_true 30 'curl -s -H "$PH" "$P/api/v1/conversations" | grep -q "a secret for PrivaPub"' && ok "akuser's DM reaches alice_akkoma" || ko "DM missing on PrivaPub"
! curl -s "$P/api/v1/timelines/public" | grep -q "a secret for PrivaPub" && ok "the DM is not on PrivaPub's public timeline" || ko "DM leaked to the public timeline"
echo " polls"
p_poll_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=cats or dogs&visibility=public&poll[options][]=cats&poll[options][]=dogs&poll[expires_in]=3600' | j "print(d['uri'])")
until_true 30 '[ "$(a_status_by_uri "$alice_on_a" "$p_poll_uri" | j "print(len(d[\"poll\"][\"options\"]))")" = "2" ]' && ok "alice_akkoma's poll reaches Akkoma as a poll" || ko "poll missing on Akkoma"
p_poll_on_a=$(a_status_by_uri "$alice_on_a" "$p_poll_uri" | j "print(d['poll']['id'])")
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/polls/$p_poll_on_a/votes" -d 'choices[]=1'
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/$alice_self/statuses" | j "print(next(s[\"poll\"][\"options\"][1][\"votes_count\"] for s in d if s[\"uri\"]==\"$p_poll_uri\"))")" = "1" ]' && ok "akuser's vote counts on PrivaPub" || ko "vote not counted on PrivaPub"
a_poll=$(acurl -X POST -H "$KH" "$AK/api/v1/statuses" -d 'status=tea or coffee, Akkoma asks&visibility=public&poll[options][]=tea&poll[options][]=coffee&poll[expires_in]=3600' | j "print(d['id'])")
until_true 30 'curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(any(s[\"poll\"] and \"Akkoma asks\" in s[\"content\"] for s in d))" | grep -q True' && ok "akuser's poll reaches PrivaPub as a poll" || ko "poll missing on PrivaPub"
a_poll_on_p=$(curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(next(s['poll']['id'] for s in d if s['poll'] and 'Akkoma asks' in s['content']))")
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/polls/$a_poll_on_p/votes" -d 'choices[]=0'
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_poll" | j "print(d[\"poll\"][\"options\"][0][\"votes_count\"])")" = "1" ]' && ok "alice_akkoma's vote counts on Akkoma" || ko "vote not counted on Akkoma"
echo " quotes"
a_quote=$(acurl -X POST -H "$KH" "$AK/api/v1/statuses" -d "status=quoting PrivaPub&visibility=public&quote_id=$p_post_on_a" | j "print(d['id'])")
until_true 30 'curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(any(\"quoting PrivaPub\" in s[\"content\"] and (s.get(\"quote\") or {}).get(\"state\")==\"accepted\" for s in d))" | grep -q True' \
&& ok "akuser's quote arrives as an accepted quote" || ko "Akkoma's quote not understood"
p_quote_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=quoting Akkoma&visibility=public&quoted_status_id=$a_post_on_p" | j "print(d['uri'])")
until_true 30 '[ "$(a_status_by_uri "$alice_on_a" "$p_quote_uri" | j "print(d and ((d.get(\"quote\") or {}).get(\"id\") or d.get(\"quote_id\") or (d.get(\"pleroma\") or {}).get(\"quote_id\")) == \"$a_post_id\")")" = "True" ]' \
&& ok "alice_akkoma's quote is a quote on Akkoma" || ko "quote not understood by Akkoma"
echo " media"
make_png "$work/red.png"
p_media=$(curl -s -X POST -H "$PH" "$P/api/v2/media" -F "file=@$work/red.png;type=image/png" -F 'description=a red square' | j "print(d['id'])")
p_media_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=a picture&visibility=public&media_ids[]=$p_media" | j "print(d['uri'])")
until_true 30 '[ "$(a_status_by_uri "$alice_on_a" "$p_media_uri" | j "print(d[\"media_attachments\"][0][\"description\"])")" = "a red square" ]' && ok "an image with alt text reaches Akkoma" || ko "image or alt text missing on Akkoma"
a_media=$(acurl -X POST -H "$KH" "$AK/api/v1/media" -F "file=@$work/red.png;type=image/png" -F 'description=a red square from Akkoma' | j "print(d['id'])")
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses" -d "status=a picture from Akkoma&visibility=public&media_ids[]=$a_media"
until_true 30 'curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(any(\"a picture from Akkoma\" in s[\"content\"] and s[\"media_attachments\"] and \"/media/proxy/\" in s[\"media_attachments\"][0][\"url\"] and s[\"media_attachments\"][0][\"description\"]==\"a red square from Akkoma\" for s in d))" | grep -q True' \
&& ok "an image with alt text from Akkoma arrives through our proxy" || ko "Akkoma's image missing, unproxied or without alt text"
echo " edits and deletes"
curl -s -o /dev/null -X PUT -H "$PH" "$P/api/v1/statuses/$p_post_id" -d 'status=Hello Akkoma from PrivaPub, edited'
until_true 30 'acurl -H "$KH" "$AK/api/v1/statuses/$p_post_on_a" | grep -q "edited"' && ok "alice_akkoma's edit reaches Akkoma" || ko "edit not applied on Akkoma"
acurl -o /dev/null -X PUT -H "$KH" "$AK/api/v1/statuses/$a_post_id" -d 'status=Hello PrivaPub from Akkoma, edited'
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$a_post_on_p/history" | j "print(len(d))")" = "2" ]' && ok "akuser's edit reaches PrivaPub with its history" || ko "Akkoma's edit not applied on PrivaPub"
cw_on_a=$(a_status_by_uri "$alice_on_a" "$cw_uri" | j "print(d['id'])")
cw_id=$(curl -s -H "$PH" "$P/api/v1/accounts/$alice_self/statuses" | j "print(next(s['id'] for s in d if s['uri']=='$cw_uri'))")
curl -s -o /dev/null -X DELETE -H "$PH" "$P/api/v1/statuses/$cw_id"
until_true 30 '[ "$(acurl -o /dev/null -w "%{http_code}" -H "$KH" "$AK/api/v1/statuses/$cw_on_a")" = "404" ]' && ok "alice_akkoma's delete reaches Akkoma" || ko "delete not applied on Akkoma"
acurl -o /dev/null -X DELETE -H "$KH" "$AK/api/v1/statuses/$a_poll"
until_true 30 '! curl -s -H "$PH" "$P/api/v1/timelines/home" | grep -q "Akkoma asks"' && ok "akuser's delete reaches PrivaPub" || ko "Akkoma's delete not applied on PrivaPub"
echo " blocks and unfollows"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$ak_on_p/unfollow"
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/accounts/relationships?id[]=$alice_on_a" | j "print(d[0][\"followed_by\"])")" = "False" ]' && ok "alice_akkoma's unfollow reaches Akkoma" || ko "unfollow not applied on Akkoma"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$ak_on_p/block"
# Akkoma never reports a remote blocker as blocked_by, so the block is read from its user_relationships (1 is a block)
a_blocked() { podman exec pasture-postgres psql -U pasture -d akkoma -tAc "select count(*) from user_relationships r join users a on a.id=r.source_id join users b on b.id=r.target_id where r.relationship_type=1 and a.nickname='alice_akkoma@privapub.test' and b.nickname='akuser'"; }
until_true 30 '[ "$(a_blocked)" = "1" ]' && ok "alice_akkoma's block reaches Akkoma" || ko "block not applied on Akkoma"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$ak_on_p/unblock"
until_true 30 '[ "$(a_blocked)" = "0" ]' && ok "alice_akkoma's unblock reaches Akkoma" || ko "unblock not applied on Akkoma"
echo " statistics"
stats_check akkoma.test akkoma
+90
View File
@@ -101,6 +101,96 @@ curl -s -o /dev/null -X DELETE -H "$PH" $P/api/v1/statuses/$cw
gcurl -s -o /dev/null -X DELETE -H "$GH" $G/api/v1/statuses/$gts_post
until_true 20 '! curl -s -H "$PH" "$P/api/v1/timelines/home" | grep -q "Hello from GoToSocial"' && ok "gtsuser's delete reaches PrivaPub" || ko "remote delete not applied"
echo "more replies, edits and undos"
gts_edit=$(gcurl -s -X POST -H "$GH" $G/api/v1/statuses -d 'status=GoToSocial before the edit&visibility=public' | j "print(d['id'])")
until_true 20 'curl -s -H "$PH" "$P/api/v1/timelines/home" | grep -q "GoToSocial before the edit"' || true
gts_edit_on_pp=$(curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(next(s['id'] for s in d if 'GoToSocial before the edit' in s['content']))")
pp_reply=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=@gtsuser@gts.test answering from PrivaPub&in_reply_to_id=$gts_edit_on_pp&visibility=public" | j "print(d['uri'])")
until_true 20 '[ "$(on_gts "$pp_reply" | j "print(d[\"statuses\"][0][\"in_reply_to_id\"] == \"$gts_edit\")")" = "True" ]' && ok "alice's reply threads under gtsuser's post" || ko "outbound reply not threaded on GoToSocial"
gcurl -s -o /dev/null -X PUT -H "$GH" $G/api/v1/statuses/$gts_edit -d 'status=GoToSocial after the edit'
until_true 20 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$gts_edit_on_pp/history" | j "print(len(d))")" = "2" ]' && ok "gtsuser's edit reaches PrivaPub with its history" || ko "GoToSocial's edit not applied"
[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$gts_edit_on_pp" | j "print(d['edited_at'] is not None)")" = "True" ] && ok "the edited post carries edited_at" || ko "no edited_at on the edited post"
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses/$gts_edit_on_pp/favourite
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses/$gts_edit_on_pp/reblog
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/statuses/$gts_edit" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "1 1" ]' || true
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses/$gts_edit_on_pp/unfavourite
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses/$gts_edit_on_pp/unreblog
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/statuses/$gts_edit" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "0 0" ]' && ok "alice's unlike and unboost reach GoToSocial" || ko "undo of like or boost not applied on GoToSocial"
pp_undo=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=like me and take it back&visibility=public' | j "print(d['id'] + ' ' + d['uri'])")
pp_undo_id=${pp_undo% *}; pp_undo_uri=${pp_undo#* }
until_true 20 '[ -n "$(on_gts "$pp_undo_uri" | j "print(d[\"statuses\"][0][\"id\"])")" ]' || true
pp_undo_on_gts=$(on_gts "$pp_undo_uri" | j "print(d['statuses'][0]['id'])")
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/statuses/$pp_undo_on_gts/favourite
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/statuses/$pp_undo_on_gts/reblog
until_true 20 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$pp_undo_id" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "1 1" ]' || true
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/statuses/$pp_undo_on_gts/unfavourite
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/statuses/$pp_undo_on_gts/unreblog
until_true 20 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$pp_undo_id" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "0 0" ]' && ok "gtsuser's unlike and unboost reach PrivaPub" || ko "GoToSocial's undo of like or boost not applied"
echo "media"
make_png "$work/red.png"
pp_media=$(curl -s -X POST -H "$PH" "$P/api/v2/media" -F "file=@$work/red.png;type=image/png" -F 'description=a red square' | j "print(d['id'])")
pp_media_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=a picture for GoToSocial&visibility=public&media_ids[]=$pp_media" | j "print(d['uri'])")
until_true 20 '[ "$(on_gts "$pp_media_uri" | j "print(d[\"statuses\"][0][\"media_attachments\"][0][\"description\"])")" = "a red square" ]' && ok "an image with alt text reaches GoToSocial" || ko "image or alt text missing on GoToSocial"
g_media=$(gcurl -s -X POST -H "$GH" "$G/api/v2/media" -F "file=@$work/red.png;type=image/png" -F 'description=a red square from GoToSocial' | j "print(d['id'])")
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/statuses -d "status=a picture from GoToSocial&visibility=public&media_ids[]=$g_media"
until_true 20 'curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(any(\"a picture from GoToSocial\" in s[\"content\"] and s[\"media_attachments\"] and \"/media/proxy/\" in s[\"media_attachments\"][0][\"url\"] and s[\"media_attachments\"][0][\"description\"]==\"a red square from GoToSocial\" for s in d))" | grep -q True' \
&& ok "an image with alt text from GoToSocial arrives through our proxy" || ko "GoToSocial's image missing, unproxied or without alt text"
echo "communities"
jwt=$(privapub_root)
alice_id=$(curl -s -H "$PH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])")
community_name="community$(date +%s)"
community=$(curl -s -X POST $P/clientapi/group/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
-d "{\"avatarId\":\"$alice_id\",\"userName\":\"$community_name\",\"name\":\"a community\",\"description\":\"for everyone\",\"isCommunity\":true}" | j "print(d['id'])")
community_on_gts=$(gcurl -s -H "$GH" "$G/api/v2/search?q=@$community_name@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/accounts/$community_on_gts/follow
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$community_on_gts" | j "print(d[0][\"following\"])")" = "True" ]' && ok "gtsuser follows a PrivaPub community" || ko "community follow not accepted"
curl -s -o /dev/null -X POST $P/clientapi/post/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
-d "{\"avatarId\":\"$alice_id\",\"title\":\"A community thread\",\"text\":\"posted into the community\",\"groupId\":\"$community\"}"
community_post=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'print(db.Post.findOne({Text:/posted into the community/}).ObjectURI)')
until_true 20 '[ "$(on_gts "$community_post" | j "print(len(d[\"statuses\"]))")" = "1" ]' && ok "the community's announce brings its post to GoToSocial" || ko "community post missing on GoToSocial"
echo "circles"
circle_name="circle$(date +%s)"
circle=$(curl -s -X POST $P/clientapi/group/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
-d "{\"avatarId\":\"$alice_id\",\"userName\":\"$circle_name\",\"name\":\"a circle\",\"description\":\"just us\",\"isCommunity\":false}" | j "print(d['id'])")
circle_on_gts=$(gcurl -s -H "$GH" "$G/api/v2/search?q=@$circle_name@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/accounts/$circle_on_gts/follow
until_true 15 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$circle_on_gts" | j "print(d[0][\"requested\"])")" = "True" ]' && ok "gtsuser's request to join a circle waits for its owner" || ko "joining the circle was not held for approval"
requester=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval "print(db.Follower.findOne({LocalActorId:'$circle', IsAccepted:false}).ActorURI)")
curl -s -o /dev/null -X POST $P/clientapi/group/approve -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
-d "{\"avatarId\":\"$alice_id\",\"groupId\":\"$circle\",\"memberActorURI\":\"$requester\"}"
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$circle_on_gts" | j "print(d[0][\"following\"])")" = "True" ]' && ok "the owner's approval makes gtsuser a circle member" || ko "circle approval did not reach GoToSocial"
curl -s -o /dev/null -X POST $P/clientapi/post/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
-d "{\"avatarId\":\"$alice_id\",\"text\":\"only the circle on GoToSocial sees this\",\"groupId\":\"$circle\"}"
circle_uri=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'print(db.Post.findOne({Text:/only the circle on GoToSocial/}).ObjectURI)')
if until_true 15 '[ "$(on_gts "$circle_uri" | j "print(len(d[\"statuses\"]))")" = "1" ]'; then
ok "a circle post reaches its GoToSocial member"
else
xf "a circle post reaches its GoToSocial member (GoToSocial keeps no post addressed only to a collection it does not know)"
fi
[ "$(pfetch -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$circle_uri")" = "404" ] && ok "the circle post is not served unsigned" || ko "circle post served unsigned"
echo "locked personas"
LT=$(privapub_token locked_alice); LH="Authorization: Bearer $LT"
curl -s -o /dev/null -X PATCH -H "$LH" "$P/api/v1/accounts/update_credentials" -d 'locked=true'
locked_on_gts=$(gcurl -s -H "$GH" "$G/api/v2/search?q=@locked_alice@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/accounts/$locked_on_gts/follow
until_true 20 '[ "$(curl -s -H "$LH" "$P/api/v1/follow_requests" | j "print(len(d))")" = "1" ]' && ok "gtsuser's follow waits on a locked persona" || ko "no follow request on the locked persona"
[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$locked_on_gts" | j "print(d[0][\"requested\"])")" = "True" ] && ok "GoToSocial shows the follow as requested" || ko "GoToSocial does not show the request"
requester=$(curl -s -H "$LH" "$P/api/v1/follow_requests" | j "print(d[0]['id'])")
curl -s -o /dev/null -X POST -H "$LH" "$P/api/v1/follow_requests/$requester/reject"
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$locked_on_gts" | j "print(d[0][\"requested\"] or d[0][\"following\"])")" = "False" ]' && ok "a rejected request is withdrawn on GoToSocial" || ko "rejection not applied on GoToSocial"
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/accounts/$locked_on_gts/follow
until_true 20 '[ "$(curl -s -H "$LH" "$P/api/v1/follow_requests" | j "print(len(d))")" = "1" ]' || true
requester=$(curl -s -H "$LH" "$P/api/v1/follow_requests" | j "print(d[0]['id'])")
curl -s -o /dev/null -X POST -H "$LH" "$P/api/v1/follow_requests/$requester/authorize"
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$locked_on_gts" | j "print(d[0][\"following\"])")" = "True" ]' && ok "an authorized request makes gtsuser a follower" || ko "authorization not applied on GoToSocial"
echo "smoke"
"$here/../smoke/mastodon-api.sh" "$P" "$PT" >/dev/null 2>&1 && ok "the deploy's Mastodon smoke check passes, signed in" || ko "the Mastodon smoke check fails"
echo "unfollow"
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/accounts/$gts_on_pp/unfollow
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$alice_on_gts" | j "print(d[0][\"followed_by\"])")" = "False" ]' && ok "alice's unfollow reaches GoToSocial" || ko "unfollow not applied"
+114
View File
@@ -0,0 +1,114 @@
# Lemmy 1.0: communities both ways (FEP-1b12), threads with titles, comments both ways, votes up and down both ways,
# private messages both ways, a moderator's removal (P7), statistics. Lemmy is driven through its v4 API.
LM=https://lemmy.test:6443
LT=$(cat "$here/.state/lemmy.token" 2>/dev/null)
# lm <method> <path> [json]: a Lemmy v4 API call as lemmyuser
lm() {
local method=$1 path=$2 body=${3:-}
if [ -n "$body" ]; then
site lemmy.test -s -X "$method" "$LM/api/v4/$path" -H "Authorization: Bearer $LT" -H 'Content-Type: application/json' -d "$body"
else
site lemmy.test -s -X "$method" "$LM/api/v4/$path" -H "Authorization: Bearer $LT"
fi
}
# the posts Lemmy holds in a community, and the one whose ap_id is given
lm_posts() { lm GET "post/list?community_id=$1&sort=new&limit=50" | j "print(json.dumps([p['post'] for p in (d.get('items') or d.get('posts') or [])]))"; }
# 1 once Lemmy runs a send worker for privapub.test
lm_worker() { podman exec pasture-postgres psql -U pasture -d lemmy -tAc "select count(*) from federation_queue_state q join instance i on i.id=q.instance_id where i.domain='privapub.test'"; }
lm_post_by_ap() { lm_posts "$1" | j "print(json.dumps(next((p for p in d if p['ap_id']=='$2'), None)))"; }
echo "lemmy"
[ -n "$LT" ] && ok "Lemmy token for lemmyuser" || { ko "Lemmy token"; return 1; }
LAT=$(privapub_token alice_lemmy)
LAH="Authorization: Bearer $LAT"
[ -n "$LAT" ] && ok "PrivaPub token for alice_lemmy" || { ko "PrivaPub token for alice_lemmy"; return 1; }
jwt=$(privapub_root)
alice_id=$(curl -s -H "$LAH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])")
dogs="dogs$(date +%s)"
cats="cats$(date +%s)"
echo " communities"
dogs_gid=$(curl -s -X POST $P/clientapi/group/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
-d "{\"avatarId\":\"$alice_id\",\"userName\":\"$dogs\",\"name\":\"Pasture dogs\",\"description\":\"a PrivaPub community\",\"isCommunity\":true}" | j "print(d['id'])")
dogs_on_lm=$(lm GET "resolve_object?q=!$dogs@privapub.test" | j "print(d['community']['id'])")
[ -n "$dogs_on_lm" ] && ok "Lemmy resolves a PrivaPub community" || ko "Lemmy cannot resolve the PrivaPub community"
# Lemmy 1.0 starts its send worker for a newly seen server at the newest activity and never sends anything queued for
# that server before then, so on a clean pasture the first follow waits for the worker (up to a minute)
until_true 45 '[ "$(lm_worker)" = "1" ]' || true
lm POST community/follow "{\"community_id\":$dogs_on_lm,\"follow\":true}" >/dev/null
until_true 30 '[ "$(lm GET "community?id=$dogs_on_lm" | j "print(((d[\"community_view\"].get(\"community_actions\") or {}).get(\"follow_state\") or \"\").lower())")" = "accepted" ]' \
&& ok "lemmyuser follows the PrivaPub community (Accept arrived)" || ko "Lemmy's community follow not accepted"
cats_on_lm=$(lm POST community "{\"name\":\"$cats\",\"title\":\"Pasture cats\"}" | j "print(d['community_view']['community']['id'])")
cats_on_p=$(curl -s -H "$LAH" "$P/api/v2/search?q=@$cats@lemmy.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
[ -n "$cats_on_p" ] && ok "PrivaPub resolves a Lemmy community" || ko "PrivaPub cannot resolve the Lemmy community"
curl -s -o /dev/null -X POST -H "$LAH" "$P/api/v1/accounts/$cats_on_p/follow"
until_true 30 '[ "$(curl -s -H "$LAH" "$P/api/v1/accounts/relationships?id[]=$cats_on_p" | j "print(d[0][\"following\"])")" = "True" ]' && ok "alice follows the Lemmy community (Accept arrived)" || ko "PrivaPub's community follow not accepted"
echo " threads"
lm_thread=$(lm POST post "{\"name\":\"A Lemmy thread\",\"body\":\"posted from Lemmy into PrivaPub\",\"community_id\":$dogs_on_lm}" | j "print(d['post_view']['post']['ap_id'])")
dogs_on_p=$(curl -s -H "$LAH" "$P/api/v1/accounts/lookup?acct=$dogs" | j "print(d['id'])")
until_true 30 '[ "$(curl -s -H "$LAH" "$P/api/v1/accounts/$dogs_on_p/statuses" | j "print(any(s[\"uri\"]==\"$lm_thread\" and (s.get(\"privapub\") or {}).get(\"title\")==\"A Lemmy thread\" for s in d))")" = "True" ]' \
&& ok "a Lemmy thread in the PrivaPub community arrives with its title" || ko "Lemmy's thread missing or untitled on PrivaPub"
curl -s -o /dev/null -X POST $P/clientapi/post/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
-d "{\"avatarId\":\"$alice_id\",\"title\":\"A PrivaPub community thread\",\"text\":\"posted into our own community\",\"groupId\":\"$dogs_gid\"}"
dogs_post=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'print(db.Post.findOne({Text:/posted into our own community/}, {}, {sort:{_id:-1}}).ObjectURI)')
until_true 30 '[ "$(lm_post_by_ap "$dogs_on_lm" "$dogs_post" | j "print(d and d[\"name\"])")" = "A PrivaPub community thread" ]' \
&& ok "a titled post in the PrivaPub community reaches Lemmy through its announce" || ko "PrivaPub community post missing on Lemmy"
# Lemmy 1.0 holds its user's thread in a remote community as pending until the community announces it back, and clears
# the flag even though it then answers that Announce 400 ("Object is not remote"): the echo is what publishes it.
until_true 30 '[ "$(lm_post_by_ap "$dogs_on_lm" "$lm_thread" | j "print(d and d[\"federation_pending\"])")" = "False" ]' \
&& ok "the PrivaPub community's announce back publishes Lemmy's thread there" || ko "Lemmy's thread stays federation_pending"
lm_cats_thread=$(lm POST post "{\"name\":\"Cats only\",\"body\":\"a Lemmy community post\",\"community_id\":$cats_on_lm}" | j "print(d['post_view']['post']['ap_id'])")
until_true 30 'curl -s -H "$LAH" "$P/api/v1/timelines/home" | j "print(any(s[\"uri\"]==\"$lm_cats_thread\" or (s.get(\"reblog\") or {}).get(\"uri\")==\"$lm_cats_thread\" for s in d))" | grep -q True' \
&& ok "the Lemmy community's announce brings its thread to alice's home" || ko "Lemmy community thread missing from alice's home"
p_thread=$(curl -s -X POST -H "$LAH" $P/api/v1/statuses --data-urlencode "status=A PrivaPub thread
@$cats@lemmy.test posted from PrivaPub" -d 'visibility=public')
p_thread_id=$(echo "$p_thread" | j "print(d['id'])"); p_thread_uri=$(echo "$p_thread" | j "print(d['uri'])")
until_true 30 '[ "$(lm_post_by_ap "$cats_on_lm" "$p_thread_uri")" != "null" ]' && ok "alice's thread lands in the Lemmy community" || ko "PrivaPub thread missing from the Lemmy community"
p_thread_on_lm=$(lm_post_by_ap "$cats_on_lm" "$p_thread_uri" | j "print(d['id'])")
echo " comments"
lm POST comment "{\"post_id\":$p_thread_on_lm,\"content\":\"a Lemmy comment on PrivaPub\"}" >/dev/null
until_true 30 '[ "$(curl -s -H "$LAH" "$P/api/v1/statuses/$p_thread_id/context" | j "print(any(\"a Lemmy comment on PrivaPub\" in s[\"content\"] for s in d[\"descendants\"]))")" = "True" ]' \
&& ok "Lemmy's comment threads under alice's post" || ko "Lemmy's comment missing on PrivaPub"
lm_cats_on_p=$(curl -s -H "$LAH" "$P/api/v1/timelines/home" | j "print(next((s.get('reblog') or s)['id'] for s in d if (s.get('reblog') or s)['uri']=='$lm_cats_thread'))")
curl -s -o /dev/null -X POST -H "$LAH" $P/api/v1/statuses -d "status=a PrivaPub comment on Lemmy&in_reply_to_id=$lm_cats_on_p&visibility=public"
lm_cats_id=$(lm GET "resolve_object?q=$lm_cats_thread" | j "print(d['post']['id'])")
until_true 30 '[ "$(lm GET "comment/list?post_id=$lm_cats_id" | j "print(any(\"a PrivaPub comment on Lemmy\" in c[\"comment\"][\"content\"] for c in (d.get(\"items\") or d.get(\"comments\") or [])))")" = "True" ]' \
&& ok "alice's reply becomes a comment on Lemmy" || ko "PrivaPub reply missing as a Lemmy comment"
echo " votes"
# Lemmy sends a vote only to the community, which relays it inside an Announce: P7 trusts those for the community's own posts.
lm POST post/like "{\"post_id\":$p_thread_on_lm,\"is_upvote\":true}" >/dev/null
if until_true 15 '[ "$(curl -s -H "$LAH" "$P/api/v1/statuses/$p_thread_id" | j "print(d[\"favourites_count\"])")" = "1" ]'; then
ok "Lemmy's upvote counts as a like on PrivaPub"
else
xf "Lemmy's upvote counts as a like on PrivaPub (votes relayed by a community are P7)"
fi
lm POST post/like "{\"post_id\":$p_thread_on_lm,\"is_upvote\":false}" >/dev/null
if until_true 15 '[ "$(curl -s -H "$LAH" "$P/api/v1/statuses/$p_thread_id" | j "print(d[\"favourites_count\"], ((d.get(\"privapub\") or {}).get(\"votes\") or {}).get(\"down\"))")" = "0 1" ]'; then
ok "Lemmy's change to a downvote reaches PrivaPub"
else
xf "Lemmy's change to a downvote reaches PrivaPub (votes relayed by a community are P7)"
fi
curl -s -o /dev/null -X POST -H "$LAH" "$P/api/v1/statuses/$lm_cats_on_p/favourite"
until_true 30 '[ "$(lm GET "post?id=$lm_cats_id" | j "print(d[\"post_view\"][\"post\"].get(\"upvotes\"))")" = "2" ]' && ok "alice's like is an upvote on Lemmy" || ko "like not counted as an upvote on Lemmy"
echo " private messages"
alice_on_lm=$(lm GET "resolve_object?q=@alice_lemmy@privapub.test" | j "print(d['person']['id'])")
lm POST private_message "{\"content\":\"a secret from Lemmy\",\"recipient_id\":$alice_on_lm}" >/dev/null
until_true 30 'curl -s -H "$LAH" "$P/api/v1/conversations" | grep -q "a secret from Lemmy"' && ok "Lemmy's private message arrives as a DM" || ko "Lemmy's private message missing on PrivaPub"
curl -s -o /dev/null -X POST -H "$LAH" $P/api/v1/statuses -d 'status=@lemmyuser@lemmy.test a secret from PrivaPub&visibility=direct'
until_true 30 'lm GET "account/notification/list?limit=50" | grep -q "a secret from PrivaPub"' && ok "alice's DM arrives as a Lemmy private message" || ko "DM missing on Lemmy"
echo " moderation"
lm POST post/remove "{\"post_id\":$lm_cats_id,\"removed\":true,\"reason\":\"pasture\"}" >/dev/null
sleep 10
if curl -s -H "$LAH" "$P/api/v1/timelines/home" | j "print(any((s.get('reblog') or s)['uri']=='$lm_cats_thread' for s in d))" | grep -q False; then
ok "a moderator's removal reaches PrivaPub"
else
xf "a moderator's removal reaches PrivaPub (the Lemmy moderation set is P7)"
fi
echo " statistics"
stats_check lemmy.test lemmy
+1 -6
View File
@@ -93,12 +93,7 @@ m_quote=$(mcurl -X POST -H "$MH" "$M/api/v1/statuses" -d "status=quoting PrivaPu
until_true 30 '[ "$(mcurl -H "$MH" "$M/api/v1/statuses/$m_quote" | j "print((d.get(\"quote\") or {}).get(\"state\"))")" = "accepted" ]' && ok "PrivaPub approves mastouser's quote (FEP-044f)" || ko "quote of a PrivaPub post not approved on Mastodon"
echo " media"
python3 -c "
import struct,zlib
w=h=8
raw=b''.join(b'\x00'+bytes([200,60,60])*w for _ in range(h))
png=b'\x89PNG\r\n\x1a\n'+b''.join(struct.pack('>I',len(c))+t+c+struct.pack('>I',zlib.crc32(t+c)&0xffffffff) for t,c in [(b'IHDR',struct.pack('>IIBBBBB',w,h,8,2,0,0,0)),(b'IDAT',zlib.compress(raw)),(b'IEND',b'')])
open('$work/red.png','wb').write(png)"
make_png "$work/red.png"
a_media=$(curl -s -X POST -H "$AH" "$P/api/v2/media" -F "file=@$work/red.png;type=image/png" -F 'description=a red square' | j "print(d['id'])")
a_media_uri=$(curl -s -X POST -H "$AH" $P/api/v1/statuses -d "status=a picture&visibility=public&media_ids[]=$a_media" | j "print(d['uri'])")
until_true 30 '[ "$(m_status_by_uri "$alice_on_m" "$a_media_uri" | j "print(d[\"media_attachments\"][0][\"description\"])")" = "a red square" ]' && ok "an image with alt text reaches Mastodon" || ko "image or alt text missing on Mastodon"
+124
View File
@@ -0,0 +1,124 @@
# Misskey 2026.10 (or Sharkey, with MISSKEY_NAME=sharkey): discovery, follows, posts and CW, replies both ways, Misskey
# reactions both ways, legacy quotes both ways, MFM source, polls, specified notes, media, deletes, blocks, statistics.
# A delivery is never checked with ap/show, which would fetch it from us; only with what Misskey already holds.
MK_NAME=${MISSKEY_NAME:-misskey}
MKH="$MK_NAME.test"
MK="https://$MKH:6443"
MKT=$(cat "$here/.state/$MK_NAME.token" 2>/dev/null)
# mk <endpoint> [json]: a Misskey API call as mkuser
mk() {
local body=${2:-'{}'}
site "$MKH" -s -X POST "$MK/api/$1" -H 'Content-Type: application/json' \
-d "$(python3 -c "import sys,json; d=json.loads(sys.argv[1]); d['i']=sys.argv[2]; print(json.dumps(d))" "$body" "$MKT")"
}
# the notes Misskey holds of an account, and the one whose uri is given
mk_notes() { mk users/notes "{\"userId\":\"$1\",\"limit\":50,\"withReplies\":true,\"withRenotes\":true}"; }
# users/relation answers a list even for one user
mk_relation() { mk users/relation "{\"userId\":\"$1\"}" | j "r=d[0] if isinstance(d,list) else d; print(r['$2'])"; }
mk_note_by_uri() { mk_notes "$1" | j "print(json.dumps(next((n for n in d if n.get('uri')=='$2'), None)))"; }
echo "$MK_NAME"
[ -n "$MKT" ] && ok "$MK_NAME token for mkuser" || { ko "$MK_NAME token"; return 1; }
KT=$(privapub_token "alice_$MK_NAME")
KH="Authorization: Bearer $KT"
[ -n "$KT" ] && ok "PrivaPub token for alice_$MK_NAME" || { ko "PrivaPub token for alice_$MK_NAME"; return 1; }
echo " discovery"
alice_on_mk=$(mk users/show "{\"username\":\"alice_$MK_NAME\",\"host\":\"privapub.test\"}" | j "print(d['id'])")
[ -n "$alice_on_mk" ] && ok "$MK_NAME resolves @alice_$MK_NAME@privapub.test" || ko "$MK_NAME cannot resolve alice"
mk_on_p=$(curl -s -H "$KH" "$P/api/v2/search?q=mkuser@$MKH&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
[ -n "$mk_on_p" ] && ok "PrivaPub resolves @mkuser@$MKH" || ko "PrivaPub cannot resolve mkuser"
echo " follows"
mk following/create "{\"userId\":\"$alice_on_mk\"}" >/dev/null
until_true 30 '[ "$(mk_relation "$alice_on_mk" isFollowing)" = "True" ]' && ok "mkuser follows alice (Accept arrived)" || ko "$MK_NAME's follow not accepted"
curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/accounts/$mk_on_p/follow"
until_true 30 '[ "$(curl -s -H "$KH" "$P/api/v1/accounts/relationships?id[]=$mk_on_p" | j "print(d[0][\"following\"])")" = "True" ]' && ok "alice follows mkuser (Accept arrived)" || ko "PrivaPub's follow not accepted"
echo " posts"
k_post=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d "status=Hello $MK_NAME from PrivaPub&visibility=public")
k_post_id=$(echo "$k_post" | j "print(d['id'])"); k_post_uri=$(echo "$k_post" | j "print(d['uri'])")
until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_post_uri")" != "null" ]' && ok "alice's post reaches $MK_NAME" || ko "post missing on $MK_NAME"
k_post_on_mk=$(mk_note_by_uri "$alice_on_mk" "$k_post_uri" | j "print(d['id'])")
mk_post=$(mk notes/create "{\"text\":\"Hello PrivaPub from $MK_NAME, \$[x2 big] words\",\"visibility\":\"public\"}" | j "print(d['createdNote']['id'])")
until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | grep -q "Hello PrivaPub from $MK_NAME"' && ok "mkuser's post reaches alice's home" || ko "$MK_NAME's post missing on PrivaPub"
mk_post_on_p=$(curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(next(s['id'] for s in d if 'Hello PrivaPub from $MK_NAME' in s['content']))")
[ "$(curl -s -H "$KH" "$P/api/v1/statuses/$mk_post_on_p" | j "print('\$[x2' in ((d.get('privapub') or {}).get('source') or {}).get('content',''))")" = "True" ] \
&& ok "the MFM source is kept" || ko "MFM source lost"
cw_uri=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d 'status=behind a warning&spoiler_text=spoilers&visibility=public' | j "print(d['uri'])")
until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$cw_uri" | j "print(d and d[\"cw\"])")" = "spoilers" ]' && ok "a content warning survives to $MK_NAME" || ko "content warning lost on $MK_NAME"
echo " replies"
mk notes/create "{\"text\":\"@alice_$MK_NAME@privapub.test replying from $MK_NAME\",\"replyId\":\"$k_post_on_mk\",\"visibility\":\"public\"}" >/dev/null
until_true 30 'curl -s -H "$KH" "$P/api/v1/notifications" | j "print(any(n[\"type\"]==\"mention\" for n in d))" | grep -q True' && ok "mkuser's reply notifies alice" || ko "reply did not notify"
until_true 15 '[ "$(curl -s -H "$KH" "$P/api/v1/statuses/$k_post_id/context" | j "print(len(d[\"descendants\"]))")" -ge 1 ]' && ok "the reply threads under alice's post" || ko "reply not threaded on PrivaPub"
k_reply_uri=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d "status=@mkuser@$MKH replying from PrivaPub&in_reply_to_id=$mk_post_on_p&visibility=public" | j "print(d['uri'])")
until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_reply_uri" | j "print(d and d[\"replyId\"])")" = "$mk_post" ]' && ok "alice's reply threads under mkuser's note" || ko "outbound reply not threaded on $MK_NAME"
echo " reactions"
mk notes/reactions/create "{\"noteId\":\"$k_post_on_mk\",\"reaction\":\"👍\"}" >/dev/null
until_true 30 'curl -s -H "$KH" "$P/api/v1/pleroma/statuses/$k_post_id/reactions" | j "print(any(r[\"name\"]==\"👍\" and r[\"count\"]==1 for r in d))" | grep -q True' && ok "mkuser's 👍 reaction reaches PrivaPub" || ko "reaction missing on PrivaPub"
mk notes/reactions/delete "{\"noteId\":\"$k_post_on_mk\"}" >/dev/null
until_true 30 '[ "$(curl -s -H "$KH" "$P/api/v1/pleroma/statuses/$k_post_id/reactions" | j "print(len(d))")" = "0" ]' && ok "mkuser's withdrawn reaction leaves PrivaPub" || ko "reaction not withdrawn on PrivaPub"
curl -s -o /dev/null -X PUT -H "$KH" "$P/api/v1/pleroma/statuses/$mk_post_on_p/reactions/%F0%9F%8E%89"
until_true 30 '[ "$(mk notes/show "{\"noteId\":\"$mk_post\"}" | j "print(d[\"reactions\"].get(\"🎉\", 0))")" = "1" ]' && ok "alice's 🎉 reaction reaches $MK_NAME" || ko "reaction missing on $MK_NAME"
# Misskey keeps one reaction per user and note, so the like goes to a note of its own
mk_liked=$(mk notes/create "{\"text\":\"like this one, PrivaPub\",\"visibility\":\"public\"}" | j "print(d['createdNote']['id'])")
until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | grep -q "like this one, PrivaPub"' || true
mk_liked_on_p=$(curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(next(s['id'] for s in d if 'like this one, PrivaPub' in s['content']))")
curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/statuses/$mk_liked_on_p/favourite"
until_true 30 '[ "$(mk notes/show "{\"noteId\":\"$mk_liked\"}" | j "print(sum(d[\"reactions\"].values()))")" = "1" ]' && ok "alice's like counts as a reaction on $MK_NAME" || ko "like not counted on $MK_NAME"
echo " quotes"
mk_quote=$(mk notes/create "{\"text\":\"quoting PrivaPub\",\"renoteId\":\"$k_post_on_mk\",\"visibility\":\"public\"}" | j "print(d['createdNote']['id'])")
until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(any(\"quoting PrivaPub\" in s[\"content\"] and (s.get(\"quote\") or {}).get(\"state\")==\"accepted\" for s in d))" | grep -q True' \
&& ok "mkuser's quote arrives as an accepted quote" || ko "$MK_NAME's quote not understood"
k_quote_uri=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d "status=quoting $MK_NAME&visibility=public&quoted_status_id=$mk_post_on_p" | j "print(d['uri'])")
until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_quote_uri" | j "print(d and d[\"renoteId\"])")" = "$mk_post" ]' && ok "alice's quote is a quote on $MK_NAME" || ko "quote not understood by $MK_NAME"
echo " polls"
k_poll_uri=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d 'status=cats or dogs&visibility=public&poll[options][]=cats&poll[options][]=dogs&poll[expires_in]=3600' | j "print(d['uri'])")
until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_poll_uri" | j "print(len(d[\"poll\"][\"choices\"]))")" = "2" ]' && ok "alice's poll reaches $MK_NAME as a poll" || ko "poll missing on $MK_NAME"
k_poll_on_mk=$(mk_note_by_uri "$alice_on_mk" "$k_poll_uri" | j "print(d['id'])")
mk notes/polls/vote "{\"noteId\":\"$k_poll_on_mk\",\"choice\":1}" >/dev/null
alice_self=$(curl -s -H "$KH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])")
until_true 30 '[ "$(curl -s -H "$KH" "$P/api/v1/accounts/$alice_self/statuses" | j "print(next(s[\"poll\"][\"options\"][1][\"votes_count\"] for s in d if s[\"uri\"]==\"$k_poll_uri\"))")" = "1" ]' && ok "mkuser's vote counts on PrivaPub" || ko "vote not counted on PrivaPub"
mk_poll=$(mk notes/create "{\"text\":\"tea or coffee, $MK_NAME asks\",\"visibility\":\"public\",\"poll\":{\"choices\":[\"tea\",\"coffee\"],\"expiredAfter\":3600000}}" | j "print(d['createdNote']['id'])")
until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(any(s[\"poll\"] and \"$MK_NAME asks\" in s[\"content\"] for s in d))" | grep -q True' && ok "mkuser's poll reaches PrivaPub as a poll" || ko "poll missing on PrivaPub"
mk_poll_on_p=$(curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(next(s['poll']['id'] for s in d if s['poll'] and '$MK_NAME asks' in s['content']))")
curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/polls/$mk_poll_on_p/votes" -d 'choices[]=0'
until_true 30 '[ "$(mk notes/show "{\"noteId\":\"$mk_poll\"}" | j "print(d[\"poll\"][\"choices\"][0][\"votes\"])")" = "1" ]' && ok "alice's vote counts on $MK_NAME" || ko "vote not counted on $MK_NAME"
echo " specified notes"
mk notes/create "{\"text\":\"@alice_$MK_NAME@privapub.test a secret for PrivaPub\",\"visibility\":\"specified\",\"visibleUserIds\":[\"$alice_on_mk\"]}" >/dev/null
until_true 30 'curl -s -H "$KH" "$P/api/v1/conversations" | grep -q "a secret for PrivaPub"' && ok "mkuser's specified note arrives as a DM" || ko "specified note missing on PrivaPub"
curl -s -o /dev/null -X POST -H "$KH" $P/api/v1/statuses -d "status=@mkuser@$MKH a secret for $MK_NAME&visibility=direct"
until_true 30 'mk notes/mentions "{\"visibility\":\"specified\"}" | grep -q "a secret for $MK_NAME"' && ok "alice's DM arrives as a specified note" || ko "DM missing on $MK_NAME"
echo " media"
make_png "$work/red.png"
k_media=$(curl -s -X POST -H "$KH" "$P/api/v2/media" -F "file=@$work/red.png;type=image/png" -F 'description=a red square' | j "print(d['id'])")
k_media_uri=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d "status=a picture for $MK_NAME&visibility=public&media_ids[]=$k_media" | j "print(d['uri'])")
until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_media_uri" | j "print(d[\"files\"][0][\"comment\"])")" = "a red square" ]' && ok "an image with alt text reaches $MK_NAME" || ko "image or alt text missing on $MK_NAME"
mk_file=$(site "$MKH" -s -X POST "$MK/api/drive/files/create" -F "i=$MKT" -F "file=@$work/red.png;type=image/png" -F "comment=a red square from $MK_NAME" | j "print(d['id'])")
mk notes/create "{\"text\":\"a picture from $MK_NAME\",\"visibility\":\"public\",\"fileIds\":[\"$mk_file\"]}" >/dev/null
until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(any(\"a picture from $MK_NAME\" in s[\"content\"] and s[\"media_attachments\"] and \"/media/proxy/\" in s[\"media_attachments\"][0][\"url\"] and s[\"media_attachments\"][0][\"description\"]==\"a red square from $MK_NAME\" for s in d))" | grep -q True' \
&& ok "an image with alt text from $MK_NAME arrives through our proxy" || ko "$MK_NAME's image missing, unproxied or without alt text"
echo " deletes"
cw_on_mk=$(mk_note_by_uri "$alice_on_mk" "$cw_uri" | j "print(d['id'])")
cw_id=$(curl -s -H "$KH" "$P/api/v1/accounts/$alice_self/statuses" | j "print(next(s['id'] for s in d if s['uri']=='$cw_uri'))")
curl -s -o /dev/null -X DELETE -H "$KH" "$P/api/v1/statuses/$cw_id"
until_true 30 '[ "$(mk notes/show "{\"noteId\":\"$cw_on_mk\"}" | j "print(d.get(\"error\",{}).get(\"code\"))")" = "NO_SUCH_NOTE" ]' && ok "alice's delete reaches $MK_NAME" || ko "delete not applied on $MK_NAME"
mk notes/delete "{\"noteId\":\"$mk_poll\"}" >/dev/null
until_true 30 '! curl -s -H "$KH" "$P/api/v1/timelines/home" | grep -q "$MK_NAME asks"' && ok "mkuser's delete reaches PrivaPub" || ko "$MK_NAME's delete not applied on PrivaPub"
echo " unfollows and blocks"
curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/accounts/$mk_on_p/unfollow"
until_true 30 '[ "$(mk_relation "$alice_on_mk" isFollowed)" = "False" ]' && ok "alice's unfollow reaches $MK_NAME" || ko "unfollow not applied on $MK_NAME"
curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/accounts/$mk_on_p/block"
until_true 30 '[ "$(mk_relation "$alice_on_mk" isBlocked)" = "True" ]' && ok "alice's block reaches $MK_NAME" || ko "block not applied on $MK_NAME"
curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/accounts/$mk_on_p/unblock"
echo " statistics"
stats_check "$MKH" "$MK_NAME"
+25
View File
@@ -0,0 +1,25 @@
# Sharkey 2025.4: Misskey's scenario under Sharkey's name, then what only Sharkey does: edits both ways, and quotes that
# carry a FEP-e232 Link tag instead of `quote`.
MISSKEY_NAME=sharkey
. "$here/scenarios/misskey.sh"
unset MISSKEY_NAME
echo " sharkey only"
# Misskey's scenario ends unfollowed and blocked, so follow again both ways
curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/accounts/$mk_on_p/follow"
mk following/create "{\"userId\":\"$alice_on_mk\"}" >/dev/null
until_true 30 '[ "$(mk_relation "$alice_on_mk" isFollowed)$(mk_relation "$alice_on_mk" isFollowing)" = "TrueTrue" ]' \
&& ok "alice and mkuser follow each other again" || ko "refollow after the block failed"
sk_note=$(mk notes/create '{"text":"before the edit","visibility":"public"}' | j "print(d['createdNote']['id'])")
until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | grep -q "before the edit"' && ok "a Sharkey note reaches alice" || ko "Sharkey note missing on PrivaPub"
mk notes/edit "{\"editId\":\"$sk_note\",\"text\":\"after the edit\",\"visibility\":\"public\"}" >/dev/null
until_true 30 '[ "$(curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(any(\"after the edit\" in s[\"content\"] and s[\"edited_at\"] for s in d))")" = "True" ]' \
&& ok "mkuser's edit arrives as an edit" || ko "Sharkey edit not applied on PrivaPub"
k_edit=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d 'status=PrivaPub before the edit&visibility=public')
k_edit_id=$(echo "$k_edit" | j "print(d['id'])"); k_edit_uri=$(echo "$k_edit" | j "print(d['uri'])")
until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_edit_uri")" != "null" ]' || true
curl -s -o /dev/null -X PUT -H "$KH" "$P/api/v1/statuses/$k_edit_id" -d 'status=PrivaPub after the edit'
until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_edit_uri" | j "print(d and d[\"text\"])")" = "PrivaPub after the edit" ]' \
&& ok "alice's edit reaches Sharkey" || ko "PrivaPub edit not applied on Sharkey"
quote_tag=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'var p=db.Post.findOne({Text:/quoting PrivaPub/}, {}, {sort:{_id:-1}}); var r=p && db.ObjectRecord.findOne({PostId:p._id.toHexString()}); print(r ? /"type"\s*:\s*"Link"/.test(r.Raw) : "none")')
[ "$quote_tag" = "true" ] && ok "Sharkey's quote carries a FEP-e232 Link tag, and is understood" || ko "Sharkey's quote record has no Link tag ($quote_tag)"
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Gets a Mastodon API token for one persona through PrivaPub's real OAuth code flow, as a client would: register an app,
# sign in with the root's password at /oauth/login, choose the persona at /oauth/authorize, read the out-of-band code and
# exchange it. Prints "<access token> <client id> <client secret>" so the caller can revoke the token afterwards.
# usage: tools/smoke/oauth.sh <base url> <root login> <root password> <persona> [scopes]
set -euo pipefail
BASE="${1:?base url}"; LOGIN="${2:?root login}"; PASSWORD="${3:?root password}"; PERSONA="${4:?persona}"; SCOPES="${5:-read}"
jar=$(mktemp -d); trap 'rm -rf "$jar"' EXIT
json() { python3 -c "import sys,json; d=json.load(sys.stdin); print($1)"; }
app=$(curl -fsS -X POST "$BASE/api/v1/apps" --data-urlencode client_name=privapub-oauth \
--data-urlencode redirect_uris=urn:ietf:wg:oauth:2.0:oob --data-urlencode "scopes=$SCOPES")
cid=$(echo "$app" | json "d['client_id']"); cs=$(echo "$app" | json "d['client_secret']")
scope_q=$(python3 -c "import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1]))" "$SCOPES")
q="client_id=$cid&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=$scope_q"
xt=$(curl -fsS -c "$jar/c" -b "$jar/c" "$BASE/oauth/login?returnUrl=/oauth/authorize?$q" \
| grep -o 'name="__RequestVerificationToken" type="hidden" value="[^"]*"' | sed 's/.*value="//;s/"//')
curl -sS -o /dev/null -c "$jar/c" -b "$jar/c" -X POST "$BASE/oauth/login" --data-urlencode "returnUrl=/oauth/authorize?$q" \
--data-urlencode "__RequestVerificationToken=$xt" --data-urlencode "userName=$LOGIN" --data-urlencode "password=$PASSWORD"
curl -fsS -c "$jar/c" -b "$jar/c" "$BASE/oauth/authorize?$q&signed_in=1" > "$jar/choose.html"
form=$(python3 - "$jar/choose.html" "$PERSONA" <<'PY'
import re, sys, urllib.parse, html
page = open(sys.argv[1]).read()
pairs = [(k, html.unescape(v)) for k, v in re.findall(r'<input type="hidden" name="([^"]*)" value="([^"]*)"', page)]
persona = None
for block in re.findall(r'<label[^>]*>(.*?)</label>', page, re.S):
if '@' + sys.argv[2] + '@' in block or '@' + sys.argv[2] + '<' in block or '>' + sys.argv[2] + '<' in block:
found = re.search(r'name="avatarId" value="([^"]*)"', block)
if found:
persona = found.group(1)
if persona is None:
sys.exit("persona '%s' is not offered at /oauth/authorize" % sys.argv[2])
print(urllib.parse.urlencode(pairs + [("avatarId", persona), ("decision", "allow")]))
PY
)
code=$(curl -fsS -c "$jar/c" -b "$jar/c" -X POST "$BASE/oauth/authorize" --data "$form" | grep -o '<code>[^<]*</code>' | sed 's/<[^>]*>//g')
[ -n "$code" ] || { echo "no authorization code" >&2; exit 1; }
token=$(curl -fsS -X POST "$BASE/oauth/token" --data-urlencode grant_type=authorization_code --data-urlencode "code=$code" \
--data-urlencode "client_id=$cid" --data-urlencode "client_secret=$cs" --data-urlencode redirect_uri=urn:ietf:wg:oauth:2.0:oob \
| json "d['access_token']")
echo "$token $cid $cs"