Files
thepraandClaude Opus 5.5 0310bbe0f9 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
2026-10-06 11:12:06 +02:00

97 lines
6.4 KiB
Bash

# 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"
}