Outbound documents speak Mastodon's dialect under PrivaPub's route names
Local text (Domain/Content/ContentRenderer): Markdig with raw HTML off and
autolinks, or plain text for API clients; then @user and @user@host are
resolved (locally or through WebFinger) into Mastodon's h-card markup and
#tags into hashtag links, skipping code, links and e-mail addresses. A
post stores the result, its mentions and its tags, and is delivered to
the mentioned actors and the parent's author as well as to followers.
Renderer:
- Mastodon's @context (toot, schema PropertyValue, discoverable,
indexable, blurhash, focalPoint, featured, alsoKnownAs, movedTo) and
FEP-2c59's webfinger;
- an actor's url is its HTML page, its fields are PropertyValue
attachments, published is cut to the day and indexable is false;
- a note's content is the stored rendering; a title becomes name and a
bold first line, a content warning without its own text is summarised
by the title or "Content warning"; Mention and Hashtag tags, contentMap,
updated, and to/cc by visibility.
Routes take the agreed names: /groupies, /stalking, /scribbles/{id},
/grunts/{id} (a Create resolves), /whispers/{id} for a DM's context. The
outbox is a collection with a first page, paged by max_id. A browser
asking for an actor or a note is redirected to /@user, and WebFinger's
profile-page points there.
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
e9529310fe
commit
e54e17708f
9 files changed
+590
-74
No files matched your search
@@ -41,26 +41,39 @@ namespace PrivaPub.Federation.Controllers
|
||||
public async Task<IActionResult> GetActor(string actor, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.Actor(local));
|
||||
if (local is not { IsFederated: true })
|
||||
return NotFound();
|
||||
if (WantsHtml() && local.Kind != LocalActorKind.Application)
|
||||
return Redirect(local.HtmlUrl);
|
||||
return Activity(ActivityPubRenderer.Actor(local));
|
||||
}
|
||||
|
||||
[HttpGet, Route("{actor}/anus")]
|
||||
public async Task<IActionResult> Outbox(string actor, CancellationToken token)
|
||||
public async Task<IActionResult> Outbox(string actor, [FromQuery] bool page, [FromQuery(Name = "max_id")] string maxId,
|
||||
CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
if (local is not { IsFederated: true })
|
||||
return NotFound();
|
||||
|
||||
var total = local.Kind == LocalActorKind.Group
|
||||
? await DB.Default.CountAsync<PostEntity>(p => p.GroupId == local.Id && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted), token)
|
||||
: await DB.Default.CountAsync<PostEntity>(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted), token);
|
||||
if (!page)
|
||||
{
|
||||
var total = local.Kind == LocalActorKind.Group
|
||||
? await DB.Default.CountAsync<PostEntity>(p => p.GroupId == local.Id && !p.DeletedAt.HasValue
|
||||
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted), token)
|
||||
: await DB.Default.CountAsync<PostEntity>(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue
|
||||
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted), token);
|
||||
return Activity(ActivityPubRenderer.OrderedCollection(local.Outbox, (int)total, default, $"{local.Outbox}?page=true"));
|
||||
}
|
||||
|
||||
var latest = await (local.Kind == LocalActorKind.Group
|
||||
? _dbEntities.Posts.Match(p => p.GroupId == local.Id && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted))
|
||||
: _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted)))
|
||||
.Sort(p => p.CreationDate, Order.Descending)
|
||||
.Limit(OutboxSize)
|
||||
.ExecuteAsync(token);
|
||||
var query = local.Kind == LocalActorKind.Group
|
||||
? _dbEntities.Posts.Match(p => p.GroupId == local.Id && !p.DeletedAt.HasValue
|
||||
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted))
|
||||
: _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue
|
||||
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted));
|
||||
if (!string.IsNullOrEmpty(maxId))
|
||||
query.Match(f => f.Lt(p => p.ID, maxId));
|
||||
var latest = await query.Sort(p => p.ID, Order.Descending).Limit(OutboxSize).ExecuteAsync(token);
|
||||
|
||||
var items = new List<JsonNode>();
|
||||
foreach (var post in latest)
|
||||
@@ -70,15 +83,15 @@ namespace PrivaPub.Federation.Controllers
|
||||
items.Add(ActivityPubRenderer.Announce(local, post.ObjectURI, $"announce-{post.ID}"));
|
||||
continue;
|
||||
}
|
||||
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
||||
var note = ActivityPubRenderer.Note(post, local, group, post.InReplyToURI);
|
||||
items.Add(ActivityPubRenderer.Create(local, note, $"create-{post.ID}"));
|
||||
items.Add(await CreateFor(post, local, token));
|
||||
}
|
||||
|
||||
return Activity(ActivityPubRenderer.OrderedCollection(local.Outbox, (int)total, items));
|
||||
var pageId = string.IsNullOrEmpty(maxId) ? $"{local.Outbox}?page=true" : $"{local.Outbox}?page=true&max_id={maxId}";
|
||||
var next = latest.Count == OutboxSize ? $"{local.Outbox}?page=true&max_id={latest[^1].ID}" : default;
|
||||
return Activity(ActivityPubRenderer.OrderedCollectionPage(pageId, local.Outbox, items, next, default));
|
||||
}
|
||||
|
||||
[HttpGet, Route("{actor}/followers")]
|
||||
[HttpGet, Route("{actor}/groupies")]
|
||||
public async Task<IActionResult> Followers(string actor, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
@@ -89,32 +102,37 @@ namespace PrivaPub.Federation.Controllers
|
||||
return Activity(ActivityPubRenderer.OrderedCollection(local.Followers, (int)count, default));
|
||||
}
|
||||
|
||||
[HttpGet, Route("{actor}/following")]
|
||||
[HttpGet, Route("{actor}/stalking")]
|
||||
public async Task<IActionResult> Following(string actor, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, Enumerable.Empty<JsonNode>()));
|
||||
return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, default));
|
||||
}
|
||||
|
||||
[HttpGet, Route("{actor}/posts/{postId}")]
|
||||
[HttpGet, Route("{actor}/scribbles/{postId}")]
|
||||
public async Task<IActionResult> Post(string actor, string postId, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
if (local == default || local.Kind != LocalActorKind.Person)
|
||||
return NotFound();
|
||||
|
||||
var post = await _dbEntities.Posts
|
||||
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted))
|
||||
.ExecuteFirstAsync(token);
|
||||
var (local, post) = await PublicPost(actor, postId, token);
|
||||
if (post == default)
|
||||
return NotFound();
|
||||
if (WantsHtml())
|
||||
return Redirect(local.PostHtmlUrl(post.ID));
|
||||
|
||||
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
||||
var note = ActivityPubRenderer.Note(post, local, group, post.InReplyToURI);
|
||||
note["@context"] = ActivityPubRenderer.ActivityStreams;
|
||||
note["@context"] = ActivityPubRenderer.Context();
|
||||
return Activity(note);
|
||||
}
|
||||
|
||||
[HttpGet, Route("{actor}/grunts/{activityId}")]
|
||||
public async Task<IActionResult> Grunt(string actor, string activityId, CancellationToken token)
|
||||
{
|
||||
if (!activityId.StartsWith("create-", StringComparison.Ordinal))
|
||||
return NotFound();
|
||||
var (local, post) = await PublicPost(actor, activityId["create-".Length..], token);
|
||||
return post == default ? NotFound() : Activity(await CreateFor(post, local, token));
|
||||
}
|
||||
|
||||
[HttpPost, Route("{actor}/mouth"), EnableRateLimiting(RateLimiting.Inbox)]
|
||||
public async Task<IActionResult> Inbox(string actor, CancellationToken token)
|
||||
{
|
||||
@@ -132,6 +150,33 @@ namespace PrivaPub.Federation.Controllers
|
||||
public async Task<IActionResult> SharedInbox(CancellationToken token) =>
|
||||
Answer(await _inbox.Receive(Request, default, token));
|
||||
|
||||
async Task<(LocalActor Actor, PostEntity Post)> PublicPost(string actor, string postId, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
if (local is not { IsFederated: true, Kind: LocalActorKind.Person })
|
||||
return default;
|
||||
var post = await _dbEntities.Posts
|
||||
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue
|
||||
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted))
|
||||
.ExecuteFirstAsync(token);
|
||||
return (local, post);
|
||||
}
|
||||
|
||||
async Task<JsonObject> CreateFor(PostEntity post, LocalActor author, CancellationToken token)
|
||||
{
|
||||
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
||||
var note = ActivityPubRenderer.Note(post, author, group, post.InReplyToURI);
|
||||
return ActivityPubRenderer.Create(author, note, $"create-{post.ID}");
|
||||
}
|
||||
|
||||
bool WantsHtml()
|
||||
{
|
||||
var accept = Request.Headers.Accept.ToString();
|
||||
return accept.Contains("text/html", StringComparison.OrdinalIgnoreCase)
|
||||
&& !accept.Contains("activity+json", StringComparison.OrdinalIgnoreCase)
|
||||
&& !accept.Contains("ld+json", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
IActionResult Answer(InboxResult result)
|
||||
{
|
||||
if (result.Error != default)
|
||||
|
||||
Reference in new issue
Block a user