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
@@ -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