P5, first batch: summaries stop hiding articles, personas get reachable names, blocks federate
Build / Build (push) Successful in 50s
Deploy / privapub.thepra.dev (push) Successful in 1m4s

- A remote `summary` is a content warning only on a Note or Question, or when `sensitive` is set. On Articles,
  Events, Videos, Pages and Audio it is an excerpt (WordPress teasers, Mobilizon dates and places, Mbin titles) and
  is now kept as `Post.Excerpt` instead of hiding the post. `Post.ObjectType` records the remote type, and the
  Mastodon API shows a remote non-Note object's title above its body again.
- Persona usernames must match `^[a-z0-9_]+$`, as groups already did; a name outside it was unreachable from
  Mastodon and Misskey.
- `postingRestrictedToMods` is defined in our JSON-LD context (Iceshrimp.NET drops undefined terms).
- `Vary: Accept` on actor and object URLs.
- Owner decision: blocks federate. A block sends `Block`, an unblock `Undo{Block}`; checked live against GoToSocial.
- Owner decision: a persona's and a group's `published`, and the day in new ids, is a random day up to two weeks
  before creation, so personas made the same day no longer share a date. Migration _007 gives existing ones theirs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 17:39:58 +02:00
1 parent 4c94254502
commit bb9bd71391
29 files changed
+236 -30

No files matched your search

+5
View File
@@ -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.
+7 -2
View File
@@ -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.
+1
View File
@@ -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_]+$";
}
}
@@ -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; }
+1 -1
View File
@@ -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))]
@@ -105,6 +105,15 @@ namespace PrivaPub.ClientModels.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Use only lower-case letters, digits and underscores..
/// </summary>
public static string InvalidUserName {
get {
return ResourceManager.GetString("InvalidUserName", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Invalid password, upper-case, lower-case and number characters required..
/// </summary>
@@ -132,6 +132,9 @@
<data name="InvalidEmail" xml:space="preserve">
<value>{0} è invalida.</value>
</data>
<data name="InvalidUserName" xml:space="preserve">
<value>Usa solo lettere minuscole, cifre e trattini bassi.</value>
</data>
<data name="InvalidPassword" xml:space="preserve">
<value>Password non valida, deve avere almeno un carattere grande, un carattere piccolo ed un numero.</value>
</data>
@@ -132,6 +132,9 @@
<data name="InvalidEmail" xml:space="preserve">
<value>{0} is invalid.</value>
</data>
<data name="InvalidUserName" xml:space="preserve">
<value>Use only lower-case letters, digits and underscores.</value>
</data>
<data name="InvalidPassword" xml:space="preserve">
<value>Invalid password, upper-case, lower-case and number characters required.</value>
</data>
@@ -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
+29
View File
@@ -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("<p><strong>Rain &amp; sun</strong></p><p>body</p>", MastodonMapper.Content(new Post
{
IsFederatedCopy = true, ObjectType = "Article", Title = "Rain & sun", ContentHtml = "<p>body</p>"
}));
[Fact]
public void A_remote_note_keeps_its_content_alone() =>
Assert.Equal("<p>body</p>", MastodonMapper.Content(new Post
{
IsFederatedCopy = true, ObjectType = "Note", Title = "a poll choice", ContentHtml = "<p>body</p>"
}));
[Fact]
public void A_remote_event_without_a_body_shows_its_excerpt() =>
Assert.Equal("<p><strong>Meetup</strong></p><p>Saturday &lt;10:00&gt;</p>", MastodonMapper.Content(new Post
{
IsFederatedCopy = true, ObjectType = "Event", Title = "Meetup", Excerpt = "Saturday <10:00>", ContentHtml = string.Empty
}));
}
}
+25 -1
View File
@@ -62,7 +62,31 @@ namespace PrivaPub.Tests.Domain
});
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id).ExecuteAnyAsync(token));
Assert.Equal("Reject", Assert.Single(await _harness.Outgoing(mallory.Id + "/inbox"))["type"]!.GetValue<string>());
var sent = (await _harness.Outgoing(mallory.Id + "/inbox")).Select(a => a["type"]!.GetValue<string>()).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<string>() == "Block");
Assert.Equal(mallory.Id, block["object"]!.GetValue<string>());
Assert.Equal(alice.Uri, block["actor"]!.GetValue<string>());
var undo = Assert.Single(sent, a => a["type"]!.GetValue<string>() == "Undo");
Assert.Equal(block["id"]!.GetValue<string>(), undo["object"]!["id"]!.GetValue<string>());
Assert.False(await DB.Default.Find<Block>().Match(b => b.AvatarId == alice.Id).ExecuteAnyAsync(token));
}
[Fact]
@@ -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": "<p>body</p>"
}
"""));
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" }""")]
@@ -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));
@@ -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
: $"<p><strong>{WebUtility.HtmlEncode(post.Title)}</strong></p>{content}";
if (post.IsFederatedCopy && string.IsNullOrWhiteSpace(content) && !string.IsNullOrEmpty(post.Excerpt))
content = $"<p>{WebUtility.HtmlEncode(post.Excerpt)}</p>";
var titled = !string.IsNullOrEmpty(post.Title) && (!post.IsFederatedCopy || post.ObjectType is not (null or "Note" or "Question"));
return titled
? $"<p><strong>{WebUtility.HtmlEncode(post.Title)}</strong></p>{content}"
: content ?? string.Empty;
}
public static string Visibility(PostVisibility visibility) => visibility switch
@@ -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<Block>().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<Following>(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<Block>(b => b.AvatarId == me.Id && b.TargetActorURI == targetActorUri);
public async Task Unblock(LocalActor me, string targetActorUri, CancellationToken token)
{
var block = await DB.Default.Find<Block>().Match(b => b.AvatarId == me.Id && b.TargetActorURI == targetActorUri).ExecuteFirstAsync(token);
if (block == default)
return;
await DB.Default.DeleteAsync<Block>(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)
{
@@ -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<string, string>(),
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
};
}
@@ -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)
@@ -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;
+2
View File
@@ -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,
+6 -3
View File
@@ -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<bool>(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<bool>(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"]),
@@ -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) =>
@@ -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<Avatar>().Find(missing).ToListAsync())
await DB.Default.Update<Avatar>().MatchID(avatar.ID).Modify(a => a.PublishedOn, PrivacyIds.PublishedDay(avatar.CreatedAt)).ExecuteAsync();
foreach (var group in await DB.Default.Collection<GroupEntity>().Find(missing).ToListAsync())
await DB.Default.Update<GroupEntity>().MatchID(group.ID).Modify(g => g.PublishedOn, PrivacyIds.PublishedDay(group.CreationDate)).ExecuteAsync();
}
}
}
@@ -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);
+2 -1
View File
@@ -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
+2
View File
@@ -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; }
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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:
+9 -6
View File
@@ -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).
+6
View File
@@ -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 ]