PieFed joins the pasture; the instance actor answers at the root

PieFed 1.7.17 (dockurr's image of the release) runs in the pasture with its
Celery worker on the shared Postgres and Redis, and scenarios/piefed.sh
checks it both ways: 29 checks, communities, titled threads, comments, votes
up and down, a community poll and a vote in it, private messages, a
moderator's lock, unlock and removal, the unfollow and statistics.

What it showed:
- PieFed sends a community's announces to the inbox of the Application at a
  peer's root (as Lemmy serves its site actor) and to /inbox otherwise.
  PrivaPub answered 404 at its root, so every announce went to an /inbox it
  does not have. The instance actor now answers at / for ActivityPub
  requests, unsigned under SecureMode as at its own address.
- PieFed keeps serving a thread its moderator removed, so the removal could
  never be checked against the post's origin. A community on the post's own
  server now speaks for it; one elsewhere still waits for the origin to say
  the post is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 15:44:13 +02:00
1 parent 0646de22bd
commit 47d6e22988
12 files changed
+304 -17

No files matched your search

+9
View File
@@ -520,6 +520,15 @@ tools/pasture/run.sh down # removes e
server started, so the scenario waits for that worker (`lm_worker`) before its first follow, and it sends what it server started, so the scenario waits for that worker (`lm_worker`) before its first follow, and it sends what it
queued every 30 seconds, so a vote or a moderator's act takes up to a minute. Its lock, unlock and unban need a queued every 30 seconds, so a vote or a moderator's act takes up to a minute. Its lock, unlock and unban need a
`reason`, or it refuses them and federates nothing. 29 checks, among them a moderator's lock, ban and removal. `reason`, or it refuses them and federates nothing. 29 checks, among them a moderator's lock, ban and removal.
- **PieFed (1.7.17):** dockurr's image of the release, its web app (with PieFed's own cron, `CRON=true`, for its send
queue) and a Celery worker on the shared Postgres and Redis (dbs 10 and 11), sharing the `pasture-piefed-media`
volume. httpx trusts only certifi's bundle, so the pasture's is mounted over it. `flask init-db` reads its admin
(pfuser) from stdin and drops every table it finds, so it runs once, after the web app's migrations ("Starting
Gunicorn"). Its API is Lemmy's v3 under `/api/alpha` with a JWT (`pf` in the scenario); `resolve_object` answers a
view (`community.community.id`). It sends a community's announces to the Application at a peer's root, assuming
`/inbox` when there is none, and keeps serving a thread its moderator removed. `scenarios/piefed.sh`, 29 checks:
communities both ways, threads with titles, comments, votes up and down both ways, a community poll and alice's vote,
private messages both ways, a moderator's lock, unlock and removal, the unfollow, statistics.
- **Hollo (0.9.19):** Fedify's microblog server on the shared Postgres, set up through its web form (which - **Hollo (0.9.19):** Fedify's microblog server on the shared Postgres, set up through its web form (which
checks `Origin` against `Host`, so the request names `hollo.test` without the port). It needs a 44-character checks `Origin` against `Host`, so the request names `hollo.test` without the port). It needs a 44-character
`SECRET_KEY`, a media directory and a `themeColor`; statuses and votes go as JSON. Town only, no scenario. `SECRET_KEY`, a media directory and a `themeColor`; statuses and votes go as JSON. Town only, no scenario.
+9 -5
View File
@@ -29,6 +29,7 @@ and every run starting clean, with signed fetches required (as privapub.thepra.d
- **Mobilizon 5.2.4** - **Mobilizon 5.2.4**
- **Gancio 1.28.2** - **Gancio 1.28.2**
- **Funkwhale 2.0.11** - **Funkwhale 2.0.11**
- **PieFed 1.7.17**
- in the town only (a seeded community checked server by server): **Hollo 0.9.19**, **Iceshrimp.NET 2026.1.2-beta**, - 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** **Pleroma 2.10.2**
@@ -44,8 +45,8 @@ Checked both ways, where the peer has the feature:
- communities and circles; - communities and circles;
- blocks and unfollows. - blocks and unfollows.
`docs/INTEROP.md` has each peer's evidence and what is still expected to fail. PeerTube, PieFed, Mbin and the others are `docs/INTEROP.md` has each peer's evidence and what is still expected to fail. Mbin and the others not yet in the pasture
covered by unit tests written in their documents' shape. are covered by unit tests written in their documents' shape.
## Supported FEPs ## Supported FEPs
@@ -89,7 +90,9 @@ The names are the project's own and are stable; resolve actors through WebFinger
personas made on the same day do not share a date. `indexable` is `false` unless the persona turns it on, and personas made on the same day do not share a date. `indexable` is `false` unless the persona turns it on, and
`discoverable` is `true` unless it turns that off (the deploy's own persona, @thepra, is undiscoverable). `discoverable` is `true` unless it turns that off (the deploy's own persona, @thepra, is undiscoverable).
- The instance actor is `/peasants/privapub` (type `Application`). It signs every fetch PrivaPub makes, so no avatar's key - The instance actor is `/peasants/privapub` (type `Application`). It signs every fetch PrivaPub makes, so no avatar's key
is used to read another server's content. is used to read another server's content. It also answers at the server's root (`/`) for a request that asks for
ActivityPub, as Lemmy's site actor does: PieFed sends a community's announces to the inbox of the Application at a
peer's root, and to `/inbox` when there is none.
## Groups ## Groups
@@ -111,8 +114,9 @@ A group is either a **community** or a **circle**.
from its own origin, never taken from the announce. They are taken from a group someone here follows, and also when from its own origin, never taken from the announce. They are taken from a group someone here follows, and also when
they relay a vote on one of our posts in a thread of that group (Lemmy sends a vote to the community alone). A post they relay a vote on one of our posts in a thread of that group (Lemmy sends a vote to the community alone). A post
keeps the group its `audience` names, however it arrived. keeps the group its `audience` names, however it arrived.
- A remote community's **moderation** reaches the posts it holds. A removal (`Announce{Delete}`) is believed once the - A remote community's **moderation** reaches the posts it holds. A removal (`Announce{Delete}`) is believed at once
post's origin answers it gone. A lock (`Announce{Lock}`, or `commentsEnabled: false` on the post) refuses replies, from a community on the post's own server, which speaks for it (PieFed keeps serving a thread its moderator removed),
and from a community elsewhere once the post's origin answers it gone. A lock (`Announce{Lock}`, or `commentsEnabled: false` on the post) refuses replies,
ours included, until `Undo{Lock}`. A ban of a persona (`Announce{Block}` with the community as `target`) shows as ours included, until `Undo{Lock}`. A ban of a persona (`Announce{Block}` with the community as `target`) shows as
`blocked_by` on the community and refuses the persona's posts and replies there until the `Undo`. `blocked_by` on the community and refuses the persona's posts and replies there until the `Undo`.
@@ -30,11 +30,12 @@ namespace PrivaPub.Tests.Federation
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
async Task<(RemoteActor Community, RemoteActor Poster, JsonObject Note, Post Post)> Announced() // a post in a community the persona follows; the community on the poster's server unless another origin is given
async Task<(RemoteActor Community, RemoteActor Poster, JsonObject Note, Post Post)> Announced(string communityOrigin = default)
{ {
var token = TestContext.Current.CancellationToken; var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice"); var (root, alice) = await _harness.Persona("alice");
var community = new RemoteActor(_harness.Peer, "cats", type: "Group"); var community = new RemoteActor(_harness.Peer, "cats", communityOrigin, type: "Group");
var poster = new RemoteActor(_harness.Peer, "poster"); var poster = new RemoteActor(_harness.Peer, "poster");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token); await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token);
await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token); await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
@@ -109,6 +110,43 @@ namespace PrivaPub.Tests.Federation
Assert.True(await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == post.ObjectURI).ExecuteAnyAsync(token)); Assert.True(await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == post.ObjectURI).ExecuteAnyAsync(token));
} }
// PieFed keeps serving a thread its moderator removed: a community on the post's own server speaks for it
[Fact]
public async Task A_removal_a_community_on_the_posts_server_announces_is_taken_though_the_post_is_still_served()
{
var token = TestContext.Current.CancellationToken;
var (community, poster, note, post) = await Announced();
await Announce(community, new JsonObject
{
["id"] = $"{Origin(poster)}/delete/{Guid.NewGuid():N}", ["type"] = "Delete", ["actor"] = poster.Id,
["object"] = note["id"]!.GetValue<string>(), ["summary"] = "off topic"
});
Assert.False(await DB.Default.Find<Post>().Match(p => p.ID == post.ID).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_removal_a_community_elsewhere_announces_waits_for_the_posts_server_to_say_it_is_gone()
{
var token = TestContext.Current.CancellationToken;
var (community, poster, note, post) = await Announced(_harness.Peer.B);
var delete = new JsonObject
{
["id"] = $"{Origin(community)}/delete/{Guid.NewGuid():N}", ["type"] = "Delete", ["actor"] = community.Id,
["object"] = note["id"]!.GetValue<string>()
};
await Announce(community, (JsonObject)delete.DeepClone());
var kept = await DB.Default.Find<Post>().Match(p => p.ID == post.ID).ExecuteAnyAsync(token);
_harness.Peer.Serve(new Uri(note["id"]!.GetValue<string>()).AbsolutePath, new JsonObject { ["id"] = note["id"]!.GetValue<string>(), ["type"] = "Tombstone" }.ToJsonString());
delete["id"] = $"{Origin(community)}/delete/{Guid.NewGuid():N}";
await Announce(community, delete);
Assert.True(kept);
Assert.False(await DB.Default.Find<Post>().Match(p => p.ID == post.ID).ExecuteAnyAsync(token));
}
[Fact] [Fact]
public async Task A_fetched_object_does_not_wear_the_signature_of_the_activity_that_caused_the_fetch() public async Task A_fetched_object_does_not_wear_the_signature_of_the_activity_that_caused_the_fetch()
{ {
+15
View File
@@ -101,6 +101,20 @@ namespace PrivaPub.Tests.Http
Assert.Equal("Application", instance.Json["type"]!.GetValue<string>()); Assert.Equal("Application", instance.Json["type"]!.GetValue<string>());
} }
// PieFed reads the inbox it sends a community's announces to from the Application at a peer's root
[Fact]
public async Task The_instance_actor_answers_at_the_root_for_activitypub_only()
{
var root = await _client.Fetch("/");
var browser = await _client.Fetch("/", Browser);
Assert.Equal(HttpStatusCode.OK, root.Status);
Assert.Equal("Application", root.Json["type"]!.GetValue<string>());
Assert.Equal($"{Base}/peasants/privapub", root.Json["id"]!.GetValue<string>());
Assert.Equal($"{Base}/peasants/privapub/mouth", root.Json["inbox"]!.GetValue<string>());
Assert.Equal(HttpStatusCode.NotFound, browser.Status);
}
[Fact] [Fact]
public async Task Users_moves_permanently_to_peasants_and_unknown_actors_are_404() public async Task Users_moves_permanently_to_peasants_and_unknown_actors_are_404()
{ {
@@ -519,6 +533,7 @@ namespace PrivaPub.Tests.Http
var instance = await client.Fetch("/peasants/privapub"); var instance = await client.Fetch("/peasants/privapub");
Assert.Equal(HttpStatusCode.OK, instance.Status); Assert.Equal(HttpStatusCode.OK, instance.Status);
Assert.Equal("Application", instance.Json["type"]!.GetValue<string>()); Assert.Equal("Application", instance.Json["type"]!.GetValue<string>());
Assert.Equal(HttpStatusCode.OK, (await client.Fetch("/")).Status);
foreach (var path in paths[..^1]) foreach (var path in paths[..^1])
Assert.True((await client.Fetch(reader.SignedGet(path))).Status == HttpStatusCode.OK, $"{path} refused a signed GET"); Assert.True((await client.Fetch(reader.SignedGet(path))).Status == HttpStatusCode.OK, $"{path} refused a signed GET");
// a browser is not asked for a signature: it is only sent to the public pages // a browser is not asked for a signature: it is only sent to the public pages
@@ -74,6 +74,17 @@ namespace PrivaPub.Federation.Controllers
return Activity(ActivityPubRenderer.Actor(local)); return Activity(ActivityPubRenderer.Actor(local));
} }
// The instance actor at the server's root, for whoever asks for ActivityPub there: PieFed reads the inbox it sends a
// community's announces to from the Application at a peer's root, as Lemmy serves its own, and assumes /inbox otherwise
[HttpGet, Route("/")]
public async Task<IActionResult> Root(CancellationToken token)
{
var accept = Request.Headers.Accept.ToString();
if (!accept.Contains("activity+json", StringComparison.OrdinalIgnoreCase) && !accept.Contains("ld+json", StringComparison.OrdinalIgnoreCase))
return NotFound();
return Activity(ActivityPubRenderer.Actor(await _localActors.GetInstanceActor(token)));
}
[HttpGet, Route("{actor}/anus")] [HttpGet, Route("{actor}/anus")]
public async Task<IActionResult> Outbox(string actor, [FromQuery] bool page, [FromQuery(Name = "max_id")] string maxId, public async Task<IActionResult> Outbox(string actor, [FromQuery] bool page, [FromQuery(Name = "max_id")] string maxId,
CancellationToken token) CancellationToken token)
@@ -448,7 +459,7 @@ namespace PrivaPub.Federation.Controllers
Response.Headers.Vary = "Accept"; Response.Headers.Vary = "Accept";
// SecureMode asks every reader of ActivityPub documents for a signature, except for the instance actor, whose key // SecureMode asks every reader of ActivityPub documents for a signature, except for the instance actor, whose key
// peers need first, and except for browsers, which only get redirected to the public pages // peers need first, and except for browsers, which only get redirected to the public pages
if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method) && !WantsHtml() if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method) && !WantsHtml() && Request.Path.Value != "/"
&& !string.Equals(context.RouteData.Values["actor"] as string, LocalActorService.InstanceUserName, StringComparison.OrdinalIgnoreCase) && !string.Equals(context.RouteData.Values["actor"] as string, LocalActorService.InstanceUserName, StringComparison.OrdinalIgnoreCase)
&& await _fetches.Requester(Request, HttpContext.RequestAborted) == default) && await _fetches.Requester(Request, HttpContext.RequestAborted) == default)
{ {
@@ -198,12 +198,15 @@ namespace PrivaPub.Federation.Inbox.Handlers
return; return;
} }
Arrival.About(visibility: deleted.Visibility, created: deleted.CreationDate); Arrival.About(visibility: deleted.Visibility, created: deleted.CreationDate);
using (var check = await _remoteActors.FetchObject(objectUri, token)) // a community on the post's own server speaks for it: PieFed keeps serving a thread its moderator removed.
if (check != default && Value(System.Text.Json.Nodes.JsonNode.Parse(check.Root.GetRawText()), "type") != "Tombstone") // One elsewhere is believed once the post's server says it is gone
{ if (!Origin.Same(objectUri, group.ActorURI))
Arrival.Drop("not-deleted"); using (var check = await _remoteActors.FetchObject(objectUri, token))
return; if (check != default && Value(System.Text.Json.Nodes.JsonNode.Parse(check.Root.GetRawText()), "type") != "Tombstone")
} {
Arrival.Drop("not-deleted");
return;
}
Arrival.Accept("removed"); Arrival.Accept("removed");
await RemoteDeletes.Remove(deleted, objectUri, token); await RemoteDeletes.Remove(deleted, objectUri, token);
break; break;
+12 -2
View File
@@ -582,9 +582,19 @@ What it showed:
- Mbin also auto-ingests Mastodon posts by hashtag and Announces them. - Mbin also auto-ingests Mastodon posts by hashtag and Announces them.
- **Gaps:** - **Gaps:**
- **P1:** Lemmy's P1 set, W2, and tolerating `source` as a string. - **P1:** Lemmy's P1 set, W2, and tolerating `source` as a string.
- **P2:** galleries; community polls (vote without `published`); post `Move`; `repliesEnabled`; `nsfl`; flairs; - **P2:** galleries; post `Move`; `repliesEnabled`; `nsfl`; flairs; publish `likes`/`shares` totals.
publish `likes`/`shares` totals.
- **P3:** `genAI`; accepted answers; batched Announces. - **P3:** `genAI`; accepted answers; batched Announces.
- **PieFed sends a community's announces to the inbox of the Application at a peer's root** (Lemmy's site actor), and
to `https://<host>/inbox` when the root answers anything else. PrivaPub answered 404 there until 2026-10-05, so every
announce went to a `/inbox` it does not have; it now serves its instance actor at `/` for ActivityPub requests.
- **PieFed keeps serving a thread its moderator removed** (200, the Page unchanged), so a removal could not be checked
against the post's origin; a community on the post's own server is now believed at once (FEDERATION.md, Groups).
- **Pasture evidence (2026-10-05, PieFed 1.7.17, `tools/pasture/scenarios/piefed.sh`):** 29 checks pass: communities
both ways (follows accepted); a PieFed thread in our community arrives titled and ours reaches PieFed through its
announce; a PieFed community's thread reaches alice's home and her thread lands in it; comments both ways; PieFed's
upvote counts as a like and its change to a downvote is applied, alice's like is an upvote there; a community poll
arrives as a poll and alice's vote counts on PieFed; private messages both ways; a moderator's lock (replies then
refused), unlock and removal; the unfollow; statistics.
### NodeBB 4.16, Discourse, Friendica ### NodeBB 4.16, Discourse, Friendica
+3
View File
@@ -69,6 +69,9 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
Funkwhale serve it; Funkwhale's answers named after our follow and its deletions of several uploads. Funkwhale serve it; Funkwhale's answers named after our follow and its deletions of several uploads.
- GoToSocial's interaction policies both ways; personas join and leave remote events; third-party replies to a - GoToSocial's interaction policies both ways; personas join and leave remote events; third-party replies to a
persona's posts passed on to its followers (owner decisions 2026-10-05). persona's posts passed on to its followers (owner decisions 2026-10-05).
- wave 2, under way: PieFed in the pasture with a scenario (2026-10-05). What it showed and was fixed: the instance
actor answers at the server's root, where PieFed looks for the inbox it announces to; a community's removal of a
post on its own server is believed at once.
- [ ] P7 Threads, communities, moderation, the social graph - [ ] P7 Threads, communities, moderation, the social graph
- [ ] P8 Signatures, discovery, the long tail - [ ] P8 Signatures, discovery, the long tail
- [x] P9 Reading at volume (owner decision 2026-10-04, back from "Cut"): lists, server-side filters, scheduled posts, - [x] P9 Reading at volume (owner decision 2026-10-04, back from "Cut"): lists, server-side filters, scheduled posts,
+5
View File
@@ -92,3 +92,8 @@ funkwhale.test {
tls internal tls internal
reverse_proxy pasture-funkwhale:80 reverse_proxy pasture-funkwhale:80
} }
piefed.test {
tls internal
reverse_proxy pasture-piefed:5000
}
+65
View File
@@ -0,0 +1,65 @@
# PieFed 1.7.17: a threadiverse server (communities, threads, comments, votes up and down, polls, flairs) in Flask, from
# dockurr's image of the release. The web app (gunicorn, with PieFed's own cron for its send queue) and a Celery worker
# share the pasture-piefed-media volume, on the shared Postgres (database piefed) and Redis (dbs 10 and 11). httpx
# trusts only certifi's bundle, so the pasture's is mounted over it. `flask init-db` asks for its admin on stdin
# (pfuser); its Lemmy-style API (/api/alpha) signs in with a JWT.
PIEFED_IMAGE=${PIEFED_IMAGE:-docker.io/dockurr/piefed:1.7.17}
PIEFED_PASSWORD=PieFed-Pasture-1
. "$here/peers/shared.sh"
piefed_env() {
local key="$here/.state/piefed/secret"
[ -s "$key" ] || { mkdir -p "$here/.state/piefed"; head -c 48 /dev/urandom | base64 -w0 | tr -d '/+=' > "$key"; }
cat <<ENV
SERVER_NAME=piefed.test
SECRET_KEY=$(cat "$key")
DATABASE_URL=postgresql+psycopg2://pasture:pasture@postgres:5432/piefed
CACHE_TYPE=RedisCache
CACHE_REDIS_URL=redis://redis:6379/10
CELERY_BROKER_URL=redis://redis:6379/11
RESULT_BACKEND=redis://redis:6379/11
ENABLE_ALPHA_API=true
CORS_ALLOW_ORIGIN=*
REDIS_MEMORY_LIMIT=-1
VOTE_QUOTA=100000
REQUESTS_CA_BUNDLE=/ca/bundle.pem
SSL_CERT_FILE=/ca/bundle.pem
ENV
}
piefed_up() {
shared_postgres_up
shared_redis_up
pg_db piefed
mkdir -p "$here/.state/piefed"
piefed_env > "$here/.state/piefed/env"
podman volume exists pasture-piefed-media || podman volume create --label pasture=1 pasture-piefed-media >/dev/null
local common=(--network $net --label pasture=1 --env-file "$here/.state/piefed/env" -v pasture-piefed-media:/app/app/static/media
-v "$ca/bundle.pem:/ca/bundle.pem:z,ro" -v "$ca/bundle.pem:/venv/lib/python3.13/site-packages/certifi/cacert.pem:z,ro")
podman run -d --replace --name pasture-piefed "${common[@]}" -e CRON=true "$PIEFED_IMAGE" >/dev/null
podman run -d --replace --name pasture-piefed-celery "${common[@]}" --entrypoint ./entrypoint_celery.sh "$PIEFED_IMAGE" >/dev/null
# the web app runs its migrations before gunicorn starts; init-db then sets the site up once, and drops every table
# it finds, so it waits for the last migration
for _ in $(seq 1 120); do
podman logs pasture-piefed 2>&1 | grep -q "Starting Gunicorn" && break
sleep 2
done
if ! podman exec pasture-postgres psql -U pasture -d piefed -tAc "select 1 from \"user\" where user_name = 'pfuser'" 2>/dev/null | grep -q 1; then
printf 'pfuser\npfuser@piefed.test\n%s\n' "$PIEFED_PASSWORD" | podman exec -i -e FLASK_APP=pyfedi.py pasture-piefed flask init-db >/dev/null 2>&1
podman restart pasture-piefed pasture-piefed-celery >/dev/null
fi
for _ in $(seq 1 90); do
site piefed.test -s -o /dev/null -w '%{http_code}' https://piefed.test:6443/api/alpha/site 2>/dev/null | grep -q 200 && break
sleep 2
done
piefed_settle
echo "piefed: https://piefed.test:6443"
}
# open registrations, no rate limit on its API for the pasture, and pfuser's JWT
piefed_settle() {
podman exec pasture-postgres psql -U pasture -d piefed -qc "update site set registration_mode = 'Open', enable_downvotes = true" >/dev/null 2>&1 || true
site piefed.test -s -X POST https://piefed.test:6443/api/alpha/user/login -H 'Content-Type: application/json' \
-d "{\"username\":\"pfuser\",\"password\":\"$PIEFED_PASSWORD\"}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['jwt'])" > "$here/.state/piefed.token" 2>/dev/null || true
}
+1 -1
View File
@@ -4,7 +4,7 @@
# API at http://127.0.0.1:6971 ($PASTURE_PORT), every site at https://<name>.test:6443 (curl --resolve <name>.test:6443:127.0.0.1 -k). # API at http://127.0.0.1:6971 ($PASTURE_PORT), every site at https://<name>.test:6443 (curl --resolve <name>.test:6443:127.0.0.1 -k).
# usage: tools/pasture/run.sh up [peer...] | add <peer...> | down [--purge] | logs <name> | ps | stats # usage: tools/pasture/run.sh up [peer...] | add <peer...> | down [--purge] | logs <name> | ps | stats
# up starts everything afresh (PrivaPub republished, Mongo empty); add starts more peers next to a running pasture. # up starts everything afresh (PrivaPub republished, Mongo empty); add starts more peers next to a running pasture.
# peers: gts (default), mastodon, misskey, sharkey, akkoma, lemmy, decepub # peers: gts (default), mastodon, misskey, sharkey, akkoma, lemmy, decepub, and every other file in peers/
set -euo pipefail set -euo pipefail
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/pasture.sh" . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/pasture.sh"
+124
View File
@@ -0,0 +1,124 @@
# PieFed 1.7.17: the threadiverse in Flask. Communities both ways (FEP-1b12), threads with titles, comments both ways,
# votes up and down both ways, a community poll, private messages both ways, a moderator's lock and removal,
# statistics. PieFed is driven through its Lemmy-style API (/api/alpha) as pfuser.
PF=https://piefed.test:6443
PFT=$(cat "$here/.state/piefed.token" 2>/dev/null)
# pf <method> <path> [json]: a PieFed API call as pfuser
pf() {
local method=$1 path=$2 body=${3:-}
if [ -n "$body" ]; then
site piefed.test -s -X "$method" "$PF/api/alpha/$path" -H "Authorization: Bearer $PFT" -H 'Content-Type: application/json' -d "$body"
else
site piefed.test -s -X "$method" "$PF/api/alpha/$path" -H "Authorization: Bearer $PFT"
fi
}
# the posts PieFed holds in a community, and the one whose ap_id is given
pf_posts() { pf GET "post/list?community_id=$1&sort=New&limit=50" | j "print(json.dumps([p['post'] for p in d.get('posts', [])]))"; }
pf_post_by_ap() { pf_posts "$1" | j "print(json.dumps(next((p for p in d if p['ap_id']=='$2'), None)))"; }
pf_comments() { pf GET "comment/list?post_id=$1&limit=50" | j "print(json.dumps([c['comment'] for c in d.get('comments', [])]))"; }
echo "piefed"
[ -n "$PFT" ] && ok "PieFed token for pfuser" || { ko "PieFed token"; return 1; }
PT=$(privapub_token alice_piefed)
PH="Authorization: Bearer $PT"
[ -n "$PT" ] && ok "PrivaPub token for alice_piefed" || { ko "PrivaPub token for alice_piefed"; return 1; }
jwt=$(privapub_root)
alice_id=$(curl -s -H "$PH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])")
run=$(date +%s)
dogs="pfdogs$run"
pies="pies$run"
echo " communities"
dogs_gid=$(curl -s -X POST $P/clientapi/group/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
-d "{\"avatarId\":\"$alice_id\",\"userName\":\"$dogs\",\"name\":\"Pasture dogs\",\"description\":\"a PrivaPub community\",\"isCommunity\":true}" | j "print(d['id'])")
dogs_on_pf=$(pf GET "resolve_object?q=!$dogs@privapub.test" | j "print(d['community']['community']['id'])")
[ -n "$dogs_on_pf" ] && ok "PieFed resolves a PrivaPub community" || ko "PieFed cannot resolve the PrivaPub community"
pf POST community/follow "{\"community_id\":$dogs_on_pf,\"follow\":true}" >/dev/null
until_true 45 '[ "$(pf GET "community?id=$dogs_on_pf" | j "print(d[\"community_view\"][\"subscribed\"])")" = "Subscribed" ]' \
&& ok "pfuser follows the PrivaPub community (Accept arrived)" || ko "PieFed's community follow not accepted ($(pf GET "community?id=$dogs_on_pf" | j "print(d[\"community_view\"][\"subscribed\"])"))"
pies_on_pf=$(pf POST community "{\"name\":\"$pies\",\"title\":\"Pasture pies\",\"description\":\"pies of the pasture\"}" | j "print(d['community_view']['community']['id'])")
pies_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=@$pies@piefed.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
[ -n "$pies_on_p" ] && ok "PrivaPub resolves a PieFed community" || ko "PrivaPub cannot resolve the PieFed community"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$pies_on_p/follow"
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$pies_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \
&& ok "alice follows the PieFed community (Accept arrived)" || ko "PrivaPub's community follow not accepted"
echo " threads"
pf_thread=$(pf POST post "{\"title\":\"A PieFed thread\",\"body\":\"posted from PieFed into PrivaPub\",\"community_id\":$dogs_on_pf}" | j "print(d['post_view']['post']['ap_id'])")
dogs_on_p=$(curl -s -H "$PH" "$P/api/v1/accounts/lookup?acct=$dogs" | j "print(d['id'])")
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/$dogs_on_p/statuses" | j "print(any(s[\"uri\"]==\"$pf_thread\" and (s.get(\"privapub\") or {}).get(\"title\")==\"A PieFed thread\" for s in d))")" = "True" ]' \
&& ok "a PieFed thread in the PrivaPub community arrives with its title" || ko "PieFed's thread missing or untitled on PrivaPub"
curl -s -o /dev/null -X POST $P/clientapi/post/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
-d "{\"avatarId\":\"$alice_id\",\"title\":\"A PrivaPub community thread $run\",\"text\":\"posted into our own community for PieFed\",\"groupId\":\"$dogs_gid\"}"
dogs_post=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'print(db.Post.findOne({Text:/posted into our own community for PieFed/}, {}, {sort:{_id:-1}}).ObjectURI)')
until_true 45 '[ "$(pf_post_by_ap "$dogs_on_pf" "$dogs_post" | j "print(d and d[\"title\"])")" = "A PrivaPub community thread $run" ]' \
&& ok "a titled post in the PrivaPub community reaches PieFed through its announce" || ko "PrivaPub community post missing on PieFed"
pf_pies_thread=$(pf POST post "{\"title\":\"Pies only $run\",\"body\":\"a PieFed community post\",\"community_id\":$pies_on_pf}" | j "print(d['post_view']['post']['ap_id'])")
p_home_has() { curl -s -H "$PH" "$P/api/v1/timelines/home?limit=40" | j "print(next(((s.get('reblog') or s)['id'] for s in d if (s.get('reblog') or s)['uri']=='$1'), ''))"; }
until_true 45 '[ -n "$(p_home_has "$pf_pies_thread")" ]' \
&& ok "the PieFed community's announce brings its thread to alice's home" || ko "PieFed community thread missing from alice's home"
pies_thread_on_p=$(p_home_has "$pf_pies_thread")
p_thread=$(curl -s -X POST -H "$PH" $P/api/v1/statuses --data-urlencode "status=A PrivaPub thread
@$pies@piefed.test posted from PrivaPub" -d 'visibility=public')
p_thread_id=$(echo "$p_thread" | j "print(d['id'])"); p_thread_uri=$(echo "$p_thread" | j "print(d['uri'])")
until_true 45 '[ "$(pf_post_by_ap "$pies_on_pf" "$p_thread_uri")" != "null" ]' && ok "alice's thread lands in the PieFed community" || ko "PrivaPub thread missing from the PieFed community"
p_thread_on_pf=$(pf_post_by_ap "$pies_on_pf" "$p_thread_uri" | j "print(d['id'])")
echo " comments"
pf POST comment "{\"post_id\":$p_thread_on_pf,\"body\":\"a PieFed comment on PrivaPub\"}" >/dev/null
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_thread_id/context" | j "print(any(\"a PieFed comment on PrivaPub\" in s[\"content\"] for s in d[\"descendants\"]))")" = "True" ]' \
&& ok "PieFed's comment threads under alice's post" || ko "PieFed's comment missing on PrivaPub"
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d "status=a PrivaPub comment on PieFed&in_reply_to_id=$pies_thread_on_p&visibility=public"
pies_thread_id=$(pf GET "resolve_object?q=$pf_pies_thread" | j "print(d['post']['post']['id'])")
until_true 45 '[ "$(pf_comments "$pies_thread_id" | j "print(any(\"a PrivaPub comment on PieFed\" in c[\"body\"] for c in d))")" = "True" ]' \
&& ok "alice's reply becomes a comment on PieFed" || ko "PrivaPub reply missing as a PieFed comment"
echo " votes"
pf POST post/like "{\"post_id\":$p_thread_on_pf,\"score\":1}" >/dev/null
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_thread_id" | j "print(d[\"favourites_count\"])")" = "1" ]' \
&& ok "PieFed's upvote counts as a like on PrivaPub" || ko "PieFed's upvote not counted"
pf POST post/like "{\"post_id\":$p_thread_on_pf,\"score\":-1}" >/dev/null
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_thread_id" | j "print(d[\"favourites_count\"], ((d.get(\"privapub\") or {}).get(\"votes\") or {}).get(\"down\"))")" = "0 1" ]' \
&& ok "PieFed's change to a downvote reaches PrivaPub" || ko "PieFed's change to a downvote not applied ($(curl -s -H "$PH" "$P/api/v1/statuses/$p_thread_id" | j "print(d[\"favourites_count\"], (d.get(\"privapub\") or {}).get(\"votes\"))"))"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$pies_thread_on_p/favourite"
until_true 45 '[ "$(pf GET "post?id=$pies_thread_id" | j "print(d[\"post_view\"][\"counts\"][\"upvotes\"])")" = "2" ]' \
&& ok "alice's like is an upvote on PieFed" || ko "like not counted as an upvote on PieFed ($(pf GET "post?id=$pies_thread_id" | j "print(d[\"post_view\"][\"counts\"])"))"
echo " polls"
ends=$(date -u -d '+2 days' +%Y-%m-%dT%H:%M:%S.000000Z)
pf_poll=$(pf POST post "{\"title\":\"Which pie $run\",\"body\":\"vote for one\",\"community_id\":$pies_on_pf,\"poll\":{\"mode\":\"single\",\"end_poll\":\"$ends\",\"choices\":[{\"id\":1,\"choice_text\":\"apple\",\"sort_order\":0},{\"id\":2,\"choice_text\":\"cherry\",\"sort_order\":1}]}}")
pf_poll_uri=$(echo "$pf_poll" | j "print(d['post_view']['post']['ap_id'])")
until_true 45 '[ -n "$(p_home_has "$pf_poll_uri")" ]' && ok "a PieFed poll reaches alice's home" || ko "the PieFed poll never arrived ($(echo "$pf_poll" | cut -c1-200))"
poll_on_p=$(p_home_has "$pf_poll_uri")
poll=$(curl -s -H "$PH" "$P/api/v1/statuses/$poll_on_p" | j "print(json.dumps(d.get('poll')))")
[ "$(echo "$poll" | j "print([o['title'] for o in d['options']])")" = "['apple', 'cherry']" ] && ok "as a poll with its choices" || ko "the PieFed poll is no poll on PrivaPub ($poll)"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/polls/$(echo "$poll" | j "print(d['id'])")/votes" -H 'Content-Type: application/json' -d '{"choices":[1]}'
pf_poll_id=$(echo "$pf_poll" | j "print(d['post_view']['post']['id'])")
until_true 60 '[ "$(pf GET "post?id=$pf_poll_id" | j "print(sum(c.get(\"num_votes\", 0) for c in d[\"post_view\"][\"post\"][\"poll\"][\"choices\"] if c[\"choice_text\"]==\"cherry\"))")" = "1" ]' \
&& ok "alice's vote counts on PieFed" || ko "alice's vote not counted on PieFed ($(pf GET "post?id=$pf_poll_id" | j "print(d[\"post_view\"][\"post\"].get(\"poll\"))"))"
echo " private messages"
alice_on_pf=$(pf GET "resolve_object?q=@alice_piefed@privapub.test" | j "print(d['person']['person']['id'])")
pf POST private_message "{\"content\":\"a secret from PieFed\",\"recipient_id\":$alice_on_pf}" >/dev/null
until_true 45 'curl -s -H "$PH" "$P/api/v1/conversations" | grep -q "a secret from PieFed"' && ok "PieFed's private message arrives as a DM" || ko "PieFed's private message missing on PrivaPub"
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d 'status=@pfuser@piefed.test a secret from PrivaPub&visibility=direct'
until_true 45 'pf GET "private_message/list?limit=50" | grep -q "a secret from PrivaPub"' && ok "alice's DM arrives as a PieFed private message" || ko "DM missing on PieFed"
echo " moderation"
p_locked() { curl -s -H "$PH" "$P/api/v1/statuses/$pies_thread_on_p" | j "print((d.get('privapub') or {}).get('locked'))"; }
pf POST post/lock "{\"post_id\":$pies_thread_id,\"locked\":true}" >/dev/null
until_true 45 '[ "$(p_locked)" = "True" ]' && ok "a moderator's lock reaches PrivaPub" || ko "a moderator's lock did not reach PrivaPub"
[ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "$PH" $P/api/v1/statuses -d "status=too late&in_reply_to_id=$pies_thread_on_p&visibility=public")" = "422" ] \
&& ok "a reply to the locked thread is refused" || ko "a reply to the locked thread was taken"
pf POST post/lock "{\"post_id\":$pies_thread_id,\"locked\":false}" >/dev/null
until_true 45 '[ "$(p_locked)" = "False" ]' && ok "a moderator's unlock reaches PrivaPub" || ko "a moderator's unlock did not reach PrivaPub"
pf POST post/remove "{\"post_id\":$pies_thread_id,\"removed\":true,\"reason\":\"pasture\"}" >/dev/null
until_true 45 '[ -z "$(p_home_has "$pf_pies_thread")" ]' && ok "a moderator's removal reaches PrivaPub" || ko "a moderator's removal did not reach PrivaPub"
echo " unfollow"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$pies_on_p/unfollow"
until_true 45 '[ "$(podman exec pasture-postgres psql -U pasture -d piefed -tAc "select count(*) from community_member m join \"user\" u on u.id = m.user_id where u.ap_profile_id like '"'%alice_piefed%'"' and m.community_id = $pies_on_pf")" = "0" ]' \
&& ok "alice's unfollow reaches the PieFed community" || ko "the PieFed community still counts alice"
echo " statistics"
stats_check piefed.test piefed