Edit history from formerRepresentations
Pleroma and Akkoma send a post's earlier versions with it. A post PrivaPub first meets after its edits now has its history, and an edit that carries them brings the versions missed in between; each is read as the post itself is. The Akkoma scenario checks a post fetched after two edits by an account no persona follows. 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
02928caac1
commit
679463eb3f
12 files changed
+280
-16
No files matched your search
@@ -518,7 +518,7 @@ tools/pasture/run.sh down # removes e
|
|||||||
shipped in the release (CAStore, certifi), so the entrypoint appends Caddy's CA there. `pleroma_ctl` passes its
|
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
|
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
|
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.
|
read from its `user_relationships`. `akkoma_token <user>` signs one of its users in. 45 checks.
|
||||||
- **Lemmy (1.0.0-beta.2):** the backend alone on the shared Postgres, its admin made by `setup` in the generated
|
- **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
|
`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
|
`DANGER_FEDERATION_ALLOW_LOCAL_IP=1`; 0.19 cannot join (its rustls trusts only its bundled roots). Its API is
|
||||||
|
|||||||
@@ -248,6 +248,8 @@ tags.
|
|||||||
content, one per choice, counted once per voter. Counts are refreshed with an `Update{Question}` at most every three
|
content, one per choice, counted once per voter. Counts are refreshed with an `Update{Question}` at most every three
|
||||||
minutes. Our own votes on other servers' polls are sent the same way to the poll's author only, without `published`.
|
minutes. Our own votes on other servers' polls are sent the same way to the poll's author only, without `published`.
|
||||||
An incoming `Update` without a newer `updated` only refreshes counts and details; it is never recorded as an edit.
|
An incoming `Update` without a newer `updated` only refreshes counts and details; it is never recorded as an edit.
|
||||||
|
A post's earlier versions in `formerRepresentations` (Pleroma, Akkoma) are its edit history, read as the post itself is:
|
||||||
|
a post first met after its edits has them, and an edit that carries them brings the versions missed in between.
|
||||||
|
|
||||||
**Quotes** (FEP-044f, with the older keys):
|
**Quotes** (FEP-044f, with the older keys):
|
||||||
- **Received.** A quote is read from `quote`, `quoteUrl`, `quoteUri`, `_misskey_quote` or a FEP-e232 `Link` tag.
|
- **Received.** A quote is read from `quote`, `quoteUrl`, `quoteUri`, `_misskey_quote` or a FEP-e232 `Link` tag.
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.Federation.Objects;
|
||||||
|
using PrivaPub.Models.Post;
|
||||||
|
using PrivaPub.Models.Social;
|
||||||
|
using PrivaPub.Tests.Support;
|
||||||
|
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Federation
|
||||||
|
{
|
||||||
|
// Pleroma's and Akkoma's edited posts carry their earlier versions (formerRepresentations): a post first met after its
|
||||||
|
// edits has its history, and an edit tells the ones missed in between
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public sealed class FormerRepresentationsTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
Harness _harness;
|
||||||
|
|
||||||
|
public async ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||||
|
_harness = await Harness.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_harness != default)
|
||||||
|
await _harness.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
|
||||||
|
static readonly DateTime Start = DateTime.UtcNow.AddHours(-1);
|
||||||
|
|
||||||
|
static JsonObject Version(string text, int minutes) => new()
|
||||||
|
{
|
||||||
|
["type"] = "Note", ["content"] = $"<p>{text}</p>", ["published"] = Start.ToString("O"),
|
||||||
|
["updated"] = minutes == 0 ? default : Start.AddMinutes(minutes).ToString("O")
|
||||||
|
};
|
||||||
|
|
||||||
|
async Task<(RemoteActor Carol, JsonObject Note)> Followed(string text, int minutes, params JsonObject[] former)
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var (_, alice) = await _harness.Persona("alice");
|
||||||
|
var carol = new RemoteActor(_harness.Peer, "carol");
|
||||||
|
await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = carol.Id, State = FollowState.Accepted }, token);
|
||||||
|
var note = Version(text, minutes);
|
||||||
|
note["id"] = $"{Origin(carol)}/objects/{Guid.NewGuid():N}";
|
||||||
|
note["attributedTo"] = carol.Id;
|
||||||
|
note["to"] = new JsonArray(Addressing.Public);
|
||||||
|
note["cc"] = new JsonArray($"{carol.Id}/followers");
|
||||||
|
if (former.Length > 0)
|
||||||
|
note["formerRepresentations"] = new JsonObject { ["type"] = "OrderedCollection", ["totalItems"] = former.Length, ["orderedItems"] = new JsonArray(former) };
|
||||||
|
return (carol, note);
|
||||||
|
}
|
||||||
|
|
||||||
|
Task Deliver(RemoteActor carol, string type, JsonObject note) => _harness.Deliver(carol, "/human-centipede", new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = $"{Origin(carol)}/activities/{Guid.NewGuid():N}", ["type"] = type, ["actor"] = carol.Id,
|
||||||
|
["to"] = new JsonArray(Addressing.Public), ["object"] = note.DeepClone()
|
||||||
|
});
|
||||||
|
|
||||||
|
Task<Post> Stored(JsonObject note) =>
|
||||||
|
DB.Default.Find<Post>().Match(p => p.ObjectURI == note["id"]!.GetValue<string>()).ExecuteSingleAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_post_first_met_after_its_edits_has_its_history()
|
||||||
|
{
|
||||||
|
var (carol, note) = await Followed("third", 40, Version("second", 20), Version("first", 0));
|
||||||
|
|
||||||
|
await Deliver(carol, "Create", note);
|
||||||
|
|
||||||
|
var post = await Stored(note);
|
||||||
|
Assert.Equal(["<p>first</p>", "<p>second</p>"], post.Revisions.Select(r => r.ContentHtml));
|
||||||
|
Assert.Equal("<p>third</p>", post.ContentHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_edit_tells_the_versions_missed_in_between()
|
||||||
|
{
|
||||||
|
var (carol, note) = await Followed("first", 0);
|
||||||
|
await Deliver(carol, "Create", note);
|
||||||
|
|
||||||
|
var edited = (JsonObject)note.DeepClone();
|
||||||
|
edited["content"] = "<p>third</p>";
|
||||||
|
edited["updated"] = Start.AddMinutes(40).ToString("O");
|
||||||
|
edited["formerRepresentations"] = new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "OrderedCollection", ["totalItems"] = 2, ["orderedItems"] = new JsonArray(Version("second", 20), Version("first", 0))
|
||||||
|
};
|
||||||
|
await Deliver(carol, "Update", edited);
|
||||||
|
|
||||||
|
var post = await Stored(note);
|
||||||
|
Assert.Equal(["<p>first</p>", "<p>second</p>"], post.Revisions.Select(r => r.ContentHtml));
|
||||||
|
Assert.Equal("<p>third</p>", post.ContentHtml);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,6 +90,36 @@ namespace PrivaPub.Tests.Federation
|
|||||||
Assert.Equal(["https://privapub.test/peasants/flor_pp98"], note.Cc);
|
Assert.Equal(["https://privapub.test/peasants/flor_pp98"], note.Cc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pleroma's edited post carries its earlier versions (formerRepresentations, newest first)
|
||||||
|
[Fact]
|
||||||
|
public void Reads_the_earlier_versions_of_a_pleroma_edit()
|
||||||
|
{
|
||||||
|
var note = NoteParser.Parse(JsonNode.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "pleroma", "edited.json"))));
|
||||||
|
|
||||||
|
var before = Assert.Single(note.FormerRepresentations);
|
||||||
|
Assert.StartsWith("tomorrow library garden bridge", before.ContentHtml);
|
||||||
|
Assert.Equal(new DateTime(2026, 10, 5, 0, 49, 58, DateTimeKind.Utc).AddTicks(8_600_710), before.EditedAt);
|
||||||
|
Assert.Null(before.SpoilerText);
|
||||||
|
Assert.EndsWith("(edited)", note.ContentHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Earlier_versions_come_oldest_first_each_read_as_the_post_is()
|
||||||
|
{
|
||||||
|
var note = NoteParser.Parse(JsonNode.Parse("""
|
||||||
|
{"id":"https://pl.example/objects/1","type":"Note","attributedTo":"https://pl.example/users/carol","content":"<p>third</p>",
|
||||||
|
"published":"2026-10-06T08:00:00Z","updated":"2026-10-06T09:00:00Z",
|
||||||
|
"formerRepresentations":{"type":"OrderedCollection","totalItems":2,"orderedItems":[
|
||||||
|
{"type":"Note","content":"<p>second</p>","summary":"careful","published":"2026-10-06T08:00:00Z","updated":"2026-10-06T08:30:00Z"},
|
||||||
|
{"type":"Note","content":"<p>first<script>x</script></p>","summary":"","published":"2026-10-06T08:00:00Z"}]}}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
Assert.Equal(["<p>first</p>", "<p>second</p>"], note.FormerRepresentations.Select(r => r.ContentHtml));
|
||||||
|
Assert.Equal([new DateTime(2026, 10, 6, 8, 0, 0, DateTimeKind.Utc), new DateTime(2026, 10, 6, 8, 30, 0, DateTimeKind.Utc)],
|
||||||
|
note.FormerRepresentations.Select(r => r.EditedAt));
|
||||||
|
Assert.Equal([(default(string), false), ("careful", true)], note.FormerRepresentations.Select(r => (r.SpoilerText, r.HasContentWarning)));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Reads_a_lemmy_page_with_a_title()
|
public void Reads_a_lemmy_page_with_a_title()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"actor": "https://pleroma.test/users/flor_pl97",
|
||||||
|
"attachment": [],
|
||||||
|
"attributedTo": "https://pleroma.test/users/flor_pl97",
|
||||||
|
"cc": [
|
||||||
|
"https://pleroma.test/users/flor_pl97/followers"
|
||||||
|
],
|
||||||
|
"content": "mountain late window map bright postcard (p10) (edited)",
|
||||||
|
"context": "https://pleroma.test/contexts/891653c5-91ba-43f3-b3ef-b066caa3fa4d",
|
||||||
|
"conversation": "https://pleroma.test/contexts/891653c5-91ba-43f3-b3ef-b066caa3fa4d",
|
||||||
|
"formerRepresentations": {
|
||||||
|
"orderedItems": [
|
||||||
|
{
|
||||||
|
"actor": "https://pleroma.test/users/flor_pl97",
|
||||||
|
"attachment": [],
|
||||||
|
"attributedTo": "https://pleroma.test/users/flor_pl97",
|
||||||
|
"cc": [
|
||||||
|
"https://pleroma.test/users/flor_pl97/followers"
|
||||||
|
],
|
||||||
|
"content": "tomorrow library garden bridge the festival bicycle together garden early quiet morning library the postcard tomorrow mountain the small rain postcard warm garden market map mountain map (p10)",
|
||||||
|
"contentMap": {
|
||||||
|
"en": "tomorrow library garden bridge the festival bicycle together garden early quiet morning library the postcard tomorrow mountain the small rain postcard warm garden market map mountain map (p10)"
|
||||||
|
},
|
||||||
|
"context": "https://pleroma.test/contexts/891653c5-91ba-43f3-b3ef-b066caa3fa4d",
|
||||||
|
"conversation": "https://pleroma.test/contexts/891653c5-91ba-43f3-b3ef-b066caa3fa4d",
|
||||||
|
"published": "2026-10-05T00:49:58.860071Z",
|
||||||
|
"repliesCount": 2,
|
||||||
|
"source": {
|
||||||
|
"content": "tomorrow library garden bridge the festival bicycle together garden early quiet morning library the postcard tomorrow mountain the small rain postcard warm garden market map mountain map (p10)",
|
||||||
|
"mediaType": "text/plain"
|
||||||
|
},
|
||||||
|
"summary": "",
|
||||||
|
"tag": [],
|
||||||
|
"to": [
|
||||||
|
"https://www.w3.org/ns/activitystreams#Public"
|
||||||
|
],
|
||||||
|
"type": "Note"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalItems": 1,
|
||||||
|
"type": "OrderedCollection"
|
||||||
|
},
|
||||||
|
"id": "https://pleroma.test/objects/6101c4fd-ed46-461c-a3fe-9742852f47da",
|
||||||
|
"published": "2026-10-05T00:49:58.860071Z",
|
||||||
|
"replies": {
|
||||||
|
"first": {
|
||||||
|
"id": "https://pleroma.test/objects/6101c4fd-ed46-461c-a3fe-9742852f47da/replies?page=true",
|
||||||
|
"orderedItems": [
|
||||||
|
"https://pleroma.test/objects/b7b7c066-51a8-46d1-8919-07b87f158887",
|
||||||
|
"https://privapub.test/peasants/vera_pp40/scribbles/6ac2f4520c61eaf3ab241d35"
|
||||||
|
],
|
||||||
|
"partOf": "https://pleroma.test/objects/6101c4fd-ed46-461c-a3fe-9742852f47da/replies",
|
||||||
|
"type": "OrderedCollectionPage"
|
||||||
|
},
|
||||||
|
"id": "https://pleroma.test/objects/6101c4fd-ed46-461c-a3fe-9742852f47da/replies",
|
||||||
|
"totalItems": 2,
|
||||||
|
"type": "OrderedCollection"
|
||||||
|
},
|
||||||
|
"repliesCount": 2,
|
||||||
|
"source": {
|
||||||
|
"content": "mountain late window map bright postcard (p10) (edited)",
|
||||||
|
"mediaType": "text/plain"
|
||||||
|
},
|
||||||
|
"summary": "",
|
||||||
|
"tag": [],
|
||||||
|
"to": [
|
||||||
|
"https://www.w3.org/ns/activitystreams#Public"
|
||||||
|
],
|
||||||
|
"type": "Note",
|
||||||
|
"updated": "2026-10-05T00:52:40.105051Z"
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
{
|
{
|
||||||
public static class RemoteEdits
|
public static class RemoteEdits
|
||||||
{
|
{
|
||||||
public const int MaxRevisions = 20;
|
public const int MaxRevisions = NoteParser.MaxRevisions;
|
||||||
|
|
||||||
//newer than what we hold; or, for a first edit, no older and actually different, because GoToSocial's timestamps are
|
//newer than what we hold; or, for a first edit, no older and actually different, because GoToSocial's timestamps are
|
||||||
//whole seconds and an edit made in the second the post was published carries updated == published
|
//whole seconds and an edit made in the second the post was published carries updated == published
|
||||||
@@ -47,6 +47,10 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//the author's server's own history (Pleroma's, Akkoma's) holds the version we had, and the edits we missed
|
||||||
|
if (note.FormerRepresentations.Count > 0)
|
||||||
|
post.Revisions = note.FormerRepresentations.ToList();
|
||||||
|
else
|
||||||
post.Revisions.Add(new PostRevision
|
post.Revisions.Add(new PostRevision
|
||||||
{
|
{
|
||||||
Title = post.Title,
|
Title = post.Title,
|
||||||
|
|||||||
@@ -133,7 +133,8 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
AuthorGone = author.AccountState == AvatarAccountState.Deleted,
|
AuthorGone = author.AccountState == AvatarAccountState.Deleted,
|
||||||
CreationDate = note.Published,
|
CreationDate = note.Published,
|
||||||
UpdateDate = note.Updated ?? note.Published,
|
UpdateDate = note.Updated ?? note.Published,
|
||||||
EditedAt = note.Updated
|
EditedAt = note.Updated,
|
||||||
|
Revisions = note.FormerRepresentations.ToList()
|
||||||
};
|
};
|
||||||
await Episode(post, note, author, token);
|
await Episode(post, note, author, token);
|
||||||
return post;
|
return post;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ namespace PrivaPub.Federation.Objects
|
|||||||
public EventDetails Event { get; init; }
|
public EventDetails Event { get; init; }
|
||||||
public PostPlace Place { get; init; }
|
public PostPlace Place { get; init; }
|
||||||
public bool? CommentsEnabled { get; init; }//Lemmy, PieFed and Mbin: false once its moderators lock it; null when not said
|
public bool? CommentsEnabled { get; init; }//Lemmy, PieFed and Mbin: false once its moderators lock it; null when not said
|
||||||
|
public IReadOnlyList<PostRevision> FormerRepresentations { get; init; } = Array.Empty<PostRevision>();//its earlier versions, oldest first
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class NoteParser
|
public static class NoteParser
|
||||||
@@ -59,6 +60,7 @@ namespace PrivaPub.Federation.Objects
|
|||||||
static readonly string[] NoteTypes = { "Note", "Article", "Page", "Question", "Event", "Video", "Audio", "Image", "ChatMessage", "PodcastEpisode" };
|
static readonly string[] NoteTypes = { "Note", "Article", "Page", "Question", "Event", "Video", "Audio", "Image", "ChatMessage", "PodcastEpisode" };
|
||||||
const int MaxAttachments = 16;
|
const int MaxAttachments = 16;
|
||||||
const int MaxTags = 64;
|
const int MaxTags = 64;
|
||||||
|
public const int MaxRevisions = 20;
|
||||||
|
|
||||||
public static NoteDocument Parse(JsonNode node)
|
public static NoteDocument Parse(JsonNode node)
|
||||||
{
|
{
|
||||||
@@ -75,8 +77,7 @@ namespace PrivaPub.Federation.Objects
|
|||||||
var summary = Value(note, "summary");
|
var summary = Value(note, "summary");
|
||||||
var excerpt = ObjectShapes.Text(summary, 1000)
|
var excerpt = ObjectShapes.Text(summary, 1000)
|
||||||
?? (note["preview"] is JsonObject preview && Value(preview, "type") == "Note" ? ObjectShapes.Text(Value(preview, "content"), 1000) : default);
|
?? (note["preview"] is JsonObject preview && Value(preview, "type") == "Note" ? ObjectShapes.Text(Value(preview, "content"), 1000) : default);
|
||||||
var sensitive = note["sensitive"] is JsonValue flag && flag.TryGetValue<bool>(out var marked) && marked;
|
var (sensitive, summaryWarns) = Warns(note, type, summary);
|
||||||
var summaryWarns = type is "Note" or "Question" || sensitive;
|
|
||||||
return new NoteDocument
|
return new NoteDocument
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
@@ -87,7 +88,7 @@ namespace PrivaPub.Federation.Objects
|
|||||||
Title = ObjectShapes.Text(Value(note, "name"), 500),
|
Title = ObjectShapes.Text(Value(note, "name"), 500),
|
||||||
SpoilerText = summaryWarns ? Plain(summary, 500) : default,
|
SpoilerText = summaryWarns ? Plain(summary, 500) : default,
|
||||||
Excerpt = summaryWarns ? default : excerpt,
|
Excerpt = summaryWarns ? default : excerpt,
|
||||||
Sensitive = sensitive || summaryWarns && !string.IsNullOrWhiteSpace(summary),
|
Sensitive = sensitive,
|
||||||
Language = language,
|
Language = language,
|
||||||
Url = UrlOf(note["url"]) ?? id,
|
Url = UrlOf(note["url"]) ?? id,
|
||||||
InReplyTo = Id(note["inReplyTo"]),
|
InReplyTo = Id(note["inReplyTo"]),
|
||||||
@@ -128,10 +129,45 @@ namespace PrivaPub.Federation.Objects
|
|||||||
Audio = ObjectShapes.Audio(type == "PodcastEpisode" ? note["audio"] as JsonObject : note),
|
Audio = ObjectShapes.Audio(type == "PodcastEpisode" ? note["audio"] as JsonObject : note),
|
||||||
Event = ObjectShapes.Event(note),
|
Event = ObjectShapes.Event(note),
|
||||||
Place = ObjectShapes.NotePlace(note),
|
Place = ObjectShapes.NotePlace(note),
|
||||||
CommentsEnabled = note["commentsEnabled"] is JsonValue comments && comments.TryGetValue<bool>(out var enabled) ? enabled : null
|
CommentsEnabled = note["commentsEnabled"] is JsonValue comments && comments.TryGetValue<bool>(out var enabled) ? enabled : null,
|
||||||
|
FormerRepresentations = Former(note, type)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a summary is a content warning only on a Note or a Question, or a post marked sensitive; elsewhere it is an excerpt
|
||||||
|
static (bool Sensitive, bool SummaryWarns) Warns(JsonObject note, string type, string summary)
|
||||||
|
{
|
||||||
|
var marked = note["sensitive"] is JsonValue flag && flag.TryGetValue<bool>(out var value) && value;
|
||||||
|
var warns = type is "Note" or "Question" || marked;
|
||||||
|
return (marked || warns && !string.IsNullOrWhiteSpace(summary), warns);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pleroma's and Akkoma's earlier versions of an edited post (`formerRepresentations`, newest first there), each read as
|
||||||
|
// the post itself is: the newest twenty, oldest first, each dated when it was written
|
||||||
|
static List<PostRevision> Former(JsonObject note, string type)
|
||||||
|
{
|
||||||
|
if (note["formerRepresentations"] is not JsonObject former || former["orderedItems"] is not JsonArray items)
|
||||||
|
return [];
|
||||||
|
return items.OfType<JsonObject>().Take(MaxRevisions * 2)
|
||||||
|
.Select(version =>
|
||||||
|
{
|
||||||
|
var summary = Value(version, "summary");
|
||||||
|
var (sensitive, warns) = Warns(version, type, summary);
|
||||||
|
return new PostRevision
|
||||||
|
{
|
||||||
|
Title = ObjectShapes.Text(Value(version, "name"), 500),
|
||||||
|
SpoilerText = warns ? Plain(summary, 500) : default,
|
||||||
|
ContentHtml = ContentSanitizer.Html(Content(version).Html),
|
||||||
|
HasContentWarning = sensitive,
|
||||||
|
EditedAt = Time(Value(version, "updated")) ?? Time(Value(version, "published")) ?? default
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.Reverse()
|
||||||
|
.OrderBy(r => r.EditedAt)
|
||||||
|
.TakeLast(MaxRevisions)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
// Castopod's PodcastEpisode: its words in `description` (a Note of its own), its sound in `audio`, its title the
|
// Castopod's PodcastEpisode: its words in `description` (a Note of its own), its sound in `audio`, its title the
|
||||||
// sound's name; read as a post with those
|
// sound's name; read as a post with those
|
||||||
static JsonObject Episode(JsonObject episode)
|
static JsonObject Episode(JsonObject episode)
|
||||||
|
|||||||
+4
-3
@@ -411,13 +411,13 @@ collections answer HTML to an ActivityPub `Accept`, so PrivaPub shows no counts
|
|||||||
| Gap | P | Client surface |
|
| Gap | P | Client surface |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Language from `@context[].@language` | P1 | `Status.language` |
|
| Language from `@context[].@language` | P1 | `Status.language` |
|
||||||
| `formerRepresentations` → our revisions | P2 | `/statuses/:id/history` |
|
| `formerRepresentations` → our revisions: **done 2026-10-06** (a post met after its edits has its history; an edit brings the versions missed) | P2 | `/statuses/:id/history` |
|
||||||
| `context`/`conversation` threading; parents and quotes we could not fetch, kept as URIs | P2 | `pleroma.context`; `akkoma.in_reply_to_apid`, `akkoma.quote_apid` precedent |
|
| `context`/`conversation` threading; parents and quotes we could not fetch, kept as URIs | P2 | `pleroma.context`; `akkoma.in_reply_to_apid`, `akkoma.quote_apid` precedent |
|
||||||
| `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 |
|
| `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 |
|
| `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
|
**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:
|
row (45 since 2026-10-06). Akkoma publishes no image, so `tools/pasture/images/akkoma` installs its OTP release, pinned by checksum. Covered:
|
||||||
- discovery and follows both ways;
|
- discovery and follows both ways;
|
||||||
- posts, CW and followers-only posts;
|
- posts, CW and followers-only posts;
|
||||||
- the `published` time kept;
|
- the `published` time kept;
|
||||||
@@ -428,7 +428,8 @@ row. Akkoma publishes no image, so `tools/pasture/images/akkoma` installs its OT
|
|||||||
- polls and votes both ways;
|
- polls and votes both ways;
|
||||||
- quotes both ways (`quote_id` out of its API, `quoteUri` in ours);
|
- quotes both ways (`quote_id` out of its API, `quoteUri` in ours);
|
||||||
- images with alt text both ways;
|
- images with alt text both ways;
|
||||||
- edits with history, and deletes, both ways;
|
- edits with history, and deletes, both ways; a post PrivaPub first meets after two edits, by an account no persona
|
||||||
|
follows, has its history from `formerRepresentations` (2026-10-06);
|
||||||
- unfollow, block and unblock;
|
- unfollow, block and unblock;
|
||||||
- statistics.
|
- statistics.
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -677,7 +677,8 @@ it, raw where it doesn't.
|
|||||||
receiving server, and a signed roll-call listing only them; honoured: a sender's digest of its followers here,
|
receiving server, and a signed roll-call listing only them; honoured: a sender's digest of its followers here,
|
||||||
mended from its list; checked live against Mastodon, all four ways a follow can be lost);
|
mended from its list; checked live against Mastodon, all four ways a follow can be lost);
|
||||||
- `indexable`/`discoverable`/`searchableBy`;
|
- `indexable`/`discoverable`/`searchableBy`;
|
||||||
- edit history from `formerRepresentations`;
|
- edit history from `formerRepresentations`: **done 2026-10-06** (Pleroma's and Akkoma's earlier versions, for a post
|
||||||
|
met after its edits and for the edits missed in between);
|
||||||
- PeerTube reply rules and `ApproveReply`.
|
- PeerTube reply rules and `ApproveReply`.
|
||||||
- **Events:** structured RSVP (`Join`/`Leave` with stable ids): **done 2026-10-05** (owner decision above), checked live
|
- **Events:** structured RSVP (`Join`/`Leave` with stable ids): **done 2026-10-05** (owner decision above), checked live
|
||||||
against Mobilizon.
|
against Mobilizon.
|
||||||
|
|||||||
@@ -17,13 +17,19 @@ akkoma_up() {
|
|||||||
done
|
done
|
||||||
podman exec pasture-akkoma /opt/akkoma/bin/pleroma_ctl user new akuser akuser@akkoma.test --password Akkoma-Pasture-Pass-1 \
|
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
|
--name akuser --admin --assume-yes >/dev/null 2>&1 || true
|
||||||
|
akkoma_token akuser > "$here/.state/akkoma.token" || true
|
||||||
|
echo "akkoma: https://akkoma.test:6443"
|
||||||
|
}
|
||||||
|
|
||||||
|
# akkoma_token <user>: an access token for one of the pasture's users (its password), through a new app and the password
|
||||||
|
# grant
|
||||||
|
akkoma_token() {
|
||||||
local app cid cs
|
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')
|
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)
|
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)
|
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 \
|
site akkoma.test -s -X POST https://akkoma.test:6443/oauth/token --data-urlencode grant_type=password --data-urlencode "username=$1" \
|
||||||
--data-urlencode password=Akkoma-Pasture-Pass-1 --data-urlencode "client_id=$cid" --data-urlencode "client_secret=$cs" \
|
--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' \
|
--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
|
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])" 2>/dev/null
|
||||||
echo "akkoma: https://akkoma.test:6443"
|
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
# Akkoma 3.20: discovery, follows, posts, CW, replies both ways, likes and boosts with their undos, emoji reactions
|
# 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
|
# both ways, DMs, polls, quotes both ways, edits with their history (a post met after its edits too), deletes, media
|
||||||
# kept, blocks, statistics. Akkoma speaks the Mastodon API, with reactions under /api/v1/pleroma.
|
# 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
|
AK=https://akkoma.test:6443
|
||||||
acurl() { curl -sk --resolve akkoma.test:6443:127.0.0.1 "$@"; }
|
acurl() { curl -sk --resolve akkoma.test:6443:127.0.0.1 "$@"; }
|
||||||
|
. "$here/peers/akkoma.sh"
|
||||||
KT=$(cat "$here/.state/akkoma.token" 2>/dev/null)
|
KT=$(cat "$here/.state/akkoma.token" 2>/dev/null)
|
||||||
KH="Authorization: Bearer $KT"
|
KH="Authorization: Bearer $KT"
|
||||||
# what Akkoma holds of an account, newest first; and the one status whose uri is given
|
# what Akkoma holds of an account, newest first; and the one status whose uri is given
|
||||||
@@ -122,6 +124,19 @@ curl -s -o /dev/null -X PUT -H "$PH" "$P/api/v1/statuses/$p_post_id" -d 'status=
|
|||||||
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"
|
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'
|
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"
|
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"
|
||||||
|
# a post PrivaPub first meets after two edits, by an Akkoma account no persona follows, comes with its earlier versions
|
||||||
|
# (formerRepresentations)
|
||||||
|
podman exec pasture-akkoma /opt/akkoma/bin/pleroma_ctl user new akwriter akwriter@akkoma.test --password Akkoma-Pasture-Pass-1 \
|
||||||
|
--name akwriter --assume-yes >/dev/null 2>&1 || true
|
||||||
|
WH="Authorization: Bearer $(akkoma_token akwriter)"
|
||||||
|
written=$(date +%s)
|
||||||
|
w_post=$(acurl -X POST -H "$WH" "$AK/api/v1/statuses" -d "status=written thrice $written&visibility=public" | j "print(d['id'])")
|
||||||
|
acurl -o /dev/null -X PUT -H "$WH" "$AK/api/v1/statuses/$w_post" -d "status=written thrice $written, again"
|
||||||
|
acurl -o /dev/null -X PUT -H "$WH" "$AK/api/v1/statuses/$w_post" -d "status=written thrice $written, at last"
|
||||||
|
w_uri=$(acurl -H "$WH" "$AK/api/v1/statuses/$w_post" | j "print(d['uri'])")
|
||||||
|
w_on_p=$(curl -s -G -H "$PH" "$P/api/v2/search" --data-urlencode "q=$w_uri" -d resolve=true -d type=statuses | j "print(d['statuses'][0]['id'])")
|
||||||
|
[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$w_on_p/history" | j "print([e['content'].split('$written')[-1].strip(' ,</p>') for e in d])")" = "['', 'again', 'at last']" ] \
|
||||||
|
&& ok "a post met after its edits has its history from Akkoma" || ko "a post met after its edits lacks its history ($(curl -s -H "$PH" "$P/api/v1/statuses/$w_on_p/history" | head -c 300))"
|
||||||
cw_on_a=$(a_status_by_uri "$alice_on_a" "$cw_uri" | j "print(d['id'])")
|
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'))")
|
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"
|
curl -s -o /dev/null -X DELETE -H "$PH" "$P/api/v1/statuses/$cw_id"
|
||||||
|
|||||||
Reference in new issue
Block a user