Lemmy takes a report only from a person or a service, about one post or comment, addressed to its community, and it answered PrivaPub's Flag (the instance actor's, an Application, with no `to` and the account and posts as its object) 400. A report of a post or comment in a community on a server whose NodeInfo names Lemmy now leaves from `privapub_reports`, a Service with its own key that names nobody: one Flag per post, `to` the community (its own audience, else its thread's), with the persona's words, or the category, in `summary` and `content`, sent to the community's inbox. This is the second exception to "a server's software is for display" (owner decision 2026-10-06, `ReportService.ServiceReportTakers`). Every other server keeps the instance actor's report. An account alone is not reported to Lemmy, which takes no such report, and `forwarded` now says whether anything left. The reporter is read unsigned in SecureMode and answers WebFinger like the instance actor. Nobody follows or mentions it, the Mastodon API has no account for it, and a migration reserves its name. Checked live: Lemmy 1.0 and 0.19 keep the reports of a thread and of a comment, with alice's words, from "Reports from privapub.test", and none names her (69 checks). A sweep of every scenario with this and the next commit: 876 checks pass; Ghost's Network feed listed alice's post too late once, and Ghost passes alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
196 lines
6.6 KiB
C#
196 lines
6.6 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.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 != default)
|
|
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)
|
|
.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 })
|
|
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();
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|