diff --git a/CLAUDE.md b/CLAUDE.md index 76f8f6f..65b5399 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -561,6 +561,11 @@ tools/pasture/run.sh down # removes e checks. - **Pins (`scenarios/pins.sh`, needs mastodon):** a Mastodon account pins and unpins while alice follows it, alice pins and unpins while it follows her, and a fresh account's earlier pin shows once PrivaPub resolves it. 8 checks. +- **Ghost (6.67) with its ActivityPub service (1.2.14):** Ghost and the service on the shared MySQL (databases `ghost` + and `activitypub`, the service's migrations run first), routed by Caddy as Ghost's own proxy does. The owner comes + from Ghost's setup API (a password that repeats the site's name is refused), staff device verification is off, and + Ghost starts again once so it sets up its publishing webhooks. Calls to the service say `Host: ghost.test`, or it + cannot find the site's keys. `scenarios/ghost.sh`, 22 checks. - **Owncast (0.3.0):** its image, its data in the `pasture-owncast` volume, federation turned on through its admin API (`owncast_admin`, its default admin / abc123) with the server's address and the actor's name. `scenarios/owncast.sh`, 13 checks. diff --git a/FEDERATION.md b/FEDERATION.md index 98fcdc5..a69d5ad 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -37,6 +37,7 @@ and every run starting clean, with signed fetches required (as privapub.thepra.d - **snac2 2.95** - **WriteFreely 0.17.2** - **Owncast 0.3.0** +- **Ghost 6.67** with its ActivityPub service 1.2.14 - **Activity-Relay 2.0.9** and **aode-relay 0.3.129**, as relays PrivaPub reads from - in the town only (a seeded community checked server by server): **Hollo 0.9.19**, **Iceshrimp.NET 2026.1.2-beta**, **Pleroma 2.10.2** diff --git a/docs/INTEROP.md b/docs/INTEROP.md index b2cc908..1d493e5 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -753,6 +753,19 @@ PeerTube's own instance account announces each new video too, which we drop: nob follow only PeerTube-like channels and accounts, never a persona. It checks the `Host` header against its own name, without a port, before it gives out its OAuth client. +### Ghost 6.67 and its ActivityPub service 1.2.14 + +- A publication is one actor, `@index@` (`/.ghost/activitypub/users/index`), served by a separate service + (Fedify) that Ghost's proxy sends `/.ghost/activitypub/*`, WebFinger and NodeInfo to. Posts go out as `Article`s with + their title; the "Network" screen follows, likes, reposts, replies and posts notes. It signed with draft-cavage here. +- The service names a site after the request's host and reads its keys from `https:///ghost/.well-known/jwks.json`, + so a request with a port in its `Host` is refused (`JWKS_MISSING`). Ghost sets up the webhooks that publish its posts + only when it starts with an owner and the service knowing the site; a fresh install has to start again once. +- **Pasture evidence (2026-10-05, `tools/pasture/scenarios/ghost.sh`):** 22 checks pass, with no change to PrivaPub: + follows both ways; the publication's post reaches alice as a titled Article, edited and deleted; alice's like and + boost count there; Ghost's like, repost, reply and note reach PrivaPub; alice's post reaches its Network feed; both + unfollows; statistics. + ### Owncast 0.3.0 - The instance is one `Service` actor (`/federation/user/`): it is followed, never follows, posts when it goes diff --git a/tools/pasture/Caddyfile b/tools/pasture/Caddyfile index 6fada18..15453fe 100644 --- a/tools/pasture/Caddyfile +++ b/tools/pasture/Caddyfile @@ -108,6 +108,22 @@ nodebb.test { reverse_proxy pasture-nodebb:4567 } +ghost.test { + tls internal + handle /.ghost/activitypub/* { + reverse_proxy pasture-ghost-ap:8080 + } + handle /.well-known/webfinger { + reverse_proxy pasture-ghost-ap:8080 + } + handle /.well-known/nodeinfo { + reverse_proxy pasture-ghost-ap:8080 + } + handle { + reverse_proxy pasture-ghost:2368 + } +} + owncast.test { tls internal reverse_proxy pasture-owncast:8080 diff --git a/tools/pasture/peers/ghost.sh b/tools/pasture/peers/ghost.sh new file mode 100644 index 0000000..53ff921 --- /dev/null +++ b/tools/pasture/peers/ghost.sh @@ -0,0 +1,76 @@ +# Ghost 6.67 with its ActivityPub service 1.2.14 (Fedify): a publication whose posts go out as Articles from its +# @index actor, and which follows, likes, reposts and replies from its "Network" screen. Ghost and the service share the +# pasture's MySQL (databases ghost and activitypub); Caddy sends /.ghost/activitypub, WebFinger and NodeInfo to the +# service and everything else to Ghost, as Ghost's own Caddyfile does. Both trust the CA through NODE_EXTRA_CA_CERTS. +# The owner is made through Ghost's setup API, staff device verification is off (the pasture sends no mail), and the +# service learns of the site the first time Ghost's identity token asks it. +GHOST_IMAGE=${GHOST_IMAGE:-docker.io/library/ghost:6.67.0-alpine} +GHOST_AP_IMAGE=${GHOST_AP_IMAGE:-ghcr.io/tryghost/activitypub:1.2.14} +GHOST_AP_MIGRATIONS_IMAGE=${GHOST_AP_MIGRATIONS_IMAGE:-ghcr.io/tryghost/activitypub-migrations:1.2.14} +# (Ghost refuses a password that repeats the site's or the owner's name) +GHOST_PASSWORD=Lantern-Meadow-Quartz-71 +. "$here/peers/shared.sh" + +ghost_up() { + shared_mysql_up + mysql_db ghost + mysql_db activitypub + podman run --rm --network $net -e MYSQL_DB='mysql://pasture:pasture@tcp(mysql:3306)/activitypub' $GHOST_AP_MIGRATIONS_IMAGE >/dev/null 2>&1 + podman volume exists pasture-ghost || podman volume create --label pasture=1 pasture-ghost >/dev/null + podman run -d --replace --name pasture-ghost-ap --label pasture=1 --network $net -v pasture-ghost:/opt/activitypub/content \ + -e NODE_ENV=production -e MYSQL_HOST=mysql -e MYSQL_USER=pasture -e MYSQL_PASSWORD=pasture -e MYSQL_DATABASE=activitypub \ + -e LOCAL_STORAGE_PATH=/opt/activitypub/content/images/activitypub -e LOCAL_STORAGE_HOSTING_URL=https://ghost.test/content/images/activitypub \ + -e ALLOW_PRIVATE_ADDRESS=true -e NODE_EXTRA_CA_CERTS=/pasture/ca/root.crt -v "$ca:/pasture/ca:z,ro" $GHOST_AP_IMAGE >/dev/null + podman run -d --replace --name pasture-ghost --label pasture=1 --network $net -v pasture-ghost:/var/lib/ghost/content \ + -e NODE_ENV=production -e url=https://ghost.test -e database__client=mysql -e database__connection__host=mysql \ + -e database__connection__user=pasture -e database__connection__password=pasture -e database__connection__database=ghost \ + -e security__staffDeviceVerification=false -e NODE_EXTRA_CA_CERTS=/pasture/ca/root.crt -v "$ca:/pasture/ca:z,ro" \ + $GHOST_IMAGE >/dev/null + for _ in $(seq 1 120); do + site ghost.test -s -o /dev/null -w '%{http_code}' https://ghost.test:6443/ghost/api/admin/site/ 2>/dev/null | grep -q 200 && break + sleep 2 + done + ghost_settle + echo "ghost: https://ghost.test:6443" +} + +# ghost_api [json]: Ghost's admin API as the owner, with the session cookie ghost_settle keeps +ghost_api() { + local method=$1 path=$2 body=${3:-} + site ghost.test -s -X "$method" "https://ghost.test:6443/ghost/api/admin/$path" -b "$here/.state/ghost/cookies" -c "$here/.state/ghost/cookies" \ + -H 'Origin: https://ghost.test' -H 'Content-Type: application/json' ${body:+-d "$body"} +} + +# the owner (ghostuser@ghost.test), its session, and the ActivityPub service told of the site +ghost_settle() { + local st="$here/.state/ghost" + mkdir -p "$st" + site ghost.test -s -o /dev/null -X POST https://ghost.test:6443/ghost/api/admin/authentication/setup/ -H 'Origin: https://ghost.test' \ + -H 'Content-Type: application/json' \ + -d "{\"setup\":[{\"name\":\"Ghost User\",\"email\":\"ghostuser@ghost.test\",\"password\":\"$GHOST_PASSWORD\",\"blogTitle\":\"Ghost pasture\"}]}" + rm -f "$st/cookies" + site ghost.test -s -o /dev/null -X POST https://ghost.test:6443/ghost/api/admin/session/ -c "$st/cookies" -H 'Origin: https://ghost.test' \ + -H 'Content-Type: application/json' -d "{\"username\":\"ghostuser@ghost.test\",\"password\":\"$GHOST_PASSWORD\"}" + ghost_ap GET v1/site >/dev/null + # Ghost sets up the webhooks that publish its posts when it starts, and only once it has an owner and the service + # knows the site: the first time, it starts again + if [ "$(podman exec pasture-mysql mysql -uroot -ppasture -N ghost -e 'select count(*) from webhooks' 2>/dev/null)" != "4" ]; then + podman restart pasture-ghost >/dev/null + for _ in $(seq 1 60); do + site ghost.test -s -o /dev/null -w '%{http_code}' https://ghost.test:6443/ghost/api/admin/site/ 2>/dev/null | grep -q 200 && break + sleep 2 + done + rm -f "$st/cookies" + site ghost.test -s -o /dev/null -X POST https://ghost.test:6443/ghost/api/admin/session/ -c "$st/cookies" -H 'Origin: https://ghost.test' \ + -H 'Content-Type: application/json' -d "{\"username\":\"ghostuser@ghost.test\",\"password\":\"$GHOST_PASSWORD\"}" + fi +} + +# ghost_ap [json]: the ActivityPub service's own API (/.ghost/activitypub/), with Ghost's identity token +ghost_ap() { + local method=$1 path=$2 body=${3:-} token + token=$(ghost_api GET identities/ | python3 -c 'import json, sys; print(json.load(sys.stdin)["identities"][0]["token"])') + # (the service names the site after the request's host, so the call says ghost.test without the workstation's port) + site ghost.test -s -X "$method" "https://ghost.test:6443/.ghost/activitypub/$path" -H 'Host: ghost.test' -H "Authorization: Bearer $token" \ + -H 'Content-Type: application/json' ${body:+-d "$body"} +} diff --git a/tools/pasture/scenarios/ghost.sh b/tools/pasture/scenarios/ghost.sh new file mode 100644 index 0000000..ed04661 --- /dev/null +++ b/tools/pasture/scenarios/ghost.sh @@ -0,0 +1,74 @@ +# Ghost 6.67 and its ActivityPub service 1.2.14: alice follows the publication and its post reaches her as a titled +# Article, edited and deleted; the publication follows alice, and from its Network screen likes, reposts and answers her +# post and posts a note; alice's like and boost count there; both unfollows; statistics. Driven through Ghost's admin +# API and the service's own API (peers/ghost.sh). +. "$here/peers/ghost.sh" +p_home_has() { curl -s -H "$PH" "$P/api/v1/timelines/home?limit=40" | j "print(next(((s.get('reblog') or s)['id'] for s in d if '$1' in ((s.get('reblog') or s)['content'] or '') or '$1' in (((s.get('reblog') or s).get('privapub') or {}).get('title') or '')), ''))"; } +p_status() { curl -s -H "$PH" "$P/api/v1/statuses/$1" | j "print(d.get('$2'))"; } +enc() { python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$1"; } + +echo "ghost" +ghost_settle >/dev/null 2>&1 +[ "$(ghost_ap GET v1/account/me | j "print(d['handle'])")" = "@index@ghost.test" ] && ok "Ghost's ActivityPub service knows the publication" || { ko "Ghost's ActivityPub service"; return 1; } +PT=$(privapub_token alice_ghost) +PH="Authorization: Bearer $PT" +[ -n "$PT" ] && ok "PrivaPub token for alice_ghost" || { ko "PrivaPub token for alice_ghost"; return 1; } +run=$(date +%s) + +echo " following the publication" +pub_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=index@ghost.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") +[ -n "$pub_on_p" ] && ok "PrivaPub resolves the publication" || ko "PrivaPub cannot resolve the publication" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$pub_on_p/follow" +until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$pub_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \ + && ok "alice follows the publication (Accept arrived)" || ko "the publication's Accept never arrived" + +echo " articles" +post=$(ghost_api POST "posts/?source=html" "{\"posts\":[{\"title\":\"A Ghost article $run\",\"html\":\"

