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