From 761cfc2cceb00203f46612a0a239cd5832fdb800 Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 10:54:32 +0200 Subject: [PATCH] Pasture: Mobilizon joins Mobilizon 5.2.4 on a PostGIS of its own (it needs the extension, which the shared Postgres has not got), trusting Caddy's CA through the bundle mounted over certifi's and castore's files (hackney trusts only those), with geocoding pointed at a closed local port. scenarios/mobilizon.sh passes its 23 checks: alice follows a group; the event its organiser makes arrives as an Event with its start, end and located place; 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. An event made through Mobilizon's API without options has its comments closed, so the scenario opens them. No RSVP yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- CLAUDE.md | 6 ++ FEDERATION.md | 1 + docs/INTEROP.md | 13 ++++ tools/pasture/Caddyfile | 5 ++ tools/pasture/peers/mobilizon.sh | 61 +++++++++++++++++++ tools/pasture/peers/shared.sh | 10 ++++ tools/pasture/scenarios/mobilizon.sh | 89 ++++++++++++++++++++++++++++ 7 files changed, 185 insertions(+) create mode 100644 tools/pasture/peers/mobilizon.sh create mode 100644 tools/pasture/scenarios/mobilizon.sh diff --git a/CLAUDE.md b/CLAUDE.md index b40ab45..54c45a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -546,6 +546,12 @@ tools/pasture/run.sh down # removes e fetch that user from itself, signed, and checking the signature recurses for about five minutes. Its API takes HTTP Basic (`nick:password`); an edit through it never federates, so the scenario edits in the web editor. `scenarios/friendica.sh`, 25 checks; the town's driver and `specs/friendica-pair.json`. +- **Mobilizon (5.2.4):** kaihuri's image of the release on a PostGIS of its own (`shared_postgis_up`: it needs the + extension), with the pasture's bundle mounted over certifi's and castore's CA files (hackney trusts only those) and + the system store, and geocoding pointed at a closed local port. `mobilizon_ctl users.new` makes mzuser (it splits + 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. - **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 ba6cc8f..0a71451 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -26,6 +26,7 @@ and every run starting clean, with signed fetches required (as privapub.thepra.d - **Pixelfed 0.14.4** - **WordPress 6 with ActivityPub 9.3.1** - **Friendica 2026.05** +- **Mobilizon 5.2.4** - 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 1b8f76f..1f8a18a 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -789,6 +789,19 @@ FEP-8a8e (draft) is the common reference. - The event is attributed to the Group. - **RSVP:** `Join{object: event}` with a stable id that can be fetched; Mobilizon answers `Accept` or `Reject`; `Leave`. + - The organiser (`actor` on the event) sends its Create, Update and Delete; the event is attributed to the group, + which announces the Event itself, not the activity. PrivaPub refused the organiser's activities as misattributed + until 2026-10-05, so an edit was lost and a deletion left the event in place; it now takes them as Mobilizon has + the event (FEDERATION.md, "Another account of the same server"). + - An event made through its API without options has its comments closed (`commentsEnabled: false`); PrivaPub + refuses replies to it, as it does to a locked thread. + - Its NodeInfo names the software "Mobilizon" (NodeInfo wants lower case) and sends + `Content-Type: application/json; profile=http://…#` with the URL unquoted, which .NET cannot parse: PrivaPub reads + the raw media type, and lowercases software names. + - **Pasture evidence (2026-10-05, Mobilizon 5.2.4, `tools/pasture/scenarios/mobilizon.sh`):** 23 checks pass: + alice follows a group; the event its organiser makes arrives as an Event with its start, end and located place; + 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. - **Friendica, Hubzilla:** RSVP with `Accept`/`Reject`/`TentativeAccept`. Hubzilla creates events as `Invite{Event}`, with HTML in `location.content`, and `-00:00` for floating times. diff --git a/tools/pasture/Caddyfile b/tools/pasture/Caddyfile index 688f288..b661fe0 100644 --- a/tools/pasture/Caddyfile +++ b/tools/pasture/Caddyfile @@ -77,3 +77,8 @@ friendica.test { tls internal reverse_proxy pasture-friendica:80 } + +mobilizon.test { + tls internal + reverse_proxy pasture-mobilizon:4000 +} diff --git a/tools/pasture/peers/mobilizon.sh b/tools/pasture/peers/mobilizon.sh new file mode 100644 index 0000000..b2136d1 --- /dev/null +++ b/tools/pasture/peers/mobilizon.sh @@ -0,0 +1,61 @@ +# Mobilizon 5.2.4 (kaihuri's image of Framasoft's release): events, groups with their discussions, and participations, +# which it federates as Join and Leave, answered with Accept or Reject. Elixir, on a PostGIS of its own +# (shared_postgis_up: it needs the extension, which the shared Postgres has not got). Its federation runs on hackney, +# which trusts certifi's compiled-in roots, so the pasture's bundle is mounted over certifi's and castore's files and +# the system store. Geocoding points at a closed local port, so no address lookup leaves the workstation. Accounts come +# from mobilizon_ctl; its API is GraphQL (/api), signed in with an access token from the login mutation. +MOBILIZON_IMAGE=${MOBILIZON_IMAGE:-docker.io/kaihuri/mobilizon:5.2.4} +MOBILIZON_PASSWORD=Mobilizon-Pasture-1 +. "$here/peers/shared.sh" + +mobilizon_env() { + local keys="$here/.state/mobilizon/keys" + [ -s "$keys" ] || { mkdir -p "$here/.state/mobilizon"; for _ in 1 2; do head -c 48 /dev/urandom | base64 -w0; echo; done > "$keys"; } + cat </dev/null + done + mkdir -p "$here/.state/mobilizon" + mobilizon_env > "$here/.state/mobilizon/env" + podman volume exists pasture-mobilizon-data || podman volume create --label pasture=1 pasture-mobilizon-data >/dev/null + podman run -d --replace --name pasture-mobilizon --network $net --label pasture=1 --env-file "$here/.state/mobilizon/env" \ + -v pasture-mobilizon-data:/var/lib/mobilizon \ + -v "$ca/bundle.pem:/etc/ssl/certs/ca-certificates.crt:z,ro" \ + -v "$ca/bundle.pem:/lib/certifi-2.15.0/priv/cacerts.pem:z,ro" \ + -v "$ca/bundle.pem:/lib/castore-1.0.19/priv/cacerts.pem:z,ro" \ + "$MOBILIZON_IMAGE" >/dev/null + for _ in $(seq 1 120); do + site mobilizon.test -s -o /dev/null -w '%{http_code}' https://mobilizon.test:6443/.well-known/nodeinfo 2>/dev/null | grep -q 200 && break + sleep 3 + done + mobilizon_settle + echo "mobilizon: https://mobilizon.test:6443" +} + +# mzuser, an account with a profile (mobilizon_ctl splits its arguments on spaces: one word for the display name) +mobilizon_settle() { + podman exec pasture-mobilizon /bin/mobilizon_ctl users.new mzuser@mobilizon.test --password "$MOBILIZON_PASSWORD" \ + --profile-username mzuser --profile-display-name Mobilizonian >/dev/null 2>&1 || true + echo "mzuser@mobilizon.test:$MOBILIZON_PASSWORD" > "$here/.state/mobilizon.token" +} diff --git a/tools/pasture/peers/shared.sh b/tools/pasture/peers/shared.sh index 3b29c1a..e1b1857 100644 --- a/tools/pasture/peers/shared.sh +++ b/tools/pasture/peers/shared.sh @@ -22,6 +22,16 @@ pg_db() { done } +# a PostGIS of its own for the peers that need the extension (Mobilizon), beside the shared Postgres that has not got it. +# Its first start runs a server on the socket alone to set itself up: only TCP answers when it is ready +shared_postgis_up() { + podman container exists pasture-postgis && return 0 + podman run -d --replace --name pasture-postgis --network $net --network-alias postgis --label pasture=1 \ + -e POSTGRES_USER=pasture -e POSTGRES_PASSWORD=pasture -e POSTGRES_DB=pasture docker.io/postgis/postgis:17-3.5-alpine >/dev/null + for _ in $(seq 1 90); do podman exec pasture-postgis pg_isready -h 127.0.0.1 -U pasture >/dev/null 2>&1 && return 0; sleep 1; done + echo "postgis did not start" >&2; return 1 +} + shared_mysql_up() { podman container exists pasture-mysql && return 0 podman run -d --replace --name pasture-mysql --network $net --network-alias mysql \ diff --git a/tools/pasture/scenarios/mobilizon.sh b/tools/pasture/scenarios/mobilizon.sh new file mode 100644 index 0000000..27f8e4d --- /dev/null +++ b/tools/pasture/scenarios/mobilizon.sh @@ -0,0 +1,89 @@ +# Mobilizon 5.2.4: a group and its events. alice_mobilizon follows the group, which announces the events its members +# organise; an event arrives as an Event with its dates and place; comments both ways; an edit and comments closed; +# deletes; a group post; the unfollow. An event made through its API without options has its comments closed +# (`commentsEnabled: false`), which PrivaPub honours, so this one opens them. Mobilizon's API is GraphQL (/api), signed in by its login mutation; what it holds is read from +# its PostGIS (database mobilizon). +MZ=https://mobilizon.test:6443 +mz_sql() { podman exec pasture-postgis psql -U pasture -d mobilizon -tAc "$1" 2>/dev/null; } +# mz [variables as JSON]: a GraphQL request as mzuser, its JSON answer +mz() { + curl -sk --resolve mobilizon.test:6443:127.0.0.1 "$MZ/api" -H "Authorization: Bearer $MZT" -H 'Content-Type: application/json' \ + -d "$(python3 -c 'import json, sys; print(json.dumps({"query": sys.argv[1], "variables": json.loads(sys.argv[2])}))' "$1" "${2:-{\}}")" +} +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 "mobilizon" +[ -s "$here/.state/mobilizon.token" ] && ok "Mobilizon credentials for mzuser" || { ko "Mobilizon credentials"; return 1; } +credentials=$(cat "$here/.state/mobilizon.token") +MZT=$(curl -sk --resolve mobilizon.test:6443:127.0.0.1 "$MZ/api" -H 'Content-Type: application/json' \ + -d "{\"query\":\"mutation{login(email:\\\"${credentials%%:*}\\\",password:\\\"${credentials#*:}\\\"){accessToken}}\"}" | j "print(d['data']['login']['accessToken'])") +[ -n "$MZT" ] && ok "mzuser signs in to Mobilizon's GraphQL API" || { ko "mzuser cannot sign in"; return 1; } +PT=$(privapub_token alice_mobilizon) +PH="Authorization: Bearer $PT" +[ -n "$PT" ] && ok "PrivaPub token for alice_mobilizon" || { ko "PrivaPub token for alice_mobilizon"; return 1; } + +echo " the group and its follower" +me=$(mz '{loggedPerson{id}}' | j "print(d['data']['loggedPerson']['id'])") +mz 'mutation($n:String!){createGroup(preferredUsername:$n,name:"Pasture events",summary:"

