Files
SocialPub/PrivaPub/Web/Pages/Pages.cs
T
thepraandClaude Opus 5.5 a5d9a89445 Communities follow FEP-1b12, circles federate to their members only
Communities:
- a post addressed to a community (to, cc or audience) is accepted
  according to its posting policy - followers, anyone, or moderators -
  and GroupDistributor announces the whole activity with `audience` to
  the community's followers, plus the object for new posts so Mastodon
  shows them; updates and deletes of community content are announced too;
- top-level posts are Pages with a name (the title, or a headline from
  the text); /flock counts members, /wardens lists moderators;
- a Mastodon client posts into a community by mentioning it, or into a
  remote group, which sets `audience`;
- an Announce of an activity from a remote group a persona follows (Lemmy)
  is followed through: the object is fetched from its own origin, kept with
  its AudienceURI, and fanned out to the group's local followers; updates
  are applied in place and deletes checked against the origin.

Circles stop being local-only: an undiscoverable Group actor whose follows
are all requests the owner approves; posts addressed to the circle and its
/flock and delivered to members' own inboxes, never announced, never
public; a remote member's post into the circle is accepted from members
only. SignedFetchAuthorizer serves circle posts and collections only to a
signed request from a member or a member server's instance actor - 404 for
anyone else. Circles never surface in search, lookups, mentions, account
ids or profile pages.

Federation:SecureMode requires a valid signature on every GET under
/peasants except the instance actor. Group forms take a posting policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:30:57 +02:00

131 lines
4.3 KiB
C#

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using MongoDB.Entities;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.StaticServices;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Web.Pages
{
public sealed record PostView(string Title, string Warning, string ContentHtml, string Url, DateTime Published, bool Edited)
{
public static PostView From(PostEntity post, LocalActor author) => new(
post.Title,
post.SpoilerText ?? (post.HasContentWarning ? ActivityPubRenderer.ContentWarning : default),
post.ContentHtml ?? ActivityPubRenderer.Html(post.Text),
author.PostHtmlUrl(post.ID),
DateTime.SpecifyKind(post.CreationDate, DateTimeKind.Utc),
post.EditedAt.HasValue);
}
public abstract class PublicPageModel : PageModel
{
protected bool WantsActivityJson()
{
var accept = Request.Headers.Accept.ToString();
return accept.Contains("activity+json", StringComparison.OrdinalIgnoreCase) || accept.Contains("ld+json", StringComparison.OrdinalIgnoreCase);
}
protected void Harden()
{
Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
Response.Headers["Referrer-Policy"] = "no-referrer";
Response.Headers["X-Content-Type-Options"] = "nosniff";
}
}
public class ProfileModel : PublicPageModel
{
const int PageSize = 20;
readonly ILocalActorService _localActors;
readonly DbEntities _dbEntities;
public ProfileModel(ILocalActorService localActors, DbEntities dbEntities)
{
_localActors = localActors;
_dbEntities = dbEntities;
}
public LocalActor Actor { get; private set; }
public string BiographyHtml { get; private set; }
public IReadOnlyList<PostView> Posts { get; private set; } = Array.Empty<PostView>();
public async Task<IActionResult> OnGetAsync(string user, CancellationToken token)
{
Actor = await _localActors.FindByUserName(user, token);
if (Actor is not { IsFederated: true, IsCircle: false } || Actor.Kind == LocalActorKind.Application)
return NotFound();
if (WantsActivityJson())
return Redirect(Actor.Uri);
Harden();
BiographyHtml = ActivityPubRenderer.Html(Actor.Summary);
var posts = await (Actor.Kind == LocalActorKind.Group
? _dbEntities.Posts.Match(p => p.GroupId == Actor.Id)
: _dbEntities.Posts.Match(p => p.GroupUserId == Actor.Id && !p.IsFederatedCopy))
.Match(VisibilityPolicy.IsPublic)
.Match(p => p.ReblogOfPostId == null)
.Sort(p => p.ID, Order.Descending)
.Limit(PageSize)
.ExecuteAsync(token);
var authors = new Dictionary<string, LocalActor> { [Actor.Id] = Actor };
var views = new List<PostView>();
foreach (var post in posts)
{
if (post.IsFederatedCopy)
continue;
if (!authors.TryGetValue(post.GroupUserId, out var author))
authors[post.GroupUserId] = author = await _localActors.FindById(LocalActorKind.Person, post.GroupUserId, token);
if (author != default)
views.Add(PostView.From(post, author));
}
Posts = views;
return Page();
}
}
public class StatusModel : PublicPageModel
{
readonly ILocalActorService _localActors;
readonly DbEntities _dbEntities;
public StatusModel(ILocalActorService localActors, DbEntities dbEntities)
{
_localActors = localActors;
_dbEntities = dbEntities;
}
public LocalActor Actor { get; private set; }
public PostView Post { get; private set; }
public string ObjectUri { get; private set; }
public async Task<IActionResult> OnGetAsync(string user, string id, CancellationToken token)
{
Actor = await _localActors.FindByUserName(user, token);
if (Actor is not { IsFederated: true, Kind: LocalActorKind.Person })
return NotFound();
var post = await _dbEntities.Posts
.Match(p => p.ID == id && p.GroupUserId == Actor.Id && !p.IsFederatedCopy && p.ReblogOfPostId == null)
.Match(VisibilityPolicy.IsPublic)
.ExecuteFirstAsync(token);
if (post == default)
return NotFound();
ObjectUri = Actor.PostUri(post.ID);
if (WantsActivityJson())
return Redirect(ObjectUri);
Harden();
Post = PostView.From(post, Actor);
return Page();
}
}
}