Files
thepraandClaude Opus 5.5 f66c280b0b Reports reach Lemmy's moderators, from an anonymous reporter
Lemmy takes a report only from a person or a service, about one post or comment, addressed to its community, and it
answered PrivaPub's Flag (the instance actor's, an Application, with no `to` and the account and posts as its object)
400. A report of a post or comment in a community on a server whose NodeInfo names Lemmy now leaves from
`privapub_reports`, a Service with its own key that names nobody: one Flag per post, `to` the community (its own
audience, else its thread's), with the persona's words, or the category, in `summary` and `content`, sent to the
community's inbox. This is the second exception to "a server's software is for display" (owner decision 2026-10-06,
`ReportService.ServiceReportTakers`). Every other server keeps the instance actor's report. An account alone is not
reported to Lemmy, which takes no such report, and `forwarded` now says whether anything left.

The reporter is read unsigned in SecureMode and answers WebFinger like the instance actor. Nobody follows or mentions
it, the Mastodon API has no account for it, and a migration reserves its name. Checked live: Lemmy 1.0 and 0.19 keep the
reports of a thread and of a comment, with alice's words, from "Reports from privapub.test", and none names her (69
checks). A sweep of every scenario with this and the next commit: 876 checks pass; Ghost's Network feed listed alice's
post too late once, and Ghost passes alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 19:23:17 +02:00

82 lines
5.7 KiB
Bash

