diff --git a/CLAUDE.md b/CLAUDE.md index 3cbc629..166a516 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -288,7 +288,8 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ counts with bare Updates. - **A poll vote is a `Note` with a `name`, an `inReplyTo` that is a poll we hold, and no content.** `CreateHandler` hands it to `PollService.Receive` before anything else, so it never becomes a reply. Our votes on other servers' polls - go only to the poll's author, without `published` (PieFed counts a vote only then). + go only to the poll's author, without `published` (PieFed counts a vote only then). A `closed` in the future is the + poll's end, not its closing: Akkoma sends an open poll's end only there (`ObjectShapes`). - **Link previews follow owner decision 1** (`Domain/Content/LinkPreviews.cs`): only public posts, queued on arrival with 0–60 s of jitter, one cached `LinkPreview` per address for the whole server, never fetched when someone reads. `LinkPreviews.Wanted` is called where posts are saved (`CreateHandler`, `StoreContext`, `StatusService.Publish`). @@ -414,13 +415,23 @@ tools/pasture/run.sh down # removes e (`federation: none`) until `admin/update-meta` says `all`, which `misskey_up` does. Its API is `POST /api/` with the token as `i` (`mk` in the scenario); `users/relation` answers a list, `users/notes` leaves replies out unless asked, and a user has one reaction per note. Never verify a delivery with `ap/show`: it fetches. 35 checks. +- **Sharkey (2025.4.7):** `peers/sharkey.sh` is the Misskey peer under another name, image, database, Redis db and + home (`/sharkey/.config`); `scenarios/sharkey.sh` runs Misskey's scenario with `MISSKEY_NAME=sharkey`, then follows + again (Misskey's ends unfollowed and blocked) and checks edits both ways and the FEP-e232 quote tag. 40 checks. +- **Akkoma (3.20.1):** no official image, so `images/akkoma` installs the OTP release (pinned by checksum; the "stable" + zip moves, and a moved one fails the build) and `akkoma_up` builds it once. Its HTTP clients read the CA bundles + shipped in the release (CAStore, certifi), so the entrypoint appends Caddy's CA there. `pleroma_ctl` passes its + arguments on unquoted: no value may contain a space. Its Linkify never takes `@user@host.test` for a mention, so the + scenario addresses alice with Pleroma's `to[]`; its API never reports a remote blocker as `blocked_by`, so the block is + read from its `user_relationships`. 44 checks. - **Lemmy (1.0.0-beta.2):** the backend alone on the shared Postgres, its admin made by `setup` in the generated `config.hjson`. It trusts Caddy's CA through `SSL_CERT_FILE` and reaches the network through `DANGER_FEDERATION_ALLOW_LOCAL_IP=1`; 0.19 cannot join (its rustls trusts only its bundled roots). Its API is `/api/v4/` with a bearer token (`lm` in the scenario), and `sort` values are lowercase. It logs no refused activity at `warn` (`LEMMY_LOG` sets `RUST_LOG`); the reason is in the 400's body. It answers our community's echo of its own activity and every bare `Announce{object}` 400 by design, and the echo is still needed (see - `docs/INTEROP.md`, Lemmy). 20 checks; relayed votes and a moderator's removal are expected failures (P7). + `docs/INTEROP.md`, Lemmy). A new Lemmy never sends what it queued for a server before its send worker for that + server started, so the scenario waits for that worker (`lm_worker`) before its first follow. 20 checks; relayed votes and a moderator's removal are expected failures (P7). - **Crawler:** `PRIVAPUB_ENV="Statistics__Crawler__Enabled=true Statistics__Crawler__Seeds__0=mastodon.test" run.sh up mastodon`, then `interop.sh crawler`. `PRIVAPUB_ENV` passes any setting to the PrivaPub container. - `run.sh up` replaces every container, Mongo included, so each run starts clean. To keep the data, republish into diff --git a/PrivaPub.Tests/Domain/OutboxTests.cs b/PrivaPub.Tests/Domain/OutboxTests.cs index f34aeae..fbfabc6 100644 --- a/PrivaPub.Tests/Domain/OutboxTests.cs +++ b/PrivaPub.Tests/Domain/OutboxTests.cs @@ -65,6 +65,8 @@ namespace PrivaPub.Tests.Domain var sent = await _harness.Outgoing(follower.SharedInbox); Assert.Equal(new[] { "Create", "Update", "Delete" }, sent.Select(a => a["type"]!.GetValue())); Assert.DoesNotContain(sent[0]["to"]!.AsArray(), n => n!.GetValue().EndsWith("#Public")); + // Pleroma reads an empty cc without a "/followers" address as a direct message + Assert.Contains(sent[0]["object"]!["cc"]!.AsArray(), n => n!.GetValue() == alice.Followers); Assert.Equal("cw", sent[1]["object"]!["summary"]!.GetValue()); Assert.NotNull(sent[1]["object"]!["updated"]); Assert.Equal(sent[0]["to"]!.ToJsonString(), sent[2]["to"]!.ToJsonString()); diff --git a/PrivaPub.Tests/Federation/ObjectShapesTests.cs b/PrivaPub.Tests/Federation/ObjectShapesTests.cs index c81b227..70d1a1e 100644 --- a/PrivaPub.Tests/Federation/ObjectShapesTests.cs +++ b/PrivaPub.Tests/Federation/ObjectShapesTests.cs @@ -190,7 +190,7 @@ namespace PrivaPub.Tests.Federation } [Fact] - public void Reads_polls_from_mastodon_misskey_and_closed_ones() + public void Reads_polls_from_mastodon_misskey_akkoma_and_closed_ones() { var mastodon = Parse(""" { "id": "https://m.example/q/1", "type": "Question", "attributedTo": "https://m.example/u", "content": "?", "endTime": "2026-10-02T00:00:00Z", "votersCount": 7, @@ -204,6 +204,10 @@ namespace PrivaPub.Tests.Federation { "id": "https://g.example/q/1", "type": "Question", "attributedTo": "https://g.example/u", "content": "?", "closed": "2026-09-30T12:00:00Z", "oneOf": [{ "type": "Note", "name": "p" }, { "type": "Note", "name": "q" }] } """); + var akkoma = Parse(""" + { "id": "https://a.example/objects/1", "type": "Question", "attributedTo": "https://a.example/users/u", "content": "?", "closed": "2099-01-01T00:00:00.000000Z", + "oneOf": [{ "type": "Note", "name": "p" }, { "type": "Note", "name": "q" }] } + """); Assert.False(mastodon.Poll.Multiple); Assert.Equal(new[] { 4, 3 }, mastodon.Poll.Options.Select(o => o.Votes)); @@ -212,6 +216,8 @@ namespace PrivaPub.Tests.Federation Assert.Equal(new[] { 2, 5 }, misskey.Poll.Options.Select(o => o.Votes)); Assert.Null(misskey.Poll.VotersCount); Assert.Equal(new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), closed.Poll.ClosedAt); + Assert.Null(akkoma.Poll.ClosedAt); + Assert.Equal(new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc), akkoma.Poll.ExpiresAt); } [Fact] diff --git a/PrivaPub.Tests/Http/MastodonStatusesTests.cs b/PrivaPub.Tests/Http/MastodonStatusesTests.cs index 4d2be47..749fc88 100644 --- a/PrivaPub.Tests/Http/MastodonStatusesTests.cs +++ b/PrivaPub.Tests/Http/MastodonStatusesTests.cs @@ -60,7 +60,8 @@ namespace PrivaPub.Tests.Http Assert.Equal(new[] { MastodonHelpers.Public }, Strings(unlistedNote["cc"])); var quietNote = creates[quiet.Text("uri")]["object"]; Assert.Equal(new[] { followers }, Strings(quietNote["to"])); - Assert.Empty(quietNote["cc"]!.AsArray()); + // named again in cc: Pleroma and Akkoma read an empty cc without a "/followers" address as a direct message + Assert.Equal(new[] { followers }, Strings(quietNote["cc"])); var whisper = Assert.Single(await carol.Delivered(since)); Assert.Equal(direct.Text("uri"), whisper["object"].Text("id")); Assert.Equal(new[] { carol.Id }, Strings(whisper["object"]!["to"])); diff --git a/PrivaPub/Federation/Objects/ObjectShapes.cs b/PrivaPub/Federation/Objects/ObjectShapes.cs index 506a613..7eb68bd 100644 --- a/PrivaPub/Federation/Objects/ObjectShapes.cs +++ b/PrivaPub/Federation/Objects/ObjectShapes.cs @@ -137,11 +137,18 @@ namespace PrivaPub.Federation.Objects JsonValue value when value.TryGetValue(out var at) => Moment(at), _ => default }; + var expiresAt = Moment(Value(note, "endTime")); + // Pleroma and Akkoma put an open poll's end in `closed`, often without `endTime`: it is closed once that has passed + if (closedAt > DateTime.UtcNow) + { + expiresAt ??= closedAt; + closedAt = default; + } return new PostPoll { Options = options, Multiple = multiple, - ExpiresAt = Moment(Value(note, "endTime")), + ExpiresAt = expiresAt, ClosedAt = closedAt, VotersCount = Int(note, "votersCount") }; diff --git a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs index ae16543..49c81c1 100644 --- a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs +++ b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs @@ -138,7 +138,9 @@ namespace PrivaPub.Federation.Rendering { PostVisibility.Circle when group != default => (new JsonArray(group.Uri, group.Flock), new JsonArray(mentions)), PostVisibility.Unlisted => (new JsonArray(author.Followers), new JsonArray(mentions.Prepend(Public).ToArray())), - PostVisibility.FollowersOnly => (new JsonArray(author.Followers), new JsonArray(mentions)), + // Pleroma and Akkoma call a post private only if a `to` contains "/followers" or its cc is not empty, and ours + // are /groupies: with an empty cc every followers-only post would be a DM there. Naming them again says nothing new. + PostVisibility.FollowersOnly => (new JsonArray(author.Followers), new JsonArray(mentions.Append(author.Followers).ToArray())), PostVisibility.Direct => (new JsonArray(mentions), new JsonArray()), _ => (new JsonArray(Public), new JsonArray(mentions.Prepend(author.Followers).ToArray())) }; diff --git a/PrivaPub/Infrastructure/RateLimiting.cs b/PrivaPub/Infrastructure/RateLimiting.cs index 27ce975..ae5e442 100644 --- a/PrivaPub/Infrastructure/RateLimiting.cs +++ b/PrivaPub/Infrastructure/RateLimiting.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Options; using PrivaPub.Federation.Objects; using PrivaPub.Federation.Signing; @@ -9,13 +10,22 @@ using System.Threading.RateLimiting; namespace PrivaPub.Infrastructure { + public class RateLimitOptions + { + public int AccountsPerMinute { get; set; } = 10;//sign-ups, sign-ins and recoveries per client address + public int InboxBurst { get; set; } = 300;//deliveries a sending origin may make at once + public int InboxPerTenSeconds { get; set; } = 50;//and the rate it earns them back + } + public static class RateLimiting { public const string Accounts = "accounts"; public const string Inbox = "inbox"; - public static IServiceCollection PrivaPubRateLimiting(this IServiceCollection service) => - service.AddRateLimiter(options => + static RateLimitOptions Limits(HttpContext context) => context.RequestServices.GetRequiredService>().Value; + + public static IServiceCollection PrivaPubRateLimiting(this IServiceCollection service, IConfiguration configuration) => + service.Configure(configuration.GetSection("RateLimits")).AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.OnRejected = (context, _) => @@ -39,13 +49,13 @@ namespace PrivaPub.Infrastructure }; options.AddPolicy(Accounts, context => RateLimitPartition.GetFixedWindowLimiter( context.Connection.RemoteIpAddress?.ToString() ?? "unknown", - _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 })); + _ => new FixedWindowRateLimiterOptions { PermitLimit = Limits(context).AccountsPerMinute, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 })); options.AddPolicy(Inbox, context => RateLimitPartition.GetTokenBucketLimiter( SenderOrigin(context.Request) ?? "unsigned:" + context.Connection.RemoteIpAddress, _ => new TokenBucketRateLimiterOptions { - TokenLimit = 300, - TokensPerPeriod = 50, + TokenLimit = Limits(context).InboxBurst, + TokensPerPeriod = Limits(context).InboxPerTenSeconds, ReplenishmentPeriod = TimeSpan.FromSeconds(10), QueueLimit = 0 })); diff --git a/PrivaPub/Program.cs b/PrivaPub/Program.cs index 0a0853a..240c9aa 100644 --- a/PrivaPub/Program.cs +++ b/PrivaPub/Program.cs @@ -59,7 +59,7 @@ try .PrivaPubFederationConfiguration(builder.Configuration) .PrivaPubStatisticsConfiguration(builder.Configuration) .PrivaPubCORSConfiguration() - .PrivaPubRateLimiting() + .PrivaPubRateLimiting(builder.Configuration) .PrivaPubOAuth(builder.Environment) .AddScoped() .AddScoped() diff --git a/docs/INTEROP.md b/docs/INTEROP.md index 68b3fab..cd0f44c 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -325,6 +325,11 @@ Firefish is dead (its site has answered 410 since February 2025). A new Misskey 2026 starts with `federation: none`. +**Pasture evidence (2026-10-03, Sharkey 2025.4.7, `tools/pasture/scenarios/sharkey.sh`):** 40 checks pass, the whole +Misskey scenario under Sharkey's name and then what only Sharkey does: +- its edits arrive as edits, and ours reach it; +- its quote carries the FEP-e232 `Link` tag and is understood. + ### Pleroma 2.10.2 and Akkoma 3.20.1 **Emits** @@ -336,6 +341,7 @@ A new Misskey 2026 starts with `federation: none`. - **Edit history:** `formerRepresentations`, an OrderedCollection of earlier versions. - **Reactions:** `EmojiReact{content: ":name:", tag:[Emoji]}`, several per user; separate from Like. - **Polls:** `votersCount` (Pleroma 2.10.1, Akkoma 3.20). A vote is a `Note{name, inReplyTo, to:[], cc:[owner]}`. + An open poll's end is in `closed`, and Akkoma sends no `endTime` (pasture, 3.20.1). - **Pleroma only:** - `ChatMessage`, sent only to actors with `capabilities.acceptsChatMessages`; - `Listen{Audio}`; @@ -348,6 +354,8 @@ A new Misskey 2026 starts with `federation: none`. - **Pleroma's inbox guard answers 400 for unknown activity types:** `Move`, `QuoteRequest` and `Bite` are not on its list (develop, 2026-09-30). Treat that 4xx as final. - **Signatures (Akkoma):** `host` must be signed and match; signatures up to 2 h old and up to 40 min in the future. +- **Visibility is guessed from addresses:** a post is private only if an address in `to` contains `/followers` or its + `cc` is not empty; otherwise it is direct. Our followers-only posts therefore name `/groupies` in `cc` too. - **Activity ids** must be at least 8 bytes. - **ObjectAgePolicy** (default in both) delists anything older than 7 days, so `published` must be accurate. - **Quotes:** Pleroma's InlineQuotePolicy rewrites incoming quotes into "RT: url" text. **Neither reads FEP-044f @@ -364,6 +372,35 @@ A new Misskey 2026 starts with `federation: none`. | `ChatMessage` in as a direct message (also needed for Lemmy, Mbin and PieFed); advertise `acceptsChatMessages` only once it is answered | P1 (in), P3 (out) | `visibility: direct`, Conversations | | `Listen`, `vcard:bday`, `backgroundUrl` | P3 | own | +**Pasture evidence (2026-10-03, Akkoma 3.20.1, `tools/pasture/scenarios/akkoma.sh`):** 44 checks pass, three runs in a +row. Akkoma publishes no image, so `tools/pasture/images/akkoma` installs its OTP release, pinned by checksum. Covered: +- discovery and follows both ways; +- posts, CW and followers-only posts; +- the `published` time kept; +- replies both ways and their notification; +- likes and boosts both ways with their undos; +- `EmojiReact` both ways and its withdrawal; +- DMs both ways and off public timelines; +- polls and votes both ways; +- quotes both ways (`quote_id` out of its API, `quoteUri` in ours); +- images with alt text both ways; +- edits with history, and deletes, both ways; +- unfollow, block and unblock; +- statistics. + +It found two bugs, both fixed: +- **Followers-only posts arrived as DMs.** Akkoma, like Pleroma, calls a post private only if an address in `to` contains + `/followers` or its `cc` is not empty. Ours is `/groupies` and a post mentioning nobody had an empty `cc`. Followers-only + posts now name the followers collection in `cc` as well, which tells nobody anything new. +- **Open polls were shown as ended, and votes refused.** An open Akkoma poll carries its end in `closed`, with no + `endTime`. A `closed` in the future is now read as the end. + +Seen along the way: +- Its Linkify never takes `@user@host.test` for a mention, whatever `validate_tld` says, so in the pasture Akkoma + addresses us with Pleroma's `to[]`. Real top-level domains are unaffected. +- It records our `Block` (`user_relationships`) but never reports a remote blocker as `blocked_by`. +- Its streamer crashes rendering a new DM conversation (`ConversationView`, a nil `last_status`); delivery is unaffected. + ### Lemmy: 0.19.20 live (lemmy.ml); 1.0.0-beta.2 (2026-09-25) in beta since May join-lemmy.org's federation page is out of date. Current Lemmy neither sends nor reads `stickied` or `commentsEnabled` @@ -458,6 +495,10 @@ What it showed: - Lemmy logs no refused activity at `warn`; the reason is in the 400's body, which our delivery does not keep. The scenario's API notes: `sort` values are lowercase (`new`), private messages and mentions are in `account/notification/list`, and `resolve_object` takes both `!community@host` and `@user@host`. +- **1.0 sends nothing it queued for a server before it started that server's send worker.** A worker starts, up to a + minute after the server is first seen, at the newest activity and skips everything older. On a clean pasture its + first Follow of our community was lost, so the scenario waits for the worker (`federation_queue_state`). On the + public network the same applies to the first thing a Lemmy sends to a PrivaPub it has just discovered. - 0.19 cannot join the pasture: its rustls trusts only its bundled roots, never Caddy's CA. ### PieFed 1.7.17 and Mbin 1.10.1 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 061b7a4..0517179 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -17,7 +17,19 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati - daily rollups, every touched server described and located, the admin statistics API, the opt-in crawler and `/stargazing`; - the pasture as plugins: GoToSocial 37/37, Mastodon 49 plus 2 expected failures. - Still open: T10 (GoToSocial gaps), T12 Misskey/Sharkey, T13 Akkoma, T14 Lemmy, and installing the geolocation timer on Max. + T10 and T12–T14: v1.17.1, 2026-10-03. The pasture runs all six peers in one pass: + - GoToSocial 54 plus 1 expected failure; + - Mastodon 49 plus 2; + - Misskey 35, Sharkey 40, Akkoma 44; + - Lemmy 20 plus 3. + + The expected failures are circle posts on GoToSocial and Mastodon, inbound Block, and Lemmy's relayed votes and + removals (P7). It found three bugs, all fixed: + - quick GoToSocial edits were lost; + - followers-only posts arrived as DMs on Pleroma and Akkoma; + - Akkoma's open polls showed as ended and refused votes. + + Still open: installing the geolocation timer on Max (`deploy/max/setup.sh`, as root). - [ ] P7 Threads, communities, moderation, the social graph - [ ] P8 Signatures, discovery, the long tail @@ -551,9 +563,9 @@ statistics gain value with every day recorded. | T1–T4 | Tests stop sharing state they don't own. CI runs every test against a throwaway mongod. The whole server runs under test (`WebApplicationFactory`). Nothing answers 500. | v1.15.1 | | M1–M6 | The interaction ledger: inbox answers, handler verdicts, delivery attempts, outbound requests, served and client traffic. Provenance fixes (fetched records, stored extensions, group-wrapped Update/Delete). | v1.16.0 | | T5–T8 | Coverage sweep over HTTP: OAuth, `/clientapi`, the Mastodon API, federation GETs, inbox gaps, jobs, migrations, pages. | v1.17.0 | -| M7–M10 | Daily rollups (`InstanceDay`, `ServerDay`). Every touched server described weekly (NodeInfo usage, instance API, snapshots). Geolocation (DB-IP Lite city and ASN). Admin statistics API under `/clientapi/admin/statistics`. | v1.18.0 | -| T9–T14 | The pasture as plugins. GoToSocial gaps, then Mastodon, Misskey/Sharkey, Akkoma and Lemmy 1.0, each run also checking that peer's statistics. | v1.18.x | -| M11 | The opt-in crawler (`PrivaPub-Stargazer`) and the `/stargazing` explainer. | v1.19.0 | +| M7–M10 | Daily rollups (`InstanceDay`, `ServerDay`). Every touched server described weekly (NodeInfo usage, instance API, snapshots). Geolocation (DB-IP Lite city and ASN). Admin statistics API under `/clientapi/admin/statistics`. | v1.17.0 (planned v1.18.0) | +| T9–T14 | The pasture as plugins. GoToSocial gaps, then Mastodon, Misskey/Sharkey, Akkoma and Lemmy 1.0, each run also checking that peer's statistics. | v1.17.0, v1.17.1 | +| M11 | The opt-in crawler (`PrivaPub-Stargazer`) and the `/stargazing` explainer. | v1.17.0 (planned v1.19.0) | Later, and not part of these steps: the public `/stargazing` statistics. It is anonymous, cached and rate-limited, and covers per-server software, self-published counts, location as projected by the rule above, availability buckets and diff --git a/tools/pasture/Caddyfile b/tools/pasture/Caddyfile index 873a78e..0cf40af 100644 --- a/tools/pasture/Caddyfile +++ b/tools/pasture/Caddyfile @@ -32,3 +32,8 @@ lemmy.test { tls internal reverse_proxy pasture-lemmy:8536 } + +akkoma.test { + tls internal + reverse_proxy pasture-akkoma:4000 +} diff --git a/tools/pasture/appsettings.Pasture.json b/tools/pasture/appsettings.Pasture.json index b9358eb..d5e7b24 100644 --- a/tools/pasture/appsettings.Pasture.json +++ b/tools/pasture/appsettings.Pasture.json @@ -25,6 +25,7 @@ "AcceptAnyCertificate": true }, "Media": { "Root": "/tmp/privapub-media" }, + "RateLimits": { "AccountsPerMinute": 1000 }, "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:80", "Protocols": "Http1AndHttp2" } } }, "Serilog": { "MinimumLevel": { "Default": "Information", "Override": { "Microsoft": "Warning", "System": "Warning" } }, diff --git a/tools/pasture/images/akkoma/Containerfile b/tools/pasture/images/akkoma/Containerfile new file mode 100644 index 0000000..ae612d4 --- /dev/null +++ b/tools/pasture/images/akkoma/Containerfile @@ -0,0 +1,16 @@ +# Akkoma publishes no image, so this is its OTP release on the Ubuntu it is built for. The "stable" zip moves; the +# checksum pins 3.20.1 and fails the build when stable has moved on. +FROM docker.io/library/ubuntu:22.04 +ARG AKKOMA_ZIP=https://akkoma-updates.s3-website.fr-par.scw.cloud/stable/akkoma-amd64.zip +ARG AKKOMA_SHA256=a06d1077dc43357923ffbebbee509f1faa2d66ae6078828ad35f9ad34ec6f96c +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates curl unzip libncurses5 libmagic1 libmagic-mgc file postgresql-client \ + && rm -rf /var/lib/apt/lists/* +RUN curl -fsSL "$AKKOMA_ZIP" -o /tmp/akkoma.zip \ + && echo "$AKKOMA_SHA256 /tmp/akkoma.zip" | sha256sum -c - \ + && unzip -q /tmp/akkoma.zip -d /tmp && mv /tmp/release /opt/akkoma && rm /tmp/akkoma.zip \ + && mkdir -p /var/lib/akkoma/uploads /var/lib/akkoma/static /etc/akkoma +ENV AKKOMA_CONFIG_PATH=/etc/akkoma/config.exs PLEROMA_CONFIG_PATH=/etc/akkoma/config.exs LANG=C.UTF-8 ELIXIR_ERL_OPTIONS=+fnu +COPY entrypoint.sh /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] diff --git a/tools/pasture/images/akkoma/entrypoint.sh b/tools/pasture/images/akkoma/entrypoint.sh new file mode 100755 index 0000000..174903a --- /dev/null +++ b/tools/pasture/images/akkoma/entrypoint.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Trusts the pasture's CA, writes the config on first start, migrates, and runs Akkoma in the foreground. +set -e +cp /pasture/ca/root.crt /usr/local/share/ca-certificates/pasture.crt && update-ca-certificates >/dev/null +# Mint and hackney read the bundles shipped in the release (CAStore, certifi), never the system's +for bundle in /opt/akkoma/lib/castore-*/priv/cacerts.pem /opt/akkoma/lib/certifi-*/priv/cacerts.pem; do + grep -q "pasture" "$bundle" || { echo "# pasture"; cat /pasture/ca/root.crt; } >> "$bundle" +done +if [ ! -f /etc/akkoma/config.exs ]; then + # pleroma_ctl passes its arguments on unquoted, so no value may contain a space + env -u AKKOMA_CONFIG_PATH -u PLEROMA_CONFIG_PATH /opt/akkoma/bin/pleroma_ctl instance gen --force --output /etc/akkoma/config.exs --output-psql /tmp/setup.psql \ + --domain akkoma.test --instance-name PastureAkkoma --admin-email admin@akkoma.test --notify-email admin@akkoma.test \ + --dbhost postgres --dbname akkoma --dbuser pasture --dbpass pasture --rum N --indexable N --db-configurable N \ + --uploads-dir /var/lib/akkoma/uploads --static-dir /var/lib/akkoma/static --listen-ip 0.0.0.0 --listen-port 4000 \ + --media-url https://akkoma.test/media --strip-uploads-metadata N --read-uploads-description Y --anonymize-uploads N \ + /dev/null + podman exec pasture-postgres sh -c "psql -U pasture -tc \"select 1 from pg_database where datname='akkoma'\" | grep -q 1 || createdb -U pasture akkoma" + podman exec pasture-postgres psql -U pasture -d akkoma -qc 'create extension if not exists citext; create extension if not exists pg_trgm; create extension if not exists "uuid-ossp";' >/dev/null + mkdir -p "$here/.state/akkoma" + podman run -d --replace --name pasture-akkoma --network $net \ + -v "$here/.state/akkoma:/etc/akkoma:z" -v "$ca:/pasture/ca:z,ro" "$AKKOMA_IMAGE" >/dev/null + for _ in $(seq 1 120); do + site akkoma.test -s -o /dev/null -w '%{http_code}' https://akkoma.test:6443/api/v1/instance 2>/dev/null | grep -q 200 && break + sleep 3 + done + podman exec pasture-akkoma /opt/akkoma/bin/pleroma_ctl user new akuser akuser@akkoma.test --password Akkoma-Pasture-Pass-1 \ + --name akuser --admin --assume-yes >/dev/null 2>&1 || true + local app cid cs + app=$(site akkoma.test -s -X POST https://akkoma.test:6443/api/v1/apps -d 'client_name=pasture&redirect_uris=urn:ietf:wg:oauth:2.0:oob&scopes=read write follow push admin') + cid=$(echo "$app" | python3 -c "import sys,json; print(json.load(sys.stdin)['client_id'])" 2>/dev/null) + cs=$(echo "$app" | python3 -c "import sys,json; print(json.load(sys.stdin)['client_secret'])" 2>/dev/null) + site akkoma.test -s -X POST https://akkoma.test:6443/oauth/token --data-urlencode grant_type=password --data-urlencode username=akuser \ + --data-urlencode password=Akkoma-Pasture-Pass-1 --data-urlencode "client_id=$cid" --data-urlencode "client_secret=$cs" \ + --data-urlencode 'scope=read write follow push admin' \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])" > "$here/.state/akkoma.token" 2>/dev/null || true + echo "akkoma: https://akkoma.test:6443" +} diff --git a/tools/pasture/peers/misskey.sh b/tools/pasture/peers/misskey.sh index ef940ef..73f0c78 100644 --- a/tools/pasture/peers/misskey.sh +++ b/tools/pasture/peers/misskey.sh @@ -33,7 +33,7 @@ allowedPrivateNetworks: - '192.168.0.0/16' YML podman run -d --replace --name pasture-$MISSKEY_NAME --network $net -e NODE_EXTRA_CA_CERTS=/pasture/ca/root.crt \ - -v "$here/.state/$MISSKEY_NAME:/misskey/.config:z,ro" -v "$ca:/pasture/ca:z,ro" $MISSKEY_IMAGE >/dev/null + -v "$here/.state/$MISSKEY_NAME:${MISSKEY_HOME:-/misskey}/.config:z,ro" -v "$ca:/pasture/ca:z,ro" $MISSKEY_IMAGE >/dev/null for _ in $(seq 1 120); do site "$MISSKEY_NAME.test" -s -o /dev/null -w '%{http_code}' -X POST "https://$MISSKEY_NAME.test:6443/api/meta" -H 'Content-Type: application/json' -d '{}' 2>/dev/null | grep -q 200 && break sleep 3 diff --git a/tools/pasture/peers/sharkey.sh b/tools/pasture/peers/sharkey.sh new file mode 100644 index 0000000..09308c9 --- /dev/null +++ b/tools/pasture/peers/sharkey.sh @@ -0,0 +1,8 @@ +# Sharkey: the Misskey peer under its own name, image, database, Redis db and home. Its scenario is Misskey's plus what +# only Sharkey sends. +. "$here/peers/misskey.sh" + +sharkey_up() { + MISSKEY_NAME=sharkey MISSKEY_IMAGE=${SHARKEY_IMAGE:-registry.activitypub.software/transfem-org/sharkey:2025.4.7} \ + MISSKEY_REDIS_DB=3 MISSKEY_HOME=/sharkey misskey_up +} diff --git a/tools/pasture/run.sh b/tools/pasture/run.sh index 80d0c08..4db885f 100755 --- a/tools/pasture/run.sh +++ b/tools/pasture/run.sh @@ -2,7 +2,7 @@ # A private test fediverse on one podman network: PrivaPub (privapub.test) and the peers asked for, behind one Caddy # whose internal CA every side is told to accept (its root is copied to .ca/root.crt). From the workstation: PrivaPub's # API at http://127.0.0.1:6971, every site at https://.test:6443 (curl --resolve .test:6443:127.0.0.1 -k). -# usage: tools/pasture/run.sh up [peer...] | down | logs | ps peers: gts (default), mastodon, misskey, lemmy +# usage: tools/pasture/run.sh up [peer...] | down | logs | ps peers: gts (default), mastodon, misskey, sharkey, akkoma, lemmy set -euo pipefail . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/pasture.sh" diff --git a/tools/pasture/scenarios/akkoma.sh b/tools/pasture/scenarios/akkoma.sh new file mode 100644 index 0000000..8b7dabd --- /dev/null +++ b/tools/pasture/scenarios/akkoma.sh @@ -0,0 +1,142 @@ +# Akkoma 3.20: discovery, follows, posts, CW, replies both ways, likes and boosts with their undos, emoji reactions +# both ways, DMs, polls, quotes both ways, edits with their history, deletes, media with alt text, the published time +# kept, blocks, statistics. Akkoma speaks the Mastodon API, with reactions under /api/v1/pleroma. +AK=https://akkoma.test:6443 +acurl() { curl -sk --resolve akkoma.test:6443:127.0.0.1 "$@"; } +KT=$(cat "$here/.state/akkoma.token" 2>/dev/null) +KH="Authorization: Bearer $KT" +# what Akkoma holds of an account, newest first; and the one status whose uri is given +a_statuses() { acurl -H "$KH" "$AK/api/v1/accounts/$1/statuses?limit=40"; } +a_status_by_uri() { a_statuses "$1" | j "print(json.dumps(next((s for s in d if s['uri']=='$2'), None)))"; } +# a PrivaPub status in alice_akkoma'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((s['id'] for s in d if '$1' in s['content']), ''))"; } + +echo "akkoma" +[ -n "$KT" ] && ok "Akkoma token for akuser" || { ko "Akkoma token"; return 1; } +PT=$(privapub_token alice_akkoma) +PH="Authorization: Bearer $PT" +[ -n "$PT" ] && ok "PrivaPub token for alice_akkoma" || { ko "PrivaPub token for alice_akkoma"; return 1; } +alice_self=$(curl -s -H "$PH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])") + +echo " discovery" +alice_on_a=$(acurl -H "$KH" "$AK/api/v2/search?q=@alice_akkoma@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") +[ -n "$alice_on_a" ] && ok "Akkoma resolves @alice_akkoma@privapub.test" || ko "Akkoma cannot resolve alice_akkoma" +ak_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=akuser@akkoma.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") +[ -n "$ak_on_p" ] && ok "PrivaPub resolves @akuser@akkoma.test" || ko "PrivaPub cannot resolve akuser" + +echo " follows" +acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/accounts/$alice_on_a/follow" +until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/accounts/relationships?id[]=$alice_on_a" | j "print(d[0][\"following\"])")" = "True" ]' && ok "akuser follows alice_akkoma (Accept arrived)" || ko "Akkoma's follow not accepted" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$ak_on_p/follow" +until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$ak_on_p" | j "print(d[0][\"following\"])")" = "True" ]' && ok "alice_akkoma follows akuser (Accept arrived)" || ko "PrivaPub's follow not accepted" + +echo " posts" +p_post=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=Hello Akkoma from PrivaPub&visibility=public') +p_post_id=$(echo "$p_post" | j "print(d['id'])"); p_post_uri=$(echo "$p_post" | j "print(d['uri'])") +until_true 30 '[ "$(a_status_by_uri "$alice_on_a" "$p_post_uri")" != "null" ]' && ok "alice_akkoma's post reaches Akkoma" || ko "post missing on Akkoma" +p_post_on_a=$(a_status_by_uri "$alice_on_a" "$p_post_uri" | j "print(d['id'])") +a_post=$(acurl -X POST -H "$KH" "$AK/api/v1/statuses" -d 'status=Hello PrivaPub from Akkoma&visibility=public') +a_post_id=$(echo "$a_post" | j "print(d['id'])") +until_true 30 '[ -n "$(p_home_id "Hello PrivaPub from Akkoma")" ]' && ok "akuser's post reaches alice_akkoma's home" || ko "Akkoma's post missing on PrivaPub" +a_post_on_p=$(p_home_id "Hello PrivaPub from Akkoma") +a_created=$(echo "$a_post" | j "print(d['created_at'][:19])") +[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$a_post_on_p" | j "print(d['created_at'][:19])")" = "$a_created" ] \ + && ok "akuser's post keeps its published time" || ko "published time changed on PrivaPub" +cw_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=behind a warning&spoiler_text=spoilers&visibility=public' | j "print(d['uri'])") +until_true 30 '[ "$(a_status_by_uri "$alice_on_a" "$cw_uri" | j "print(d and d[\"spoiler_text\"]==\"spoilers\" and d[\"sensitive\"])")" = "True" ]' && ok "a content warning survives to Akkoma" || ko "content warning lost on Akkoma" +fo_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=only for followers&visibility=private' | j "print(d['uri'])") +until_true 30 '[ "$(a_status_by_uri "$alice_on_a" "$fo_uri" | j "print(d and d[\"visibility\"])")" = "private" ]' && ok "a followers-only post reaches Akkoma as private" || ko "followers-only post missing or widened on Akkoma" + +echo " replies" +acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses" -d "status=@alice_akkoma@privapub.test replying from Akkoma&in_reply_to_id=$p_post_on_a&visibility=public" +until_true 30 'curl -s -H "$PH" "$P/api/v1/notifications" | j "print(any(n[\"type\"]==\"mention\" for n in d))" | grep -q True' && ok "akuser's reply notifies alice_akkoma" || ko "reply did not notify" +until_true 15 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id/context" | j "print(len(d[\"descendants\"]))")" -ge 1 ]' && ok "the reply threads under alice_akkoma's post" || ko "reply not threaded on PrivaPub" +curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d "status=@akuser@akkoma.test replying from PrivaPub&in_reply_to_id=$a_post_on_p&visibility=public" +until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post_id/context" | j "print(len(d[\"descendants\"]))")" -ge 1 ]' && ok "alice_akkoma's reply threads under akuser's post" || ko "outbound reply not threaded on Akkoma" + +echo " likes, boosts and reactions" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$a_post_on_p/favourite" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$a_post_on_p/reblog" +until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post_id" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "1 1" ]' && ok "alice_akkoma's like and boost count on Akkoma" || ko "like or boost not counted on Akkoma" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$a_post_on_p/unfavourite" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$a_post_on_p/unreblog" +until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post_id" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "0 0" ]' && ok "alice_akkoma's unlike and unboost reach Akkoma" || ko "undo of like or boost not applied on Akkoma" +acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses/$p_post_on_a/favourite" +acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses/$p_post_on_a/reblog" +until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "1 1" ]' && ok "akuser's like and boost count on PrivaPub" || ko "like or boost not counted on PrivaPub" +acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses/$p_post_on_a/unfavourite" +acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses/$p_post_on_a/unreblog" +until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "0 0" ]' && ok "akuser's unlike and unboost reach PrivaPub" || ko "undo of like or boost not applied on PrivaPub" +acurl -o /dev/null -X PUT -H "$KH" "$AK/api/v1/pleroma/statuses/$p_post_on_a/reactions/%F0%9F%8E%89" +until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id" | j "print(any(r[\"name\"]==\"🎉\" and r[\"count\"]==1 for r in d.get(\"emoji_reactions\") or []))")" = "True" ]' \ + && ok "akuser's 🎉 (EmojiReact) arrives as a reaction" || ko "Akkoma's reaction missing on PrivaPub" +acurl -o /dev/null -X DELETE -H "$KH" "$AK/api/v1/pleroma/statuses/$p_post_on_a/reactions/%F0%9F%8E%89" +until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id" | j "print(len(d.get(\"emoji_reactions\") or []))")" = "0" ]' && ok "akuser's withdrawn reaction is gone" || ko "withdrawn reaction still on PrivaPub" +curl -s -o /dev/null -X PUT -H "$PH" "$P/api/v1/pleroma/statuses/$a_post_on_p/reactions/%F0%9F%91%8D" +until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post_id" | j "print(any(r[\"name\"]==\"👍\" and r[\"count\"]==1 for r in (d.get(\"pleroma\") or {}).get(\"emoji_reactions\") or d.get(\"emoji_reactions\") or []))")" = "True" ]' \ + && ok "alice_akkoma's 👍 is a reaction on Akkoma" || ko "PrivaPub's reaction missing on Akkoma" +curl -s -o /dev/null -X DELETE -H "$PH" "$P/api/v1/pleroma/statuses/$a_post_on_p/reactions/%F0%9F%91%8D" +until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post_id" | j "print(len((d.get(\"pleroma\") or {}).get(\"emoji_reactions\") or d.get(\"emoji_reactions\") or []))")" = "0" ]' && ok "alice_akkoma's withdrawn reaction is gone from Akkoma" || ko "withdrawn reaction still on Akkoma" + +echo " direct messages" +curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d 'status=@akuser@akkoma.test a secret for Akkoma&visibility=direct' +until_true 30 'acurl -H "$KH" "$AK/api/v1/conversations" | grep -q "a secret for Akkoma"' && ok "alice_akkoma's DM reaches akuser" || ko "DM missing on Akkoma" +# Akkoma's Linkify never takes @user@host.test for a mention, so its DM names alice with Pleroma's to[] instead +acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses" -d 'status=a secret for PrivaPub&visibility=direct&to[]=alice_akkoma@privapub.test' +until_true 30 'curl -s -H "$PH" "$P/api/v1/conversations" | grep -q "a secret for PrivaPub"' && ok "akuser's DM reaches alice_akkoma" || ko "DM missing on PrivaPub" +! curl -s "$P/api/v1/timelines/public" | grep -q "a secret for PrivaPub" && ok "the DM is not on PrivaPub's public timeline" || ko "DM leaked to the public timeline" + +echo " polls" +p_poll_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=cats or dogs&visibility=public&poll[options][]=cats&poll[options][]=dogs&poll[expires_in]=3600' | j "print(d['uri'])") +until_true 30 '[ "$(a_status_by_uri "$alice_on_a" "$p_poll_uri" | j "print(len(d[\"poll\"][\"options\"]))")" = "2" ]' && ok "alice_akkoma's poll reaches Akkoma as a poll" || ko "poll missing on Akkoma" +p_poll_on_a=$(a_status_by_uri "$alice_on_a" "$p_poll_uri" | j "print(d['poll']['id'])") +acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/polls/$p_poll_on_a/votes" -d 'choices[]=1' +until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/$alice_self/statuses" | j "print(next(s[\"poll\"][\"options\"][1][\"votes_count\"] for s in d if s[\"uri\"]==\"$p_poll_uri\"))")" = "1" ]' && ok "akuser's vote counts on PrivaPub" || ko "vote not counted on PrivaPub" +a_poll=$(acurl -X POST -H "$KH" "$AK/api/v1/statuses" -d 'status=tea or coffee, Akkoma asks&visibility=public&poll[options][]=tea&poll[options][]=coffee&poll[expires_in]=3600' | j "print(d['id'])") +until_true 30 'curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(any(s[\"poll\"] and \"Akkoma asks\" in s[\"content\"] for s in d))" | grep -q True' && ok "akuser's poll reaches PrivaPub as a poll" || ko "poll missing on PrivaPub" +a_poll_on_p=$(curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(next(s['poll']['id'] for s in d if s['poll'] and 'Akkoma asks' in s['content']))") +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/polls/$a_poll_on_p/votes" -d 'choices[]=0' +until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_poll" | j "print(d[\"poll\"][\"options\"][0][\"votes_count\"])")" = "1" ]' && ok "alice_akkoma's vote counts on Akkoma" || ko "vote not counted on Akkoma" + +echo " quotes" +a_quote=$(acurl -X POST -H "$KH" "$AK/api/v1/statuses" -d "status=quoting PrivaPub&visibility=public"e_id=$p_post_on_a" | j "print(d['id'])") +until_true 30 'curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(any(\"quoting PrivaPub\" in s[\"content\"] and (s.get(\"quote\") or {}).get(\"state\")==\"accepted\" for s in d))" | grep -q True' \ + && ok "akuser's quote arrives as an accepted quote" || ko "Akkoma's quote not understood" +p_quote_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=quoting Akkoma&visibility=public"ed_status_id=$a_post_on_p" | j "print(d['uri'])") +until_true 30 '[ "$(a_status_by_uri "$alice_on_a" "$p_quote_uri" | j "print(d and ((d.get(\"quote\") or {}).get(\"id\") or d.get(\"quote_id\") or (d.get(\"pleroma\") or {}).get(\"quote_id\")) == \"$a_post_id\")")" = "True" ]' \ + && ok "alice_akkoma's quote is a quote on Akkoma" || ko "quote not understood by Akkoma" + +echo " media" +make_png "$work/red.png" +p_media=$(curl -s -X POST -H "$PH" "$P/api/v2/media" -F "file=@$work/red.png;type=image/png" -F 'description=a red square' | j "print(d['id'])") +p_media_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=a picture&visibility=public&media_ids[]=$p_media" | j "print(d['uri'])") +until_true 30 '[ "$(a_status_by_uri "$alice_on_a" "$p_media_uri" | j "print(d[\"media_attachments\"][0][\"description\"])")" = "a red square" ]' && ok "an image with alt text reaches Akkoma" || ko "image or alt text missing on Akkoma" +a_media=$(acurl -X POST -H "$KH" "$AK/api/v1/media" -F "file=@$work/red.png;type=image/png" -F 'description=a red square from Akkoma' | j "print(d['id'])") +acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses" -d "status=a picture from Akkoma&visibility=public&media_ids[]=$a_media" +until_true 30 'curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(any(\"a picture from Akkoma\" in s[\"content\"] and s[\"media_attachments\"] and \"/media/proxy/\" in s[\"media_attachments\"][0][\"url\"] and s[\"media_attachments\"][0][\"description\"]==\"a red square from Akkoma\" for s in d))" | grep -q True' \ + && ok "an image with alt text from Akkoma arrives through our proxy" || ko "Akkoma's image missing, unproxied or without alt text" + +echo " edits and deletes" +curl -s -o /dev/null -X PUT -H "$PH" "$P/api/v1/statuses/$p_post_id" -d 'status=Hello Akkoma from PrivaPub, edited' +until_true 30 'acurl -H "$KH" "$AK/api/v1/statuses/$p_post_on_a" | grep -q "edited"' && ok "alice_akkoma's edit reaches Akkoma" || ko "edit not applied on Akkoma" +acurl -o /dev/null -X PUT -H "$KH" "$AK/api/v1/statuses/$a_post_id" -d 'status=Hello PrivaPub from Akkoma, edited' +until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$a_post_on_p/history" | j "print(len(d))")" = "2" ]' && ok "akuser's edit reaches PrivaPub with its history" || ko "Akkoma's edit not applied on PrivaPub" +cw_on_a=$(a_status_by_uri "$alice_on_a" "$cw_uri" | j "print(d['id'])") +cw_id=$(curl -s -H "$PH" "$P/api/v1/accounts/$alice_self/statuses" | j "print(next(s['id'] for s in d if s['uri']=='$cw_uri'))") +curl -s -o /dev/null -X DELETE -H "$PH" "$P/api/v1/statuses/$cw_id" +until_true 30 '[ "$(acurl -o /dev/null -w "%{http_code}" -H "$KH" "$AK/api/v1/statuses/$cw_on_a")" = "404" ]' && ok "alice_akkoma's delete reaches Akkoma" || ko "delete not applied on Akkoma" +acurl -o /dev/null -X DELETE -H "$KH" "$AK/api/v1/statuses/$a_poll" +until_true 30 '! curl -s -H "$PH" "$P/api/v1/timelines/home" | grep -q "Akkoma asks"' && ok "akuser's delete reaches PrivaPub" || ko "Akkoma's delete not applied on PrivaPub" + +echo " blocks and unfollows" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$ak_on_p/unfollow" +until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/accounts/relationships?id[]=$alice_on_a" | j "print(d[0][\"followed_by\"])")" = "False" ]' && ok "alice_akkoma's unfollow reaches Akkoma" || ko "unfollow not applied on Akkoma" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$ak_on_p/block" +# Akkoma never reports a remote blocker as blocked_by, so the block is read from its user_relationships (1 is a block) +a_blocked() { podman exec pasture-postgres psql -U pasture -d akkoma -tAc "select count(*) from user_relationships r join users a on a.id=r.source_id join users b on b.id=r.target_id where r.relationship_type=1 and a.nickname='alice_akkoma@privapub.test' and b.nickname='akuser'"; } +until_true 30 '[ "$(a_blocked)" = "1" ]' && ok "alice_akkoma's block reaches Akkoma" || ko "block not applied on Akkoma" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$ak_on_p/unblock" +until_true 30 '[ "$(a_blocked)" = "0" ]' && ok "alice_akkoma's unblock reaches Akkoma" || ko "unblock not applied on Akkoma" + +echo " statistics" +stats_check akkoma.test akkoma diff --git a/tools/pasture/scenarios/lemmy.sh b/tools/pasture/scenarios/lemmy.sh index aae0900..b382b4b 100644 --- a/tools/pasture/scenarios/lemmy.sh +++ b/tools/pasture/scenarios/lemmy.sh @@ -13,6 +13,8 @@ lm() { } # the posts Lemmy holds in a community, and the one whose ap_id is given lm_posts() { lm GET "post/list?community_id=$1&sort=new&limit=50" | j "print(json.dumps([p['post'] for p in (d.get('items') or d.get('posts') or [])]))"; } +# 1 once Lemmy runs a send worker for privapub.test +lm_worker() { podman exec pasture-postgres psql -U pasture -d lemmy -tAc "select count(*) from federation_queue_state q join instance i on i.id=q.instance_id where i.domain='privapub.test'"; } lm_post_by_ap() { lm_posts "$1" | j "print(json.dumps(next((p for p in d if p['ap_id']=='$2'), None)))"; } echo "lemmy" @@ -30,6 +32,9 @@ dogs_gid=$(curl -s -X POST $P/clientapi/group/insert -H 'Content-Type: applicati -d "{\"avatarId\":\"$alice_id\",\"userName\":\"$dogs\",\"name\":\"Pasture dogs\",\"description\":\"a PrivaPub community\",\"isCommunity\":true}" | j "print(d['id'])") dogs_on_lm=$(lm GET "resolve_object?q=!$dogs@privapub.test" | j "print(d['community']['id'])") [ -n "$dogs_on_lm" ] && ok "Lemmy resolves a PrivaPub community" || ko "Lemmy cannot resolve the PrivaPub community" +# Lemmy 1.0 starts its send worker for a newly seen server at the newest activity and never sends anything queued for +# that server before then, so on a clean pasture the first follow waits for the worker (up to a minute) +until_true 45 '[ "$(lm_worker)" = "1" ]' || true lm POST community/follow "{\"community_id\":$dogs_on_lm,\"follow\":true}" >/dev/null until_true 30 '[ "$(lm GET "community?id=$dogs_on_lm" | j "print(((d[\"community_view\"].get(\"community_actions\") or {}).get(\"follow_state\") or \"\").lower())")" = "accepted" ]' \ && ok "lemmyuser follows the PrivaPub community (Accept arrived)" || ko "Lemmy's community follow not accepted" diff --git a/tools/pasture/scenarios/sharkey.sh b/tools/pasture/scenarios/sharkey.sh new file mode 100644 index 0000000..f38a8b5 --- /dev/null +++ b/tools/pasture/scenarios/sharkey.sh @@ -0,0 +1,25 @@ +# Sharkey 2025.4: Misskey's scenario under Sharkey's name, then what only Sharkey does: edits both ways, and quotes that +# carry a FEP-e232 Link tag instead of `quote`. +MISSKEY_NAME=sharkey +. "$here/scenarios/misskey.sh" +unset MISSKEY_NAME + +echo " sharkey only" +# Misskey's scenario ends unfollowed and blocked, so follow again both ways +curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/accounts/$mk_on_p/follow" +mk following/create "{\"userId\":\"$alice_on_mk\"}" >/dev/null +until_true 30 '[ "$(mk_relation "$alice_on_mk" isFollowed)$(mk_relation "$alice_on_mk" isFollowing)" = "TrueTrue" ]' \ + && ok "alice and mkuser follow each other again" || ko "refollow after the block failed" +sk_note=$(mk notes/create '{"text":"before the edit","visibility":"public"}' | j "print(d['createdNote']['id'])") +until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | grep -q "before the edit"' && ok "a Sharkey note reaches alice" || ko "Sharkey note missing on PrivaPub" +mk notes/edit "{\"editId\":\"$sk_note\",\"text\":\"after the edit\",\"visibility\":\"public\"}" >/dev/null +until_true 30 '[ "$(curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(any(\"after the edit\" in s[\"content\"] and s[\"edited_at\"] for s in d))")" = "True" ]' \ + && ok "mkuser's edit arrives as an edit" || ko "Sharkey edit not applied on PrivaPub" +k_edit=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d 'status=PrivaPub before the edit&visibility=public') +k_edit_id=$(echo "$k_edit" | j "print(d['id'])"); k_edit_uri=$(echo "$k_edit" | j "print(d['uri'])") +until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_edit_uri")" != "null" ]' || true +curl -s -o /dev/null -X PUT -H "$KH" "$P/api/v1/statuses/$k_edit_id" -d 'status=PrivaPub after the edit' +until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_edit_uri" | j "print(d and d[\"text\"])")" = "PrivaPub after the edit" ]' \ + && ok "alice's edit reaches Sharkey" || ko "PrivaPub edit not applied on Sharkey" +quote_tag=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'var p=db.Post.findOne({Text:/quoting PrivaPub/}, {}, {sort:{_id:-1}}); var r=p && db.ObjectRecord.findOne({PostId:p._id.toHexString()}); print(r ? /"type"\s*:\s*"Link"/.test(r.Raw) : "none")') +[ "$quote_tag" = "true" ] && ok "Sharkey's quote carries a FEP-e232 Link tag, and is understood" || ko "Sharkey's quote record has no Link tag ($quote_tag)"