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 ]