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,95 @@
|
|||||||
|
# Federation
|
||||||
|
|
||||||
|
PrivaPub is an ActivityPub server written in C#. This document follows
|
||||||
|
[FEP-67ff](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md) and describes how it federates.
|
||||||
|
|
||||||
|
## Supported federation protocols and standards
|
||||||
|
|
||||||
|
- [ActivityPub](https://www.w3.org/TR/activitypub/) (server-to-server)
|
||||||
|
- [WebFinger](https://webfinger.net/)
|
||||||
|
- [HTTP Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures), `rsa-sha256` / `hs2019` with RSA keys
|
||||||
|
- [NodeInfo](https://nodeinfo.diaspora.software/) 2.0 and 2.1
|
||||||
|
|
||||||
|
## Supported FEPs
|
||||||
|
|
||||||
|
- [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md)
|
||||||
|
- [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md)
|
||||||
|
- [FEP-2c59: Discovery of a WebFinger address from an ActivityPub actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2c59/fep-2c59.md)
|
||||||
|
|
||||||
|
Planned: FEP-1b12 (communities), FEP-8fcf (followers synchronisation), FEP-5feb (`indexable`), FEP-7628 (Move),
|
||||||
|
FEP-044f (quotes).
|
||||||
|
|
||||||
|
## Actors
|
||||||
|
|
||||||
|
Every account is an *avatar*: one private login can own several, and they are deliberately unlinkable. Nothing in an
|
||||||
|
actor document, a collection, NodeInfo or a delivery relates two avatars of the same login.
|
||||||
|
|
||||||
|
| Thing | Address |
|
||||||
|
|---|---|
|
||||||
|
| Actor (Person, Group, Application) | `/peasants/{name}` (`/users/{name}` redirects) |
|
||||||
|
| Inbox | `/peasants/{name}/mouth` |
|
||||||
|
| Outbox | `/peasants/{name}/anus` |
|
||||||
|
| Shared inbox | `/human-centipede` |
|
||||||
|
| Followers / following | `/peasants/{name}/groupies`, `/peasants/{name}/stalking` |
|
||||||
|
| Objects | `/peasants/{name}/scribbles/{id}` |
|
||||||
|
| Activities | `/peasants/{name}/grunts/{id}` |
|
||||||
|
| Direct-message context | `/peasants/{name}/whispers/{id}` |
|
||||||
|
| Profile and post pages | `/@{name}`, `/@{name}/{id}` |
|
||||||
|
|
||||||
|
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`.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Groups
|
||||||
|
|
||||||
|
A group is either a **community** or a **circle**.
|
||||||
|
|
||||||
|
- A community is a `Group` actor. It accepts `Follow` and re-shares (`Announce`) posts from its followers that address
|
||||||
|
it. Full FEP-1b12 behaviour (announcing activities, `audience`, moderation) is planned.
|
||||||
|
- A circle is private and does not federate yet: its actor, collections and WebFinger answer 404, and its posts are never
|
||||||
|
delivered.
|
||||||
|
|
||||||
|
## Activities
|
||||||
|
|
||||||
|
Received:
|
||||||
|
|
||||||
|
| Activity | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `Follow` | follows an avatar or community; `Accept` is sent unless the community approves members by hand |
|
||||||
|
| `Undo{Follow}` | unfollows |
|
||||||
|
| `Create{Note, Article, Page, Question, …}` | stored when it addresses or mentions a local avatar, replies to a local post, or is addressed to a community the author follows |
|
||||||
|
| `Update{Note}` | replaces the content; the previous version is kept |
|
||||||
|
| `Update{Person}` | refetches the actor |
|
||||||
|
| `Delete` | deletes the object, or the actor and its follows |
|
||||||
|
|
||||||
|
Sent: `Create{Note}`, `Delete{Tombstone}`, `Accept{Follow}`, `Announce` (communities).
|
||||||
|
|
||||||
|
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".
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Security rules a peer will notice
|
||||||
|
|
||||||
|
- **Signatures.** Inbox POSTs must be signed over `(request-target)`, `host`, `digest` and `date` (or `(created)`). The
|
||||||
|
date may be at most one hour old and fifteen minutes ahead. A bad signature gets 401, malformed input 400, an accepted
|
||||||
|
activity 202, too many requests 429. Activities are processed after the 202.
|
||||||
|
- **Origins.** An actor document is accepted only from the address it names as its `id`. A key only if its actor lists
|
||||||
|
it with `owner` set to the actor and on the actor's origin. An activity's `id`, and any object it creates, updates or
|
||||||
|
deletes, must be on its actor's origin. An embedded object from another origin is fetched from that origin.
|
||||||
|
- **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three
|
||||||
|
redirects and read at most 1 MB.
|
||||||
|
- **HTML.** Received HTML is sanitised to Mastodon's allowlist.
|
||||||
|
- **Delivery.** Failed deliveries are retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused,
|
||||||
|
starting at an hour and growing to a week.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- Likes, boosts, follow requests to remote accounts, media uploads and polls are not implemented yet.
|
||||||
|
- Collections expose counts, not members.
|
||||||
|
- Only `rsa-sha256`-style keys are verified. RFC 9421 signatures are planned.
|
||||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using MongoDB.Entities;
|
using MongoDB.Entities;
|
||||||
|
|
||||||
using PrivaPub.Models.Federation;
|
using PrivaPub.Models.Federation;
|
||||||
|
using PrivaPub.Models.Post;
|
||||||
using PrivaPub.Models.User;
|
using PrivaPub.Models.User;
|
||||||
using PrivaPub.StaticServices;
|
using PrivaPub.StaticServices;
|
||||||
|
|
||||||
@@ -64,24 +65,37 @@ namespace PrivaPub.Federation.Controllers
|
|||||||
{
|
{
|
||||||
var document = new JsonObject
|
var document = new JsonObject
|
||||||
{
|
{
|
||||||
["links"] = new JsonArray(new JsonObject
|
["links"] = new JsonArray(
|
||||||
{
|
new JsonObject
|
||||||
["rel"] = "http://nodeinfo.diaspora.software/ns/schema/2.0",
|
{
|
||||||
["href"] = $"{_localActors.BaseAddress}/nodeinfo/2.0"
|
["rel"] = "http://nodeinfo.diaspora.software/ns/schema/2.1",
|
||||||
})
|
["href"] = $"{_localActors.BaseAddress}/nodeinfo/2.1"
|
||||||
|
},
|
||||||
|
new JsonObject
|
||||||
|
{
|
||||||
|
["rel"] = "http://nodeinfo.diaspora.software/ns/schema/2.0",
|
||||||
|
["href"] = $"{_localActors.BaseAddress}/nodeinfo/2.0"
|
||||||
|
})
|
||||||
};
|
};
|
||||||
return Content(document.ToJsonString(), "application/json; charset=utf-8");
|
return Content(document.ToJsonString(), "application/json; charset=utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet, Route("/nodeinfo/2.0")]
|
[HttpGet, Route("/nodeinfo/{version:regex(^2\\.[[01]]$)}")]
|
||||||
public async Task<IActionResult> NodeInfo(CancellationToken token)
|
public async Task<IActionResult> NodeInfo(string version, CancellationToken token)
|
||||||
{
|
{
|
||||||
var users = await DB.Default.CountAsync<Avatar>(a => !a.DeletionAt.HasValue, token);
|
var users = await DB.Default.CountAsync<Avatar>(a => !a.DeletionAt.HasValue, token);
|
||||||
var posts = await DB.Default.CountAsync<PostEntity>(p => !p.IsFederatedCopy, token);
|
var posts = await DB.Default.CountAsync<PostEntity>(p => !p.IsFederatedCopy && !p.DeletedAt.HasValue
|
||||||
|
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted), token);
|
||||||
|
var software = new JsonObject { ["name"] = "privapub", ["version"] = BuildInfo.Ref };
|
||||||
|
if (version == "2.1")
|
||||||
|
{
|
||||||
|
software["repository"] = "https://git.thepra.dev/thepra/SocialPub";
|
||||||
|
software["homepage"] = "https://git.thepra.dev/thepra/SocialPub";
|
||||||
|
}
|
||||||
var document = new JsonObject
|
var document = new JsonObject
|
||||||
{
|
{
|
||||||
["version"] = "2.0",
|
["version"] = version,
|
||||||
["software"] = new JsonObject { ["name"] = "privapub", ["version"] = BuildInfo.Ref },
|
["software"] = software,
|
||||||
["protocols"] = new JsonArray("activitypub"),
|
["protocols"] = new JsonArray("activitypub"),
|
||||||
["services"] = new JsonObject { ["inbound"] = new JsonArray(), ["outbound"] = new JsonArray() },
|
["services"] = new JsonObject { ["inbound"] = new JsonArray(), ["outbound"] = new JsonArray() },
|
||||||
["openRegistrations"] = true,
|
["openRegistrations"] = true,
|
||||||
@@ -90,9 +104,15 @@ namespace PrivaPub.Federation.Controllers
|
|||||||
["users"] = new JsonObject { ["total"] = users },
|
["users"] = new JsonObject { ["total"] = users },
|
||||||
["localPosts"] = posts
|
["localPosts"] = posts
|
||||||
},
|
},
|
||||||
["metadata"] = new JsonObject()
|
["metadata"] = new JsonObject
|
||||||
|
{
|
||||||
|
["nodeName"] = "PrivaPub",
|
||||||
|
["nodeDescription"] = "A small ActivityPub server where one private login keeps several unlinkable public personas.",
|
||||||
|
["federation"] = new JsonObject { ["document"] = "https://git.thepra.dev/thepra/SocialPub/src/branch/master/FEDERATION.md" }
|
||||||
|
}
|
||||||
};
|
};
|
||||||
return Content(document.ToJsonString(), "application/json; profile=\"http://nodeinfo.diaspora.software/ns/schema/2.0#\"; charset=utf-8");
|
return Content(document.ToJsonString(),
|
||||||
|
$"application/json; profile=\"http://nodeinfo.diaspora.software/ns/schema/{version}#\"; charset=utf-8");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,6 +142,8 @@ namespace PrivaPub.Middleware
|
|||||||
[new OpenApiSecuritySchemeReference("Bearer", document)] = []
|
[new OpenApiSecuritySchemeReference("Bearer", document)] = []
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
.AddRazorPages(options => options.RootDirectory = "/Web/Pages")
|
||||||
|
.Services
|
||||||
.AddControllers(options => { options.Filters.Add<OperationCancelledExceptionFilter>(); })
|
.AddControllers(options => { options.Filters.Add<OperationCancelledExceptionFilter>(); })
|
||||||
.AddJsonOptions(options =>
|
.AddJsonOptions(options =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ try
|
|||||||
builtAt = BuildInfo.BuiltAt,
|
builtAt = BuildInfo.BuiltAt,
|
||||||
}));
|
}));
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
app.MapRazorPages();
|
||||||
//app.MapFallbackToFile("index.html");
|
//app.MapFallbackToFile("index.html");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -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