# Shared by interop.sh and the scenarios: check helpers, PrivaPub personas and tokens, the statistics check.
P=http://127.0.0.1:${PASTURE_PORT:-6971}
work=$(mktemp -d); trap 'rm -rf "$work"' EXIT
pass=0; fail=0; expected=0
# every check is also appended to $INTEROP_JSONL (out/scenarios.jsonl), which the town's report reads next to its own
INTEROP_JSONL=${INTEROP_JSONL:-$here/out/scenarios.jsonl}
mkdir -p "$(dirname "$INTEROP_JSONL")"
record() { python3 -c 'import json,sys,time; print(json.dumps({"source":"scenario","peer":sys.argv[1],"check":sys.argv[2],"status":sys.argv[3],"t":time.strftime("%Y-%m-%dT%H:%M:%SZ",time.gmtime())}))' \
"${INTEROP_PEER:-}" "$1" "$2" >> "$INTEROP_JSONL"; }
ok() { echo " ok $*"; pass=$((pass+1)); record "$*" pass; }
ko() { echo " FAIL $*"; fail=$((fail+1)); record "$*" fail; }
xf() { echo " xf $* (expected to fail until a later phase)"; expected=$((expected+1)); record "$*" xfail; }
j() { python3 -c "import sys,json
try: d=json.load(sys.stdin)
except Exception: d=None
$1" 2>/dev/null; }
until_true() { local tries=$1; shift; for _ in $(seq 1 "$tries"); do if eval "$@"; then return 0; fi; sleep 2; done; return 1; }
site() { curl -k --resolve "$1:6443:127.0.0.1" "${@:2}"; }
# fetches one of PrivaPub's own https URIs (ids, scribbles) from the workstation, through Caddy
pfetch() { curl -sk --connect-to privapub.test:443:127.0.0.1:6443 "$@"; }
# make_png <path>: an 8x8 red PNG, for uploads
# the status an unsigned ActivityPub GET of a PrivaPub document gets
pstatus() { pfetch -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$1"; }
# SecureMode (PRIVAPUB_ENV="Federation__SecureMode=true") answers every unsigned GET but the instance actor's 401, so a
# post's author answering 401 means it is on; then 401 is what "not served" and "gone" look like to an unsigned reader
secure_mode() { [ "$(pstatus "${1%%/scribbles/*}")" = "401" ]; }
unserved() { local code; code=$(pstatus "$1"); if secure_mode "$1"; then [ "$code" = 401 ]; else [ "$code" = 404 ]; fi; }
gone_unsigned() { local code; code=$(pstatus "$1"); if secure_mode "$1"; then [ "$code" = 401 ]; else [ "$code" = 410 ]; fi; }
make_png() { python3 -c "
import struct,zlib
w=h=8
raw=b''.join(b'\x00'+bytes([200,60,60])*w for _ in range(h))
png=b'\x89PNG\r\n\x1a\n'+b''.join(struct.pack('>I',len(c))+t+c+struct.pack('>I',zlib.crc32(t+c)&0xffffffff) for t,c in [(b'IHDR',struct.pack('>IIBBBBB',w,h,8,2,0,0,0)),(b'IDAT',zlib.compress(raw)),(b'IEND',b'')])
open('$1','wb').write(png)"; }
ROOT_USER=pastureroot; ROOT_PASS='Pasture-Pass-1!'
privapub_root() {
local root
root=$(curl -s -X POST $P/clientapi/user/signup -H 'Content-Type: application/json' -d "{\"userName\":\"$ROOT_USER\",\"password\":\"$ROOT_PASS\"}")
[ -n "$(echo "$root" | j "print(d['token'])")" ] || root=$(curl -s -X POST $P/clientapi/user/login -H 'Content-Type: application/json' -d "{\"userName\":\"$ROOT_USER\",\"password\":\"$ROOT_PASS\"}")
echo "$root" | j "print(d['token'])"
}
# privapub_token <persona>: creates the persona under the pasture root if needed and returns a Mastodon token for it,
# through the same OAuth flow the deploy's smoke check uses (tools/smoke/oauth.sh).
privapub_token() {
local persona=$1 jwt
jwt=$(privapub_root)
curl -s -o /dev/null -X POST $P/clientapi/avatar/private/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
-d "{\"userName\":\"$persona\",\"name\":\"$persona of PrivaPub\",\"biography\":\"testing federation\"}"
"$here/../smoke/oauth.sh" "$P" "$ROOT_USER" "$ROOT_PASS" "$persona" "read write follow" | cut -d' ' -f1
}
# p_report <authorization header> <account id> <reason> <status id...>: a persona reports an account's posts and asks for
# the report to be forwarded; prints whether PrivaPub says it was (Mastodon's "forwarded")
p_report() {
local auth=$1 account=$2 reason=$3; shift 3
local ids=() id
for id in "$@"; do ids+=(-d "status_ids[]=$id"); done
curl -s -X POST -H "$auth" "$P/api/v1/reports" -d "account_id=$account" "${ids[@]}" --data-urlencode "comment=$reason" \
-d 'category=other' -d 'forward=true' | j "print(d['forwarded'])"
}
# the anonymous Service that carries PrivaPub's reports to the servers that take them only from a person or a service
P_REPORTER=https://privapub.test/peasants/privapub_reports
# stats_check <host> <software>: the admin statistics name the peer's software and count traffic both ways.
stats_check() {
local host=$1 software=$2 admin found
podman exec -w /app pasture-privapub /app/PrivaPub admin promote "$ROOT_USER" >/dev/null 2>&1 || true
admin=$(curl -s -X POST $P/clientapi/user/login -H 'Content-Type: application/json' -d "{\"userName\":\"$ROOT_USER\",\"password\":\"$ROOT_PASS\"}" | j "print(d['token'])")
until_true 30 'found=$(curl -s -H "Authorization: Bearer $admin" "$P/clientapi/admin/statistics/hosts/$host?days=1"); [ "$(echo "$found" | j "print((d[\"instance\"] or {}).get(\"software\"))")" = "$software" ]' \
&& ok "statistics describe $host as $software" || ko "statistics do not describe $host as $software"
found=$(curl -s -H "Authorization: Bearer $admin" "$P/clientapi/admin/statistics/hosts/$host?days=1")
[ "$(echo "$found" | j "print(any(k.startswith('in:') for day in d['days'] for k in day['counters']))")" = "True" ] \
&& ok "statistics count what $host sent" || ko "no inbound statistics for $host"
[ "$(echo "$found" | j "print(any(k.startswith('out:') and ':ok' in k for day in d['days'] for k in day['counters']))")" = "True" ] \
&& ok "statistics count what we delivered to $host" || ko "no outbound statistics for $host"
[ "$(echo "$found" | j "print('$ROOT_USER' not in json.dumps(d['events']) and 'alice' not in json.dumps(d['events']))")" = "True" ] \
&& ok "statistics for $host name no account" || ko "statistics for $host name an account"
}