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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-06 00:49:18 +02:00
1 parent a584134858
commit 12b51a211a
6 files changed
+185

No files matched your search

+16
View File
@@ -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
+76
View File
@@ -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 <method> <path> [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 <method> <path> [json]: the ActivityPub service's own API (/.ghost/activitypub/<path>), 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"}
}
+74
View File
@@ -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\":\"<p>Published on Ghost, $run.</p>\",\"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\":\"<p>Published on Ghost, $run, then edited.</p>\",\"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