Pasture: Pixelfed and WordPress join
Pixelfed 0.14.4 (FrankenPHP with Horizon and the scheduler, on the shared Postgres and Redis) and WordPress 6 with ActivityPub 9.3.1 (on a new shared MySQL, WP-Cron run by a sidecar) are peers now, each with its scenario: Pixelfed 26 checks (photos both ways with alt text, its place arriving as Rome with its coordinates, comments, likes, a boost, edits and deletes), WordPress 17 (an Article with its title, our reply, like and boost kept as comments of their kinds, its edit and removal). Pixelfed has a town driver and a pair spec: 145 checks pass, 2 expected (G-0007). What they showed: Pixelfed names our posts by their page (fixed in26dac40); on PostgreSQL its migration making caption nullable never runs, so every remote boost failed (the pasture applies it); it files a DM it fetches as followers-only (G-0007, upstream); Passport refuses a token whose user id equals its client's id. WordPress signs with RFC 9421 first, which PrivaPub now verifies (c5a69d2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
c5a69d240a
commit
01e5aa4ddd
14 files changed
+601
-4
No files matched your search
@@ -514,6 +514,18 @@ tools/pasture/run.sh down # removes e
|
||||
Caddy's CA through `SSL_CERT_FILE`. Accounts come from its own `/api/iceshrimp/auth/register` (its login cuts the
|
||||
connection short for a name it does not know), Mastodon API tokens from its OAuth form, which prints the out-of-band
|
||||
code in the page. Its `jobs` table shows what it queued for whom. Town only, no scenario.
|
||||
- **Pixelfed (0.14.4):** serversideup's FrankenPHP image on the shared Postgres (database pixelfed) and Redis (dbs 5 and
|
||||
6), with Horizon and the scheduler as sidecars of the same image sharing its storage volume, and Caddy's CA mounted
|
||||
as its system bundle. `pixelfed_settle` makes `caption` nullable (its PostgreSQL migration never runs, so every remote
|
||||
boost failed), creates Passport's keys and restarts the web server so FrankenPHP reads them, numbers the OAuth
|
||||
clients from a million (Passport refuses a token whose user id equals its client's id), and imports its cities.
|
||||
Tokens are personal access tokens made through tinker (`App\Models\User`). Every top-level post needs a picture.
|
||||
`scenarios/pixelfed.sh`, 26 checks; the town's driver and `specs/pixelfed-pair.json`.
|
||||
- **WordPress (6, ActivityPub plugin 9.3.1):** the official image on a shared MySQL 8.4 (`shared_mysql_up`, ready only
|
||||
when it answers over TCP: its first start runs a server without networking), installed and configured by wp-cli,
|
||||
its CA bundle (`wp-includes/certificates/ca-bundle.crt`) given Caddy's root, and WP-Cron run every five seconds by a
|
||||
sidecar (`DISABLE_WP_CRON`), since the plugin federates from it. Authors are actors; the REST API takes application
|
||||
passwords. `scenarios/wordpress.sh`, 17 checks.
|
||||
- **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.
|
||||
|
||||
@@ -22,6 +22,11 @@ and every run starting clean, with signed fetches required (as privapub.thepra.d
|
||||
- **Sharkey 2025.4.7**
|
||||
- **Akkoma 3.20.1**
|
||||
- **Lemmy 1.0.0-beta.2**
|
||||
- **PeerTube 8.3.1**
|
||||
- **Pixelfed 0.14.4**
|
||||
- **WordPress 6 with ActivityPub 9.3.1**
|
||||
- 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**
|
||||
|
||||
Checked both ways, where the peer has the feature:
|
||||
- follows, locked accounts included;
|
||||
|
||||
+27
-1
@@ -682,9 +682,30 @@ without a port, before it gives out its OAuth client.
|
||||
- ~~**P2:** Pixelfed `location` → own `privapub.place`, display only and never re-federated~~ done:
|
||||
`ObjectShapes.NotePlace` keeps a `Place` with coordinates (Pixelfed sends them as strings) in `Post.Place`, an edit
|
||||
replaces it, and nothing renders it back out.
|
||||
- **P2:** `commentsEnabled: false` disables replies.
|
||||
- ~~**P2:** `commentsEnabled: false` disables replies~~ done (2026-10-05): the post is locked, as a community's lock
|
||||
locks it, and our replies are refused.
|
||||
- **P3:** ignore `Add{Story}` without an error; answer `FeatureRequest` with `Reject`.
|
||||
|
||||
**Pasture evidence (2026-10-05, Pixelfed 0.14.4, `tools/pasture/scenarios/pixelfed.sh`):** 26 checks pass. Follows
|
||||
both ways; photos both ways with their alt text, Pixelfed's with its place (Rome, from its own list of cities) shown
|
||||
with its coordinates and its picture served through our proxy; comments both ways; likes both ways, a boost and an
|
||||
unlike; an edit and deletes both ways; statistics. The town's pair (`specs/pixelfed-pair.json`) passes 145 checks with
|
||||
2 expected failures (G-0007). What it showed:
|
||||
- **Pixelfed names our post by its page** (`/@name/<id>`, the post's `url`) in its Like, Announce and Undo, so its
|
||||
likes were dropped as unknown objects until PrivaPub took a page address of its own posts for their id (26dac40).
|
||||
- **A remote post is kept with its `url` as `uri`** and its id as `object_url`; anything read from its database must
|
||||
match either.
|
||||
- **On PostgreSQL every remote boost failed** (and every DM, by Pixelfed's own note): the migration meant to make
|
||||
`caption` and `rendered` nullable there checks for a connection named `postgres`, while Laravel's is `pgsql`, so it
|
||||
never runs. The pasture applies it; a Pixelfed on PostgreSQL in the wild still has the bug.
|
||||
- **A delivered DM is handled as one, a fetched one is not** (G-0007): resolving a DM's address makes Pixelfed's instance
|
||||
actor fetch it, which we allow since a recipient lives there, and `getScope` files it as followers-only, readable by
|
||||
the sender's followers on Pixelfed.
|
||||
- A top-level post without a picture is dropped as it arrives, and a reply is kept only under a post it holds. A Note
|
||||
from an account nobody there follows is dropped too (`AP_INGEST_STORE_NOTES_WITHOUT_FOLLOWERS`).
|
||||
- Passport refuses a token whose user id equals its client's id (it takes it for a client-credentials token), so the
|
||||
pasture numbers its OAuth clients from a million.
|
||||
|
||||
### Long-form: WordPress plugin 9.3.1, Ghost 6, WriteFreely 0.17.2
|
||||
|
||||
FEP-b2b8 (draft) describes the shape: plain-text `name`, a `summary` teaser (≤500), full HTML `content`, `image`, and
|
||||
@@ -700,6 +721,11 @@ a `preview` Note fallback.
|
||||
any 4xx. Seen in the pasture (2026-10-05): until PrivaPub verified RFC 9421, its first delivery got 401 and was sent
|
||||
again with draft-cavage, which it then kept using.
|
||||
- It drops followers-only replies.
|
||||
- **Pasture evidence (2026-10-05, WordPress 6 with ActivityPub 9.3.1, `tools/pasture/scenarios/wordpress.sh`):** 17
|
||||
checks pass: alice follows an author and is kept as its follower; a published post arrives as an Article with its
|
||||
title; her reply becomes a comment on the post, her like and boost comments of their kinds (`like`, `repost`); the
|
||||
author's edit and the post's removal reach PrivaPub; statistics. It federates from WP-Cron, and sends its requests
|
||||
through its own CA bundle (`wp-includes/certificates`).
|
||||
- **Ghost 6** (its ActivityPub service is separate, built on Fedify):
|
||||
- Article with `image` as a bare string and `preview`; members-only parts removed.
|
||||
- It refetches every object signed, **never applies remote Updates**, and accepts only Note and Article.
|
||||
|
||||
@@ -62,3 +62,13 @@ peertube.test {
|
||||
tls internal
|
||||
reverse_proxy pasture-peertube:9000
|
||||
}
|
||||
|
||||
pixelfed.test {
|
||||
tls internal
|
||||
reverse_proxy pasture-pixelfed:8080
|
||||
}
|
||||
|
||||
wordpress.test {
|
||||
tls internal
|
||||
reverse_proxy pasture-wordpress:80
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
# Pixelfed 0.14: photo posts (Notes with Image attachments, alt text, a `location` Place), comments, likes, follows and
|
||||
# collections, and FEP-8fcf followers sync. Laravel on FrankenPHP (serversideup's image), on the shared Postgres
|
||||
# (database pixelfed) and Redis (dbs 5 and 6), with Horizon (which runs every federation job) and the scheduler as
|
||||
# sidecars of the same image sharing its storage volume. It trusts Caddy's CA through the bundle mounted as its system
|
||||
# store; its URL guard refuses private addresses with no switch, which the pasture's public-looking subnet passes. Its
|
||||
# admin is pfuser; tokens are Passport personal access tokens made through tinker.
|
||||
PIXELFED_IMAGE=${PIXELFED_IMAGE:-ghcr.io/pixelfed/pixelfed:v0.14.4}
|
||||
PIXELFED_PASSWORD=Pixelfed-Pasture-1
|
||||
. "$here/peers/shared.sh"
|
||||
|
||||
pixelfed_env() {
|
||||
local key_file="$here/.state/pixelfed/app.key"
|
||||
[ -s "$key_file" ] || { mkdir -p "$here/.state/pixelfed"; echo "base64:$(head -c 32 /dev/urandom | base64)" > "$key_file"; }
|
||||
cat <<EOF
|
||||
APP_NAME=Pasture Pixelfed
|
||||
APP_ENV=production
|
||||
APP_KEY=$(cat "$key_file")
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://pixelfed.test
|
||||
APP_DOMAIN=pixelfed.test
|
||||
ADMIN_DOMAIN=pixelfed.test
|
||||
SESSION_DOMAIN=pixelfed.test
|
||||
TRUST_PROXIES=*
|
||||
LOG_CHANNEL=stderr
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=pixelfed
|
||||
DB_USERNAME=pasture
|
||||
DB_PASSWORD=pasture
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_DB=5
|
||||
REDIS_CACHE_DB=6
|
||||
CACHE_DRIVER=redis
|
||||
CACHE_STORE=redis
|
||||
QUEUE_DRIVER=redis
|
||||
QUEUE_CONNECTION=redis
|
||||
SESSION_DRIVER=redis
|
||||
BROADCAST_DRIVER=log
|
||||
MAIL_MAILER=log
|
||||
MAIL_DRIVER=log
|
||||
ACTIVITY_PUB=true
|
||||
AP_REMOTE_FOLLOW=true
|
||||
AP_INBOX=true
|
||||
AP_OUTBOX=true
|
||||
AP_SHAREDINBOX=true
|
||||
OPEN_REGISTRATION=true
|
||||
ENFORCE_EMAIL_VERIFICATION=false
|
||||
OAUTH_ENABLED=true
|
||||
PF_MAX_USERS=100000
|
||||
PF_ENFORCE_MAX_USERS=false
|
||||
INSTANCE_DISCOVER_PUBLIC=true
|
||||
PF_OPTIMIZE_IMAGES=false
|
||||
PF_OPTIMIZE_VIDEOS=false
|
||||
PF_MIN_REGISTRATION_AGE=0
|
||||
EOF
|
||||
}
|
||||
|
||||
pixelfed_run() { # name, then the command (none: the web server)
|
||||
local name=$1; shift
|
||||
podman run -d --replace --name "$name" --network $net --env-file "$here/.state/pixelfed/env" "$@"
|
||||
}
|
||||
|
||||
pixelfed_up() {
|
||||
shared_postgres_up
|
||||
shared_redis_up
|
||||
pg_db pixelfed
|
||||
mkdir -p "$here/.state/pixelfed"
|
||||
pixelfed_env > "$here/.state/pixelfed/env"
|
||||
podman volume exists pasture-pixelfed-storage || podman volume create --label pasture=1 pasture-pixelfed-storage >/dev/null
|
||||
local mounts=(-v pasture-pixelfed-storage:/var/www/html/storage -v "$ca/bundle.pem:/etc/ssl/certs/ca-certificates.crt:z,ro")
|
||||
pixelfed_run pasture-pixelfed -e AUTORUN_ENABLED=true "${mounts[@]}" "$PIXELFED_IMAGE" >/dev/null
|
||||
for _ in $(seq 1 100); do
|
||||
site pixelfed.test -s -o /dev/null -w '%{http_code}' https://pixelfed.test:6443/api/nodeinfo/2.0.json 2>/dev/null | grep -q 200 && break
|
||||
sleep 3
|
||||
done
|
||||
pixelfed_run pasture-pixelfed-horizon "${mounts[@]}" "$PIXELFED_IMAGE" php artisan horizon >/dev/null
|
||||
pixelfed_run pasture-pixelfed-cron "${mounts[@]}" "$PIXELFED_IMAGE" php artisan schedule:work >/dev/null
|
||||
pixelfed_settle
|
||||
echo "pixelfed: https://pixelfed.test:6443"
|
||||
}
|
||||
|
||||
pixelfed_artisan() { podman exec pasture-pixelfed php artisan "$@"; }
|
||||
|
||||
# the instance actor, Passport's keys and personal client, the admin pfuser and its token. Passport takes a token whose
|
||||
# user id equals its client's id for a client-credentials token and refuses it, so the clients are numbered from a
|
||||
# million, past any user. FrankenPHP keeps the keys it read at boot: the web server restarts once they exist.
|
||||
pixelfed_settle() {
|
||||
# 0.14.4's migration making caption and rendered nullable on PostgreSQL checks for a connection named "postgres",
|
||||
# never Laravel's "pgsql", so it does nothing and every remote boost (and DM) fails on NOT NULL: done here instead
|
||||
podman exec pasture-postgres psql -U pasture -d pixelfed -qc \
|
||||
"alter table statuses alter column caption drop not null, alter column rendered drop not null" >/dev/null
|
||||
pixelfed_artisan instance:actor >/dev/null 2>&1 || true
|
||||
if ! podman exec pasture-pixelfed test -s storage/oauth-private.key; then
|
||||
pixelfed_artisan passport:keys --force >/dev/null 2>&1 || true
|
||||
podman restart pasture-pixelfed >/dev/null
|
||||
for _ in $(seq 1 60); do podman exec pasture-pixelfed curl -sf -o /dev/null http://localhost:8080/api/nodeinfo/2.0.json && break; sleep 2; done
|
||||
fi
|
||||
if [ "$(podman exec pasture-postgres psql -U pasture -d pixelfed -Atc "select count(*) from oauth_clients where personal_access_client and not revoked and id >= 1000000")" = "0" ]; then
|
||||
podman exec pasture-postgres psql -U pasture -d pixelfed -qc "update oauth_clients set revoked = true where personal_access_client;
|
||||
select setval('oauth_clients_id_seq', greatest((select coalesce(max(id), 0) from oauth_clients), 1000000));" >/dev/null
|
||||
pixelfed_artisan passport:client --personal --name=pasture --no-interaction >/dev/null 2>&1 || true
|
||||
fi
|
||||
pixelfed_user pfuser admin
|
||||
pixelfed_token pfuser > "$here/.state/pixelfed.token"
|
||||
# the cities a post's place is chosen from (a couple of minutes, once)
|
||||
[ "$(podman exec pasture-postgres psql -U pasture -d pixelfed -Atc "select count(*) from places")" != "0" ] \
|
||||
|| pixelfed_artisan import:cities --no-interaction >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# pixelfed_user <name> [admin]: an account with a confirmed email and the pasture's password (user:admin only asks, and
|
||||
# answers no without a terminal, so an admin is made in the database)
|
||||
pixelfed_user() {
|
||||
pixelfed_artisan user:create --name="$1" --username="$1" --email="$1@pixelfed.test" --password="$PIXELFED_PASSWORD" \
|
||||
--confirm_email=1 --no-interaction >/dev/null 2>&1 || true
|
||||
[ "${2:-}" = "admin" ] && podman exec pasture-postgres psql -U pasture -d pixelfed -qc "update users set is_admin = true where username = '$1'" >/dev/null
|
||||
return 0
|
||||
}
|
||||
|
||||
# pixelfed_token <name>: a personal access token with every scope the Mastodon API asks for
|
||||
pixelfed_token() {
|
||||
podman exec pasture-pixelfed php artisan tinker --execute \
|
||||
"echo App\\Models\\User::where('username', '$1')->first()->createToken('pasture', ['read', 'write', 'follow', 'push'])->accessToken;" 2>/dev/null | tail -1
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# WordPress 6 with the ActivityPub plugin 9.3.1: posts as Articles (or Notes when short), comments as replies, each
|
||||
# author an actor and the blog one too, likes and boosts kept as comments of their kind. On a shared MySQL 8.4 (database
|
||||
# wordpress). The plugin federates from WP-Cron, so a sidecar runs the due events every few seconds. WordPress sends its
|
||||
# requests through its own CA bundle (wp-includes/certificates), to which Caddy's root is appended; its URL guard refuses
|
||||
# private addresses with no switch, which the pasture's public-looking subnet passes. The admin is wpuser; the town's
|
||||
# accounts are authors with application passwords for the REST API.
|
||||
WORDPRESS_IMAGE=${WORDPRESS_IMAGE:-docker.io/library/wordpress:6-apache}
|
||||
WORDPRESS_CLI_IMAGE=${WORDPRESS_CLI_IMAGE:-docker.io/library/wordpress:cli}
|
||||
WORDPRESS_ACTIVITYPUB=9.3.1
|
||||
WORDPRESS_PASSWORD=Wordpress-Pasture-1
|
||||
. "$here/peers/shared.sh"
|
||||
|
||||
shared_mysql_up() {
|
||||
podman container exists pasture-mysql && return 0
|
||||
podman run -d --replace --name pasture-mysql --network $net --network-alias mysql \
|
||||
-e MYSQL_ROOT_PASSWORD=pasture -e MYSQL_USER=pasture -e MYSQL_PASSWORD=pasture docker.io/library/mysql:8.4 >/dev/null
|
||||
# its first start runs a temporary server without networking to set itself up: only TCP answers when it is ready
|
||||
for _ in $(seq 1 90); do podman exec pasture-mysql mysqladmin ping -h127.0.0.1 --protocol=tcp -uroot -ppasture --silent >/dev/null 2>&1 && return 0; sleep 2; done
|
||||
echo "mysql did not start" >&2; return 1
|
||||
}
|
||||
|
||||
# mysql_db <name>: a database of the shared MySQL for one peer, the pasture user owning it
|
||||
mysql_db() {
|
||||
podman exec pasture-mysql mysql -uroot -ppasture -e \
|
||||
"create database if not exists \`$1\` character set utf8mb4 collate utf8mb4_unicode_ci; grant all on \`$1\`.* to 'pasture'@'%';" 2>/dev/null
|
||||
}
|
||||
|
||||
# wp <args>: wp-cli against the site, sharing its files
|
||||
wp() {
|
||||
podman run --rm --network $net --volumes-from pasture-wordpress --user 33:33 -e HOME=/tmp \
|
||||
-e WORDPRESS_DB_HOST=mysql -e WORDPRESS_DB_USER=pasture -e WORDPRESS_DB_PASSWORD=pasture -e WORDPRESS_DB_NAME=wordpress \
|
||||
"$WORDPRESS_CLI_IMAGE" wp "$@"
|
||||
}
|
||||
|
||||
wordpress_up() {
|
||||
shared_mysql_up
|
||||
mysql_db wordpress
|
||||
podman volume exists pasture-wordpress-html || podman volume create --label pasture=1 pasture-wordpress-html >/dev/null
|
||||
# behind Caddy: https as the proxy says, and the site's own address
|
||||
local extra="if (isset(\$_SERVER['HTTP_X_FORWARDED_PROTO']) && \$_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') { \$_SERVER['HTTPS'] = 'on'; }
|
||||
define('WP_HOME', 'https://wordpress.test'); define('WP_SITEURL', 'https://wordpress.test'); define('DISABLE_WP_CRON', true);
|
||||
define('FS_METHOD', 'direct');"
|
||||
podman run -d --replace --name pasture-wordpress --network $net \
|
||||
-e WORDPRESS_DB_HOST=mysql -e WORDPRESS_DB_USER=pasture -e WORDPRESS_DB_PASSWORD=pasture -e WORDPRESS_DB_NAME=wordpress \
|
||||
-e WORDPRESS_CONFIG_EXTRA="$extra" -v pasture-wordpress-html:/var/www/html -v "$ca:/pasture/ca:z,ro" \
|
||||
"$WORDPRESS_IMAGE" >/dev/null
|
||||
for _ in $(seq 1 60); do podman exec pasture-wordpress test -f /var/www/html/wp-config.php 2>/dev/null && break; sleep 2; done
|
||||
if ! wp core is-installed >/dev/null 2>&1; then
|
||||
wp core install --url=https://wordpress.test --title="Pasture WordPress" --admin_user=wpuser \
|
||||
--admin_password="$WORDPRESS_PASSWORD" --admin_email=wpuser@wordpress.test --skip-email >/dev/null
|
||||
fi
|
||||
wp rewrite structure '/%postname%/' --hard >/dev/null
|
||||
wp plugin is-installed activitypub 2>/dev/null || wp plugin install activitypub --version=$WORDPRESS_ACTIVITYPUB >/dev/null
|
||||
wp plugin activate activitypub >/dev/null
|
||||
# both the blog and its authors are actors
|
||||
wp option update activitypub_actor_mode actor_blog >/dev/null
|
||||
podman exec pasture-wordpress sh -c 'grep -q "Caddy Local Authority" wp-includes/certificates/ca-bundle.crt || { echo; cat /pasture/ca/root.crt; } >> wp-includes/certificates/ca-bundle.crt'
|
||||
podman run -d --replace --name pasture-wordpress-cron --network $net --volumes-from pasture-wordpress --user 33:33 -e HOME=/tmp \
|
||||
-e WORDPRESS_DB_HOST=mysql -e WORDPRESS_DB_USER=pasture -e WORDPRESS_DB_PASSWORD=pasture -e WORDPRESS_DB_NAME=wordpress \
|
||||
--entrypoint sh "$WORDPRESS_CLI_IMAGE" -c 'while :; do wp cron event run --due-now >/dev/null 2>&1; sleep 5; done' >/dev/null
|
||||
wait_http https://wordpress.test:6443/ 1 >/dev/null 2>&1 || true
|
||||
for _ in $(seq 1 60); do
|
||||
site wordpress.test -s -o /dev/null -w '%{http_code}' "https://wordpress.test:6443/.well-known/webfinger?resource=acct:wpuser@wordpress.test" 2>/dev/null | grep -q 200 && break
|
||||
sleep 2
|
||||
done
|
||||
wordpress_app_password wpuser > "$here/.state/wordpress.token"
|
||||
echo "wordpress: https://wordpress.test:6443"
|
||||
}
|
||||
|
||||
# wordpress_user <name>: an author (who publishes, and so is an actor)
|
||||
wordpress_user() {
|
||||
wp user get "$1" >/dev/null 2>&1 || wp user create "$1" "$1@wordpress.test" --role=author --user_pass="$WORDPRESS_PASSWORD" >/dev/null
|
||||
}
|
||||
|
||||
# wordpress_app_password <name>: "name:password" for the REST API's basic authentication
|
||||
wordpress_app_password() {
|
||||
echo "$1:$(wp user application-password create "$1" pasture --porcelain)"
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
# Pixelfed 0.14: follows both ways; photo posts both ways with their alt text, Pixelfed's with its place (a city of its
|
||||
# own list), shown on PrivaPub with that place; comments both ways; likes and a boost; an edit and deletes both ways;
|
||||
# statistics. What Pixelfed holds is read from its Postgres (database pixelfed), never fetched.
|
||||
PF=https://pixelfed.test:6443
|
||||
pfc() { curl -sk --resolve pixelfed.test:6443:127.0.0.1 "$@"; }
|
||||
FT=$(cat "$here/.state/pixelfed.token" 2>/dev/null)
|
||||
FH="Authorization: Bearer $FT"
|
||||
# pf_sql <sql>: one value from Pixelfed's database
|
||||
pf_sql() { podman exec pasture-postgres psql -U pasture -d pixelfed -Atc "$1"; }
|
||||
# pf_photo <text> <alt> [place_id]: a photo post by pfuser, its id
|
||||
pf_photo() {
|
||||
local media
|
||||
make_png "$work/pf.png"
|
||||
media=$(pfc -H "$FH" -X POST "$PF/api/v1/media" -F "file=@$work/pf.png;type=image/png" -F "description=$2" | j "print(d['id'])")
|
||||
pfc -H "$FH" -X POST "$PF/api/v1/statuses" --data-urlencode "status=$1" -d visibility=public -d "media_ids[]=$media" \
|
||||
${3:+-d place_id=$3} | j "print(d['id'])"
|
||||
}
|
||||
# a PrivaPub status in alice_pixelfed's home whose text has the given words
|
||||
p_home_id() { 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 '')), ''))"; }
|
||||
|
||||
echo "pixelfed"
|
||||
[ -n "$FT" ] && ok "Pixelfed token for pfuser" || { ko "Pixelfed token"; return 1; }
|
||||
PT=$(privapub_token alice_pixelfed)
|
||||
PH="Authorization: Bearer $PT"
|
||||
[ -n "$PT" ] && ok "PrivaPub token for alice_pixelfed" || { ko "PrivaPub token for alice_pixelfed"; return 1; }
|
||||
|
||||
echo " discovery and follows"
|
||||
alice_on_pf=$(pfc -H "$FH" "$PF/api/v2/search?q=@alice_pixelfed@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
|
||||
[ -n "$alice_on_pf" ] && ok "Pixelfed resolves @alice_pixelfed@privapub.test" || ko "Pixelfed cannot resolve alice_pixelfed"
|
||||
pf_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=pfuser@pixelfed.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
|
||||
[ -n "$pf_on_p" ] && ok "PrivaPub resolves @pfuser@pixelfed.test" || ko "PrivaPub cannot resolve pfuser"
|
||||
pfc -o /dev/null -H "$FH" -X POST "$PF/api/v1/accounts/$alice_on_pf/follow"
|
||||
until_true 30 '[ "$(pfc -H "$FH" "$PF/api/v1/accounts/relationships?id[]=$alice_on_pf" | j "print(d[0][\"following\"])")" = "True" ]' \
|
||||
&& ok "pfuser follows alice_pixelfed (Accept arrived)" || ko "Pixelfed's follow not accepted"
|
||||
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$pf_on_p/follow"
|
||||
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$pf_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \
|
||||
&& ok "alice_pixelfed follows pfuser (Accept arrived)" || ko "PrivaPub's follow not accepted"
|
||||
|
||||
echo " photos"
|
||||
rome=$(pf_sql "select id from places where name = 'Rome' and country = 'Italy' limit 1")
|
||||
pf_post=$(pf_photo "A Pixelfed photo from Rome" "a red square in Rome" "$rome")
|
||||
[ -n "$pf_post" ] && ok "pfuser posts a photo with a place" || ko "Pixelfed refused the photo"
|
||||
until_true 45 '[ -n "$(p_home_id "A Pixelfed photo from Rome")" ]' && ok "pfuser's photo reaches alice_pixelfed's home" || ko "pfuser's photo never arrived"
|
||||
pf_on_p_post=$(p_home_id "A Pixelfed photo from Rome")
|
||||
pf_status=$(curl -s -H "$PH" "$P/api/v1/statuses/$pf_on_p_post")
|
||||
[ "$(echo "$pf_status" | j "print(d['media_attachments'][0]['description'])")" = "a red square in Rome" ] && ok "its alt text arrives" || ko "its alt text is lost"
|
||||
case "$(echo "$pf_status" | j "print(d['media_attachments'][0]['url'])")" in
|
||||
*privapub.test/*) ok "its picture is served through PrivaPub's proxy" ;;
|
||||
*) ko "its picture is not proxied" ;;
|
||||
esac
|
||||
[ "$(echo "$pf_status" | j "p=(d.get('privapub') or {}).get('place') or {}; print(p.get('name'), round(p.get('latitude') or 0), round(p.get('longitude') or 0))")" = "Rome 42 13" ] \
|
||||
&& ok "its place arrives: Rome, at its coordinates" || ko "its place is lost: $(echo "$pf_status" | j "print((d.get('privapub') or {}).get('place'))")"
|
||||
make_png "$work/photo.png"
|
||||
p_media=$(curl -s -H "$PH" -X POST "$P/api/v2/media" -F "file=@$work/photo.png;type=image/png" -F "description=a red square from PrivaPub" | j "print(d['id'])")
|
||||
p_post=$(curl -s -H "$PH" -X POST "$P/api/v1/statuses" -d "status=A PrivaPub photo for Pixelfed&visibility=public&media_ids[]=$p_media")
|
||||
p_post_id=$(echo "$p_post" | j "print(d['id'])"); p_post_uri=$(echo "$p_post" | j "print(d['uri'])")
|
||||
until_true 45 '[ -n "$(pf_sql "select id from statuses where uri = '"'$p_post_uri'"' or object_url = '"'$p_post_uri'"'")" ]' \
|
||||
&& ok "alice_pixelfed's photo reaches Pixelfed" || ko "alice_pixelfed's photo missing on Pixelfed"
|
||||
p_on_pf=$(pf_sql "select id from statuses where uri = '$p_post_uri' or object_url = '$p_post_uri'")
|
||||
[ "$(pf_sql "select caption from media where status_id = '$p_on_pf'")" = "a red square from PrivaPub" ] && ok "its alt text arrives on Pixelfed" || ko "its alt text is lost on Pixelfed"
|
||||
|
||||
echo " comments"
|
||||
pfc -o /dev/null -H "$FH" -X POST "$PF/api/v1/statuses" --data-urlencode "status=@alice_pixelfed@privapub.test a Pixelfed comment" -d "in_reply_to_id=$p_on_pf" -d visibility=public
|
||||
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id/context" | j "print(any(\"a Pixelfed comment\" in s[\"content\"] for s in d[\"descendants\"]))")" = "True" ]' \
|
||||
&& ok "pfuser's comment threads under alice_pixelfed's photo" || ko "pfuser's comment missing on PrivaPub"
|
||||
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses" -d "status=@pfuser@pixelfed.test a PrivaPub comment&in_reply_to_id=$pf_on_p_post&visibility=public"
|
||||
until_true 45 '[ "$(pf_sql "select count(*) from statuses where in_reply_to_id = '"'$pf_post'"' and caption like '"'%a PrivaPub comment%'"'")" = "1" ]' \
|
||||
&& ok "alice_pixelfed's comment threads under pfuser's photo on Pixelfed" || ko "alice_pixelfed's comment missing on Pixelfed"
|
||||
|
||||
echo " likes and boosts"
|
||||
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$pf_on_p_post/favourite"
|
||||
until_true 45 '[ "$(pf_sql "select likes_count from statuses where id = '"'$pf_post'"'")" = "1" ]' && ok "alice_pixelfed's like counts on Pixelfed" || ko "like not counted on Pixelfed"
|
||||
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$pf_on_p_post/reblog"
|
||||
until_true 45 '[ "$(pf_sql "select reblogs_count from statuses where id = '"'$pf_post'"'")" = "1" ]' && ok "alice_pixelfed's boost counts on Pixelfed" || ko "boost not counted on Pixelfed"
|
||||
pfc -o /dev/null -H "$FH" -X POST "$PF/api/v1/statuses/$p_on_pf/favourite"
|
||||
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id" | j "print(d[\"favourites_count\"])")" = "1" ]' && ok "pfuser's like counts on PrivaPub" || ko "Pixelfed's like not counted"
|
||||
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$pf_on_p_post/unfavourite"
|
||||
until_true 45 '[ "$(pf_sql "select likes_count from statuses where id = '"'$pf_post'"'")" = "0" ]' && ok "alice_pixelfed's unlike reaches Pixelfed" || ko "unlike not applied on Pixelfed"
|
||||
|
||||
echo " edits and deletes"
|
||||
pfc -o /dev/null -H "$FH" -X PUT "$PF/api/v1/statuses/$pf_post" --data-urlencode "status=A Pixelfed photo from Rome, edited"
|
||||
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$pf_on_p_post" | j "print(\"edited\" in d[\"content\"] and d[\"edited_at\"] is not None)")" = "True" ]' \
|
||||
&& ok "pfuser's edit reaches PrivaPub" || ko "Pixelfed's edit not applied"
|
||||
curl -s -o /dev/null -X DELETE -H "$PH" "$P/api/v1/statuses/$p_post_id"
|
||||
until_true 45 '[ -z "$(pf_sql "select id from statuses where id = '"'$p_on_pf'"' and deleted_at is null")" ]' \
|
||||
&& ok "alice_pixelfed's delete reaches Pixelfed" || ko "delete not applied on Pixelfed"
|
||||
pfc -o /dev/null -H "$FH" -X DELETE "$PF/api/v1/statuses/$pf_post"
|
||||
until_true 45 '[ "$(curl -s -o /dev/null -w "%{http_code}" -H "$PH" "$P/api/v1/statuses/$pf_on_p_post")" = "404" ]' \
|
||||
&& ok "pfuser's delete reaches PrivaPub" || ko "Pixelfed's delete not applied"
|
||||
|
||||
echo " statistics"
|
||||
stats_check pixelfed.test pixelfed
|
||||
@@ -0,0 +1,58 @@
|
||||
# WordPress 6 with ActivityPub 9.3: a persona follows an author; a published post arrives as an Article with its title;
|
||||
# the persona's reply, like and boost are kept by WordPress as comments of their kind; the post's edit and its removal
|
||||
# reach PrivaPub; statistics. WordPress federates from WP-Cron, run every few seconds by its sidecar. What it holds is
|
||||
# read from its MySQL (database wordpress), never fetched.
|
||||
WP=https://wordpress.test:6443
|
||||
wpc() { curl -sk --resolve wordpress.test:6443:127.0.0.1 "$@"; }
|
||||
WT=$(cat "$here/.state/wordpress.token" 2>/dev/null)
|
||||
# wp_sql <sql>: one value from WordPress's database
|
||||
wp_sql() { podman exec pasture-mysql mysql -upasture -ppasture wordpress -Nse "$1" 2>/dev/null; }
|
||||
# a PrivaPub status in alice_wordpress's home whose title or text has the given words
|
||||
p_home_id() { 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 "wordpress"
|
||||
[ -n "$WT" ] && ok "WordPress application password for wpuser" || { ko "WordPress application password"; return 1; }
|
||||
PT=$(privapub_token alice_wordpress)
|
||||
PH="Authorization: Bearer $PT"
|
||||
[ -n "$PT" ] && ok "PrivaPub token for alice_wordpress" || { ko "PrivaPub token for alice_wordpress"; return 1; }
|
||||
|
||||
echo " discovery and follows"
|
||||
wp_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=wpuser@wordpress.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
|
||||
[ -n "$wp_on_p" ] && ok "PrivaPub resolves @wpuser@wordpress.test" || ko "PrivaPub cannot resolve wpuser"
|
||||
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$wp_on_p/follow"
|
||||
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$wp_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \
|
||||
&& ok "alice_wordpress follows wpuser (Accept arrived)" || ko "WordPress's Accept never arrived"
|
||||
until_true 30 '[ "$(wp_sql "select count(*) from wp_posts where post_type = '"'ap_actor'"' and guid like '"'%alice_wordpress%'"'")" -ge 1 ]' \
|
||||
&& ok "WordPress keeps alice_wordpress as a follower" || ko "WordPress does not list the follower"
|
||||
|
||||
echo " posts"
|
||||
wp_post=$(wpc -u "$WT" -X POST "$WP/wp-json/wp/v2/posts" -H 'Content-Type: application/json' \
|
||||
-d '{"title":"A WordPress article for PrivaPub","content":"<p>Written in WordPress, read in PrivaPub.</p>","status":"publish"}' | j "print(d['id'])")
|
||||
[ -n "$wp_post" ] && ok "wpuser publishes a post" || ko "WordPress refused the post"
|
||||
until_true 60 '[ -n "$(p_home_id "A WordPress article for PrivaPub")" ]' && ok "the post reaches alice_wordpress's home" || ko "the post never arrived"
|
||||
wp_on_p_post=$(p_home_id "A WordPress article for PrivaPub")
|
||||
wp_status=$(curl -s -H "$PH" "$P/api/v1/statuses/$wp_on_p_post")
|
||||
[ "$(echo "$wp_status" | j "print((d.get('privapub') or {}).get('title'))")" = "A WordPress article for PrivaPub" ] && ok "it arrives as an article with its title" \
|
||||
|| ko "its title is lost: $(echo "$wp_status" | j "print(d.get('privapub'))" | head -c 200)"
|
||||
|
||||
echo " replies, likes and boosts"
|
||||
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses" -d "status=@wpuser@wordpress.test a reply from PrivaPub&in_reply_to_id=$wp_on_p_post&visibility=public"
|
||||
until_true 45 '[ "$(wp_sql "select count(*) from wp_comments where comment_post_ID = '"$wp_post"' and comment_content like '"'%a reply from PrivaPub%'"'")" = "1" ]' \
|
||||
&& ok "alice_wordpress's reply becomes a comment on the post" || ko "the reply is not a comment on WordPress"
|
||||
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$wp_on_p_post/favourite"
|
||||
until_true 45 '[ "$(wp_sql "select count(*) from wp_comments where comment_post_ID = '"$wp_post"' and comment_type = '"'like'"'")" -ge 1 ]' \
|
||||
&& ok "alice_wordpress's like is kept by WordPress" || ko "the like is not kept on WordPress"
|
||||
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$wp_on_p_post/reblog"
|
||||
until_true 45 '[ "$(wp_sql "select count(*) from wp_comments where comment_post_ID = '"$wp_post"' and comment_type = '"'repost'"'")" -ge 1 ]' \
|
||||
&& ok "alice_wordpress's boost is kept by WordPress" || ko "the boost is not kept on WordPress"
|
||||
|
||||
echo " edits and removals"
|
||||
wpc -o /dev/null -u "$WT" -X POST "$WP/wp-json/wp/v2/posts/$wp_post" -H 'Content-Type: application/json' -d '{"content":"<p>Written in WordPress, read in PrivaPub, then edited.</p>"}'
|
||||
until_true 60 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$wp_on_p_post" | j "print(\"then edited\" in d[\"content\"] and d[\"edited_at\"] is not None)")" = "True" ]' \
|
||||
&& ok "wpuser's edit reaches PrivaPub" || ko "WordPress's edit not applied"
|
||||
wpc -o /dev/null -u "$WT" -X DELETE "$WP/wp-json/wp/v2/posts/$wp_post"
|
||||
until_true 60 '[ "$(curl -s -o /dev/null -w "%{http_code}" -H "$PH" "$P/api/v1/statuses/$wp_on_p_post")" = "404" ]' \
|
||||
&& ok "wpuser's removal reaches PrivaPub" || ko "WordPress's removal not applied"
|
||||
|
||||
echo " statistics"
|
||||
stats_check wordpress.test wordpress
|
||||
@@ -28,6 +28,9 @@ RECEIVES = {
|
||||
# Misskey and its forks drop a reply whose parent they cannot fetch ("Error in inReplyTo ... 404"): a reply to a
|
||||
# followers-only post or a DM they do not hold never lands there, whoever sent it (docs/INTEROP.md, Misskey)
|
||||
DROPS_ORPHAN_REPLIES = {"misskey", "sharkey"}
|
||||
# Pixelfed is a photo platform: it keeps a post only when it carries a picture (a Note without one is dropped as it
|
||||
# arrives), and a reply only to a post it holds
|
||||
NEEDS_PICTURE = {"pixelfed"}
|
||||
|
||||
|
||||
class World:
|
||||
@@ -205,6 +208,9 @@ class Sweep:
|
||||
and parent["visibility"] not in ("public", "unlisted")
|
||||
if orphan:
|
||||
continue
|
||||
if p in NEEDS_PICTURE and (o.get("kind") not in ("image", "video") if parent is None
|
||||
else not (stored.get(p, {}).get(parent["uri"]) and stored[p][parent["uri"]].exists)):
|
||||
continue
|
||||
if p in want and (receives is None or kind in receives):
|
||||
fetched = p in o.get("fetched_on", {})
|
||||
# whether a follower of the author lives on p, or only the accounts it mentions or answers
|
||||
|
||||
@@ -6,6 +6,7 @@ from dialects.iceshrimp import Iceshrimp
|
||||
from dialects.lemmy_api import LemmyApi
|
||||
from dialects.mastodon import Mastodon
|
||||
from dialects.misskey_api import Misskey, Sharkey
|
||||
from dialects.pixelfed import Pixelfed
|
||||
from dialects.pleroma import Pleroma
|
||||
from dialects.privapub import PrivaPub
|
||||
|
||||
@@ -20,6 +21,7 @@ DRIVERS = {
|
||||
"hollo": (Hollo, "hollo.test"),
|
||||
"iceshrimp": (Iceshrimp, "iceshrimp.test"),
|
||||
"pleroma": (Pleroma, "pleroma.test"),
|
||||
"pixelfed": (Pixelfed, "pixelfed.test"),
|
||||
}
|
||||
|
||||
_made = {}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Pixelfed 0.14: accounts made with `artisan user:create` in its container, tokens as Passport personal access tokens made
|
||||
through tinker (peers/pixelfed.sh numbers its clients past every user, or Passport refuses them), objects read from its
|
||||
Postgres (`pixelfed`). Every post is a photo post: Pixelfed refuses one without media ("Empty statuses are not allowed"),
|
||||
so a post the plan gives no picture gets one; replies are comments and may be words alone. A local status keeps no uri:
|
||||
it is /p/<username>/<id>. A remote post is kept with its page address (its `url`) as `uri` when it has one, and its id
|
||||
as `object_url`."""
|
||||
import dataclasses
|
||||
|
||||
from core import podman
|
||||
from dialects.base import Stored, Unsupported
|
||||
from dialects.mastodon_api import MastodonApi
|
||||
|
||||
# Pixelfed's statuses.visibility, as the town names visibilities
|
||||
VIS = {"public": "public", "unlisted": "unlisted", "private": "followers", "direct": "direct"}
|
||||
|
||||
|
||||
class Pixelfed(MastodonApi):
|
||||
platform = "pixelfed"
|
||||
caps = frozenset({"post", "reply", "cw", "media", "like", "boost", "bookmark", "follow", "block", "mute", "delete",
|
||||
"edit", "profile"})
|
||||
container = "pasture-pixelfed"
|
||||
db = "pixelfed"
|
||||
|
||||
def provision(self, accounts):
|
||||
existing = {r["username"] for r in podman.psql(self.db, "select username from users")}
|
||||
for a in accounts:
|
||||
if a.username in existing:
|
||||
continue
|
||||
podman.exec_(self.container, "php", "artisan", "user:create", f"--name={a.name or a.username}", f"--username={a.username}",
|
||||
f"--email={a.username}@{self.host}", f"--password={a.password}", "--confirm_email=1", "--no-interaction")
|
||||
return [self.session_from_token(a, self._token(a.username)) for a in accounts]
|
||||
|
||||
def _token(self, username):
|
||||
out = podman.exec_(self.container, "php", "artisan", "tinker", "--execute",
|
||||
f"echo App\\Models\\User::where('username', '{username}')->first()"
|
||||
"->createToken('pasture-town', ['read', 'write', 'follow', 'push'])->accessToken;")
|
||||
return out.strip().splitlines()[-1].strip()
|
||||
|
||||
def post(self, s, spec):
|
||||
if spec.kind not in ("note", "image"):
|
||||
raise Unsupported(self.platform, f"post a {spec.kind}")
|
||||
if spec.poll:
|
||||
raise Unsupported(self.platform, "post a poll")
|
||||
if not spec.media and not spec.reply_to_uri:
|
||||
spec = dataclasses.replace(spec, media=[{"kind": "image", "seed": spec.text[:24], "alt": "a picture for the words"}])
|
||||
return super().post(s, spec)
|
||||
|
||||
def _rows(self, uris):
|
||||
if not uris:
|
||||
return {}
|
||||
local = {u: u.rsplit("/", 1)[1] for u in uris if u.startswith(f"{self.base}/p/")}
|
||||
rows = podman.psql(self.db, f"""
|
||||
select s.id::text as local_id, s.uri as stored_uri, s.object_url,
|
||||
'{self.base}/p/' || p.username || '/' || s.id as local_uri,
|
||||
s.visibility, s.caption as text, s.cw_summary as cw, s.edited_at is not null as edited,
|
||||
s.deleted_at is not null as deleted,
|
||||
coalesce(r.object_url, r.uri, case when r.id is not null then '{self.base}/p/' || rp.username || '/' || r.id end) as parent_uri,
|
||||
s.likes_count as likes, s.reblogs_count as boosts, s.reply_count as replies
|
||||
from statuses s join profiles p on p.id = s.profile_id
|
||||
left join statuses r on r.id = s.in_reply_to_id left join profiles rp on rp.id = r.profile_id
|
||||
where s.reblog_of_id is null and (s.uri = any(string_to_array(:'p1', ' ')) or s.object_url = any(string_to_array(:'p1', ' '))
|
||||
or s.id::text = any(string_to_array(:'p2', ' ')))""", " ".join(uris), " ".join(local.values()) or "-")
|
||||
out = {}
|
||||
for r in rows:
|
||||
# the address the post was asked by: its id (object_url), its uri, or a local post's /p/ address
|
||||
uri = next((u for u in (r["object_url"], r["stored_uri"], r["local_uri"]) if u in uris), None)
|
||||
if uri is None:
|
||||
continue
|
||||
out[uri] = Stored(True, bool(r["deleted"]), r["local_id"], VIS.get(r["visibility"], r["visibility"]), r["text"], r["cw"],
|
||||
bool(r["edited"]), r["parent_uri"], r["likes"], r["boosts"], r["replies"], None, {}, r)
|
||||
return out
|
||||
@@ -93,5 +93,19 @@
|
||||
"note": "Locks (Announce{Lock}, its Undo, or commentsEnabled false) refuse replies; a ban (Announce{Block} with the community as target, or the moderator's own Block sent straight to us) shows as blocked_by on the community and refuses the persona there until the Undo. Checked live by the Lemmy scenario; no town cell yet.",
|
||||
"closed": "2026-10-05",
|
||||
"fixed_in": "A community's moderators lock threads and ban members (2026-10-05)"
|
||||
},
|
||||
{
|
||||
"id": "G-0007",
|
||||
"title": "Pixelfed files a direct message it fetches (rather than receives) as followers-only, so the sender's followers there can read it",
|
||||
"match": {
|
||||
"feature": "hide\\.direct",
|
||||
"observer": "pixelfed"
|
||||
},
|
||||
"kind": "peer",
|
||||
"phase": "upstream",
|
||||
"code": "pixelfed 0.14.4 app/Util/ActivityPub/Helpers.php getScope(): anything neither public nor unlisted is 'private'; only the delivery path checks DirectMessageValidator::isDirect",
|
||||
"opened": "2026-10-05",
|
||||
"status": "open",
|
||||
"note": "A delivered DM is handled as one. Resolving its address (a recipient pasting its link; the town's driver does it to reply) makes Pixelfed's instance actor fetch it, which PrivaPub allows since a recipient lives there, and the copy is stored with scope private."
|
||||
}
|
||||
]
|
||||
@@ -20,10 +20,10 @@ from core.rng import Rng
|
||||
GENERATOR_VERSION = 1
|
||||
HOSTS = {"privapub": "privapub.test", "gts": "gts.test", "mastodon": "mastodon.test", "misskey": "misskey.test",
|
||||
"sharkey": "sharkey.test", "akkoma": "akkoma.test", "lemmy": "lemmy.test", "hollo": "hollo.test",
|
||||
"iceshrimp": "iceshrimp.test", "pleroma": "pleroma.test"}
|
||||
"iceshrimp": "iceshrimp.test", "pleroma": "pleroma.test", "pixelfed": "pixelfed.test"}
|
||||
SHORT = {"privapub": "pp", "gts": "gt", "mastodon": "ms", "misskey": "mk", "sharkey": "sk", "akkoma": "ak", "lemmy": "lm",
|
||||
"hollo": "ho", "iceshrimp": "is", "pleroma": "pl"}
|
||||
MICRO = ("privapub", "gts", "mastodon", "misskey", "sharkey", "akkoma", "hollo", "iceshrimp", "pleroma")
|
||||
"hollo": "ho", "iceshrimp": "is", "pleroma": "pl", "pixelfed": "pf"}
|
||||
MICRO = ("privapub", "gts", "mastodon", "misskey", "sharkey", "akkoma", "hollo", "iceshrimp", "pleroma", "pixelfed")
|
||||
CAPS = {
|
||||
"privapub": {"post", "reply", "cw", "media", "poll", "like", "boost", "bookmark", "follow", "block", "mute", "report",
|
||||
"dm", "delete", "edit", "vote", "react", "quote", "profile", "circle", "community", "located"},
|
||||
@@ -44,6 +44,9 @@ CAPS = {
|
||||
"dm", "delete", "edit", "vote", "quote", "react", "profile"},
|
||||
"iceshrimp": {"post", "reply", "cw", "media", "poll", "like", "boost", "bookmark", "follow", "block", "mute", "report",
|
||||
"dm", "delete", "edit", "vote", "quote", "react", "profile"},
|
||||
# a photo platform: every post carries a picture (the driver adds one), and it has no polls or DMs through its API
|
||||
"pixelfed": {"post", "reply", "cw", "media", "like", "boost", "bookmark", "follow", "block", "mute", "delete", "edit",
|
||||
"profile"},
|
||||
}
|
||||
|
||||
FIRST = ["ada", "bo", "cleo", "dario", "elif", "femi", "gaia", "hiro", "ines", "jun", "kai", "lia", "milo", "nadia",
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"schema": "pasture-town/1",
|
||||
"name": "pixelfed-pair",
|
||||
"seed": 20261005,
|
||||
"peers": {
|
||||
"privapub": {
|
||||
"roots": 1,
|
||||
"personas": [
|
||||
2,
|
||||
2
|
||||
],
|
||||
"circles": 1,
|
||||
"communities": 0
|
||||
},
|
||||
"pixelfed": {
|
||||
"accounts": 3
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"locked": 0.2,
|
||||
"bot": 0.0,
|
||||
"fields": [
|
||||
0,
|
||||
2
|
||||
],
|
||||
"avatar": 0.9,
|
||||
"header": 0.3,
|
||||
"bioWords": [
|
||||
5,
|
||||
12
|
||||
],
|
||||
"langs": {
|
||||
"en": 80,
|
||||
"it": 20
|
||||
}
|
||||
},
|
||||
"graph": {
|
||||
"follows": [
|
||||
2,
|
||||
4
|
||||
],
|
||||
"mutual": 0.6,
|
||||
"pending": 0.0,
|
||||
"rejected": 0.0
|
||||
},
|
||||
"circleMembers": 2,
|
||||
"content": {
|
||||
"posts": 24,
|
||||
"mix": {
|
||||
"text": 40,
|
||||
"cw": 10,
|
||||
"tags": 10,
|
||||
"mention": 10,
|
||||
"image": 20,
|
||||
"poll": 10
|
||||
},
|
||||
"visibility": {
|
||||
"public": 50,
|
||||
"unlisted": 10,
|
||||
"followers": 20,
|
||||
"direct": 10,
|
||||
"circle": 10
|
||||
},
|
||||
"replyRounds": 2,
|
||||
"replies": 0.5,
|
||||
"deeperReplies": 0.4
|
||||
},
|
||||
"interactions": {
|
||||
"likes": [
|
||||
0,
|
||||
3
|
||||
],
|
||||
"boosts": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"react": 0.4,
|
||||
"vote": 0.8,
|
||||
"bookmark": 0.1
|
||||
},
|
||||
"mutations": {
|
||||
"edit": 0.15,
|
||||
"delete": 0.05,
|
||||
"blocks": 0,
|
||||
"mutes": 1,
|
||||
"reports": 0
|
||||
},
|
||||
"time": {
|
||||
"roundSettleSeconds": 15,
|
||||
"graphSettleSeconds": 20,
|
||||
"settleSeconds": 30,
|
||||
"deadlineSeconds": 120
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user