P7: thread backfill reads conversation containers and asks with If-None-Match
Build / Build (push) Failing after 7m52s
Build / Build (push) Failing after 7m52s
The FetchReplies job reads a thread's FEP-171b contextHistory before its FEP-7888 context: the posts each Add (or Forte's plain Create) brought in, read from their own servers. A thread collection read whole keeps its ETag when the document itself changes with every post (it counts them, or holds them all with no further page); the next read sends it as If-None-Match and a 304 ends the job. The ETag is kept as sent, since NodeBB's has no quotes and the typed header drops it. A context naming a post we hold (Forte's first post) is not fetched. Checked in the pasture: Mastodon 4.7.3 answers the second read 304; NodeBB 4.16's unquoted ETag is kept (23/23 in its scenario); a Forte thread completes through its replies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
fb4949b511
commit
62189bf11d
10 files changed
+268
-35
No files matched your search
+5
-3
@@ -394,9 +394,11 @@ Posts with a location (shown to nearby users of this server) never leave the ser
|
|||||||
- **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three
|
- **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three
|
||||||
redirects and read at most 1 MB.
|
redirects and read at most 1 MB.
|
||||||
- **Threads.** A reply's missing parents are fetched, up to 10 levels. When someone here opens a public remote thread,
|
- **Threads.** A reply's missing parents are fetched, up to 10 levels. When someone here opens a public remote thread,
|
||||||
its replies are read from their servers, at most once an hour: the thread's `context` collection (FEP-7888) if it has
|
its replies are read from their servers, at most once an hour: the thread's conversation container (FEP-171b's
|
||||||
one, else its `replies` (PeerTube's `comments`) and theirs, two levels down; 5 pages and 100 posts at most. Only
|
`contextHistory`: the posts its owner's `Add`s brought in) or its `context` collection (FEP-7888) if it has one, else
|
||||||
public and unlisted replies are kept, each fetched from its own origin.
|
its `replies` (PeerTube's `comments`) and theirs, two levels down; 5 pages and 100 posts at most. Only public and
|
||||||
|
unlisted replies are kept, each fetched from its own origin. A thread collection read whole, which counts its posts or
|
||||||
|
holds them all, is asked again with `If-None-Match` and its last ETag; a 304 ends the read.
|
||||||
- **Reading our documents (SecureMode).** privapub.thepra.dev answers ActivityPub GETs only when they are signed, like
|
- **Reading our documents (SecureMode).** privapub.thepra.dev answers ActivityPub GETs only when they are signed, like
|
||||||
Mastodon's authorized fetch; the server's own actors (the instance actor `/peasants/privapub` and the reporter
|
Mastodon's authorized fetch; the server's own actors (the instance actor `/peasants/privapub` and the reporter
|
||||||
`/peasants/privapub_reports`) are the exception, since their keys are needed first.
|
`/peasants/privapub_reports`) are the exception, since their keys are needed first.
|
||||||
|
|||||||
@@ -216,6 +216,103 @@ namespace PrivaPub.Tests.Federation
|
|||||||
Assert.All(_harness.Peer.Requests.Where(r => r.Path.EndsWith("/replies")), r => Assert.False(string.IsNullOrEmpty(r.Signature)));
|
Assert.All(_harness.Peer.Requests.Where(r => r.Path.EndsWith("/replies")), r => Assert.False(string.IsNullOrEmpty(r.Signature)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_conversation_container_is_read_before_the_context_and_only_its_added_posts_taken()
|
||||||
|
{
|
||||||
|
var owner = new RemoteActor(_harness.Peer, "owner");
|
||||||
|
var answerer = new RemoteActor(_harness.Peer, "answerer");
|
||||||
|
var root = PublicNote(owner, "<p>a conversation</p>");
|
||||||
|
var history = NewId(owner, "conversations");
|
||||||
|
root["contextHistory"] = history;
|
||||||
|
root["context"] = NewId(owner, "never");
|
||||||
|
Served(root);
|
||||||
|
var embedded = Served(Reply(answerer, "added whole", root));
|
||||||
|
var referenced = Served(Reply(owner, "added by reference", embedded));
|
||||||
|
var liked = Served(Reply(answerer, "only liked", root));
|
||||||
|
var target = new JsonObject { ["type"] = "OrderedCollection", ["id"] = history, ["attributedTo"] = owner.Id };
|
||||||
|
JsonObject Add(JsonNode activity) => new()
|
||||||
|
{
|
||||||
|
["id"] = NewId(owner, "activities"), ["type"] = "Add", ["actor"] = owner.Id, ["object"] = activity, ["target"] = target.DeepClone()
|
||||||
|
};
|
||||||
|
var create = Served(Create(owner, referenced));
|
||||||
|
create["object"] = IdOf(referenced);
|
||||||
|
Served(create);
|
||||||
|
var byReference = Served(Add(IdOf(create)));
|
||||||
|
Served(new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = history, ["type"] = "OrderedCollection", ["attributedTo"] = owner.Id, ["collectionOf"] = "Activity",
|
||||||
|
["orderedItems"] = new JsonArray(Create(owner, root), Add(Create(answerer, embedded)), IdOf(byReference), Add(Activity(answerer, "Like", JsonValue.Create(IdOf(liked)))))
|
||||||
|
});
|
||||||
|
var held = await Held(root);
|
||||||
|
|
||||||
|
Assert.Equal(1, await RunReplies(held));
|
||||||
|
var stored = await Stored(embedded, referenced, liked);
|
||||||
|
Assert.Equal(new[] { IdOf(embedded), IdOf(referenced) }.Order(), stored.Keys.Order());
|
||||||
|
Assert.Equal(held.ID, stored[IdOf(embedded)].AnsweringToPostId);
|
||||||
|
Assert.Equal(stored[IdOf(embedded)].ID, stored[IdOf(referenced)].AnsweringToPostId);
|
||||||
|
Assert.DoesNotContain(_harness.Peer.Requests, r => r.Path == new Uri(root["context"]!.GetValue<string>()).AbsolutePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_unchanged_context_is_not_read_again_while_it_counts_the_thread()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var poster = new RemoteActor(_harness.Peer, "poster");
|
||||||
|
var answerer = new RemoteActor(_harness.Peer, "answerer");
|
||||||
|
var root = PublicNote(poster, "<p>a topic</p>");
|
||||||
|
var context = NewId(poster, "topics");
|
||||||
|
var path = new Uri(context).AbsolutePath;
|
||||||
|
root["context"] = context;
|
||||||
|
Served(root);
|
||||||
|
var first = Served(Reply(answerer, "first", root));
|
||||||
|
var later = Served(Reply(poster, "later", root));
|
||||||
|
// NodeBB's: every post inline, counted, and an ETag without quotes
|
||||||
|
JsonObject Thread(params JsonObject[] notes) => new()
|
||||||
|
{
|
||||||
|
["id"] = context, ["type"] = "OrderedCollection", ["totalItems"] = notes.Length,
|
||||||
|
["orderedItems"] = new JsonArray(notes.Select(n => (JsonNode)IdOf(n)).ToArray())
|
||||||
|
};
|
||||||
|
_harness.Peer.ServeTagged(path, Thread(root, first).ToJsonString(), "4f930186eb");
|
||||||
|
var held = await Held(root);
|
||||||
|
var handler = new RepliesJobHandler(_harness.Db, _harness.Remote, _harness.RemotePosts, _harness.Queue);
|
||||||
|
|
||||||
|
await handler.Handle(RepliesJobHandler.For(held, 1), token);
|
||||||
|
Assert.Equal("4f930186eb", (await DB.Default.Find<ThreadCollection>().Match(c => c.URI == context).ExecuteFirstAsync(token)).ETag);
|
||||||
|
// the same version: a 304, and nothing read
|
||||||
|
_harness.Peer.ServeTagged(path, Thread(root, first, later).ToJsonString(), "4f930186eb");
|
||||||
|
await handler.Handle(RepliesJobHandler.For(held, 1), token);
|
||||||
|
Assert.Equal("4f930186eb", _harness.Peer.Requests.Last(r => r.Path == path).Headers["If-None-Match"]);
|
||||||
|
Assert.Single(await Stored(first, later));
|
||||||
|
// a new one
|
||||||
|
_harness.Peer.ServeTagged(path, Thread(root, first, later).ToJsonString(), "5a0b");
|
||||||
|
await handler.Handle(RepliesJobHandler.For(held, 1), token);
|
||||||
|
Assert.Equal(2, (await Stored(first, later)).Count);
|
||||||
|
Assert.Equal("5a0b", (await DB.Default.Find<ThreadCollection>().Match(c => c.URI == context).ExecuteFirstAsync(token)).ETag);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_paged_contexts_etag_is_not_kept_since_its_first_page_hides_what_is_added_later()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var poster = new RemoteActor(_harness.Peer, "poster");
|
||||||
|
var root = PublicNote(poster, "<p>a long thread</p>");
|
||||||
|
var context = NewId(poster, "contexts");
|
||||||
|
root["context"] = context;
|
||||||
|
Served(root);
|
||||||
|
var answer = Served(Reply(poster, "on the second page", root));
|
||||||
|
_harness.Peer.ServeTagged(new Uri(context).AbsolutePath, new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = context, ["type"] = "Collection",
|
||||||
|
["first"] = new JsonObject { ["type"] = "CollectionPage", ["items"] = new JsonArray(IdOf(root)), ["next"] = context + "/2" }
|
||||||
|
}.ToJsonString(), "W/\"first\"");
|
||||||
|
Served(new JsonObject { ["id"] = context + "/2", ["type"] = "CollectionPage", ["items"] = new JsonArray(IdOf(answer)) });
|
||||||
|
var held = await Held(root);
|
||||||
|
|
||||||
|
await new RepliesJobHandler(_harness.Db, _harness.Remote, _harness.RemotePosts, _harness.Queue).Handle(RepliesJobHandler.For(held, 1), token);
|
||||||
|
Assert.Single(await Stored(answer));
|
||||||
|
Assert.False(await DB.Default.Find<ThreadCollection>().Match(c => c.URI == context && c.ETag != null).ExecuteAnyAsync(token));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task A_threads_context_collection_brings_every_reply_at_once()
|
public async Task A_threads_context_collection_brings_every_reply_at_once()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ namespace PrivaPub.Tests.Support
|
|||||||
readonly ConcurrentDictionary<string, (string Text, string ContentType)> _documents = new();
|
readonly ConcurrentDictionary<string, (string Text, string ContentType)> _documents = new();
|
||||||
readonly ConcurrentDictionary<string, (int Status, TimeSpan Delay)> _answers = new();
|
readonly ConcurrentDictionary<string, (int Status, TimeSpan Delay)> _answers = new();
|
||||||
readonly ConcurrentDictionary<string, (byte[] Bytes, string ContentType)> _files = new();
|
readonly ConcurrentDictionary<string, (byte[] Bytes, string ContentType)> _files = new();
|
||||||
|
readonly ConcurrentDictionary<string, string> _etags = new();
|
||||||
|
|
||||||
public int Port { get; }
|
public int Port { get; }
|
||||||
public string A => $"http://127.0.0.1:{Port}";
|
public string A => $"http://127.0.0.1:{Port}";
|
||||||
@@ -64,6 +65,15 @@ namespace PrivaPub.Tests.Support
|
|||||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (peer._etags.TryGetValue(key, out var etag))
|
||||||
|
{
|
||||||
|
if (context.Request.Headers.IfNoneMatch.ToString() == etag)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status304NotModified;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.Response.Headers.ETag = etag;
|
||||||
|
}
|
||||||
context.Response.ContentType = document.ContentType;
|
context.Response.ContentType = document.ContentType;
|
||||||
await context.Response.WriteAsync(document.Text.Replace("{A}", peer.A).Replace("{B}", peer.B));
|
await context.Response.WriteAsync(document.Text.Replace("{A}", peer.A).Replace("{B}", peer.B));
|
||||||
});
|
});
|
||||||
@@ -74,6 +84,13 @@ namespace PrivaPub.Tests.Support
|
|||||||
|
|
||||||
public void Serve(string path, string json) => _documents[path] = (json, "application/activity+json");
|
public void Serve(string path, string json) => _documents[path] = (json, "application/activity+json");
|
||||||
|
|
||||||
|
// a document with an ETag: answered 304 to a request that names it in If-None-Match
|
||||||
|
public void ServeTagged(string path, string json, string etag)
|
||||||
|
{
|
||||||
|
_documents[path] = (json, "application/activity+json");
|
||||||
|
_etags[path] = etag;
|
||||||
|
}
|
||||||
|
|
||||||
public void ServeText(string path, string text, string contentType) => _documents[path] = (text, contentType);
|
public void ServeText(string path, string text, string contentType) => _documents[path] = (text, contentType);
|
||||||
|
|
||||||
public void ServeFile(string path, byte[] bytes, string contentType) => _files[path] = (bytes, contentType);
|
public void ServeFile(string path, byte[] bytes, string contentType) => _files[path] = (bytes, contentType);
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ namespace PrivaPub.Federation.Actors
|
|||||||
public interface IRemoteActorService
|
public interface IRemoteActorService
|
||||||
{
|
{
|
||||||
Task<FetchedJson> FetchObject(string uri, CancellationToken token);
|
Task<FetchedJson> FetchObject(string uri, CancellationToken token);
|
||||||
|
// the same, or NotModified while the version `etag` names is still current
|
||||||
|
Task<FetchedJson> FetchObject(string uri, string etag, CancellationToken token) => FetchObject(uri, token);
|
||||||
Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token);
|
Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token);
|
||||||
Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token);
|
Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token);
|
||||||
bool KeyTemporarilyUnavailable(string keyId) => false;
|
bool KeyTemporarilyUnavailable(string keyId) => false;
|
||||||
@@ -50,13 +52,17 @@ namespace PrivaPub.Federation.Actors
|
|||||||
_options = options;
|
_options = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<FetchedJson> FetchObject(string uri, CancellationToken token)
|
public Task<FetchedJson> FetchObject(string uri, CancellationToken token) => FetchObject(uri, default, token);
|
||||||
|
|
||||||
|
public async Task<FetchedJson> FetchObject(string uri, string etag, CancellationToken token)
|
||||||
{
|
{
|
||||||
using var scope = HttpScope.Default("object");
|
using var scope = HttpScope.Default("object");
|
||||||
var signer = await _localActors.GetInstanceActor(token);
|
var signer = await _localActors.GetInstanceActor(token);
|
||||||
var fetched = await Get(uri, signer, token);
|
var fetched = string.IsNullOrEmpty(etag)
|
||||||
if (fetched == default)
|
? await Get(uri, signer, token)
|
||||||
return default;
|
: await _http.GetJsonIfChanged(uri, Accept, etag, request => HttpSignatures.Sign(request, signer, body: null), token);
|
||||||
|
if (fetched == default || fetched.NotModified)
|
||||||
|
return fetched;
|
||||||
|
|
||||||
var id = Text(fetched.Root, "id");
|
var id = Text(fetched.Root, "id");
|
||||||
if (Origin.IsDocumentAt(id, fetched.FinalUri))
|
if (Origin.IsDocumentAt(id, fetched.FinalUri))
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
public sealed record RepliesPayload(string PostId, int Depth);
|
public sealed record RepliesPayload(string PostId, int Depth);
|
||||||
|
|
||||||
// What was said under a remote post on other servers, read when a persona opens its thread, so the thread shows more
|
// What was said under a remote post on other servers, read when a persona opens its thread, so the thread shows more
|
||||||
// than what happened to reach us. The thread's own collection comes first (FEP-7888's `context`, which Mastodon 4.5+
|
// than what happened to reach us. The thread's own collections come first: its conversation container (FEP-171b's
|
||||||
// serves with every reply at any depth); otherwise the post's `replies` (PeerTube's `comments`), and the replies'
|
// `contextHistory`, every activity its owner added to the thread), then its posts (FEP-7888's `context`, which
|
||||||
// own, two levels down. Read with the instance actor's signature, never a persona's, a few pages and at most
|
// Mastodon 4.5+, Mitra and NodeBB serve with every reply at any depth), sent the ETag of the last whole read so an
|
||||||
|
// unchanged thread costs one 304; otherwise the post's `replies` (PeerTube's `comments`, Forte's whole thread), and the
|
||||||
|
// replies' own, two levels down. Read with the instance actor's signature, never a persona's, a few pages and at most
|
||||||
// MaxItems posts a job; each one is fetched from its own server and stored like any post fetched for a thread
|
// MaxItems posts a job; each one is fetched from its own server and stored like any post fetched for a thread
|
||||||
// (RemotePosts.StoreContext: public and unlisted only, its author checked against its origin).
|
// (RemotePosts.StoreContext: public and unlisted only, its author checked against its origin).
|
||||||
public class RepliesJobHandler : IJobHandler
|
public class RepliesJobHandler : IJobHandler
|
||||||
@@ -74,14 +76,35 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
if (await Document(post, token) is not { } document)
|
if (await Document(post, token) is not { } document)
|
||||||
return JobOutcome.Done;
|
return JobOutcome.Done;
|
||||||
|
|
||||||
if (await Collection(document["context"], token) is { } thread)
|
foreach (var name in new[] { "contextHistory", "context" })
|
||||||
{
|
{
|
||||||
await StoreAll(thread, token);
|
var uri = Id(document[name]);
|
||||||
|
// Forte's `context` is the thread's first post
|
||||||
|
if (uri != default && await _dbEntities.Posts.Match(p => p.ObjectURI == uri).ExecuteAnyAsync(token))
|
||||||
|
continue;
|
||||||
|
var known = uri == default ? default : await DB.Default.Find<ThreadCollection>().Match(c => c.URI == uri).ExecuteFirstAsync(token);
|
||||||
|
var (thread, etag, unchanged) = await Collection(document[name], known?.ETag, token);
|
||||||
|
if (unchanged)
|
||||||
|
{
|
||||||
|
await DB.Default.Update<ThreadCollection>().MatchID(known.ID).Modify(c => c.ReadAt, DateTime.UtcNow).ExecuteAsync(token);
|
||||||
|
return JobOutcome.Done;
|
||||||
|
}
|
||||||
|
if (thread == default)
|
||||||
|
continue;
|
||||||
|
var (_, read) = await StoreAll(thread, name == "contextHistory", token);
|
||||||
|
var whole = read && Counted(thread);
|
||||||
|
if (uri != default && (known != default || whole && etag != default))
|
||||||
|
await DB.Default.Update<ThreadCollection>()
|
||||||
|
.Match(c => c.URI == uri)
|
||||||
|
.Modify(c => c.ETag, whole ? etag : default)
|
||||||
|
.Modify(c => c.ReadAt, DateTime.UtcNow)
|
||||||
|
.Option(o => o.IsUpsert = true)
|
||||||
|
.ExecuteAsync(token);
|
||||||
return JobOutcome.Done;
|
return JobOutcome.Done;
|
||||||
}
|
}
|
||||||
if (await Collection(document["replies"] ?? document["comments"], token) is not { } replies)
|
if ((await Collection(document["replies"] ?? document["comments"], default, token)).Collection is not { } replies)
|
||||||
return JobOutcome.Done;
|
return JobOutcome.Done;
|
||||||
var answers = await StoreAll(replies, token);
|
var (answers, _) = await StoreAll(replies, false, token);
|
||||||
if (payload.Depth < MaxDepth)
|
if (payload.Depth < MaxDepth)
|
||||||
await _queue.EnqueueMany(answers.Where(a => a.IsFederatedCopy && a.AnsweringToPostId == post.ID).Take(MaxBranches)
|
await _queue.EnqueueMany(answers.Where(a => a.IsFederatedCopy && a.AnsweringToPostId == post.ID).Take(MaxBranches)
|
||||||
.Select(a => For(a, payload.Depth + 1)), token);
|
.Select(a => For(a, payload.Depth + 1)), token);
|
||||||
@@ -100,35 +123,41 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
return fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText()) as JsonObject;
|
return fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText()) as JsonObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
// a collection as its server serves it now: what a post carries is read again by its id, since an embedded first
|
// a collection as its server serves it now, and its ETag: what a post carries is read again by its id, since an
|
||||||
// page is as old as the post; a value that is no web address (Pleroma's `context` is a tag: URI), or no collection,
|
// embedded first page is as old as the post; a value that is no web address (Pleroma's `context` is a tag: URI), or no
|
||||||
// gives nothing
|
// collection, gives nothing, and one whose server still has the version `etag` names is unchanged
|
||||||
async Task<JsonObject> Collection(JsonNode node, CancellationToken token)
|
async Task<(JsonObject Collection, string ETag, bool Unchanged)> Collection(JsonNode node, string etag, CancellationToken token)
|
||||||
{
|
{
|
||||||
var uri = Id(node);
|
var uri = Id(node);
|
||||||
|
string served = default;
|
||||||
if (uri != default && Uri.TryCreate(uri, UriKind.Absolute, out var parsed) && (parsed.Scheme == Uri.UriSchemeHttps || parsed.Scheme == Uri.UriSchemeHttp))
|
if (uri != default && Uri.TryCreate(uri, UriKind.Absolute, out var parsed) && (parsed.Scheme == Uri.UriSchemeHttps || parsed.Scheme == Uri.UriSchemeHttp))
|
||||||
{
|
{
|
||||||
using var scope = HttpScope.For("replies");
|
using var scope = HttpScope.For("replies");
|
||||||
using var fetched = await _remoteActors.FetchObject(uri, token);
|
using var fetched = await _remoteActors.FetchObject(uri, etag, token);
|
||||||
|
if (fetched?.NotModified == true)
|
||||||
|
return (default, etag, true);
|
||||||
node = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText());
|
node = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText());
|
||||||
|
served = fetched?.ETag;
|
||||||
}
|
}
|
||||||
return node is JsonObject collection && Value(collection, "type") is { } type && CollectionTypes.Contains(type) ? collection : default;
|
return (node is JsonObject collection && Value(collection, "type") is { } type && CollectionTypes.Contains(type) ? collection : default, served, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// every post the collection lists, stored when we lack it: its pages in order, at most MaxPages and MaxItems
|
// every post the collection lists, stored when we lack it: its pages in order, at most MaxPages and MaxItems; whether
|
||||||
async Task<List<PostEntity>> StoreAll(JsonObject collection, CancellationToken token)
|
// it was read to its end. A conversation container's items are its owner's Adds of the thread's activities.
|
||||||
|
async Task<(List<PostEntity> Posts, bool Whole)> StoreAll(JsonObject collection, bool activities, CancellationToken token)
|
||||||
{
|
{
|
||||||
var posts = new List<PostEntity>();
|
var posts = new List<PostEntity>();
|
||||||
var seen = 0;
|
var seen = 0;
|
||||||
var page = collection["orderedItems"] is JsonArray || collection["items"] is JsonArray ? collection : await Page(collection["first"], token);
|
var page = collection["orderedItems"] is JsonArray || collection["items"] is JsonArray ? collection : await Page(collection["first"], token);
|
||||||
for (var pages = 0; page != default && pages < MaxPages && seen < MaxItems; pages++)
|
for (var pages = 0; page != default; pages++)
|
||||||
{
|
{
|
||||||
|
if (pages == MaxPages)
|
||||||
|
return (posts, false);
|
||||||
foreach (var item in Items(page))
|
foreach (var item in Items(page))
|
||||||
{
|
{
|
||||||
if (++seen > MaxItems)
|
if (++seen > MaxItems)
|
||||||
break;
|
return (posts, false);
|
||||||
// a context may list the activities that made the thread (FEP-f228) rather than its posts
|
var id = activities ? await Added(item, token) : PostId(item);
|
||||||
var id = Value(item, "type") is "Create" or "Update" ? Id(item["object"]) : Id(item);
|
|
||||||
if (id == default)
|
if (id == default)
|
||||||
continue;
|
continue;
|
||||||
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token)
|
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token)
|
||||||
@@ -138,7 +167,32 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
}
|
}
|
||||||
page = page["next"] is { } next ? await Page(next, token) : default;
|
page = page["next"] is { } next ? await Page(next, token) : default;
|
||||||
}
|
}
|
||||||
return posts;
|
return (posts, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// whether the document itself changes with every post added to the thread, so its ETag is the thread's: it counts
|
||||||
|
// them (NodeBB), or holds every one with no further page (a short Mastodon thread); a long Mastodon thread's first
|
||||||
|
// page stays as it was while its last one grows
|
||||||
|
static bool Counted(JsonObject collection)
|
||||||
|
{
|
||||||
|
if (collection["totalItems"] is JsonValue)
|
||||||
|
return true;
|
||||||
|
var page = collection["orderedItems"] is JsonArray || collection["items"] is JsonArray ? collection : collection["first"] as JsonObject;
|
||||||
|
return page != default && (page["orderedItems"] is JsonArray || page["items"] is JsonArray) && page["next"] == default;
|
||||||
|
}
|
||||||
|
|
||||||
|
// a context may list the activities that made the thread (FEP-f228) rather than its posts
|
||||||
|
static string PostId(JsonNode item) => Value(item, "type") is "Create" or "Update" ? Id(item["object"]) : Id(item);
|
||||||
|
|
||||||
|
// the post an activity in a conversation container brought into the thread, added by its owner or (Forte's) not: a
|
||||||
|
// Create's or an Update's object; a like, a removal or anything else adds no post. Only its id is taken, and the post
|
||||||
|
// is read from its own server.
|
||||||
|
async Task<string> Added(JsonNode item, CancellationToken token)
|
||||||
|
{
|
||||||
|
var activity = await Page(item, token);
|
||||||
|
if (activity != default && Value(activity, "type") == "Add")
|
||||||
|
activity = await Page(activity["object"], token);
|
||||||
|
return activity != default && Value(activity, "type") is "Create" or "Update" ? Id(activity["object"]) : default;
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task<JsonObject> Page(JsonNode node, CancellationToken token)
|
async Task<JsonObject> Page(JsonNode node, CancellationToken token)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ namespace PrivaPub.Infrastructure.Data
|
|||||||
public static class Indexes
|
public static class Indexes
|
||||||
{
|
{
|
||||||
public static readonly TimeSpan TombstoneLifetime = TimeSpan.FromDays(90);
|
public static readonly TimeSpan TombstoneLifetime = TimeSpan.FromDays(90);
|
||||||
|
public static readonly TimeSpan ThreadCollectionLifetime = TimeSpan.FromDays(30);
|
||||||
|
|
||||||
public static async Task Create(CancellationToken token = default)
|
public static async Task Create(CancellationToken token = default)
|
||||||
{
|
{
|
||||||
@@ -166,6 +167,8 @@ namespace PrivaPub.Infrastructure.Data
|
|||||||
await Plain<ObjectRecord>(token, r => r.PostId);
|
await Plain<ObjectRecord>(token, r => r.PostId);
|
||||||
await Unique<DeletedObject>(d => d.ObjectURI, Builders<DeletedObject>.Filter.Gt(d => d.ObjectURI, ""), token);
|
await Unique<DeletedObject>(d => d.ObjectURI, Builders<DeletedObject>.Filter.Gt(d => d.ObjectURI, ""), token);
|
||||||
await DB.Default.Index<DeletedObject>().Key(d => d.DeletedAt, KeyType.Ascending).Option(o => o.ExpireAfter = TombstoneLifetime).CreateAsync(token);
|
await DB.Default.Index<DeletedObject>().Key(d => d.DeletedAt, KeyType.Ascending).Option(o => o.ExpireAfter = TombstoneLifetime).CreateAsync(token);
|
||||||
|
await Unique<ThreadCollection>(c => c.URI, Builders<ThreadCollection>.Filter.Gt(c => c.URI, ""), token);
|
||||||
|
await DB.Default.Index<ThreadCollection>().Key(c => c.ReadAt, KeyType.Ascending).Option(o => o.ExpireAfter = ThreadCollectionLifetime).CreateAsync(token);
|
||||||
await DB.Default.Index<Downvote>().Key(d => d.PostId, KeyType.Ascending).Key(d => d.ActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token);
|
await DB.Default.Index<Downvote>().Key(d => d.PostId, KeyType.Ascending).Key(d => d.ActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token);
|
||||||
await Plain<Downvote>(token, d => d.ActivityURI);
|
await Plain<Downvote>(token, d => d.ActivityURI);
|
||||||
await DB.Default.Index<PollVote>().Key(v => v.PostId, KeyType.Ascending).Key(v => v.ActorURI, KeyType.Ascending).Key(v => v.Choice, KeyType.Ascending)
|
await DB.Default.Index<PollVote>().Key(v => v.PostId, KeyType.Ascending).Key(v => v.ActorURI, KeyType.Ascending).Key(v => v.Choice, KeyType.Ascending)
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ namespace PrivaPub.Infrastructure.Http
|
|||||||
public Uri FinalUri { get; init; }
|
public Uri FinalUri { get; init; }
|
||||||
public JsonDocument Document { get; init; }
|
public JsonDocument Document { get; init; }
|
||||||
public JsonElement Root => Document.RootElement;
|
public JsonElement Root => Document.RootElement;
|
||||||
|
public string ETag { get; init; }
|
||||||
|
// the origin answered 304 to an If-None-Match: no document, the one read before still stands
|
||||||
|
public bool NotModified { get; init; }
|
||||||
|
|
||||||
public void Dispose() => Document?.Dispose();
|
public void Dispose() => Document?.Dispose();
|
||||||
}
|
}
|
||||||
@@ -24,6 +27,9 @@ namespace PrivaPub.Infrastructure.Http
|
|||||||
bool IsAllowed(Uri target);
|
bool IsAllowed(Uri target);
|
||||||
Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token);
|
Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token);
|
||||||
Task<(int Status, FetchedJson Json)> GetJsonStatus(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token);
|
Task<(int Status, FetchedJson Json)> GetJsonStatus(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token);
|
||||||
|
/// <summary>The document, or one that is <see cref="FetchedJson.NotModified"/> when its origin still has the version
|
||||||
|
/// <paramref name="etag"/> names.</summary>
|
||||||
|
Task<FetchedJson> GetJsonIfChanged(string url, string accept, string etag, Action<HttpRequestMessage> sign, CancellationToken token);
|
||||||
bool FailedTemporarily(string url);
|
bool FailedTemporarily(string url);
|
||||||
Task<(Uri FinalUri, string Html)> GetPage(string url, CancellationToken token);
|
Task<(Uri FinalUri, string Html)> GetPage(string url, CancellationToken token);
|
||||||
Task<HttpResponseMessage> OpenMedia(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token);
|
Task<HttpResponseMessage> OpenMedia(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token);
|
||||||
@@ -291,7 +297,20 @@ namespace PrivaPub.Infrastructure.Http
|
|||||||
var exchange = new Exchange(url, HttpScope.Purpose ?? "object");
|
var exchange = new Exchange(url, HttpScope.Purpose ?? "object");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await GetJson(url, accept, sign, exchange, token);
|
return await GetJson(url, accept, sign, default, exchange, token);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Record(exchange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<FetchedJson> GetJsonIfChanged(string url, string accept, string etag, Action<HttpRequestMessage> sign, CancellationToken token)
|
||||||
|
{
|
||||||
|
var exchange = new Exchange(url, HttpScope.Purpose ?? "object");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await GetJson(url, accept, sign, etag, exchange, token);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -308,7 +327,7 @@ namespace PrivaPub.Infrastructure.Http
|
|||||||
var exchange = new Exchange(url, HttpScope.Purpose ?? "object");
|
var exchange = new Exchange(url, HttpScope.Purpose ?? "object");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var json = await GetJson(url, accept, sign, exchange, token);
|
var json = await GetJson(url, accept, sign, default, exchange, token);
|
||||||
return (exchange.Status ?? 0, json);
|
return (exchange.Status ?? 0, json);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -317,7 +336,7 @@ namespace PrivaPub.Infrastructure.Http
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, Exchange exchange, CancellationToken token)
|
async Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, string etag, Exchange exchange, CancellationToken token)
|
||||||
{
|
{
|
||||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
|
||||||
{
|
{
|
||||||
@@ -338,11 +357,15 @@ namespace PrivaPub.Infrastructure.Http
|
|||||||
for (var hop = 0; hop <= MaxRedirects; hop++)
|
for (var hop = 0; hop <= MaxRedirects; hop++)
|
||||||
{
|
{
|
||||||
using var request = Request(target, accept);
|
using var request = Request(target, accept);
|
||||||
|
if (!string.IsNullOrEmpty(etag))
|
||||||
|
request.Headers.TryAddWithoutValidation("If-None-Match", etag);
|
||||||
sign?.Invoke(request);
|
sign?.Invoke(request);
|
||||||
|
|
||||||
using var response = await _httpClientFactory.CreateClient(ClientName)
|
using var response = await _httpClientFactory.CreateClient(ClientName)
|
||||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
|
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
|
||||||
exchange.Answered(response);
|
exchange.Answered(response);
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotModified && !string.IsNullOrEmpty(etag))
|
||||||
|
return new FetchedJson { FinalUri = target, ETag = etag, NotModified = true };
|
||||||
|
|
||||||
if (IsRedirect(response.StatusCode))
|
if (IsRedirect(response.StatusCode))
|
||||||
{
|
{
|
||||||
@@ -370,7 +393,7 @@ namespace PrivaPub.Infrastructure.Http
|
|||||||
return Refuse(negativeKey, url, "a body over the size limit", exchange, "too-large");
|
return Refuse(negativeKey, url, "a body over the size limit", exchange, "too-large");
|
||||||
exchange.Bytes = body.Length;
|
exchange.Bytes = body.Length;
|
||||||
|
|
||||||
return new FetchedJson { FinalUri = target, Document = JsonDocument.Parse(body) };
|
return new FetchedJson { FinalUri = target, Document = JsonDocument.Parse(body), ETag = RawETag(response) };
|
||||||
}
|
}
|
||||||
return Refuse(negativeKey, url, "too many redirects", exchange, "too-many-redirects");
|
return Refuse(negativeKey, url, "too many redirects", exchange, "too-many-redirects");
|
||||||
}
|
}
|
||||||
@@ -658,6 +681,10 @@ namespace PrivaPub.Infrastructure.Http
|
|||||||
return buffer.ToArray();
|
return buffer.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// as sent, since it is only ever sent back: NodeBB's carries no quotes, which the typed header rejects
|
||||||
|
static string RawETag(HttpResponseMessage response) =>
|
||||||
|
response.Headers.TryGetValues("ETag", out var values) ? values.FirstOrDefault() : default;
|
||||||
|
|
||||||
static bool IsRedirect(HttpStatusCode status) =>
|
static bool IsRedirect(HttpStatusCode status) =>
|
||||||
status is HttpStatusCode.MovedPermanently or HttpStatusCode.Found or HttpStatusCode.SeeOther
|
status is HttpStatusCode.MovedPermanently or HttpStatusCode.Found or HttpStatusCode.SeeOther
|
||||||
or HttpStatusCode.TemporaryRedirect or HttpStatusCode.PermanentRedirect;
|
or HttpStatusCode.TemporaryRedirect or HttpStatusCode.PermanentRedirect;
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
namespace PrivaPub.Models.Federation
|
||||||
|
{
|
||||||
|
// a thread's collection on another server as we last read it whole: its ETag, sent back as If-None-Match so an unchanged
|
||||||
|
// thread costs its server one 304 (NodeBB's context carries a digest of its posts)
|
||||||
|
public class ThreadCollection : Entity
|
||||||
|
{
|
||||||
|
public string URI { get; set; }
|
||||||
|
public string ETag { get; set; }
|
||||||
|
public DateTime ReadAt { get; set; } = DateTime.UtcNow;//expires after Indexes.ThreadCollectionLifetime
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-2
@@ -96,7 +96,9 @@ Priorities, used throughout:
|
|||||||
- `Move`.
|
- `Move`.
|
||||||
- `QuoteRequest`, its `Accept`/`Reject` (with `result`), and `Delete{QuoteAuthorization}`.
|
- `QuoteRequest`, its `Accept`/`Reject` (with `result`), and `Delete{QuoteAuthorization}`.
|
||||||
- `FeatureRequest`/`FeatureAuthorization` (4.6).
|
- `FeatureRequest`/`FeatureAuthorization` (4.6).
|
||||||
- **Threads:** `context` is a dereferenceable collection of the thread (FEP-7888, threads started on 4.5+).
|
- **Threads:** `context` is a dereferenceable collection of the thread (FEP-7888, threads started on 4.5+). It
|
||||||
|
answers `If-None-Match` with a 304 (a weak ETag over the body, 4.7.3), but the body embeds only the first page, so its
|
||||||
|
ETag stands for the thread only while the thread fits on that page.
|
||||||
- **Interaction counts:** `likes` and `shares` carry `totalItems`.
|
- **Interaction counts:** `likes` and `shares` carry `totalItems`.
|
||||||
- **Quotes:** `quote`, plus `quoteUri` and `_misskey_quote`, `quoteAuthorization`, and `interactionPolicy.canQuote`.
|
- **Quotes:** `quote`, plus `quoteUri` and `_misskey_quote`, `quoteAuthorization`, and `interactionPolicy.canQuote`.
|
||||||
- **Link previews:** from 4.7, a `{type: Link, href}` **attachment** names the link a card is made from (FEP-8967).
|
- **Link previews:** from 4.7, a `{type: Link, href}` **attachment** names the link a card is made from (FEP-8967).
|
||||||
@@ -659,7 +661,9 @@ What it showed:
|
|||||||
- **NodeBB:**
|
- **NodeBB:**
|
||||||
- A topic's first post is an `Article` with `name`, **`summary` = an excerpt** and `preview`; replies are Notes.
|
- A topic's first post is an `Article` with `name`, **`summary` = an excerpt** and `preview`; replies are Notes.
|
||||||
- Categories are `Group`s **without `followers`**.
|
- Categories are `Group`s **without `followers`**.
|
||||||
- `context` is a paged collection with an **ETag digest**; NodeBB refetches with `If-None-Match`.
|
- `context` is a collection with an **ETag digest** of its posts, sent **without quotes** (not an RFC 9110
|
||||||
|
entity-tag: .NET's typed header drops it, so PrivaPub keeps the raw value); it carries `totalItems` and its items
|
||||||
|
inline, and answers `If-None-Match` with a 304. NodeBB refetches with it too.
|
||||||
- Since 4.15, an Announce of anything but a Create or a plain object is accepted only from Group actors.
|
- Since 4.15, an Announce of anything but a Create or a plain object is accepted only from Group actors.
|
||||||
- It sends `Move`/`Remove` of a whole context (FEP-f15d) and `Add{post → context}` (FEP-11dd).
|
- It sends `Move`/`Remove` of a whole context (FEP-f15d) and `Add{post → context}` (FEP-11dd).
|
||||||
- Chats are private Notes, threaded by `inReplyTo` into the room they answer.
|
- Chats are private Notes, threaded by `inReplyTo` into the room they answer.
|
||||||
@@ -855,6 +859,8 @@ without a port, before it gives out its OAuth client.
|
|||||||
- Its Mastodon API resolves an account elsewhere only when the search is not limited to a type
|
- Its Mastodon API resolves an account elsewhere only when the search is not limited to a type
|
||||||
(`/api/v2/search?resolve=true`, no `type=accounts`); reactions go through Pleroma's route
|
(`/api/v2/search?resolve=true`, no `type=accounts`); reactions go through Pleroma's route
|
||||||
(`PUT /api/v1/pleroma/statuses/:id/reactions/:emoji`).
|
(`PUT /api/v1/pleroma/statuses/:id/reactions/:emoji`).
|
||||||
|
- **Threads:** a post's `context` is `/collections/conversations/<id>`, a collection whose first page lists the
|
||||||
|
thread's post ids (FEP-7888), with no ETag; no `contextHistory`.
|
||||||
- **Pasture evidence (2026-10-05, `tools/pasture/scenarios/mitra.sh`):** 28 checks pass, with no change to PrivaPub:
|
- **Pasture evidence (2026-10-05, `tools/pasture/scenarios/mitra.sh`):** 28 checks pass, with no change to PrivaPub:
|
||||||
follows both ways, posts, replies both ways, likes and reposts both ways, an emoji reaction, a poll and alice's vote,
|
follows both ways, posts, replies both ways, likes and reposts both ways, an emoji reaction, a poll and alice's vote,
|
||||||
edits and deletions both ways, direct messages both ways, the unfollow, statistics.
|
edits and deletions both ways, direct messages both ways, the unfollow, statistics.
|
||||||
@@ -918,6 +924,11 @@ without a port, before it gives out its OAuth client.
|
|||||||
- **Everything in a thread is added to its conversation** (FEP-171b): its edits come only as `Add{Update}`, under the
|
- **Everything in a thread is added to its conversation** (FEP-171b): its edits come only as `Add{Update}`, under the
|
||||||
same activity id as the post's `Create`, and likes as `Add{Like}`. PrivaPub unwraps Create, Update and Delete, telling
|
same activity id as the post's `Create`, and likes as `Add{Like}`. PrivaPub unwraps Create, Update and Delete, telling
|
||||||
an Update from the Create whose id it reuses by what it carries.
|
an Update from the Create whose id it reuses by what it carries.
|
||||||
|
- **Its conversation container is not named by its posts:** `contextHistory` (and `context`) pointing at
|
||||||
|
`…/conversation/<uuid>` ride on the activities only; a Note's `context` is the thread's first post and it has no
|
||||||
|
`contextHistory`, though the FEP asks the first post for one. The container (public threads: unsigned GETs work)
|
||||||
|
lists the first post's plain `Create`, then an `Add{Create}` for every post. A post's `replies` lists every comment of
|
||||||
|
the thread, embedded, and is what PrivaPub reads to complete a Forte thread (checked live 2026-10-07).
|
||||||
- **Its own collections name themselves wrongly:** a channel's followers, following and outbox give an `id` with the
|
- **Its own collections name themselves wrongly:** a channel's followers, following and outbox give an `id` with the
|
||||||
gateway path twice and a trailing `?`, which 404s, so their counts are not read.
|
gateway path twice and a trailing `?`, which 404s, so their counts are not read.
|
||||||
- **Running it:** no image is published; `images/forte` builds its tag with composer on Hubzilla's PHP image, behind an
|
- **Running it:** no image is published; `images/forte` builds its tag with composer on Hubzilla's PHP image, behind an
|
||||||
|
|||||||
+5
-2
@@ -677,8 +677,11 @@ it, raw where it doesn't.
|
|||||||
|
|
||||||
#### P7 Threads, communities and the social graph
|
#### P7 Threads, communities and the social graph
|
||||||
- **Thread backfill:**
|
- **Thread backfill:**
|
||||||
- read in order: `contextHistory`, then `context` (paged, with ETag), then `replies`. **Done (2026-10-05), but for
|
- read in order: `contextHistory`, then `context` (paged, with ETag), then `replies`. **Done (2026-10-05; `contextHistory`
|
||||||
`contextHistory` and ETags:** a persona opening a public remote thread queues `FetchReplies` for the post and its
|
and ETags 2026-10-07: a container's `Add`ed Creates and Updates are read for their posts; a whole read of a collection
|
||||||
|
that counts its posts or holds them all keeps its ETag, sent back as `If-None-Match`, a 304 checked live on Mastodon
|
||||||
|
4.7.3 and NodeBB 4.16, whose ETag has no quotes; neither Forte nor Mitra puts `contextHistory` on a post, and Forte's
|
||||||
|
`replies` already holds its whole thread):** a persona opening a public remote thread queues `FetchReplies` for the post and its
|
||||||
root, at most hourly each. The job reads the thread's FEP-7888 `context` collection (posts or, as FEP-f228 allows,
|
root, at most hourly each. The job reads the thread's FEP-7888 `context` collection (posts or, as FEP-f228 allows,
|
||||||
the activities that made them), else the post's `replies` (PeerTube's `comments`) and theirs, two levels down. It
|
the activities that made them), else the post's `replies` (PeerTube's `comments`) and theirs, two levels down. It
|
||||||
reads at most 5 pages and 100 posts per job, signed by the instance actor, and stores what it lacks through
|
reads at most 5 pages and 100 posts per job, signed by the instance actor, and stores what it lacks through
|
||||||
|
|||||||
Reference in new issue
Block a user