diff --git a/CLAUDE.md b/CLAUDE.md index 54c45a8..7c4eac0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -552,6 +552,12 @@ tools/pasture/run.sh down # removes e its arguments on spaces); the API is GraphQL (`/api`), signed in by the `login` mutation. An event made through it without options has its comments closed. `scenarios/mobilizon.sh`, 23 checks: a group and its events (dates, place), comments both ways, the organiser's edit, closed comments and deletes, a group post, the unfollow. +- **Gancio (1.28.2):** cisti's image on sqlite in the `pasture-gancio-data` volume, whose `config.json` is written before + the first start (without it Gancio waits in its setup wizard), trusting Caddy's CA through `NODE_EXTRA_CA_CERTS`. + `gancio users create` makes gcadmin; `gancio settings set enable_resources true` keeps fediverse replies. One + Application actor, `relay`, publishes the agenda. Its API takes a token from an OAuth password grant + (`/oauth/login`, client_id `self`); an event is a multipart POST (`--form-string` for text starting with `<`). + `scenarios/gancio.sh`, 17 checks. - **SecureMode:** `PRIVAPUB_ENV="Federation__SecureMode=true" run.sh up …`, as production runs. A check of what an unsigned reader sees uses `unserved` and `gone_unsigned` (`lib/interop.sh`), which expect 401 when SecureMode is on and 404 or 410 when it is off. diff --git a/FEDERATION.md b/FEDERATION.md index 0a71451..d508549 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -27,6 +27,7 @@ and every run starting clean, with signed fetches required (as privapub.thepra.d - **WordPress 6 with ActivityPub 9.3.1** - **Friendica 2026.05** - **Mobilizon 5.2.4** +- **Gancio 1.28.2** - 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 1f8a18a..da85fd6 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -803,6 +803,10 @@ FEP-8a8e (draft) is the common reference. comments both ways; the organiser's edit, closing the comments (PrivaPub then refuses a reply) and deletes of a comment and the event; a group post with its title; the unfollow; statistics. No RSVP yet. - **Gancio:** a single Application actor; `location` is an **array** of `VirtualLocation` and `Place`; no RSVP. + - **Pasture evidence (2026-10-05, Gancio 1.28.2, `tools/pasture/scenarios/gancio.sh`):** 17 checks pass: alice follows + its actor `relay`; a published event arrives as an Event with its start, end and place; her reply is kept as one of + the event's resources (once `enable_resources` is on); its edit and deletion reach PrivaPub; the unfollow; + statistics. - **Friendica, Hubzilla:** RSVP with `Accept`/`Reject`/`TentativeAccept`. Hubzilla creates events as `Invite{Event}`, with HTML in `location.content`, and `-00:00` for floating times. - **FEP-8a8e:** a server that does not handle joins answers `Join` with `Ignore`. diff --git a/tools/pasture/Caddyfile b/tools/pasture/Caddyfile index b661fe0..02e95c9 100644 --- a/tools/pasture/Caddyfile +++ b/tools/pasture/Caddyfile @@ -82,3 +82,8 @@ mobilizon.test { tls internal reverse_proxy pasture-mobilizon:4000 } + +gancio.test { + tls internal + reverse_proxy pasture-gancio:13120 +} diff --git a/tools/pasture/peers/gancio.sh b/tools/pasture/peers/gancio.sh new file mode 100644 index 0000000..808253d --- /dev/null +++ b/tools/pasture/peers/gancio.sh @@ -0,0 +1,46 @@ +# Gancio 1.28.2: a shared agenda. One Application actor (`relay`, its instance_name) publishes every confirmed event as +# an Event (a place, or an online location; times in seconds); fediverse replies become an event's "resources" once +# they are enabled. Node, on its own sqlite in the pasture-gancio-data volume, trusting Caddy's CA through +# NODE_EXTRA_CA_CERTS. Its configuration is a config.json in that volume, written before its first start (without it, +# Gancio waits in its setup wizard); its admin is made by its CLI. Its API takes a token from an OAuth password grant +# (/oauth/login, client_id self). +GANCIO_IMAGE=${GANCIO_IMAGE:-docker.io/cisti/gancio:1.28.2} +GANCIO_PASSWORD=Gancio-Pasture-1 + +gancio_cli() { podman exec -e GANCIO_DATA=/home/node/data -w /home/node/data pasture-gancio gancio "$@"; } + +gancio_up() { + podman volume exists pasture-gancio-data || podman volume create --label pasture=1 pasture-gancio-data >/dev/null + podman run --rm -i -v pasture-gancio-data:/data --entrypoint sh "$GANCIO_IMAGE" -c 'cat > /data/config.json && chown -R node /data' <<'JSON' +{ + "baseurl": "https://gancio.test", + "hostname": "gancio.test", + "server": { "host": "0.0.0.0", "port": 13120 }, + "log_level": "info", + "log_path": "/home/node/data/logs", + "db": { "dialect": "sqlite", "storage": "/home/node/data/gancio.sqlite", "logging": false }, + "user_locale": "/home/node/data/user_locale", + "upload_path": "/home/node/data/uploads" +} +JSON + podman run -d --replace --name pasture-gancio --network $net --label pasture=1 -e GANCIO_DATA=/home/node/data -w /home/node/data \ + -e NODE_EXTRA_CA_CERTS=/ca/root.crt -v "$ca/root.crt:/ca/root.crt:z,ro" -v pasture-gancio-data:/home/node/data "$GANCIO_IMAGE" >/dev/null + for _ in $(seq 1 90); do + site gancio.test -s -o /dev/null -w '%{http_code}' https://gancio.test:6443/.well-known/nodeinfo 2>/dev/null | grep -q 200 && break + sleep 2 + done + gancio_settle + echo "gancio: https://gancio.test:6443" +} + +# its admin, fediverse replies taken as resources, and a restart so the settings hold +gancio_settle() { + gancio_cli users create gcadmin@gancio.test "$GANCIO_PASSWORD" admin >/dev/null 2>&1 || true + gancio_cli settings set enable_resources true >/dev/null 2>&1 || true + podman restart pasture-gancio >/dev/null + for _ in $(seq 1 60); do + site gancio.test -s -o /dev/null -w '%{http_code}' https://gancio.test:6443/.well-known/nodeinfo 2>/dev/null | grep -q 200 && break + sleep 2 + done + echo "gcadmin@gancio.test:$GANCIO_PASSWORD" > "$here/.state/gancio.token" +} diff --git a/tools/pasture/scenarios/gancio.sh b/tools/pasture/scenarios/gancio.sh new file mode 100644 index 0000000..523b1d9 --- /dev/null +++ b/tools/pasture/scenarios/gancio.sh @@ -0,0 +1,70 @@ +# Gancio 1.28.2: its instance actor `relay` (an Application) publishes the agenda. alice_gancio follows it; an event +# arrives as an Event with its start, end and place; its edit and its deletion; alice's reply kept as one of the event's +# resources; the unfollow. Its API takes a bearer token from an OAuth password grant (client_id self); what it holds is +# read from a copy of its sqlite. +GC=https://gancio.test:6443 +gc() { curl -sk --resolve gancio.test:6443:127.0.0.1 -H "Authorization: Bearer $GCT" "$@"; } +# gc_sql : one value from Gancio's sqlite, copied out +gc_sql() { + podman cp pasture-gancio:/home/node/data/gancio.sqlite "$here/.state/gancio.sqlite" 2>/dev/null + python3 -c 'import sqlite3, sys; r = sqlite3.connect(sys.argv[1]).execute(sys.argv[2]).fetchone(); print("" if r is None else r[0])' \ + "$here/.state/gancio.sqlite" "$1" +} +p_home_has() { curl -s -H "$PH" "$P/api/v1/timelines/home?limit=40" | j "print(next((o['id'] for o in ((s.get('reblog') or s) for s in d) if '$1' in (o['content'] or '') or '$1' in ((o.get('privapub') or {}).get('title') or '')), ''))"; } + +echo "gancio" +[ -s "$here/.state/gancio.token" ] && ok "Gancio credentials for gcadmin" || { ko "Gancio credentials"; return 1; } +credentials=$(cat "$here/.state/gancio.token") +GCT=$(curl -sk --resolve gancio.test:6443:127.0.0.1 -X POST "$GC/oauth/login" --data-urlencode grant_type=password \ + --data-urlencode client_id=self --data-urlencode "username=${credentials%%:*}" --data-urlencode "password=${credentials#*:}" | j "print(d['access_token'])") +[ -n "$GCT" ] && ok "gcadmin signs in to Gancio's API" || { ko "gcadmin cannot sign in"; return 1; } +PT=$(privapub_token alice_gancio) +PH="Authorization: Bearer $PT" +[ -n "$PT" ] && ok "PrivaPub token for alice_gancio" || { ko "PrivaPub token for alice_gancio"; return 1; } + +echo " the agenda and its follower" +relay_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=relay@gancio.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") +[ -n "$relay_on_p" ] && ok "PrivaPub resolves @relay@gancio.test" || ko "PrivaPub cannot resolve Gancio's actor" +# a run before this one left the follow: unfollow first, so the follow below is a new request +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$relay_on_p/unfollow"; sleep 3 +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$relay_on_p/follow" +until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$relay_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \ + && ok "alice_gancio follows Gancio's agenda (Accept arrived)" || ko "Gancio's Accept never arrived" + +echo " events" +title="A concert in the pasture $(date +%s)" +begins=$(date -u -d '+4 days 20:00' +%s); ends=$(date -u -d '+4 days 23:00' +%s) +# (--form-string: with -F a value starting with "<" names a file to read) +event=$(gc -X POST "$GC/api/event" --form-string "title=$title" --form-string "description=

