From 12b51a211aa94dc0aa9747a6a747525789c33c69 Mon Sep 17 00:00:00 2001 From: thepra Date: Tue, 6 Oct 2026 00:49:18 +0200 Subject: [PATCH] Pasture: Ghost 6.67 and its ActivityPub service Ghost joins the pasture with its ActivityPub service (Fedify) on the shared MySQL, routed by Caddy as Ghost's own proxy does. scenarios/ghost.sh: follows both ways, the publication's titled Articles with their edit and deletion, likes and boosts both ways, Ghost's reply and note, alice's post in its Network feed, both unfollows and statistics: 22 checks, with no change to PrivaPub. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- CLAUDE.md | 5 +++ FEDERATION.md | 1 + docs/INTEROP.md | 13 ++++++ tools/pasture/Caddyfile | 16 +++++++ tools/pasture/peers/ghost.sh | 76 ++++++++++++++++++++++++++++++++ tools/pasture/scenarios/ghost.sh | 74 +++++++++++++++++++++++++++++++ 6 files changed, 185 insertions(+) create mode 100644 tools/pasture/peers/ghost.sh create mode 100644 tools/pasture/scenarios/ghost.sh 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