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
97 lines
6.4 KiB
Bash
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"
|
|
}
|