Published on Ghost, $run.

\",\"status\":\"published\"}]}") +post_id=$(echo "$post" | j "print(d['posts'][0]['id'])"); updated_at=$(echo "$post" | j "print(d['posts'][0]['updated_at'])") +until_true 60 '[ -n "$(p_home_has "A Ghost article $run")" ]' && ok "the publication's post reaches alice's home" || ko "the publication's post never reached alice" +art_on_p=$(p_home_has "A Ghost article $run") +[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$art_on_p" | j "print((d.get('privapub') or {}).get('title'))")" = "A Ghost article $run" ] \ + && ok "as an Article with its title" || ko "the article arrived without its title ($(curl -s -H "$PH" "$P/api/v1/statuses/$art_on_p" | j "print((d.get('privapub') or {}).get('title'))"))" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$art_on_p/favourite" +art_uri=$(curl -s -H "$PH" "$P/api/v1/statuses/$art_on_p" | j "print(d['uri'])") +until_true 45 '[ "$(ghost_ap GET "v1/post/$(enc "$art_uri")" | j "print(d.get(\"likeCount\"))")" = "1" ]' \ + && ok "alice's like counts on Ghost" || ko "alice's like never counted on Ghost ($(ghost_ap GET "v1/post/$(enc "$art_uri")" | j "print(d.get('likeCount'))"))" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$art_on_p/reblog" +until_true 45 '[ "$(ghost_ap GET "v1/post/$(enc "$art_uri")" | j "print(d.get(\"repostCount\"))")" = "1" ]' \ + && ok "alice's boost counts on Ghost" || ko "alice's boost never counted on Ghost" +ghost_api PUT "posts/$post_id/?source=html" "{\"posts\":[{\"title\":\"A Ghost article $run\",\"html\":\"

