P5, first batch: summaries stop hiding articles, personas get reachable names, blocks federate
- 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:
1 parent
4c94254502
commit
bb9bd71391
29 files changed
+236
-30
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 & 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 <10:00></p>", MastodonMapper.Content(new Post
|
||||
{
|
||||
IsFederatedCopy = true, ObjectType = "Event", Title = "Meetup", Excerpt = "Saturday <10:00>", ContentHtml = string.Empty
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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 ]
|
||||
Reference in new issue
Block a user