diff --git a/CLAUDE.md b/CLAUDE.md index 04cb983..d776436 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -561,6 +561,9 @@ tools/pasture/run.sh down # removes e checks. - **Pins (`scenarios/pins.sh`, needs mastodon):** a Mastodon account pins and unpins while alice follows it, alice pins and unpins while it follows her, and a fresh account's earlier pin shows once PrivaPub resolves it. 8 checks. +- **Vernissage (1.43.0):** its image on SQLite in the `pasture-vernissage` volume, its queues on the shared Redis's + database 0 (it federates nothing without them; every other database is taken), its seeded admin (admin / admin) + logged in by the scenario. `scenarios/vernissage.sh`, 19 checks. - **BookWyrm (0.9.3):** its image, migrated, on the shared Postgres (database `bookwyrm`) and Redis (databases 14 and 15), gunicorn and one Celery worker (federation runs there). It has no client API: bwuser (named `bwuser@bookwyrm.test`, as its signup names users), a book and every status come from its Django shell diff --git a/FEDERATION.md b/FEDERATION.md index 496d25d..3dedec4 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -39,6 +39,7 @@ and every run starting clean, with signed fetches required (as privapub.thepra.d - **Owncast 0.3.0** - **Ghost 6.67** with its ActivityPub service 1.2.14 - **BookWyrm 0.9.3** +- **Vernissage 1.43.0** - **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 71b0925..c789087 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -753,6 +753,16 @@ PeerTube's own instance account announces each new video too, which we drop: nob follow only PeerTube-like channels and accounts, never a persona. It checks the `Host` header against its own name, without a port, before it gives out its OAuth client. +### Vernissage 1.43.0 + +- Photo sharing: a top-level post carries its photos (`Image`, with alt text, and EXIF in its API); comments are plain + replies. It names a PNG `image/jpeg` in its attachment. +- It federates only with its queues on (Redis); its API answers 400 to an attachment's metadata unless the whole + attachment comes back with it, and takes a new status from the same account only every 40 seconds or so. +- **Pasture evidence (2026-10-05, `tools/pasture/scenarios/vernissage.sh`):** 19 checks pass, with no change to + PrivaPub: follows both ways; a photo with its alt text reaches alice; her like, boost and comment land there; her + photo reaches the admin's home and its like and comment come back; a deletion; the unfollow; statistics. + ### BookWyrm 0.9.3 - Its statuses are `Review`, `Comment` and `Quotation` about a book (`inReplyToBook`) for other BookWyrms, and "pure" diff --git a/tools/pasture/Caddyfile b/tools/pasture/Caddyfile index 0b90856..f828a39 100644 --- a/tools/pasture/Caddyfile +++ b/tools/pasture/Caddyfile @@ -108,6 +108,11 @@ nodebb.test { reverse_proxy pasture-nodebb:4567 } +vernissage.test { + tls internal + reverse_proxy pasture-vernissage:8080 +} + bookwyrm.test { tls internal reverse_proxy pasture-bookwyrm:8000 diff --git a/tools/pasture/peers/vernissage.sh b/tools/pasture/peers/vernissage.sh new file mode 100644 index 0000000..1ff7ac9 --- /dev/null +++ b/tools/pasture/peers/vernissage.sh @@ -0,0 +1,25 @@ +# Vernissage 1.43.0: photo sharing in Swift (Vapor), Pixelfed's neighbour: every top-level post carries photos with +# their EXIF (camera, lens, film...), comments thread under them. Its image on SQLite with local storage in its own +# volume and its queues on the shared Redis (database 0, the last free one: without queues it federates nothing), as +# vernissage.test; its TLS client reads the system's roots, over which the pasture's bundle goes. It seeds an +# admin (admin/admin) whose token comes from its login API. +VERNISSAGE_IMAGE=${VERNISSAGE_IMAGE:-docker.io/mczachurski/vernissage-server:v1.43.0} + +. "$here/peers/shared.sh" + +vernissage_up() { + shared_redis_up + podman volume exists pasture-vernissage || podman volume create --label pasture=1 pasture-vernissage >/dev/null + podman run -d --replace --name pasture-vernissage --label pasture=1 --network $net -v pasture-vernissage:/data \ + -e VERNISSAGE_BASEADDRESS=https://vernissage.test -e VERNISSAGE_CONNECTIONSTRING=/data/vernissage.db -e VERNISSAGE_QUEUEURL=redis://redis:6379/0 \ + -v "$ca/bundle.pem:/etc/ssl/certs/ca-certificates.crt:z,ro" $VERNISSAGE_IMAGE >/dev/null + for _ in $(seq 1 60); do + site vernissage.test -s -o /dev/null -w '%{http_code}' https://vernissage.test:6443/.well-known/nodeinfo 2>/dev/null | grep -q 200 && break + sleep 2 + done + mkdir -p "$here/.state/vernissage" + site vernissage.test -s -X POST https://vernissage.test:6443/api/v1/account/login -H 'Content-Type: application/json' \ + -d '{"userNameOrEmail":"admin","password":"admin"}' \ + | python3 -c 'import json, sys; print(json.load(sys.stdin).get("accessToken", ""))' > "$here/.state/vernissage/admin.token" + echo "vernissage: https://vernissage.test:6443" +} diff --git a/tools/pasture/scenarios/vernissage.sh b/tools/pasture/scenarios/vernissage.sh new file mode 100644 index 0000000..2f58878 --- /dev/null +++ b/tools/pasture/scenarios/vernissage.sh @@ -0,0 +1,81 @@ +# Vernissage 1.43.0: follows both ways; a photo post with its alt text and camera reaches alice; her like, boost and +# comment land there; her photo post reaches the admin's home and its like and comment come back; a deletion; the +# unfollow; statistics. Driven through its API as its seeded admin. +VE=https://vernissage.test:6443 +VT=$(site vernissage.test -s -X POST $VE/api/v1/account/login -H 'Content-Type: application/json' -d '{"userNameOrEmail":"admin","password":"admin"}' | j "print(d['accessToken'])") +ve() { site vernissage.test -s -H "Authorization: Bearer $VT" "$@"; } +ve_json() { local method=$1 path=$2 body=$3; ve -X "$method" "$VE$path" -H 'Content-Type: application/json' -d "$body"; } +# ve_post : a status, waited for when Vernissage says statuses come too often (one every 40 seconds or so) +ve_post() { + local answer + for _ in $(seq 1 12); do + answer=$(ve_json POST /api/v1/statuses "$1") + echo "$answer" | grep -q statusCreationTooFrequent || break + sleep 5 + done + echo "$answer" +} +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 '')), ''))"; } +p_status() { curl -s -H "$PH" "$P/api/v1/statuses/$1" | j "print(d.get('$2'))"; } + +echo "vernissage" +[ -n "$VT" ] && ok "Vernissage token for admin" || { ko "Vernissage token"; return 1; } +PT=$(privapub_token alice_vern) +PH="Authorization: Bearer $PT" +[ -n "$PT" ] && ok "PrivaPub token for alice_vern" || { ko "PrivaPub token for alice_vern"; return 1; } +run=$(date +%s) + +echo " follows" +ve "$VE/api/v1/search?query=alice_vern%40privapub.test&type=users" >/dev/null +ve -o /dev/null -X POST "$VE/api/v1/users/alice_vern@privapub.test/follow" +until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/verify_credentials" | j "print(d[\"followers_count\"])")" -ge 1 ]' \ + && ok "the admin follows alice" || ko "Vernissage's follow never reached alice" +ve_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=admin@vernissage.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") +[ -n "$ve_on_p" ] && ok "PrivaPub resolves Vernissage's admin" || ko "PrivaPub cannot resolve Vernissage's admin" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$ve_on_p/follow" +until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$ve_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \ + && ok "alice follows the admin (Accept arrived)" || ko "Vernissage's Accept never arrived" + +echo " photos" +png="$here/.state/vernissage/photo.png" +make_png "$png" +# (its upload answers with the attachment, which goes back whole with the alt text and the camera) +uploaded=$(ve -X POST "$VE/api/v1/attachments" -F "file=@$png;type=image/png") +att=$(echo "$uploaded" | j "print(d['id'])") +ve_json PUT "/api/v1/attachments/$att" "$(echo "$uploaded" | j "d.update(description='a red square $run', make='Leica', model='M6'); print(json.dumps(d))")" >/dev/null +post=$(ve_post "{\"note\":\"a Vernissage photo $run\",\"visibility\":\"public\",\"sensitive\":false,\"commentsDisabled\":false,\"attachmentIds\":[\"$att\"]}") +post_id=$(echo "$post" | j "print(d['id'])") +until_true 60 '[ -n "$(p_home_has "a Vernissage photo $run")" ]' && ok "the admin's photo reaches alice's home" || ko "the photo never reached alice ($(echo "$post" | head -c 200))" +photo_on_p=$(p_home_has "a Vernissage photo $run") +[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$photo_on_p" | j "print(d['media_attachments'][0]['description'] if d['media_attachments'] else None)")" = "a red square $run" ] \ + && ok "with its picture and alt text" || ko "the photo arrived without its picture or alt text" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$photo_on_p/favourite" +until_true 45 '[ "$(ve "$VE/api/v1/statuses/$post_id" | j "print(d[\"favouritesCount\"])")" = "1" ]' && ok "alice's like counts on Vernissage" || ko "alice's like never counted on Vernissage" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$photo_on_p/reblog" +until_true 45 '[ "$(ve "$VE/api/v1/statuses/$post_id" | j "print(d[\"reblogsCount\"])")" = "1" ]' && ok "alice's boost counts on Vernissage" || ko "alice's boost never counted on Vernissage" +curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d "status=@admin@vernissage.test a PrivaPub comment $run&in_reply_to_id=$photo_on_p&visibility=public" +until_true 45 've "$VE/api/v1/statuses/$post_id/context" | grep -q "a PrivaPub comment $run"' && ok "alice's comment threads under the photo" || ko "alice's comment never reached Vernissage" + +echo " alice's photo" +upload=$(curl -s -X POST -H "$PH" "$P/api/v2/media" -F "file=@$png;type=image/png" -F "description=a PrivaPub picture $run") +p_post=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=a PrivaPub photo for Vernissage $run&visibility=public&media_ids[]=$(echo "$upload" | j "print(d['id'])")") +p_post_id=$(echo "$p_post" | j "print(d['id'])") +until_true 60 've "$VE/api/v1/timelines/home?limit=40" | grep -q "a PrivaPub photo for Vernissage $run"' && ok "alice's photo reaches the admin's home" || ko "alice's photo never reached Vernissage" +p_on_ve=$(ve "$VE/api/v1/timelines/home?limit=40" | j "print(next((s['id'] for s in d.get('data', d) if 'a PrivaPub photo for Vernissage $run' in (s.get('note') or '')), ''))") +ve -o /dev/null -X POST "$VE/api/v1/statuses/$p_on_ve/favourite" +until_true 45 '[ "$(p_status $p_post_id favourites_count)" = "1" ]' && ok "the admin's like counts on PrivaPub" || ko "Vernissage's like never counted" +ve_post "{\"note\":\"a Vernissage comment $run\",\"visibility\":\"public\",\"sensitive\":false,\"commentsDisabled\":false,\"replyToStatusId\":\"$p_on_ve\",\"attachmentIds\":[]}" >/dev/null +until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id/context" | j "print(any(\"a Vernissage comment $run\" in s[\"content\"] for s in d[\"descendants\"]))")" = "True" ]' \ + && ok "the admin's comment threads under alice's photo" || ko "Vernissage's comment missing on PrivaPub" + +echo " deletions" +ve -o /dev/null -X DELETE "$VE/api/v1/statuses/$post_id" +until_true 45 '[ "$(curl -s -o /dev/null -w "%{http_code}" -H "$PH" "$P/api/v1/statuses/$photo_on_p")" = "404" ]' \ + && ok "the admin's deletion reaches PrivaPub" || ko "the deleted photo still shows on PrivaPub" + +echo " unfollow" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$ve_on_p/unfollow" +until_true 45 '[ "$(ve "$VE/api/v1/users/admin" | j "print(d[\"followersCount\"])")" = "0" ]' && ok "alice's unfollow reaches Vernissage" || ko "Vernissage still counts alice" + +echo " statistics" +stats_check vernissage.test vernissage