events in the pasture

",visibility:PUBLIC,openness:OPEN){id}}' \ + '{"n":"pastureevents"}' >/dev/null +group=$(mz '{group(preferredUsername:"pastureevents"){id}}' | j "print(d['data']['group']['id'])") +[ -n "$group" ] && ok "mzuser runs the group pastureevents" || ko "the group could not be made" +group_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=pastureevents@mobilizon.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") +[ -n "$group_on_p" ] && ok "PrivaPub resolves @pastureevents@mobilizon.test" || ko "PrivaPub cannot resolve the group" +# 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/$group_on_p/unfollow"; sleep 3 +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$group_on_p/follow" +until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$group_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \ + && ok "alice_mobilizon follows the group (Accept arrived)" || ko "the group's Accept never arrived" + +echo " events" +title="A picnic in the pasture $(date +%s)" +begins=$(date -u -d '+3 days' +%Y-%m-%dT18:00:00Z); ends=$(date -u -d '+3 days' +%Y-%m-%dT21:00:00Z) +event=$(mz 'mutation($t:String!,$b:DateTime!,$e:DateTime!,$o:ID!,$g:ID){createEvent(title:$t,description:"

bring bread

",beginsOn:$b,endsOn:$e,organizerActorId:$o,attributedToId:$g,visibility:PUBLIC,joinOptions:FREE,options:{commentModeration:ALLOW_ALL},physicalAddress:{description:"Villa Borghese",locality:"Roma",country:"Italia",geom:"12.4922;41.9142"}){id uuid url}}' \ + "{\"t\":\"$title\",\"b\":\"$begins\",\"e\":\"$ends\",\"o\":\"$me\",\"g\":\"$group\"}") +event_id=$(echo "$event" | j "print(d['data']['createEvent']['id'])"); event_url=$(echo "$event" | j "print(d['data']['createEvent']['url'])") +[ -n "$event_id" ] && ok "mzuser organises an event for the group" || ko "the event could not be made: $(echo "$event" | cut -c1-200)" +until_true 60 '[ -n "$(p_home_has "$title")" ]' && ok "the group's event reaches alice_mobilizon'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])")" = "${begins:0:16} ${ends:0:16}" ] \ + && 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 '')) and p.get('latitude') for p in e.get('places', [])))" | grep -q True \ + && ok "and with its place, located" || ko "the event's place is missing" + +echo " comments" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses" -d "status=a PrivaPub comment on the picnic&in_reply_to_id=$e_on_p&visibility=public" +until_true 45 '[ "$(mz_sql "select count(*) from comments where text like '"'%a PrivaPub comment on the picnic%'"' and deleted_at is null")" -ge 1 ]' \ + && ok "alice_mobilizon's reply becomes a comment on the event" || ko "the comment never reached Mobilizon" +comment=$(mz 'mutation($e:ID!){createComment(eventId:$e,text:"

a Mobilizon comment on the picnic

"){id}}' "{\"e\":\"$event_id\"}" | j "print(d['data']['createComment']['id'])") +until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$e_on_p/context" | j "print(any(\"a Mobilizon comment on the picnic\" in s[\"content\"] for s in d[\"descendants\"]))")" = "True" ]' \ + && ok "mzuser's comment threads under the event on PrivaPub" || ko "Mobilizon's comment missing on PrivaPub" + +echo " edits and deletes" +mz 'mutation($e:ID!,$t:String!){updateEvent(eventId:$e,title:$t){id}}' "{\"e\":\"$event_id\",\"t\":\"$title, moved indoors\"}" >/dev/null +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" +mz 'mutation($e:ID!){updateEvent(eventId:$e,options:{commentModeration:CLOSED}){id}}' "{\"e\":\"$event_id\"}" >/dev/null +until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$e_on_p" | j "print((d.get(\"privapub\") or {}).get(\"locked\"))")" = "True" ]' \ + && ok "closing its comments locks the event on PrivaPub" || ko "the event's closed comments not applied" +[ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "$PH" "$P/api/v1/statuses" -d "status=too late&in_reply_to_id=$e_on_p&visibility=public")" = "422" ] \ + && ok "and a reply to it is refused" || ko "a reply to the closed event was taken" +mz 'mutation($c:ID!){deleteComment(commentId:$c){id}}' "{\"c\":\"$comment\"}" >/dev/null +until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$e_on_p/context" | j "print(any(\"a Mobilizon comment on the picnic\" in s[\"content\"] for s in d[\"descendants\"]))")" = "False" ]' \ + && ok "mzuser's deleted comment leaves PrivaPub" || ko "the deleted comment stays on PrivaPub" +mz 'mutation($e:ID!){deleteEvent(eventId:$e){id}}' "{\"e\":\"$event_id\"}" >/dev/null +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 " group posts" +post_title="News from the pasture $(date +%s)" +mz 'mutation($g:ID!,$t:String!){createPost(attributedToId:$g,title:$t,body:"

the picnic moved indoors

",visibility:PUBLIC){id}}' \ + "{\"g\":\"$group\",\"t\":\"$post_title\"}" >/dev/null +until_true 60 '[ -n "$(p_home_has "$post_title")" ]' && ok "the group's post reaches alice_mobilizon's home, with its title" || ko "the group's post never arrived" + +echo " unfollow" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$group_on_p/unfollow" +until_true 45 '[ "$(mz_sql "select count(*) from followers f join actors a on a.id = f.actor_id where a.url like '"'%alice_mobilizon%'"'")" = "0" ]' \ + && ok "alice_mobilizon's unfollow reaches the group" || ko "the group still counts alice_mobilizon" + +echo " statistics" +stats_check mobilizon.test mobilizon