From 0aea550c6cbf5262fe351c16f9a941ff65d461aa Mon Sep 17 00:00:00 2001 From: thepra Date: Tue, 6 Oct 2026 00:28:11 +0200 Subject: [PATCH] Pasture: WriteFreely 0.17.2 WriteFreely joins the pasture, built from its release with openssl beside it (it makes each blog's keys with the command); scenarios/writefreely.sh has alice follow a blog and receive its post as a titled Article, its edit and its deletion, then unfollow: 13 checks, with no change to PrivaPub. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- CLAUDE.md | 3 ++ FEDERATION.md | 1 + docs/INTEROP.md | 10 ++++ tools/pasture/Caddyfile | 5 ++ .../pasture/images/writefreely/Containerfile | 9 ++++ tools/pasture/peers/writefreely.sh | 49 +++++++++++++++++++ tools/pasture/scenarios/writefreely.sh | 46 +++++++++++++++++ 7 files changed, 123 insertions(+) create mode 100644 tools/pasture/images/writefreely/Containerfile create mode 100644 tools/pasture/peers/writefreely.sh create mode 100644 tools/pasture/scenarios/writefreely.sh diff --git a/CLAUDE.md b/CLAUDE.md index c7d2397..501f632 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. +- **WriteFreely (0.17.2):** built from its release tarball with `openssl` beside it (`images/writefreely`, it makes + each blog's keys with the command), on SQLite in the `pasture-writefreely` volume, which the scenario reads from the + host. wfuser and its blog come from its CLI; its API takes a login. `scenarios/writefreely.sh`, 13 checks. - **snac2 (2.95):** built from its tag (`images/snac2`, the project publishes no image), its data in the `pasture-snac` volume from `snac init` and `snac adduser`; the password comes from `snac resetpwd` and the token from its login form and a code grant. Its API lags behind what it has taken in, so `scenarios/snac.sh` reads its files diff --git a/FEDERATION.md b/FEDERATION.md index 03318af..148e0b9 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -35,6 +35,7 @@ and every run starting clean, with signed fetches required (as privapub.thepra.d - **Smithereen 1.0.3** - **Mitra 5.9.1** - **snac2 2.95** +- **WriteFreely 0.17.2** - **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 f891037..5cc6851 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. +### WriteFreely 0.17.2 + +- Each blog is an actor (`/api/collections/`) whose posts go out as `Article`s with their title and the whole + text; a blog follows nobody and takes no replies or likes, so nothing goes the other way. +- It makes each blog's keys with the `openssl` command when the blog first federates: without it every delivery to + the blog answers 500 (its release tarball needs the command beside it). +- **Pasture evidence (2026-10-05, `tools/pasture/scenarios/writefreely.sh`):** 13 checks pass, with no change to + PrivaPub: alice follows a blog, its post reaches her home as an Article with its title, its edit and deletion follow, + the unfollow, statistics. + ### snac2 2.95 - Keeps everything in files and works through what it receives one message at a time, sometimes minutes behind; its diff --git a/tools/pasture/Caddyfile b/tools/pasture/Caddyfile index 89ec13c..55fb04d 100644 --- a/tools/pasture/Caddyfile +++ b/tools/pasture/Caddyfile @@ -108,6 +108,11 @@ nodebb.test { reverse_proxy pasture-nodebb:4567 } +writefreely.test { + tls internal + reverse_proxy pasture-writefreely:8080 +} + snac.test { tls internal reverse_proxy pasture-snac:8001 diff --git a/tools/pasture/images/writefreely/Containerfile b/tools/pasture/images/writefreely/Containerfile new file mode 100644 index 0000000..6d0012d --- /dev/null +++ b/tools/pasture/images/writefreely/Containerfile @@ -0,0 +1,9 @@ +# WriteFreely 0.17.2 from its release tarball: the project's image stopped at 0.9. It makes each blog's keys with the +# openssl command +FROM docker.io/library/alpine:3.22 +RUN apk -U --no-progress --no-cache add ca-certificates curl libc6-compat openssl \ + && curl -fsSL https://github.com/writefreely/writefreely/releases/download/v0.17.2/writefreely_0.17.2_linux_amd64.tar.gz \ + | tar -xz -C /opt \ + && apk del curl +WORKDIR /opt/writefreely +EXPOSE 8080 diff --git a/tools/pasture/peers/writefreely.sh b/tools/pasture/peers/writefreely.sh new file mode 100644 index 0000000..f6e173b --- /dev/null +++ b/tools/pasture/peers/writefreely.sh @@ -0,0 +1,49 @@ +# WriteFreely 0.17.2: blogs, each an ActivityPub actor whose posts go out as Articles (title, summary, the whole text). +# A blog can be followed, never follow; it takes no replies. Built from its release (images/writefreely), on SQLite in its +# own volume, as writefreely.test; Go trusts the bundle the pasture mounts over the system one. wfuser and its blog +# come from its CLI, and its API takes a login. +WRITEFREELY_IMAGE=${WRITEFREELY_IMAGE:-localhost/pasture-writefreely:0.17.2} +WRITEFREELY_PASSWORD=WriteFreely-Pasture-Pass-1 + +writefreely_up() { + podman image exists "$WRITEFREELY_IMAGE" || podman build -q -t "$WRITEFREELY_IMAGE" "$here/images/writefreely" >/dev/null + local st="$here/.state/writefreely" + mkdir -p "$st" + cat > "$st/config.ini" <<-EOF2 + [server] + port = 8080 + bind = 0.0.0.0 + keys_parent_dir = /data + + [database] + type = sqlite3 + filename = /data/writefreely.db + + [app] + site_name = WriteFreely pasture + host = https://writefreely.test + single_user = false + open_registration = true + max_blogs = 4 + federation = true + public_stats = true + local_timeline = true + EOF2 + podman volume exists pasture-writefreely || podman volume create --label pasture=1 pasture-writefreely >/dev/null + local run="podman run --rm -v pasture-writefreely:/data -v $st/config.ini:/opt/writefreely/config.ini:Z,ro $WRITEFREELY_IMAGE" + if ! $run test -s /data/writefreely.db; then + $run sh -c 'mkdir -p /data/keys && ./writefreely keys generate && ./writefreely db init' >/dev/null 2>&1 + $run ./writefreely user create --admin "wfuser:$WRITEFREELY_PASSWORD" >/dev/null 2>&1 + fi + podman run -d --replace --name pasture-writefreely --label pasture=1 --network $net -v pasture-writefreely:/data \ + -v "$st/config.ini:/opt/writefreely/config.ini:Z,ro" -v "$ca/bundle.pem:/etc/ssl/certs/ca-certificates.crt:z,ro" \ + $WRITEFREELY_IMAGE ./writefreely serve >/dev/null + for _ in $(seq 1 30); do + site writefreely.test -s -o /dev/null -w '%{http_code}' https://writefreely.test:6443/api/me 2>/dev/null | grep -q -E '200|401' && break + sleep 1 + done + site writefreely.test -s -X POST https://writefreely.test:6443/api/auth/login -H 'Content-Type: application/json' \ + -d "{\"alias\":\"wfuser\",\"pass\":\"$WRITEFREELY_PASSWORD\"}" \ + | python3 -c 'import json, sys; print(json.load(sys.stdin)["data"]["access_token"])' > "$st/wfuser.token" + echo "writefreely: https://writefreely.test:6443" +} diff --git a/tools/pasture/scenarios/writefreely.sh b/tools/pasture/scenarios/writefreely.sh new file mode 100644 index 0000000..b3b6290 --- /dev/null +++ b/tools/pasture/scenarios/writefreely.sh @@ -0,0 +1,46 @@ +# WriteFreely 0.17.2: alice follows a blog; a post there reaches her home as an Article with its title; its edit and its +# deletion follow; the unfollow; statistics. A blog follows nobody and takes no replies or likes, so nothing goes the +# other way. Driven through its API as wfuser, with the token peers/writefreely.sh got. +WF=https://writefreely.test:6443 +WFT=$(cat "$here/.state/writefreely/wfuser.token" 2>/dev/null) +wf() { site writefreely.test -s -H "Authorization: Token $WFT" -H 'Content-Type: application/json' "$@"; } +# wf_sql : from WriteFreely's SQLite, read in its volume +wf_sql() { python3 -c 'import sqlite3, sys; print("\n".join("|".join(map(str, r)) for r in sqlite3.connect(sys.argv[1]).execute(sys.argv[2])))' \ + "$(podman volume inspect pasture-writefreely --format '{{.Mountpoint}}')/writefreely.db" "$1"; } +p_home_has() { curl -s -H "$PH" "$P/api/v1/timelines/home?limit=40" | j "print(next((s['id'] for s in d if '$1' in (s['content'] or '') or '$1' in ((s.get('privapub') or {}).get('title') or '')), ''))"; } + +echo "writefreely" +[ "$(wf "$WF/api/me" | j "print(d['data']['username'])")" = "wfuser" ] && ok "WriteFreely token for wfuser" || { ko "WriteFreely token"; return 1; } +PT=$(privapub_token alice_wf) +PH="Authorization: Bearer $PT" +[ -n "$PT" ] && ok "PrivaPub token for alice_wf" || { ko "PrivaPub token for alice_wf"; return 1; } +run=$(date +%s) + +echo " following the blog" +blog_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=wfuser@writefreely.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") +[ -n "$blog_on_p" ] && ok "PrivaPub resolves the blog" || ko "PrivaPub cannot resolve the blog" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$blog_on_p/follow" +until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$blog_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \ + && ok "alice follows the blog (Accept arrived)" || ko "the blog's Accept never arrived" + +echo " articles" +post=$(wf -X POST "$WF/api/collections/wfuser/posts" -d "{\"title\":\"A WriteFreely article $run\",\"body\":\"Written on a blog, $run.\"}") +post_id=$(echo "$post" | j "print(d['data']['id'])") +until_true 45 '[ -n "$(p_home_has "A WriteFreely article $run")" ]' && ok "the blog's post reaches alice's home" || ko "the blog's post never reached alice" +on_p=$(p_home_has "A WriteFreely article $run") +[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$on_p" | j "print((d.get('privapub') or {}).get('title'))")" = "A WriteFreely article $run" ] \ + && ok "as an Article with its title" || ko "the article arrived without its title" +wf -o /dev/null -X POST "$WF/api/posts/$post_id" -d "{\"title\":\"A WriteFreely article $run\",\"body\":\"Written on a blog, $run, then edited.\"}" +until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$on_p" | j "print(\"then edited\" in d[\"content\"])")" = "True" ]' \ + && ok "its edit reaches PrivaPub" || ko "its edit never reached PrivaPub" +wf -o /dev/null -X DELETE "$WF/api/posts/$post_id" +until_true 45 '[ "$(curl -s -o /dev/null -w "%{http_code}" -H "$PH" "$P/api/v1/statuses/$on_p")" = "404" ]' \ + && ok "its deletion reaches PrivaPub" || ko "the deleted article still shows on PrivaPub" + +echo " unfollow" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$blog_on_p/unfollow" +until_true 45 '[ "$(wf_sql "select count(*) from remotefollows f join remoteusers u on u.id = f.remote_user_id where u.actor_id like '"'%alice_wf'"'")" = "0" ]' \ + && ok "alice's unfollow reaches the blog" || ko "the blog still counts alice" + +echo " statistics" +stats_check writefreely.test writefreely