Roles (user, moderator, administrator) from the CLI and /clientapi/admin/roots/{id}/role, never one's own nor the last
administrator. Silence keeps an account's posts out of public timelines, hashtags, trends, search and relays while its
followers still get them; suspension refuses and revokes a local persona's tokens, answers 404 for its posts and
collections, holds back every delivery and tells other servers nothing new, its actor unchanged; a suspended remote
account's activities are dropped and nothing goes to its own inbox (a shared one still serves its server). A banned
login's personas are held back in memory alone (Restrictions), so nothing pairs them in stored data. Personas and groups
are deleted one at a time (PersonaRemoval, GroupRemoval), by their login or an administrator with the password. Reports
resolve with an action and a note; posts are taken down, a remote copy hidden. Warnings reach the persona as Mastodon's
moderation_warning. Every staff act goes to the staff's log. Migration _020 limits posts from servers already silenced;
a restore keeps the moderation made since the backup.
Verified: interop.sh moderation gts mastodon with SecureMode, 194 passed; restore.sh, 25 passed, now with a suspension,
a silence and a takedown made after the backup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
197 lines
6.7 KiB
C#
197 lines
6.7 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, Suspended: false } || Actor.IsServerActor)
|
|
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 is { Suspended: false })
|
|
views.Add(PostView.From(post, author));
|
|
}
|
|
Posts = views;
|
|
return Page();
|
|
}
|
|
}
|
|
|
|
// The hashtag links our posts carry (/tags/{tag}): this server's public posts with that tag, as a page like a profile's.
|
|
public class TagModel : PublicPageModel
|
|
{
|
|
const int PageSize = 20;
|
|
|
|
readonly ILocalActorService _localActors;
|
|
readonly DbEntities _dbEntities;
|
|
|
|
public TagModel(ILocalActorService localActors, DbEntities dbEntities)
|
|
{
|
|
_localActors = localActors;
|
|
_dbEntities = dbEntities;
|
|
}
|
|
|
|
public string Tag { get; private set; }
|
|
public IReadOnlyList<PostView> Posts { get; private set; } = Array.Empty<PostView>();
|
|
|
|
public async Task<IActionResult> OnGetAsync(string tag, CancellationToken token)
|
|
{
|
|
Tag = tag?.TrimStart('#').ToLowerInvariant();
|
|
if (string.IsNullOrEmpty(Tag) || Tag.Length > 100)
|
|
return NotFound();
|
|
Harden();
|
|
var posts = await _dbEntities.Posts
|
|
.Match(p => p.Tags.Contains(Tag) && !p.IsFederatedCopy && p.ReblogOfPostId == null && p.Visibility == PostVisibility.Public)
|
|
.Match(VisibilityPolicy.IsPublic)
|
|
.Match(VisibilityPolicy.IsListed)
|
|
.Sort(p => p.ID, Order.Descending)
|
|
.Limit(PageSize)
|
|
.ExecuteAsync(token);
|
|
var authors = new Dictionary<string, LocalActor>();
|
|
var views = new List<PostView>();
|
|
foreach (var post in posts)
|
|
{
|
|
if (!authors.TryGetValue(post.GroupUserId, out var author))
|
|
authors[post.GroupUserId] = author = await _localActors.FindById(LocalActorKind.Person, post.GroupUserId, token);
|
|
if (author is { IsFederated: true, Suspended: false })
|
|
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, Suspended: false })
|
|
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();
|
|
}
|
|
}
|
|
|
|
public class StargazingModel : PublicPageModel
|
|
{
|
|
readonly Microsoft.Extensions.Options.IOptionsMonitor<Infrastructure.Statistics.StatisticsOptions> _options;
|
|
readonly Microsoft.Extensions.Options.IOptionsMonitor<Models.AppConfiguration> _app;
|
|
|
|
readonly Infrastructure.Geo.IGeoLocator _geo;
|
|
|
|
public StargazingModel(Microsoft.Extensions.Options.IOptionsMonitor<Infrastructure.Statistics.StatisticsOptions> options,
|
|
Microsoft.Extensions.Options.IOptionsMonitor<Models.AppConfiguration> app, Infrastructure.Geo.IGeoLocator geo)
|
|
{
|
|
_options = options;
|
|
_app = app;
|
|
_geo = geo;
|
|
}
|
|
|
|
public bool CrawlerEnabled => _options.CurrentValue.Crawler.Enabled;
|
|
public string GeoSource => _geo.Source;
|
|
public string UserAgent => Federation.Crawler.Stargazer.UserAgent(_app.CurrentValue.BackendBaseAddress);
|
|
|
|
public void OnGet() => Harden();
|
|
}
|
|
}
|