Published on Ghost, $run, then edited.

\",\"updated_at\":\"$updated_at\"}]}" >/dev/null +until_true 60 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$art_on_p" | j "print(\"then edited\" in d[\"content\"])")" = "True" ]' \ + && ok "its edit reaches PrivaPub" || ko "its edit never reached PrivaPub" + +echo " the publication follows alice" +ghost_ap POST "v1/actions/follow/$(enc "@alice_ghost@privapub.test")" >/dev/null +until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/verify_credentials" | j "print(d[\"followers_count\"])")" -ge 1 ]' \ + && ok "the publication follows alice" || ko "Ghost's follow never reached alice" +p_post=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=a PrivaPub post for Ghost $run&visibility=public") +p_uri=$(echo "$p_post" | j "print(d['uri'])"); p_post_id=$(echo "$p_post" | j "print(d['id'])") +until_true 45 'ghost_ap GET v1/feed/notes | grep -q "a PrivaPub post for Ghost $run"' && ok "alice's post reaches the publication's Network feed" || ko "alice's post never reached Ghost" +ghost_ap POST "v1/actions/like/$(enc "$p_uri")" >/dev/null +until_true 45 '[ "$(p_status $p_post_id favourites_count)" = "1" ]' && ok "Ghost's like counts on PrivaPub" || ko "Ghost's like never counted" +ghost_ap POST "v1/actions/repost/$(enc "$p_uri")" >/dev/null +until_true 45 '[ "$(p_status $p_post_id reblogs_count)" = "1" ]' && ok "Ghost's repost is a boost on PrivaPub" || ko "Ghost's repost never counted" +ghost_ap POST "v1/actions/reply/$(enc "$p_uri")" "{\"content\":\"a Ghost reply $run\"}" >/dev/null +until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id/context" | j "print(any(\"a Ghost reply $run\" in s[\"content\"] for s in d[\"descendants\"]))")" = "True" ]' \ + && ok "Ghost's reply threads under alice's post" || ko "Ghost's reply missing on PrivaPub" +ghost_ap POST v1/actions/note "{\"content\":\"a Ghost note $run\"}" >/dev/null +until_true 45 '[ -n "$(p_home_has "a Ghost note $run")" ]' && ok "the publication's note reaches alice's home" || ko "the publication's note never reached alice" + +echo " deletions" +ghost_api DELETE "posts/$post_id/" >/dev/null +until_true 60 '[ "$(curl -s -o /dev/null -w "%{http_code}" -H "$PH" "$P/api/v1/statuses/$art_on_p")" = "404" ]' \ + && ok "the article's deletion reaches PrivaPub" || ko "the deleted article still shows on PrivaPub" + +echo " unfollows" +ghost_ap POST "v1/actions/unfollow/$(enc "@alice_ghost@privapub.test")" >/dev/null +until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/verify_credentials" | j "print(d[\"followers_count\"])")" = "0" ]' \ + && ok "the publication's unfollow reaches alice" || ko "alice still counts the publication" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$pub_on_p/unfollow" +until_true 45 '[ "$(ghost_ap GET v1/account/me | j "print(d[\"followerCount\"])")" = "0" ]' \ + && ok "alice's unfollow reaches the publication" || ko "Ghost still counts alice" + +echo " statistics" +stats_check ghost.test ghost