Profile and post pages, NodeInfo 2.1, and FEDERATION.md
/@user and /@user/{id} are Razor pages showing an avatar's or community's
public and unlisted posts: no scripts, a strict CSP, no-referrer, noindex,
and an alternate link to the ActivityPub document. A client asking them
for activity+json is redirected to the actor or note, and the actor and
note redirect browsers here.
NodeInfo answers 2.1 as well as 2.0 (repository, homepage, a link to
FEDERATION.md) and counts only public local posts.
FEDERATION.md (FEP-67ff) lists the protocols, FEPs, route names,
activities and the security rules a peer will notice.
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
e54e17708f
commit
9ec1f9930b
11 files changed
+368
-12
No files matched your search
@@ -0,0 +1,128 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
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 } || 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(p => !p.DeletedAt.HasValue && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted))
|
||||
.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.DeletedAt.HasValue
|
||||
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted))
|
||||
.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
@page "/@{user}"
|
||||
@model PrivaPub.Web.Pages.ProfileModel
|
||||
@{
|
||||
ViewData["Title"] = $"{Model.Actor.Name} (@{Model.Actor.Handle})";
|
||||
ViewData["Alternate"] = Model.Actor.Uri;
|
||||
}
|
||||
<header>
|
||||
<h1>@Model.Actor.Name</h1>
|
||||
<div class="handle">@@@Model.Actor.Handle</div>
|
||||
</header>
|
||||
<div class="bio">@Html.Raw(Model.BiographyHtml)</div>
|
||||
@if (Model.Actor.Fields.Count > 0)
|
||||
{
|
||||
<dl class="fields">
|
||||
@foreach (var field in Model.Actor.Fields)
|
||||
{
|
||||
<dt>@field.Key</dt>
|
||||
<dd>@if (Uri.TryCreate(field.Value, UriKind.Absolute, out var link) && link.Scheme is "https" or "http")
|
||||
{
|
||||
<a href="@field.Value" rel="nofollow noopener noreferrer me">@field.Value</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
@field.Value
|
||||
}</dd>
|
||||
}
|
||||
</dl>
|
||||
}
|
||||
@foreach (var post in Model.Posts)
|
||||
{
|
||||
<partial name="_Post" model="post" />
|
||||
}
|
||||
@if (Model.Posts.Count == 0)
|
||||
{
|
||||
<p class="meta">Nothing public yet.</p>
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, noarchive, nofollow">
|
||||
<title>@ViewData["Title"]</title>
|
||||
@if (ViewData["Alternate"] is string alternate)
|
||||
{
|
||||
<link rel="alternate" type="application/activity+json" href="@alternate">
|
||||
}
|
||||
<style>
|
||||
:root { --bg: #fafaf8; --fg: #1d1d1b; --muted: #6b6b66; --line: #deded8; --accent: #6b3fa0; }
|
||||
@@media (prefers-color-scheme: dark) { :root { --bg: #17161a; --fg: #ecebe6; --muted: #9a99a2; --line: #2e2c33; --accent: #b794e6; } }
|
||||
body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.5 system-ui, sans-serif; }
|
||||
main { max-width: 640px; margin: 0 auto; padding: 24px 16px; }
|
||||
a { color: var(--accent); }
|
||||
header h1 { margin: 0; font-size: 1.5rem; }
|
||||
.handle, .meta { color: var(--muted); font-size: .9rem; }
|
||||
.bio { margin: 12px 0; }
|
||||
dl.fields { display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; margin: 12px 0; }
|
||||
dl.fields dt { color: var(--muted); }
|
||||
dl.fields dd { margin: 0; overflow-wrap: anywhere; }
|
||||
article { border-top: 1px solid var(--line); padding: 16px 0; overflow-wrap: anywhere; }
|
||||
article h2 { font-size: 1.1rem; margin: 0 0 8px; }
|
||||
.invisible { display: none; }
|
||||
.ellipsis::after { content: "…"; }
|
||||
summary { cursor: pointer; }
|
||||
footer { color: var(--muted); font-size: .8rem; margin-top: 32px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
@RenderBody()
|
||||
<footer>PrivaPub</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
@model PrivaPub.Web.Pages.PostView
|
||||
<article>
|
||||
@if (!string.IsNullOrEmpty(Model.Title))
|
||||
{
|
||||
<h2>@Model.Title</h2>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.Warning))
|
||||
{
|
||||
<details>
|
||||
<summary>@Model.Warning</summary>
|
||||
@Html.Raw(Model.ContentHtml)
|
||||
</details>
|
||||
}
|
||||
else
|
||||
{
|
||||
@Html.Raw(Model.ContentHtml)
|
||||
}
|
||||
<div class="meta"><a href="@Model.Url"><time datetime="@Model.Published.ToString("O")">@Model.Published.ToString("yyyy-MM-dd HH:mm") UTC</time></a>@(Model.Edited ? " · edited" : "")</div>
|
||||
</article>
|
||||
@@ -0,0 +1,11 @@
|
||||
@page "/@{user}/{id}"
|
||||
@model PrivaPub.Web.Pages.StatusModel
|
||||
@{
|
||||
ViewData["Title"] = $"{Model.Actor.Name}: {Model.Post.Title ?? "post"}";
|
||||
ViewData["Alternate"] = Model.ObjectUri;
|
||||
}
|
||||
<header>
|
||||
<h1><a href="@Model.Actor.HtmlUrl">@Model.Actor.Name</a></h1>
|
||||
<div class="handle">@@@Model.Actor.Handle</div>
|
||||
</header>
|
||||
<partial name="_Post" model="Model.Post" />
|
||||
@@ -0,0 +1,3 @@
|
||||
@using PrivaPub.Web.Pages
|
||||
@namespace PrivaPub.Web.Pages
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@@ -0,0 +1,3 @@
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
}
|
||||
Reference in new issue
Block a user