diff --git a/CLAUDE.md b/CLAUDE.md index ceb1bb8..a6e3820 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -607,6 +607,11 @@ tools/pasture/run.sh down # removes e announced by the other), that alice's public post goes to the relays (and through both to Mastodon: Activity-Relay's forward on its FEP-8b32 proof, after the scenario makes Mastodon read alice's keys anew) and nothing less public, and has Mastodon leave again, so the town sees no relayed posts. 16 checks. +- **Castopod (1.15.5, `peers/castopod.sh`):** the official image on the shared MySQL (database `castopod`, file cache, + `CP_DISABLE_HTTPS`), its database and superadmin (admin@castopod.test) from `spark`, its REST API switched on in the + `.env` the image rewrites at each start, `spark fediverse:broadcast` looping in the container. The podcast `@pod` is + made and published through its admin pages (`cp_web`, CSRF on each form). `scenarios/castopod.sh`, 15 checks; the + episode's sound is made with ffmpeg on the workstation. - **Forte (26.9.10, `peers/forte.sh`):** built from its tag by `images/forte` (composer on Hubzilla's PHP image, an nginx routing through `index.php?req=`), on the shared MySQL (database `forte`), its `.htconfig.php` written to `.state/forte`, a cron sidecar (`src/Daemon/Run.php Cron`). The pasture bundle is mounted over `library/cacert.pem`, diff --git a/FEDERATION.md b/FEDERATION.md index ce95084..75f2bd4 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -46,6 +46,7 @@ and every run starting clean, with signed fetches required (as privapub.thepra.d - **Vernissage 1.43.0** - **Hubzilla 11.4.1** with its pubcrawl addon - **Forte 26.9.10** (portable identities: actors served through `/.well-known/apgateway/did:key:…`) +- **Castopod 1.15.5** (podcasts as actors, their episodes with the sound) - **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 ed11025..942b788 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -854,6 +854,22 @@ without a port, before it gives out its OAuth client. never as the relay's boost), and nobody's home; a persona's public post goes to the relays, nothing less public, and reaches Mastodon through both (forwarded on its proof, and announced). +### Castopod 1.15.5 + +- **A podcast is the actor** (`@pod@castopod.test`); its people are not. It accepts follows by itself. +- **An episode goes out as a Note that only links to the episode's page**; the page serves a `PodcastEpisode` (its words + in `description`, its sound in `audio`, its cover in `image`). PrivaPub reads `PodcastEpisode`, and a Note that is + only a link to its author's episode takes the episode's sound, title, cover and words (one signed fetch), so the post + plays. +- **Publishes NodeInfo2 only** (`/.well-known/x-nodeinfo2`); PrivaPub's server description falls back to it. +- **Running it:** the official image on the shared MySQL with a file cache; `spark install:init-database` (a second run + stops before seeding: `db:seed AppSeeder`) and `install:create-superadmin`; a podcast is made and published only + through its admin pages (a 1400px square cover, a 3:1 banner and an empty `location_name` are required, or the form + fails); episodes through its REST API (`restapi.*` appended to the `.env` it writes at each start); its fediverse + queue goes out through `spark fediverse:broadcast`. +- **Pasture evidence (2026-10-06, `tools/pasture/scenarios/castopod.sh`):** 15 checks pass: alice follows the podcast, + an episode reaches her with its sound, her like, boost and comment land there, the unfollow, statistics. + ### Forte 26.9.10 - **Portable identities (FEP-ef61):** a channel's actor is `https:///.well-known/apgateway/did:key:z6Mk…/actor`, diff --git a/tools/pasture/Caddyfile b/tools/pasture/Caddyfile index e3b7baf..89570c4 100644 --- a/tools/pasture/Caddyfile +++ b/tools/pasture/Caddyfile @@ -108,6 +108,11 @@ nodebb.test { reverse_proxy pasture-nodebb:4567 } +castopod.test { + tls internal + reverse_proxy pasture-castopod:8080 +} + forte.test { tls internal reverse_proxy pasture-forte:8080 diff --git a/tools/pasture/peers/castopod.sh b/tools/pasture/peers/castopod.sh new file mode 100644 index 0000000..19f7a95 --- /dev/null +++ b/tools/pasture/peers/castopod.sh @@ -0,0 +1,96 @@ +# Castopod 1.15.5: podcasts as ActivityPub actors, their episodes as posts with the audio. The official image (FrankenPHP +# on 8080) on the shared MySQL (database castopod), its cache in files (the shared Redis has no database left), HTTPS +# left to Caddy (CP_DISABLE_HTTPS). It trusts Caddy's CA through the bundle mounted as its system store. Its database +# and superadmin come from its spark commands; its fediverse queue goes out only through `spark fediverse:broadcast`, +# run each minute in the container. A podcast is made through its admin pages (they alone take one); episodes through +# its REST API, switched on with basic authentication. +CASTOPOD_IMAGE=${CASTOPOD_IMAGE:-docker.io/castopod/castopod:1.15.5} +CASTOPOD_PASSWORD=Castopod-Pasture-1 +CASTOPOD_API=pasture:Castopod-Api-1 +. "$here/peers/shared.sh" + +castopod_spark() { podman exec -w /var/www/html pasture-castopod php spark "$@"; } + +castopod_up() { + shared_mysql_up + mysql_db castopod + mkdir -p "$here/.state/castopod" + [ -s "$here/.state/castopod/env" ] || { + echo "CP_BASEURL=https://castopod.test" + echo "CP_MEDIA_BASEURL=https://castopod.test" + echo -e "CP_DATABASE_HOSTNAME=mysql\nCP_DATABASE_NAME=castopod\nCP_DATABASE_USERNAME=pasture\nCP_DATABASE_PASSWORD=pasture" + echo "CP_ANALYTICS_SALT=$(openssl rand -hex 32)" + echo -e "CP_CACHE_HANDLER=file\nCP_DISABLE_HTTPS=1" + } > "$here/.state/castopod/env" + podman volume exists pasture-castopod-media || podman volume create --label pasture=1 pasture-castopod-media >/dev/null + podman run -d --replace --name pasture-castopod --network $net --label pasture=1 --env-file "$here/.state/castopod/env" \ + -v pasture-castopod-media:/var/www/html/public/media -v "$ca/bundle.pem:/etc/ssl/certs/ca-certificates.crt:z,ro" \ + "$CASTOPOD_IMAGE" >/dev/null + for _ in $(seq 1 90); do + podman exec pasture-castopod test -f /var/www/html/.env 2>/dev/null && castopod_spark list >/dev/null 2>&1 && break + sleep 2 + done + castopod_spark install:init-database >/dev/null 2>&1 + # (a second init stops at the first migration it already ran, before seeding: the categories and languages it needs) + [ "$(podman exec pasture-mysql mysql -upasture -ppasture castopod -Nse "select count(*) from cp_categories" 2>/dev/null)" != "0" ] \ + || castopod_spark db:seed AppSeeder >/dev/null 2>&1 + printf '%s\n%s\n' "$CASTOPOD_PASSWORD" "$CASTOPOD_PASSWORD" \ + | podman exec -i -w /var/www/html pasture-castopod php spark install:create-superadmin -n admin -e admin@castopod.test >/dev/null 2>&1 || true + # its REST API, which makes and publishes episodes, with basic authentication + # (the image writes .env anew at each start, read-only) + podman exec -u root pasture-castopod sh -c "chmod u+w /var/www/html/.env; grep -q '^restapi.enabled' /var/www/html/.env \ + || printf 'restapi.enabled=true\nrestapi.basicAuth=true\nrestapi.basicAuthUsername=${CASTOPOD_API%%:*}\nrestapi.basicAuthPassword=${CASTOPOD_API#*:}\n' >> /var/www/html/.env; chmod a-w /var/www/html/.env" + podman exec -d -w /var/www/html pasture-castopod sh -c 'while true; do php spark fediverse:broadcast >/dev/null 2>&1; sleep 30; done' + for _ in $(seq 1 60); do + site castopod.test -s -o /dev/null -w '%{http_code}' https://castopod.test:6443/.well-known/nodeinfo 2>/dev/null | grep -q 200 && break + sleep 2 + done + castopod_podcast + echo "castopod: https://castopod.test:6443" +} + +# cp_web : Castopod's admin pages as its superadmin, signed in (a CSRF token on every form) +cp_web() { + local jar="$here/.state/castopod/admin.cookies" + if ! site castopod.test -s -b "$jar" -o /dev/null -w '%{http_code}' https://castopod.test:6443/cp-admin | grep -q 200; then + rm -f "$jar" + local token + token=$(site castopod.test -s -c "$jar" https://castopod.test:6443/cp-auth/login | grep -oE 'name="csrf_test_name" value="[^"]+"' | head -1 | sed 's/.*value="//;s/"//') + site castopod.test -s -o /dev/null -b "$jar" -c "$jar" -X POST https://castopod.test:6443/cp-auth/login --data-urlencode "csrf_test_name=$token" \ + -d email=admin@castopod.test --data-urlencode "password=$CASTOPOD_PASSWORD" + fi + site castopod.test -s -b "$jar" -c "$jar" "$@" +} +cp_token() { cp_web "https://castopod.test:6443$1" | grep -oE 'name="csrf_test_name" value="[^"]+"' | head -1 | sed 's/.*value="//;s/"//'; } + +# square(file, side) / banner: the pictures a podcast must have (a 1400px square cover, a 3:1 banner) +cp_picture() { python3 -c " +import struct,zlib +w,h=$2,$3 +raw=(b'\x00'+bytes([40,120,200])*w)*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,9)),(b'IEND',b'')]) +open('$1','wb').write(png)"; } + +# the podcast @pod: only its admin pages make one (its banner is required too, and an absent location fails it) +castopod_podcast() { + [ -n "$(podman exec pasture-mysql mysql -upasture -ppasture castopod -Nse "select id from cp_podcasts where handle = 'pod'" 2>/dev/null)" ] && return 0 + local category + category=$(podman exec pasture-mysql mysql -upasture -ppasture castopod -Nse "select id from cp_categories where code = 'technology'" 2>/dev/null) + cp_picture "$here/.state/castopod/cover.png" 1400 1400 + cp_picture "$here/.state/castopod/banner.png" 1500 500 + cp_web -o /dev/null -X POST https://castopod.test:6443/cp-admin/podcasts/new -F "csrf_test_name=$(cp_token /cp-admin/podcasts/new)" \ + -F "cover=@$here/.state/castopod/cover.png;type=image/png" -F "banner=@$here/.state/castopod/banner.png;type=image/png" \ + -F "title=Pasture Podcast" -F "description=A podcast of the pasture" -F type=episodic -F medium=podcast -F language=en \ + -F "category=$category" -F parental_advisory=clean -F owner_name=Pasture -F owner_email=admin@castopod.test -F handle=pod \ + -F location_name= -F custom_rss= -F verify_txt= + castopod_publish_podcast +} + +# a podcast made is a draft: its pages, its episodes' pages and their objects answer 404 until it is published +castopod_publish_podcast() { + [ "$(podman exec pasture-mysql mysql -upasture -ppasture castopod -Nse "select published_at is not null from cp_podcasts where handle = 'pod'" 2>/dev/null)" = "1" ] && return 0 + local token + token=$(cp_web https://castopod.test:6443/cp-admin/podcasts/1/publish | grep -oE 'name="csrf_test_name" value="[^"]+"' | tail -1 | sed 's/.*value="//;s/"//') + cp_web -o /dev/null -X POST https://castopod.test:6443/cp-admin/podcasts/1/publish --data-urlencode "csrf_test_name=$token" \ + -d client_timezone=UTC -d publication_method=now --data-urlencode "message=The pasture's podcast is on the air" +} diff --git a/tools/pasture/scenarios/castopod.sh b/tools/pasture/scenarios/castopod.sh new file mode 100644 index 0000000..7dec6b7 --- /dev/null +++ b/tools/pasture/scenarios/castopod.sh @@ -0,0 +1,59 @@ +# Castopod 1.15.5: alice follows the podcast @pod; an episode published through its REST API reaches her with its audio; +# her like, reply and boost land on it there; the unfollow; statistics (from NodeInfo2, all Castopod publishes). The +# episode's sound is made with ffmpeg; Castopod's own fediverse tables (cp_fediverse_*) say what it holds. +. "$here/peers/castopod.sh" +CP=https://castopod.test:6443 +cpc() { site castopod.test -s -u "$CASTOPOD_API" "$@"; } +cp_sql() { podman exec pasture-mysql mysql -upasture -ppasture castopod -Nse "$1" 2>/dev/null; } +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'))"; } + +echo "castopod" +[ "$(cpc "$CP/api/rest/v1/podcasts" | j "print(next((p['handle'] for p in d), ''))")" = "pod" ] && ok "Castopod's REST API and the podcast @pod" \ + || { ko "Castopod's REST API ($(cpc "$CP/api/rest/v1/podcasts" | head -c 200))"; return 1; } +PT=$(privapub_token alice_castopod) +PH="Authorization: Bearer $PT" +[ -n "$PT" ] && ok "PrivaPub token for alice_castopod" || { ko "PrivaPub token for alice_castopod"; return 1; } +run=$(date +%s) + +echo " follows" +pod_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=pod@castopod.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") +[ -n "$pod_on_p" ] && ok "PrivaPub resolves the podcast @pod" || ko "PrivaPub cannot resolve @pod" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$pod_on_p/follow" +until_true 60 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$pod_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \ + && ok "alice follows @pod (Accept arrived)" || ko "Castopod's Accept never arrived" + +echo " episodes" +mp3="$here/.state/castopod/episode.mp3" +[ -s "$mp3" ] || ffmpeg -loglevel error -f lavfi -i "sine=frequency=440:duration=4" -q:a 9 "$mp3" +episode=$(cpc -X POST "$CP/api/rest/v1/episodes" -F created_by=1 -F updated_by=1 -F podcast_id=1 -F "title=A pasture episode $run" \ + -F "slug=a-pasture-episode-$run" -F "description=Listen to the meadow $run" -F type=full -F parental_advisory=clean -F location_name= \ + -F custom_rss= -F "audio_file=@$mp3;type=audio/mpeg" | j "print(d.get('id', ''))") +[ -n "$episode" ] && ok "Castopod makes the episode" || ko "Castopod refused the episode" +cpc -o /dev/null -X POST "$CP/api/rest/v1/episodes/$episode/publish" -F created_by=1 -F publication_method=now -F client_timezone=UTC +castopod_spark fediverse:broadcast >/dev/null 2>&1 +until_true 90 '[ -n "$(p_home_has "A pasture episode $run")" ] || [ -n "$(p_home_has "Listen to the meadow $run")" ]' \ + && ok "the episode reaches alice's home" || ko "the episode never reached alice" +ep_on_p=$(p_home_has "A pasture episode $run"); [ -n "$ep_on_p" ] || ep_on_p=$(p_home_has "Listen to the meadow $run") +[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$ep_on_p" | j "print(any(m['type'] == 'audio' for m in d['media_attachments']) or bool((d.get('privapub') or {}).get('audio')))")" = "True" ] \ + && ok "with its audio" || ko "the episode arrived without its audio ($(curl -s -H "$PH" "$P/api/v1/statuses/$ep_on_p" | head -c 300))" +ep_uri=$(p_status "$ep_on_p" uri) + +echo " alice answers" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$ep_on_p/favourite" +until_true 60 '[ "$(cp_sql "select count(*) from cp_fediverse_favourites f join cp_fediverse_actors a on a.id = f.actor_id where a.uri like '"'%alice_castopod%'"'")" -ge 1 ]' \ + && ok "alice's like lands on Castopod" || ko "alice's like never reached Castopod" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$ep_on_p/reblog" +until_true 60 '[ "$(cp_sql "select count(*) from cp_fediverse_posts p join cp_fediverse_actors a on a.id = p.actor_id where a.uri like '"'%alice_castopod%'"' and p.reblog_of_id is not null")" -ge 1 ]' \ + && ok "alice's boost lands on Castopod" || ko "alice's boost never reached Castopod" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses" -d "status=@pod@castopod.test a PrivaPub comment $run&in_reply_to_id=$ep_on_p&visibility=public" +until_true 60 '[ "$(cp_sql "select count(*) from cp_fediverse_posts where message like '"'%a PrivaPub comment $run%'"' and in_reply_to_id is not null")" -ge 1 ]' \ + && ok "alice's comment threads under the episode" || ko "alice's comment never reached Castopod" + +echo " unfollow" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$pod_on_p/unfollow" +until_true 60 '[ "$(cp_sql "select count(*) from cp_fediverse_follows f join cp_fediverse_actors a on a.id = f.actor_id where a.uri like '"'%alice_castopod%'"'")" = "0" ]' \ + && ok "alice's unfollow reaches Castopod" || ko "Castopod still counts alice" + +echo " statistics" +stats_check castopod.test castopod