diff --git a/CLAUDE.md b/CLAUDE.md
index c2eb02a..d3bae61 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -217,6 +217,11 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
equal to the login.
- **One username space:** personas, groups and the instance reserve their name in `ReservedName` (unique index) before
they are saved; `LocalActorService.TryReserveUserName` is the only way to claim one.
+- **Usernames are `Constants.UserNameRegex`** (`^[a-z0-9_]+$`): the intersection of what Mastodon and Misskey accept. A
+ name outside it creates a persona nobody on those servers can reach.
+- **An actor's `published` is `PublishedOn`**, a random whole day up to two weeks before creation, so personas made the
+ same day do not share a date. New persona and group ids carry that same day (`GenerateNewID`), because local clients
+ see ids. Never emit `CreatedAt`.
- **Per-avatar state stays per avatar:** blocks, mutes, notifications, follows. Nothing may relate sibling avatars.
- **Blocks federate** (owner decision, 2026-10-01): a block is sent as `Block` from the blocking avatar, an unblock as
`Undo{Block}`. Reports still leave as `Flag` from the instance actor, never from the reporting avatar.
diff --git a/FEDERATION.md b/FEDERATION.md
index c37f8f2..5ed3c86 100644
--- a/FEDERATION.md
+++ b/FEDERATION.md
@@ -56,7 +56,8 @@ actor document, a collection, NodeInfo or a delivery relates two avatars of the
The names are the project's own and are stable; resolve actors through WebFinger, not by guessing a path.
- The key is `{actor}#main-key`, RSA 2048, served as SPKI PEM with `owner` set to the actor.
-- `published` on an actor is truncated to the day. `indexable` is `false`.
+- `published` on an actor is a whole day, chosen at random up to two weeks before the account was made, so two
+ personas made on the same day do not share a date. `indexable` is `false`.
- The instance actor is `/peasants/privapub` (type `Application`). It signs every fetch PrivaPub makes, so no avatar's key
is used to read another server's content.
@@ -100,13 +101,17 @@ Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Update{Note}`, `Update{Person}`
- **Attachments** are `Document`s with `mediaType`, `name` (alt text), `blurhash`, `focalPoint`, `width` and `height`.
Uploaded files have all metadata removed.
- **Pinned posts** are the actor's `featured` collection (`/trophies`); `featuredTags` is `/tattoos`.
-- **Blocks are never sent.** A blocked account is sent `Reject{Follow}` if it followed, and is unfollowed.
+- **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it
+ followed); an unblock sends `Undo{Block}`.
- **Reports** are sent as `Flag` by the instance actor, never by the reporting account.
A `Create`'s `Note` carries Mastodon's `content`, `contentMap`, `summary` and `sensitive`, plus `Mention` and `Hashtag`
tags. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show
`name`. A content warning without its own text uses the title, or "Content warning".
+Received `summary` is a content warning only on a `Note` or `Question`, or when `sensitive` is `true`. On an `Article`,
+`Page`, `Event`, `Video` or `Audio` it is an excerpt or description, kept as such and never hidden.
+
Visibility is expressed in `to`/`cc` the way Mastodon does it: public, unlisted, followers-only and direct. Inbound
followers-only posts are recognised by the author's own `followers` collection.
diff --git a/PrivaPub.ClientModels/Constants.cs b/PrivaPub.ClientModels/Constants.cs
index 26bb794..3b9cd0d 100644
--- a/PrivaPub.ClientModels/Constants.cs
+++ b/PrivaPub.ClientModels/Constants.cs
@@ -9,5 +9,6 @@
public const int GroupInvitationLength = 64;
public const string PasswordRegex = @"^(?=.*)(?=.*[a-z])(?=.*[A-Z])(?!.*\s).*$";
+ public const string UserNameRegex = "^[a-z0-9_]+$";
}
}
\ No newline at end of file
diff --git a/PrivaPub.ClientModels/Group/InsertGroupForm.cs b/PrivaPub.ClientModels/Group/InsertGroupForm.cs
index 95e7a35..5410f9e 100644
--- a/PrivaPub.ClientModels/Group/InsertGroupForm.cs
+++ b/PrivaPub.ClientModels/Group/InsertGroupForm.cs
@@ -12,7 +12,7 @@ namespace PrivaPub.ClientModels.Group
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(32, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
- RegularExpression("^[a-z0-9_]+$", ErrorMessageResourceName = "EmptySpacesNotAllowed", ErrorMessageResourceType = typeof(ErrorsResource)),
+ RegularExpression(Constants.UserNameRegex, ErrorMessageResourceName = "InvalidUserName", ErrorMessageResourceType = typeof(ErrorsResource)),
NoWhiteSpaces]
public string UserName { get; set; }
diff --git a/PrivaPub.ClientModels/InvitationLoginForm.cs b/PrivaPub.ClientModels/InvitationLoginForm.cs
index 8c08647..7ebf52d 100644
--- a/PrivaPub.ClientModels/InvitationLoginForm.cs
+++ b/PrivaPub.ClientModels/InvitationLoginForm.cs
@@ -12,7 +12,7 @@ namespace PrivaPub.ClientModels
public string InvitationCode { get; set; }
[StringLength(32, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
- RegularExpression("^[a-z0-9_]+$", ErrorMessageResourceName = "EmptySpacesNotAllowed", ErrorMessageResourceType = typeof(ErrorsResource))]
+ RegularExpression(Constants.UserNameRegex, ErrorMessageResourceName = "InvalidUserName", ErrorMessageResourceType = typeof(ErrorsResource))]
public string AvatarUserName { get; set; }
[StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
diff --git a/PrivaPub.ClientModels/Resources/ErrorsResource.Designer.cs b/PrivaPub.ClientModels/Resources/ErrorsResource.Designer.cs
index fcddb9e..124dc9a 100644
--- a/PrivaPub.ClientModels/Resources/ErrorsResource.Designer.cs
+++ b/PrivaPub.ClientModels/Resources/ErrorsResource.Designer.cs
@@ -105,6 +105,15 @@ namespace PrivaPub.ClientModels.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Use only lower-case letters, digits and underscores..
+ ///
+ public static string InvalidUserName {
+ get {
+ return ResourceManager.GetString("InvalidUserName", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Invalid password, upper-case, lower-case and number characters required..
///
diff --git a/PrivaPub.ClientModels/Resources/ErrorsResource.it.resx b/PrivaPub.ClientModels/Resources/ErrorsResource.it.resx
index 8d3f199..7f1619b 100644
--- a/PrivaPub.ClientModels/Resources/ErrorsResource.it.resx
+++ b/PrivaPub.ClientModels/Resources/ErrorsResource.it.resx
@@ -132,6 +132,9 @@
{0} è invalida.
+
+ Usa solo lettere minuscole, cifre e trattini bassi.
+
Password non valida, deve avere almeno un carattere grande, un carattere piccolo ed un numero.
diff --git a/PrivaPub.ClientModels/Resources/ErrorsResource.resx b/PrivaPub.ClientModels/Resources/ErrorsResource.resx
index ba9282e..96f911e 100644
--- a/PrivaPub.ClientModels/Resources/ErrorsResource.resx
+++ b/PrivaPub.ClientModels/Resources/ErrorsResource.resx
@@ -132,6 +132,9 @@
{0} is invalid.
+
+ Use only lower-case letters, digits and underscores.
+
Invalid password, upper-case, lower-case and number characters required.
diff --git a/PrivaPub.ClientModels/User/Avatar/InsertAvatarForm.cs b/PrivaPub.ClientModels/User/Avatar/InsertAvatarForm.cs
index 712872d..ad413ee 100644
--- a/PrivaPub.ClientModels/User/Avatar/InsertAvatarForm.cs
+++ b/PrivaPub.ClientModels/User/Avatar/InsertAvatarForm.cs
@@ -20,6 +20,7 @@ namespace PrivaPub.ClientModels.User.Avatar
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(Constants.MaxAvatarNameLength, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource)),
+ RegularExpression(Constants.UserNameRegex, ErrorMessageResourceName = "InvalidUserName", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = nameof(UserName), ResourceType = typeof(FieldsNameResource))]
public string UserName { get; set; }//preferredUsername
diff --git a/PrivaPub.Tests/Api/StatusContentTests.cs b/PrivaPub.Tests/Api/StatusContentTests.cs
new file mode 100644
index 0000000..64944ae
--- /dev/null
+++ b/PrivaPub.Tests/Api/StatusContentTests.cs
@@ -0,0 +1,29 @@
+using PrivaPub.Api.Mastodon.Mappers;
+using PrivaPub.Models.Post;
+
+namespace PrivaPub.Tests.Api
+{
+ public class StatusContentTests
+ {
+ [Fact]
+ public void A_remote_article_shows_its_title_above_its_body() =>
+ Assert.Equal("
Rain & sun
body
", MastodonMapper.Content(new Post
+ {
+ IsFederatedCopy = true, ObjectType = "Article", Title = "Rain & sun", ContentHtml = "body
"
+ }));
+
+ [Fact]
+ public void A_remote_note_keeps_its_content_alone() =>
+ Assert.Equal("body
", MastodonMapper.Content(new Post
+ {
+ IsFederatedCopy = true, ObjectType = "Note", Title = "a poll choice", ContentHtml = "body
"
+ }));
+
+ [Fact]
+ public void A_remote_event_without_a_body_shows_its_excerpt() =>
+ Assert.Equal("Meetup
Saturday <10:00>
", MastodonMapper.Content(new Post
+ {
+ IsFederatedCopy = true, ObjectType = "Event", Title = "Meetup", Excerpt = "Saturday <10:00>", ContentHtml = string.Empty
+ }));
+ }
+}
diff --git a/PrivaPub.Tests/Domain/RelationshipTests.cs b/PrivaPub.Tests/Domain/RelationshipTests.cs
index c5ac8bd..0d6fc38 100644
--- a/PrivaPub.Tests/Domain/RelationshipTests.cs
+++ b/PrivaPub.Tests/Domain/RelationshipTests.cs
@@ -62,7 +62,31 @@ namespace PrivaPub.Tests.Domain
});
Assert.False(await DB.Default.Find().Match(f => f.LocalActorId == alice.Id).ExecuteAnyAsync(token));
- Assert.Equal("Reject", Assert.Single(await _harness.Outgoing(mallory.Id + "/inbox"))["type"]!.GetValue());
+ var sent = (await _harness.Outgoing(mallory.Id + "/inbox")).Select(a => a["type"]!.GetValue()).ToList();
+ Assert.Equal(new[] { "Block", "Reject" }, sent.Order());
+ }
+
+ [Fact]
+ public async Task A_block_is_told_to_the_blocked_server_and_so_is_the_unblock()
+ {
+ var token = TestContext.Current.CancellationToken;
+ var (_, alice) = await _harness.Persona("alice");
+ var mallory = new RemoteActor(_harness.Peer, "mallory");
+ await _harness.Deliver(mallory, "/human-centipede", new JsonObject
+ {
+ ["id"] = $"{Origin(mallory)}/follows/{Guid.NewGuid():N}", ["type"] = "Follow", ["actor"] = mallory.Id, ["object"] = alice.Uri
+ });
+
+ await _harness.Relationships.Block(alice, mallory.Id, default, token);
+ await _harness.Relationships.Unblock(alice, mallory.Id, token);
+
+ var sent = await _harness.Outgoing(mallory.Id + "/inbox");
+ var block = Assert.Single(sent, a => a["type"]!.GetValue() == "Block");
+ Assert.Equal(mallory.Id, block["object"]!.GetValue());
+ Assert.Equal(alice.Uri, block["actor"]!.GetValue());
+ var undo = Assert.Single(sent, a => a["type"]!.GetValue() == "Undo");
+ Assert.Equal(block["id"]!.GetValue(), undo["object"]!["id"]!.GetValue());
+ Assert.False(await DB.Default.Find().Match(b => b.AvatarId == alice.Id).ExecuteAnyAsync(token));
}
[Fact]
diff --git a/PrivaPub.Tests/Federation/NoteParserTests.cs b/PrivaPub.Tests/Federation/NoteParserTests.cs
index b777186..3cc11af 100644
--- a/PrivaPub.Tests/Federation/NoteParserTests.cs
+++ b/PrivaPub.Tests/Federation/NoteParserTests.cs
@@ -90,6 +90,28 @@ namespace PrivaPub.Tests.Federation
Assert.Equal("https://k.example/notes/8", note.QuoteUri);
}
+ [Theory]
+ [InlineData("Article", false, false)]
+ [InlineData("Event", false, false)]
+ [InlineData("Video", false, false)]
+ [InlineData("Page", false, false)]
+ [InlineData("Note", false, true)]
+ [InlineData("Question", false, true)]
+ [InlineData("Article", true, true)]
+ public void A_summary_warns_only_on_notes_or_when_marked_sensitive(string type, bool sensitive, bool warns)
+ {
+ var note = NoteParser.Parse(JsonNode.Parse($$"""
+ {
+ "id": "https://w.example/?p=12", "type": "{{type}}", "name": "Twelve", "summary": "Saturday, 10:00 at the library",
+ "sensitive": {{(sensitive ? "true" : "false")}}, "attributedTo": "https://w.example/author", "content": "body
"
+ }
+ """));
+
+ Assert.Equal(warns, note.Sensitive);
+ Assert.Equal(warns ? "Saturday, 10:00 at the library" : null, note.SpoilerText);
+ Assert.Equal(warns ? null : "Saturday, 10:00 at the library", note.Excerpt);
+ }
+
[Theory]
[InlineData("""{ "id": "https://x.example/1", "type": "Person" }""")]
[InlineData("""{ "id": "not a uri", "type": "Note" }""")]
diff --git a/PrivaPub.Tests/Infrastructure/PrivacyIdsTests.cs b/PrivaPub.Tests/Infrastructure/PrivacyIdsTests.cs
index 07ab7d2..ca833da 100644
--- a/PrivaPub.Tests/Infrastructure/PrivacyIdsTests.cs
+++ b/PrivaPub.Tests/Infrastructure/PrivacyIdsTests.cs
@@ -12,15 +12,18 @@ namespace PrivaPub.Tests.Infrastructure
[Fact]
public void Persona_ids_carry_only_the_day_and_share_nothing_else()
{
- var first = (string)new Avatar().GenerateNewID();
- var second = (string)new Avatar().GenerateNewID();
+ var avatar = new Avatar();
+ var first = (string)avatar.GenerateNewID();
+ var second = (string)new Avatar { PublishedOn = avatar.PublishedOn }.GenerateNewID();
+ var group = new GroupEntity();
Assert.True(ObjectId.TryParse(first, out var a));
Assert.True(ObjectId.TryParse(second, out var b));
- Assert.Equal(DateTime.UtcNow.Date, a.CreationTime);
+ Assert.Equal(avatar.PublishedOn, a.CreationTime);
+ Assert.InRange(a.CreationTime, DateTime.UtcNow.Date.AddDays(-13), DateTime.UtcNow.Date);
Assert.Equal(a.CreationTime, b.CreationTime);
Assert.NotEqual(first[8..14], second[8..14]);
- Assert.Equal(DateTime.UtcNow.Date, ObjectId.Parse((string)new GroupEntity().GenerateNewID()).CreationTime);
+ Assert.Equal(group.PublishedOn, ObjectId.Parse((string)group.GenerateNewID()).CreationTime);
}
[Fact]
@@ -49,6 +52,17 @@ namespace PrivaPub.Tests.Infrastructure
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]
+ public void A_persona_publishes_a_day_within_two_weeks_before_its_creation()
+ {
+ var created = new DateTime(2026, 10, 1, 15, 30, 0, DateTimeKind.Utc);
+ var days = Enumerable.Range(0, 200).Select(_ => PrivacyIds.PublishedDay(created)).ToList();
+
+ Assert.All(days, d => Assert.Equal(TimeSpan.Zero, d.TimeOfDay));
+ Assert.All(days, d => Assert.InRange(d, created.Date.AddDays(-13), created.Date));
+ Assert.True(days.Distinct().Count() > 1);
+ }
+
[Fact]
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));
diff --git a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs
index 1149f3d..4076b80 100644
--- a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs
+++ b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs
@@ -225,9 +225,12 @@ namespace PrivaPub.Api.Mastodon.Mappers
public static string Content(PostEntity post)
{
var content = post.ContentHtml ?? ActivityPubRenderer.Html(post.Text);
- return string.IsNullOrEmpty(post.Title) || post.IsFederatedCopy
- ? content ?? string.Empty
- : $"{WebUtility.HtmlEncode(post.Title)}
{content}";
+ if (post.IsFederatedCopy && string.IsNullOrWhiteSpace(content) && !string.IsNullOrEmpty(post.Excerpt))
+ content = $"{WebUtility.HtmlEncode(post.Excerpt)}
";
+ var titled = !string.IsNullOrEmpty(post.Title) && (!post.IsFederatedCopy || post.ObjectType is not (null or "Note" or "Question"));
+ return titled
+ ? $"{WebUtility.HtmlEncode(post.Title)}
{content}"
+ : content ?? string.Empty;
}
public static string Visibility(PostVisibility visibility) => visibility switch
diff --git a/PrivaPub/Domain/Relationships/RelationshipService.cs b/PrivaPub/Domain/Relationships/RelationshipService.cs
index 5d531ee..208bad7 100644
--- a/PrivaPub/Domain/Relationships/RelationshipService.cs
+++ b/PrivaPub/Domain/Relationships/RelationshipService.cs
@@ -38,12 +38,14 @@ namespace PrivaPub.Domain.Relationships
public async Task Block(LocalActor me, string targetActorUri, string targetAccountId, CancellationToken token)
{
+ var block = new Block { AvatarId = me.Id, TargetAccountId = targetAccountId, TargetActorURI = targetActorUri };
try
{
- await DB.Default.SaveAsync(new Block { AvatarId = me.Id, TargetAccountId = targetAccountId, TargetActorURI = targetActorUri }, token);
+ await DB.Default.SaveAsync(block, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
+ block = await DB.Default.Find().Match(b => b.AvatarId == me.Id && b.TargetActorURI == targetActorUri).ExecuteFirstAsync(token);
}
await _follows.UnfollowAs(me, targetActorUri, token);
@@ -67,10 +69,44 @@ namespace PrivaPub.Domain.Relationships
await DB.Default.DeleteAsync(f => f.TargetActorURI == me.Uri && f.AvatarId == targetAccountId);
}
await Forget(me, targetAccountId, token);
+ if (block != default)
+ await Tell(me, targetActorUri, BlockActivity(me, block), token);
}
- public async Task Unblock(LocalActor me, string targetActorUri, CancellationToken token) =>
- await DB.Default.DeleteAsync(b => b.AvatarId == me.Id && b.TargetActorURI == targetActorUri);
+ public async Task Unblock(LocalActor me, string targetActorUri, CancellationToken token)
+ {
+ var block = await DB.Default.Find().Match(b => b.AvatarId == me.Id && b.TargetActorURI == targetActorUri).ExecuteFirstAsync(token);
+ if (block == default)
+ return;
+ await DB.Default.DeleteAsync(block.ID);
+ await Tell(me, targetActorUri, new JsonObject
+ {
+ ["@context"] = ActivityPubRenderer.ActivityStreams,
+ ["id"] = me.ActivityUri($"undo-block-{block.ID}"),
+ ["type"] = "Undo",
+ ["actor"] = me.Uri,
+ ["object"] = BlockActivity(me, block)
+ }, token);
+ }
+
+ static JsonObject BlockActivity(LocalActor me, Block block) => new()
+ {
+ ["@context"] = ActivityPubRenderer.ActivityStreams,
+ ["id"] = me.ActivityUri($"block-{block.ID}"),
+ ["type"] = "Block",
+ ["actor"] = me.Uri,
+ ["object"] = block.TargetActorURI,
+ ["to"] = new JsonArray(block.TargetActorURI)
+ };
+
+ async Task Tell(LocalActor me, string targetActorUri, JsonObject activity, CancellationToken token)
+ {
+ if (targetActorUri.StartsWith(me.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
+ return;
+ var target = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == targetActorUri).ExecuteFirstAsync(token);
+ if (!string.IsNullOrEmpty(target?.InboxURL))
+ await _delivery.Enqueue(me, new[] { target.InboxURL }, activity, token);
+ }
public async Task Mute(LocalActor me, string targetActorUri, string targetAccountId, bool hideNotifications, TimeSpan? duration, CancellationToken token)
{
diff --git a/PrivaPub/Federation/Actors/LocalActorService.cs b/PrivaPub/Federation/Actors/LocalActorService.cs
index cbdebdc..457baaf 100644
--- a/PrivaPub/Federation/Actors/LocalActorService.cs
+++ b/PrivaPub/Federation/Actors/LocalActorService.cs
@@ -202,7 +202,7 @@ namespace PrivaPub.Federation.Actors
ThumbnailURL = avatar.ThumbnailURL,
PrivateKeyPem = avatar.PrivateKey,
PublicKeyPem = avatar.PublicKey,
- Published = avatar.CreatedAt,
+ Published = avatar.PublishedOn,
Fields = avatar.Fields ?? new Dictionary(),
ManuallyApprovesFollowers = avatar.Settings?.IsLocked == true,
Discoverable = avatar.Settings?.IsDiscoverable != false,
@@ -227,7 +227,7 @@ namespace PrivaPub.Federation.Actors
ManuallyApprovesFollowers = group.Kind == GroupKind.Circle || group.ManuallyApprovesMembers,
IsCircle = group.Kind == GroupKind.Circle,
PostingRestrictedToModerators = group.PostingPolicy == PostingPolicy.Moderators,
- Published = group.CreationDate,
+ Published = group.PublishedOn,
BaseAddress = BaseAddress
};
}
diff --git a/PrivaPub/Federation/Controllers/PeasantsController.cs b/PrivaPub/Federation/Controllers/PeasantsController.cs
index cd49983..af6ae89 100644
--- a/PrivaPub/Federation/Controllers/PeasantsController.cs
+++ b/PrivaPub/Federation/Controllers/PeasantsController.cs
@@ -317,6 +317,8 @@ namespace PrivaPub.Federation.Controllers
[NonAction]
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
+ if (HttpMethods.IsGet(Request.Method))
+ Response.Headers.Vary = "Accept";
if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method)
&& !string.Equals(context.RouteData.Values["actor"] as string, LocalActorService.InstanceUserName, StringComparison.OrdinalIgnoreCase)
&& await _fetches.Requester(Request, HttpContext.RequestAborted) == default)
diff --git a/PrivaPub/Federation/Inbox/Handlers/UpdateHandler.cs b/PrivaPub/Federation/Inbox/Handlers/UpdateHandler.cs
index 0084111..f4d8937 100644
--- a/PrivaPub/Federation/Inbox/Handlers/UpdateHandler.cs
+++ b/PrivaPub/Federation/Inbox/Handlers/UpdateHandler.cs
@@ -72,6 +72,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
post.Title = note.Title;
post.SpoilerText = note.SpoilerText;
+ post.Excerpt = note.Excerpt;
post.HasContentWarning = note.Sensitive;
post.Text = note.ContentHtml;
post.ContentHtml = note.ContentHtml;
diff --git a/PrivaPub/Federation/Inbox/RemotePosts.cs b/PrivaPub/Federation/Inbox/RemotePosts.cs
index 7daf9db..ce07f7d 100644
--- a/PrivaPub/Federation/Inbox/RemotePosts.cs
+++ b/PrivaPub/Federation/Inbox/RemotePosts.cs
@@ -70,8 +70,10 @@ namespace PrivaPub.Federation.Inbox
Url = note.Url,
ContextURI = note.Context,
QuoteURI = note.QuoteUri,
+ ObjectType = note.Type,
Title = note.Title,
SpoilerText = note.SpoilerText,
+ Excerpt = note.Excerpt,
HasContentWarning = note.Sensitive,
Text = note.ContentHtml,
ContentHtml = note.ContentHtml,
diff --git a/PrivaPub/Federation/Objects/NoteParser.cs b/PrivaPub/Federation/Objects/NoteParser.cs
index 5d13cfd..af83672 100644
--- a/PrivaPub/Federation/Objects/NoteParser.cs
+++ b/PrivaPub/Federation/Objects/NoteParser.cs
@@ -15,6 +15,7 @@ namespace PrivaPub.Federation.Objects
public string ContentHtml { get; init; }
public string Title { get; init; }
public string SpoilerText { get; init; }
+ public string Excerpt { get; init; }
public bool Sensitive { get; init; }
public string Language { get; init; }
public string Url { get; init; }
@@ -48,6 +49,8 @@ namespace PrivaPub.Federation.Objects
var (contentHtml, language) = Content(note);
var summary = Value(note, "summary");
+ var sensitive = note["sensitive"] is JsonValue flag && flag.TryGetValue(out var marked) && marked;
+ var summaryWarns = type is "Note" or "Question" || sensitive;
return new NoteDocument
{
Id = id,
@@ -55,9 +58,9 @@ namespace PrivaPub.Federation.Objects
AttributedTo = Attribution(note["attributedTo"]),
ContentHtml = ContentSanitizer.Html(contentHtml),
Title = Plain(Value(note, "name"), 500),
- SpoilerText = Plain(summary, 500),
- Sensitive = note["sensitive"] is JsonValue sensitive && sensitive.TryGetValue(out var s) && s
- || !string.IsNullOrWhiteSpace(summary),
+ SpoilerText = summaryWarns ? Plain(summary, 500) : default,
+ Excerpt = summaryWarns ? default : Plain(summary, 1000),
+ Sensitive = sensitive || summaryWarns && !string.IsNullOrWhiteSpace(summary),
Language = language,
Url = UrlOf(note["url"]) ?? id,
InReplyTo = Id(note["inReplyTo"]),
diff --git a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs
index 3bf3985..018d23b 100644
--- a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs
+++ b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs
@@ -41,7 +41,9 @@ namespace PrivaPub.Federation.Rendering
["schema"] = "http://schema.org#",
["PropertyValue"] = "schema:PropertyValue",
["value"] = "schema:value",
- ["webfinger"] = "https://purl.archive.org/socialweb/webfinger#webfinger"
+ ["webfinger"] = "https://purl.archive.org/socialweb/webfinger#webfinger",
+ ["lemmy"] = "https://join-lemmy.org/ns#",
+ ["postingRestrictedToMods"] = "lemmy:postingRestrictedToMods"
});
public static string Html(string markdown) =>
diff --git a/PrivaPub/Infrastructure/Data/Migrations/_007_personas_publish_a_spread_day.cs b/PrivaPub/Infrastructure/Data/Migrations/_007_personas_publish_a_spread_day.cs
new file mode 100644
index 0000000..a5e6037
--- /dev/null
+++ b/PrivaPub/Infrastructure/Data/Migrations/_007_personas_publish_a_spread_day.cs
@@ -0,0 +1,23 @@
+using MongoDB.Bson;
+using MongoDB.Driver;
+using MongoDB.Entities;
+
+using PrivaPub.Infrastructure.Ids;
+using PrivaPub.Models.User;
+
+using GroupEntity = PrivaPub.Models.Group.Group;
+
+namespace PrivaPub.Infrastructure.Data.Migrations
+{
+ public class _007_personas_publish_a_spread_day : IMigration
+ {
+ public async Task UpgradeAsync()
+ {
+ var missing = new BsonDocument(nameof(Avatar.PublishedOn), new BsonDocument("$exists", false));
+ foreach (var avatar in await DB.Default.Collection().Find(missing).ToListAsync())
+ await DB.Default.Update().MatchID(avatar.ID).Modify(a => a.PublishedOn, PrivacyIds.PublishedDay(avatar.CreatedAt)).ExecuteAsync();
+ foreach (var group in await DB.Default.Collection().Find(missing).ToListAsync())
+ await DB.Default.Update().MatchID(group.ID).Modify(g => g.PublishedOn, PrivacyIds.PublishedDay(group.CreationDate)).ExecuteAsync();
+ }
+ }
+}
diff --git a/PrivaPub/Infrastructure/Ids/PrivacyIds.cs b/PrivaPub/Infrastructure/Ids/PrivacyIds.cs
index 5aac6c1..3228021 100644
--- a/PrivaPub/Infrastructure/Ids/PrivacyIds.cs
+++ b/PrivaPub/Infrastructure/Ids/PrivacyIds.cs
@@ -9,6 +9,11 @@ namespace PrivaPub.Infrastructure.Ids
{
public static string ForDay(DateTime when) => At(DateTime.SpecifyKind(when, DateTimeKind.Utc).Date);
+ public static DateTime PublishedDay(DateTime created) =>
+ DateTime.SpecifyKind(created, DateTimeKind.Utc).Date.AddDays(-RandomNumberGenerator.GetInt32(0, PublishedSpreadDays));
+
+ const int PublishedSpreadDays = 14;
+
public static string Arrived(DateTime published) =>
DateTime.SpecifyKind(published, DateTimeKind.Utc) > DateTime.UtcNow - LiveWindow ? ObjectId.GenerateNewId().ToString() : At(published);
diff --git a/PrivaPub/Models/Group/Group.cs b/PrivaPub/Models/Group/Group.cs
index c6197d5..472ce76 100644
--- a/PrivaPub/Models/Group/Group.cs
+++ b/PrivaPub/Models/Group/Group.cs
@@ -31,10 +31,11 @@ namespace PrivaPub.Models.Group
public string HashedInvitationPassword { get; set; }
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
+ public DateTime PublishedOn { get; set; } = PrivacyIds.PublishedDay(DateTime.UtcNow);
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? DeletionAt { get; set; }
- public override object GenerateNewID() => PrivacyIds.ForDay(DateTime.UtcNow);
+ public override object GenerateNewID() => PrivacyIds.ForDay(PublishedOn);
}
public class GroupMember
diff --git a/PrivaPub/Models/Post/Post.cs b/PrivaPub/Models/Post/Post.cs
index 5a94fd9..973ef45 100644
--- a/PrivaPub/Models/Post/Post.cs
+++ b/PrivaPub/Models/Post/Post.cs
@@ -17,6 +17,7 @@ namespace PrivaPub.Models.Post
public PostVisibility Visibility { get; set; }
public string Title { get; set; }
public string SpoilerText { get; set; }
+ public string Excerpt { get; set; }//a non-Note's summary: a teaser, a description, an event's date and place
public string Text { get; set; }
public string ContentHtml { get; set; }
public ContentFormat ContentFormat { get; set; }
@@ -35,6 +36,7 @@ namespace PrivaPub.Models.Post
public bool IsFederatedCopy { get; set; }
public string ObjectURI { get; set; }//the Note's id
+ public string ObjectType { get; set; }//the remote object's type: Note, Article, Page, Video, Event…; null for local posts
public string ActivityURI { get; set; }//the Create's id
public string ActorURI { get; set; }//attributedTo
public string Url { get; set; }
diff --git a/PrivaPub/Models/User/Avatar.cs b/PrivaPub/Models/User/Avatar.cs
index 3bb3fdd..770a9f5 100644
--- a/PrivaPub/Models/User/Avatar.cs
+++ b/PrivaPub/Models/User/Avatar.cs
@@ -32,12 +32,13 @@ namespace PrivaPub.Models.User
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+ public DateTime PublishedOn { get; set; } = PrivacyIds.PublishedDay(DateTime.UtcNow);//the actor's published: a few days before CreatedAt, so same-day personas differ
public DateTime? SilencedAt { get; set; }
public DateTime? SuspendedAt { get; set; }
public DateTime? BannedAt { get; set; }
public DateTime? DeletionAt { get; set; }
- public override object GenerateNewID() => PrivacyIds.ForDay(DateTime.UtcNow);
+ public override object GenerateNewID() => PrivacyIds.ForDay(PublishedOn);
}
public class ForeignAvatar : Entity
diff --git a/docs/INTEROP.md b/docs/INTEROP.md
index 36a18f1..1af708e 100644
--- a/docs/INTEROP.md
+++ b/docs/INTEROP.md
@@ -39,7 +39,7 @@ Priorities, used throughout:
- **Live check:** the whole follow, post, reply, like, boost, DM, edit and delete set round-trips with GoToSocial
0.22.1 (`tools/pasture/`).
-**Wrong today (P1, cheap)**
+**Wrong at the time of the research (P1, cheap; all three fixed in v1.7.0)**
- **`summary` is read as a content warning on every object type.** It is one only on a `Note`, or when
`sensitive: true` is set. Elsewhere it is something else:
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 97655c4..cac04a9 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -408,12 +408,15 @@ details view of where each object came from. So the server keeps everything it r
it, raw where it doesn't.
#### P5 Lose nothing (wire tolerance and full objects)
-- **Cheap fixes that are wrong today:**
- - `summary` is a content warning only on a `Note`, or when `sensitive` is set; elsewhere it is an excerpt or
- description (INTEROP W2).
- - Persona and group usernames must match Mastodon's and Misskey's pattern.
- - Define every term we emit in our JSON-LD context.
- - `Vary: Accept`; every activity id dereferences.
+- **Cheap fixes that were wrong** (done in v1.7.0):
+ - `summary` is a content warning only on a `Note`/`Question`, or when `sensitive` is set; elsewhere it is an excerpt
+ (INTEROP W2). Remote titles now show in the Mastodon API for non-Note objects.
+ - Persona usernames match Mastodon's and Misskey's pattern (groups already did).
+ - Every term we emit is defined in our JSON-LD context.
+ - `Vary: Accept`.
+ - Still open: every activity id dereferences.
+- **Owner decisions that were small** (done in v1.7.0): blocks federate (`Block`, `Undo{Block}`); a persona's and a
+ group's `published` is a random day up to two weeks before its creation (migration `_007`).
- **Parsing every shape:**
- `url`, `icon`, `image`, `attachment` and `attributedTo` as a value, an object or an array (W1).
- A Markdown `content` and `source` (W3).
diff --git a/tools/pasture/interop.sh b/tools/pasture/interop.sh
index 6c236aa..fc8599e 100755
--- a/tools/pasture/interop.sh
+++ b/tools/pasture/interop.sh
@@ -113,5 +113,11 @@ echo "unfollow"
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/accounts/$gts_on_pp/unfollow
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$alice_on_gts" | j "print(d[0][\"followed_by\"])")" = "False" ]' && ok "alice's unfollow reaches GoToSocial" || ko "unfollow not applied"
+echo "blocks"
+curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/accounts/$gts_on_pp/block
+until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$alice_on_gts" | j "print(d[0][\"blocked_by\"])")" = "True" ]' && ok "alice's block reaches GoToSocial" || ko "block not applied"
+curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/accounts/$gts_on_pp/unblock
+until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$alice_on_gts" | j "print(d[0][\"blocked_by\"])")" = "False" ]' && ok "alice's unblock reaches GoToSocial" || ko "unblock not applied"
+
echo; echo "$pass passed, $fail failed"
[ "$fail" = 0 ]