Roles (user, moderator, administrator) from the CLI and /clientapi/admin/roots/{id}/role, never one's own nor the last
administrator. Silence keeps an account's posts out of public timelines, hashtags, trends, search and relays while its
followers still get them; suspension refuses and revokes a local persona's tokens, answers 404 for its posts and
collections, holds back every delivery and tells other servers nothing new, its actor unchanged; a suspended remote
account's activities are dropped and nothing goes to its own inbox (a shared one still serves its server). A banned
login's personas are held back in memory alone (Restrictions), so nothing pairs them in stored data. Personas and groups
are deleted one at a time (PersonaRemoval, GroupRemoval), by their login or an administrator with the password. Reports
resolve with an action and a note; posts are taken down, a remote copy hidden. Warnings reach the persona as Mastodon's
moderation_warning. Every staff act goes to the staff's log. Migration _020 limits posts from servers already silenced;
a restore keeps the moderation made since the backup.
Verified: interop.sh moderation gts mastodon with SecureMode, 194 passed; restore.sh, 25 passed, now with a suspension,
a silence and a takedown made after the backup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
116 lines
10 KiB
Bash
116 lines
10 KiB
Bash
# Backup and restore (owner decisions 2026-10-07), on the live pasture and through the administrator's endpoints: the
|
|
# server is backed up, then life goes on: alice_restore deletes a post and makes another, mastouser follows her, she
|
|
# blocks bob_restore, carol_restore is made, and the staff suspend dave_restore, silence mrestore on Mastodon and take
|
|
# down a post of alice's. The backup is restored: PrivaPub stops, restores it as it starts again, and nothing protective
|
|
# is undone: the deleted post stays gone, the post made since answers as deleted, mastouser still follows her, the block
|
|
# holds, carol's name stays taken, and the suspension, the silence and the takedown hold (R6). Every session ends, so the scenario signs in again, and at
|
|
# the end the town's PrivaPub accounts too (town.sh renew privapub). Run it alone: it restores the whole server. Needs
|
|
# the mastodon peer.
|
|
M=https://mastodon.test:6443
|
|
mcurl() { curl -sk --resolve mastodon.test:6443:127.0.0.1 "$@"; }
|
|
. "$here/peers/mastodon.sh"
|
|
m_rails() { podman exec pasture-mastodon bin/rails runner "$1" 2>/dev/null | tail -1; }
|
|
p_mongo() { podman exec pasture-mongo mongosh --quiet PrivaPub --eval "$1"; }
|
|
staff() { local method=$1 path=$2; shift 2; curl -s -X "$method" -H "$RH" -H 'Content-Type: application/json' "$P$path" "$@"; }
|
|
|
|
echo "restore"
|
|
podman exec -w /app pasture-privapub /app/PrivaPub admin promote "$ROOT_USER" >/dev/null 2>&1 || true
|
|
JWT=$(privapub_root)
|
|
RH="Authorization: Bearer $JWT"
|
|
AT=$(privapub_token alice_restore)
|
|
BT=$(privapub_token bob_restore)
|
|
AH="Authorization: Bearer $AT"
|
|
[ -n "$AT" ] && [ -n "$BT" ] && ok "PrivaPub tokens for alice_restore and bob_restore" || { ko "PrivaPub tokens for alice_restore and bob_restore"; return 1; }
|
|
run=$(date +%s)
|
|
alice=$(curl -s -H "$AH" "$P/api/v1/accounts/verify_credentials")
|
|
alice_id=$(echo "$alice" | j "print(d['id'])")
|
|
alice_uri=$(echo "$alice" | j "print(d['url'])" | sed 's|/@|/peasants/|')
|
|
bob_id=$(curl -s -H "Authorization: Bearer $BT" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])")
|
|
regret=$(curl -s -X POST -H "$AH" "$P/api/v1/statuses" -d "status=a post regretted later $run&visibility=public")
|
|
regret_id=$(echo "$regret" | j "print(d['id'])"); regret_uri=$(echo "$regret" | j "print(d['uri'])")
|
|
DT=$(privapub_token dave_restore)
|
|
dave_id=$(curl -s -H "Authorization: Bearer $DT" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])")
|
|
staff POST /clientapi/moderator/accounts/moderate -d "{\"accountId\":\"$dave_id\",\"action\":\"unsuspend\"}" >/dev/null
|
|
down_id=$(curl -s -X POST -H "$AH" "$P/api/v1/statuses" -d "status=a post the staff take down later $run&visibility=public" | j "print(d['id'])")
|
|
mastodon_user mrestore
|
|
mr_id=$(curl -s -H "$AH" "$P/api/v2/search?q=mrestore@mastodon.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
|
|
staff POST /clientapi/moderator/accounts/moderate -d "{\"accountId\":\"$mr_id\",\"action\":\"unsilence\"}" >/dev/null
|
|
[ -n "$dave_id" ] && [ -n "$down_id" ] && [ -n "$mr_id" ] && ok "dave_restore, a post to take down and mrestore on Mastodon" || ko "the moderation's subjects: $dave_id $down_id $mr_id"
|
|
# mastouser follows nobody here yet: the follow comes after the backup
|
|
m_follows() { m_rails "puts Follow.exists?(account: Account.find_local(\"mastouser\"), target_account: Account.find_by(uri: \"$alice_uri\")) ? \"True\" : \"False\""; }
|
|
alice_on_m=$(mcurl -H "Authorization: Bearer $(mastodon_token)" "$M/api/v2/search?q=@alice_restore@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
|
|
if [ "$(m_follows)" = "True" ]; then
|
|
mcurl -o /dev/null -X POST -H "Authorization: Bearer $(mastodon_token)" "$M/api/v1/accounts/$alice_on_m/unfollow"
|
|
until_true 30 '[ "$(m_follows)" = "False" ]'
|
|
fi
|
|
curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$bob_id/unblock"
|
|
|
|
backups() { curl -s -H "$RH" "$P/clientapi/admin/backups"; }
|
|
before=$(backups | j "print(' '.join(b['id'] for b in d['backups']))")
|
|
[ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "$RH" "$P/clientapi/admin/backups")" = "202" ] && ok "a backup is asked for" || ko "the backup was refused"
|
|
backup=""
|
|
newest() { backups | j "
|
|
ids=[b['id'] for b in d['backups'] if b['id'] not in '$before'.split()]
|
|
print(ids[0] if ids and d.get('running') is None else '')"; }
|
|
until_true 300 '[ -n "$(newest)" ]' && backup=$(newest)
|
|
[ -n "$backup" ] && ok "backed up as $backup" || { ko "the backup was never made"; return 1; }
|
|
|
|
echo " life goes on"
|
|
curl -s -o /dev/null -X DELETE -H "$AH" "$P/api/v1/statuses/$regret_id"
|
|
since=$(curl -s -X POST -H "$AH" "$P/api/v1/statuses" -d "status=made after the backup $run&visibility=public")
|
|
since_uri=$(echo "$since" | j "print(d['uri'])")
|
|
mcurl -o /dev/null -X POST -H "Authorization: Bearer $(mastodon_token)" "$M/api/v1/accounts/$alice_on_m/follow"
|
|
until_true 45 '[ "$(m_follows)" = "True" ]' && ok "mastouser follows alice after the backup" || ko "mastouser never followed alice"
|
|
until_true 30 '[ "$(curl -s -H "$AH" "$P/api/v1/accounts/$alice_id/followers" | j "print(any(a[\"acct\"]==\"mastouser@mastodon.test\" for a in d))")" = "True" ]' \
|
|
&& ok "PrivaPub has mastouser following alice" || ko "PrivaPub never had the follower"
|
|
curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$bob_id/block"
|
|
carol="carol_restore_$run"
|
|
curl -s -o /dev/null -X POST -H "$RH" -H 'Content-Type: application/json' "$P/clientapi/avatar/private/insert" \
|
|
-d "{\"userName\":\"$carol\",\"name\":\"carol\",\"biography\":\"made after the backup\"}"
|
|
staff POST /clientapi/moderator/accounts/moderate -d "{\"accountId\":\"$dave_id\",\"action\":\"suspend\",\"note\":\"after the backup\"}" >/dev/null
|
|
staff POST /clientapi/moderator/accounts/moderate -d "{\"accountId\":\"$mr_id\",\"action\":\"silence\",\"note\":\"after the backup\"}" >/dev/null
|
|
staff POST /clientapi/moderator/posts/remove -d "{\"postId\":\"$down_id\",\"note\":\"after the backup\"}" >/dev/null
|
|
[ "$(staff GET "/clientapi/moderator/accounts/$dave_id" | j "print(bool(d.get('suspendedAt')))")" = True ] \
|
|
&& [ "$(staff GET "/clientapi/moderator/accounts/$mr_id" | j "print(bool(d.get('silencedAt')))")" = True ] \
|
|
&& [ "$(curl -s -o /dev/null -w '%{http_code}' -H "$AH" "$P/api/v1/statuses/$down_id")" = 404 ] \
|
|
&& ok "the staff suspend dave_restore, silence mrestore and take a post down after the backup" || ko "the staff's acts after the backup"
|
|
|
|
echo " restored"
|
|
[ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "$RH" -H 'Content-Type: application/json' "$P/clientapi/admin/backups/$backup/restore" \
|
|
-d "{\"password\":\"$ROOT_PASS\",\"host\":\"elsewhere.test\"}")" = "422" ] && ok "a restore with the wrong host is refused" || ko "a restore with the wrong host was taken"
|
|
[ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "$RH" -H 'Content-Type: application/json' "$P/clientapi/admin/backups/$backup/restore" \
|
|
-d "{\"password\":\"$ROOT_PASS\",\"host\":\"privapub.test\"}")" = "202" ] && ok "the restore is asked for" || { ko "the restore was refused"; return 1; }
|
|
# it stops within seconds, restores as it starts again, and every session ends: the old token is refused once it is back
|
|
until_true 300 '[ "$(curl -s -o /dev/null -w "%{http_code}" -H "$RH" "$P/clientapi/admin/backups")" = "401" ]' \
|
|
&& ok "PrivaPub came back, and the administrator's session ended" || { ko "PrivaPub never came back from the restore"; podman logs --tail 30 pasture-privapub; return 1; }
|
|
JWT=$(privapub_root); RH="Authorization: Bearer $JWT"
|
|
[ "$(backups | j "print(d['lastRestore']['backup'])")" = "$backup" ] && ok "the last restore is $backup" || ko "the last restore is not $backup"
|
|
[ "$(curl -s -o /dev/null -w '%{http_code}' -H "$AH" "$P/api/v1/accounts/verify_credentials")" = "401" ] && ok "alice's old token is refused" || ko "alice's old token still works"
|
|
AT=$(privapub_token alice_restore); AH="Authorization: Bearer $AT"
|
|
|
|
[ "$(curl -s -o /dev/null -w '%{http_code}' -H "$AH" "$P/api/v1/statuses/$regret_id")" = "404" ] && ok "the post deleted after the backup stays deleted" || ko "the deleted post came back"
|
|
gone_unsigned "$regret_uri" && ok "its address answers as gone" || ko "its address answers $(pstatus "$regret_uri")"
|
|
gone_unsigned "$since_uri" && ok "the post made after the backup answers as gone" || ko "the post made after the backup answers $(pstatus "$since_uri")"
|
|
[ "$(curl -s -H "$AH" "$P/api/v1/accounts/$alice_id/followers" | j "print(any(a['acct']=='mastouser@mastodon.test' for a in d))")" = "True" ] \
|
|
&& ok "mastouser still follows alice" || ko "the follower gained after the backup was lost"
|
|
[ "$(m_follows)" = "True" ] && ok "Mastodon still has the follow" || ko "Mastodon lost the follow"
|
|
[ "$(curl -s -H "$AH" "$P/api/v1/accounts/relationships?id[]=$bob_id" | j "print(d[0]['blocking'])")" = "True" ] \
|
|
&& ok "alice still blocks bob_restore" || ko "the block made after the backup was undone"
|
|
[ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "$RH" -H 'Content-Type: application/json' "$P/clientapi/avatar/private/insert" \
|
|
-d "{\"userName\":\"$carol\",\"name\":\"carol\",\"biography\":\"again\"}")" != "200" ] && ok "carol's name stays taken" || ko "carol's name was free again"
|
|
[ "$(staff GET "/clientapi/moderator/accounts/$dave_id" | j "print(bool(d.get('suspendedAt')))")" = True ] \
|
|
&& ok "dave_restore stays suspended" || ko "the suspension made after the backup was undone"
|
|
[ -z "$(privapub_token dave_restore)" ] && ok "and gets no token" || ko "the suspended persona got a token after the restore"
|
|
[ "$(staff GET "/clientapi/moderator/accounts/$mr_id" | j "print(bool(d.get('silencedAt')))")" = True ] \
|
|
&& ok "mrestore stays silenced" || ko "the silence made after the backup was undone"
|
|
[ "$(curl -s -o /dev/null -w '%{http_code}' -H "$AH" "$P/api/v1/statuses/$down_id")" = 404 ] \
|
|
&& ok "the post taken down stays down" || ko "the post taken down after the backup came back"
|
|
staff POST /clientapi/moderator/accounts/moderate -d "{\"accountId\":\"$dave_id\",\"action\":\"unsuspend\"}" >/dev/null
|
|
staff POST /clientapi/moderator/accounts/moderate -d "{\"accountId\":\"$mr_id\",\"action\":\"unsilence\"}" >/dev/null
|
|
status=$(podman exec -w /app pasture-privapub /app/PrivaPub admin restore --status 2>/dev/null | grep -o "personas [^,;]*" | head -1)
|
|
echo "$status" | grep -q "$carol" && ok "the report names carol among the personas made since" || ko "the report: $status"
|
|
|
|
# the town signs in again; the record goes, so the followers' digests (FEP-8fcf, followsync) are not resting for two weeks
|
|
"$here/town.sh" renew privapub >/dev/null && ok "the town's PrivaPub accounts signed in again" || ko "the town could not sign in again"
|
|
p_mongo "db.RestoreRecord.deleteMany({})" >/dev/null
|