bring a blanket

" -F "start_datetime=$begins" \ + -F "end_datetime=$ends" -F "place_name=Villa Borghese" -F "place_address=Roma" -F "place_latitude=41.9142" -F "place_longitude=12.4922") +event_id=$(echo "$event" | j "print(d['id'])") +[ -n "$event_id" ] && ok "gcadmin publishes an event" || ko "the event could not be made: $(echo "$event" | cut -c1-200)" +until_true 60 '[ -n "$(p_home_has "$title")" ]' && ok "the event reaches alice_gancio's home" || ko "the event never arrived" +e_on_p=$(p_home_has "$title") +event_json=$(curl -s -H "$PH" "$P/api/v1/statuses/$e_on_p") +[ "$(echo "$event_json" | j "e = (d.get('privapub') or {}).get('event') or {}; print(e.get('start', '')[:16], e.get('end', '')[:16])")" \ + = "$(date -u -d "@$begins" +%Y-%m-%dT%H:%M) $(date -u -d "@$ends" +%Y-%m-%dT%H:%M)" ] \ + && ok "it arrives as an event with its start and end" || ko "the event's dates are missing ($(echo "$event_json" | j "print((d.get('privapub') or {}).get('event'))"))" +echo "$event_json" | j "e = (d.get('privapub') or {}).get('event') or {}; print(any('Borghese' in ((p.get('name') or '') + (p.get('address') or '')) for p in e.get('places', [])))" | grep -q True \ + && ok "and with its place" || ko "the event's place is missing" + +echo " replies" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses" -d "status=@relay@gancio.test see you at the concert&in_reply_to_id=$e_on_p&visibility=public" +until_true 45 '[ "$(gc_sql "select count(*) from resources where data like '"'%see you at the concert%'"'")" -ge 1 ]' \ + && ok "alice_gancio's reply is kept as one of the event's resources" || ko "the reply never reached Gancio" + +echo " edits and deletes" +gc -o /dev/null -X PUT "$GC/api/event" -F "id=$event_id" --form-string "title=$title, moved indoors" -F "start_datetime=$begins" -F "end_datetime=$ends" \ + -F "place_name=Villa Borghese" -F "place_address=Roma" +until_true 45 'curl -s -H "$PH" "$P/api/v1/statuses/$e_on_p" | grep -q "moved indoors"' && ok "the event's edit reaches PrivaPub" || ko "the event's edit not applied" +gc -o /dev/null -X DELETE "$GC/api/event/$event_id" +until_true 45 '[ "$(curl -s -o /dev/null -w "%{http_code}" -H "$PH" "$P/api/v1/statuses/$e_on_p")" = "404" ]' \ + && ok "the deleted event leaves PrivaPub" || ko "the deleted event stays on PrivaPub" + +echo " unfollow" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$relay_on_p/unfollow" +until_true 45 '[ "$(gc_sql "select count(*) from ap_users where ap_id like '"'%alice_gancio%'"' and follower = 1")" = "0" ]' \ + && ok "alice_gancio's unfollow reaches Gancio" || ko "Gancio still counts alice_gancio as a follower" + +echo " statistics" +stats_check gancio.test gancio