Remote posts take their timeline place on arrival, and an unedited one has no edit date
Found by the first live run against GoToSocial: - A remote post's id came from its published second plus random bytes, so a reply arriving in the same second as the post it answers could sort under it, and a late arrival landed behind a client's since_id. A post published within the last hour now gets an id for its arrival; backfill keeps its published time. - NoteParser.Time returned default(DateTime) for a missing "updated", so every remote post was stored as edited in year one. Migration _006 clears the stored ones. - published now carries milliseconds, so peers that derive ids from it (GoToSocial, Mastodon) keep our posts in order within a second. 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
51f2c10c6f
commit
31542a181e
9 files changed
+56
-6
No files matched your search
@@ -54,6 +54,7 @@ namespace PrivaPub.Tests.Federation
|
|||||||
Assert.Equal("a cat", media.Description);
|
Assert.Equal("a cat", media.Description);
|
||||||
Assert.Equal(new[] { 0.5f, -1f }, media.Focus);
|
Assert.Equal(new[] { 0.5f, -1f }, media.Focus);
|
||||||
Assert.Equal(new DateTime(2026, 9, 30, 10, 0, 0, DateTimeKind.Utc), note.Published);
|
Assert.Equal(new DateTime(2026, 9, 30, 10, 0, 0, DateTimeKind.Utc), note.Published);
|
||||||
|
Assert.Null(note.Updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ namespace PrivaPub.Tests.Federation
|
|||||||
Language = "en",
|
Language = "en",
|
||||||
Mentions = new() { new PostMention { ActorURI = "https://m.example/users/bob", Handle = "@bob@m.example" } },
|
Mentions = new() { new PostMention { ActorURI = "https://m.example/users/bob", Handle = "@bob@m.example" } },
|
||||||
Tags = new() { "cats" },
|
Tags = new() { "cats" },
|
||||||
CreationDate = new DateTime(2026, 9, 30, 17, 45, 12, DateTimeKind.Utc)
|
CreationDate = new DateTime(2026, 9, 30, 17, 45, 12, 345, DateTimeKind.Utc)
|
||||||
};
|
};
|
||||||
|
|
||||||
var note = ActivityPubRenderer.Note(post, Alice, default, "https://m.example/users/bob/statuses/1");
|
var note = ActivityPubRenderer.Note(post, Alice, default, "https://m.example/users/bob/statuses/1");
|
||||||
@@ -67,7 +67,7 @@ namespace PrivaPub.Tests.Federation
|
|||||||
Assert.Equal("<p><strong>On <cats></strong></p><p>body</p>", note["content"]!.GetValue<string>());
|
Assert.Equal("<p><strong>On <cats></strong></p><p>body</p>", note["content"]!.GetValue<string>());
|
||||||
Assert.Equal("On <cats>", note["summary"]!.GetValue<string>());
|
Assert.Equal("On <cats>", note["summary"]!.GetValue<string>());
|
||||||
Assert.True(note["sensitive"]!.GetValue<bool>());
|
Assert.True(note["sensitive"]!.GetValue<bool>());
|
||||||
Assert.Equal("2026-09-30T17:45:12Z", note["published"]!.GetValue<string>());
|
Assert.Equal("2026-09-30T17:45:12.345Z", note["published"]!.GetValue<string>());
|
||||||
Assert.Equal(new[] { ActivityPubRenderer.Public }, note["to"]!.AsArray().Select(n => n!.GetValue<string>()));
|
Assert.Equal(new[] { ActivityPubRenderer.Public }, note["to"]!.AsArray().Select(n => n!.GetValue<string>()));
|
||||||
Assert.Equal(new[] { Alice.Followers, "https://m.example/users/bob" }, note["cc"]!.AsArray().Select(n => n!.GetValue<string>()));
|
Assert.Equal(new[] { Alice.Followers, "https://m.example/users/bob" }, note["cc"]!.AsArray().Select(n => n!.GetValue<string>()));
|
||||||
var tags = note["tag"]!.AsArray();
|
var tags = note["tag"]!.AsArray();
|
||||||
|
|||||||
@@ -33,6 +33,22 @@ namespace PrivaPub.Tests.Infrastructure
|
|||||||
Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), ObjectId.Parse(older).CreationTime);
|
Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), ObjectId.Parse(older).CreationTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_post_arriving_live_sorts_after_everything_already_stored()
|
||||||
|
{
|
||||||
|
var local = ObjectId.GenerateNewId().ToString();
|
||||||
|
var reply = PrivacyIds.Arrived(DateTime.UtcNow.AddSeconds(-30));
|
||||||
|
var skewed = PrivacyIds.Arrived(DateTime.UtcNow.AddYears(50));
|
||||||
|
|
||||||
|
Assert.True(string.CompareOrdinal(local, reply) < 0);
|
||||||
|
Assert.True(string.CompareOrdinal(reply, skewed) < 0);
|
||||||
|
Assert.True(ObjectId.Parse(skewed).CreationTime < DateTime.UtcNow.AddMinutes(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_backfilled_post_keeps_its_published_time() =>
|
||||||
|
Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), ObjectId.Parse(PrivacyIds.Arrived(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc))).CreationTime);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void A_published_time_in_the_future_is_clamped() =>
|
public void A_published_time_in_the_future_is_clamped() =>
|
||||||
Assert.True(ObjectId.Parse(PrivacyIds.At(DateTime.UtcNow.AddYears(50))).CreationTime < DateTime.UtcNow.AddDays(2));
|
Assert.True(ObjectId.Parse(PrivacyIds.At(DateTime.UtcNow.AddYears(50))).CreationTime < DateTime.UtcNow.AddDays(2));
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
: DateTime.UtcNow;
|
: DateTime.UtcNow;
|
||||||
var reblog = new PostEntity
|
var reblog = new PostEntity
|
||||||
{
|
{
|
||||||
ID = PrivacyIds.At(published),
|
ID = PrivacyIds.Arrived(published),
|
||||||
ObjectURI = announceId,
|
ObjectURI = announceId,
|
||||||
ActivityURI = announceId,
|
ActivityURI = announceId,
|
||||||
ActorURI = actor.ActorURI,
|
ActorURI = actor.ActorURI,
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
|
|
||||||
return new PostEntity
|
return new PostEntity
|
||||||
{
|
{
|
||||||
ID = PrivacyIds.At(note.Published),
|
ID = PrivacyIds.Arrived(note.Published),
|
||||||
ObjectURI = note.Id,
|
ObjectURI = note.Id,
|
||||||
ActorURI = author.ActorURI,
|
ActorURI = author.ActorURI,
|
||||||
AuthorAccountId = author.ID,
|
AuthorAccountId = author.ID,
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ namespace PrivaPub.Federation.Objects
|
|||||||
static DateTime? Time(string value) =>
|
static DateTime? Time(string value) =>
|
||||||
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)
|
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)
|
||||||
? parsed.UtcDateTime
|
? parsed.UtcDateTime
|
||||||
: default;
|
: (DateTime?)null;
|
||||||
|
|
||||||
static int? Int(JsonNode node) =>
|
static int? Int(JsonNode node) =>
|
||||||
node is JsonValue value && value.TryGetValue<int>(out var number) && number is > 0 and < 100_000 ? number : (int?)null;
|
node is JsonValue value && value.TryGetValue<int>(out var number) && number is > 0 and < 100_000 ? number : (int?)null;
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ namespace PrivaPub.Federation.Rendering
|
|||||||
string.IsNullOrEmpty(markdown) ? string.Empty : Markdown.ToHtml(markdown, Pipeline).Trim();
|
string.IsNullOrEmpty(markdown) ? string.Empty : Markdown.ToHtml(markdown, Pipeline).Trim();
|
||||||
|
|
||||||
public static string Timestamp(DateTime value) =>
|
public static string Timestamp(DateTime value) =>
|
||||||
DateTime.SpecifyKind(value, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture);
|
DateTime.SpecifyKind(value, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
public static string Day(DateTime value) =>
|
public static string Day(DateTime value) =>
|
||||||
DateTime.SpecifyKind(value, DateTimeKind.Utc).Date.ToString("yyyy-MM-ddT00:00:00Z", CultureInfo.InvariantCulture);
|
DateTime.SpecifyKind(value, DateTimeKind.Utc).Date.ToString("yyyy-MM-ddT00:00:00Z", CultureInfo.InvariantCulture);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.Models.Post;
|
||||||
|
|
||||||
|
namespace PrivaPub.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
public class _006_unedited_remote_posts_lose_year_one : IMigration
|
||||||
|
{
|
||||||
|
public async Task UpgradeAsync()
|
||||||
|
{
|
||||||
|
var posts = await DB.Default.Find<Post>()
|
||||||
|
.Match(p => p.EditedAt == DateTime.MinValue || p.UpdateDate == DateTime.MinValue || p.Revisions.Any(r => r.EditedAt == DateTime.MinValue))
|
||||||
|
.ExecuteAsync();
|
||||||
|
foreach (var post in posts)
|
||||||
|
{
|
||||||
|
foreach (var revision in post.Revisions.Where(r => r.EditedAt == DateTime.MinValue))
|
||||||
|
revision.EditedAt = post.CreationDate;
|
||||||
|
await DB.Default.Update<Post>().MatchID(post.ID)
|
||||||
|
.Modify(p => p.EditedAt, post.EditedAt == DateTime.MinValue ? null : post.EditedAt)
|
||||||
|
.Modify(p => p.UpdateDate, post.UpdateDate == DateTime.MinValue ? post.CreationDate : post.UpdateDate)
|
||||||
|
.Modify(p => p.Revisions, post.Revisions)
|
||||||
|
.ExecuteAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
using MongoDB.Bson;
|
||||||
|
|
||||||
namespace PrivaPub.Infrastructure.Ids
|
namespace PrivaPub.Infrastructure.Ids
|
||||||
{
|
{
|
||||||
public static class PrivacyIds
|
public static class PrivacyIds
|
||||||
{
|
{
|
||||||
public static string ForDay(DateTime when) => At(DateTime.SpecifyKind(when, DateTimeKind.Utc).Date);
|
public static string ForDay(DateTime when) => At(DateTime.SpecifyKind(when, DateTimeKind.Utc).Date);
|
||||||
|
|
||||||
|
public static string Arrived(DateTime published) =>
|
||||||
|
DateTime.SpecifyKind(published, DateTimeKind.Utc) > DateTime.UtcNow - LiveWindow ? ObjectId.GenerateNewId().ToString() : At(published);
|
||||||
|
|
||||||
|
static readonly TimeSpan LiveWindow = TimeSpan.FromHours(1);
|
||||||
|
|
||||||
public static string At(DateTime when)
|
public static string At(DateTime when)
|
||||||
{
|
{
|
||||||
var seconds = Math.Clamp(new DateTimeOffset(DateTime.SpecifyKind(when, DateTimeKind.Utc)).ToUnixTimeSeconds(), 0,
|
var seconds = Math.Clamp(new DateTimeOffset(DateTime.SpecifyKind(when, DateTimeKind.Utc)).ToUnixTimeSeconds(), 0,
|
||||||
|
|||||||
Reference in new issue
Block a user