Castopod in the pasture
Castopod 1.15.5 (peers/castopod.sh): the official image on the shared MySQL, its podcast @pod made and published through its admin pages, episodes through its REST API, its fediverse queue sent by spark. scenarios/castopod.sh, 15 checks: the follow, an episode reaching alice with its sound, her like, boost and comment landing there, the unfollow, statistics (from NodeInfo2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
69ab8e50eb
commit
0310bbe0f9
6 files changed
+182
No files matched your search
@@ -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`,
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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://<server>/.well-known/apgateway/did:key:z6Mk…/actor`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <curl args...>: 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"
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user