- A community's announces resolve, as FEDERATION.md and ROADMAP always said Announce ids do. GroupDistributor keeps
each one it sends (GroupAnnouncement, unique by id). /grunts serves it while the post it carries is shown and 410
after, and also serves the announce-{postId} ids the group outbox lists, which now keep a stable `published`
instead of the time of the fetch.
- A persona's boost points at the boosted post: its `url` in the Mastodon API is the boosted post's page, and a browser
following the boost's id is redirected there instead of getting JSON.
- /tags/{tag}, where every Hashtag link we send points, is now a public page of this server's public posts with that
tag, with the same strict CSP and noindex as the profile pages.
- Grouped notifications (/api/v2/notifications, its unread count, a group, its accounts and dismiss). We advertise
api_versions.mastodon = 7 so clients show quotes, and clients that trust it call these; they answered 404. Likes and
boosts of one post group together, as do follows within an hour. The version string stays 4.2.0 until streaming
and Web Push exist.
- A remote account's follower, following and post counts are what its server publishes. AccountCountsJob reads its
collections' totalItems from its own origin, signed by the instance actor, at most daily and only after the account
was fetched, never when someone looks. They used to be 0.
- The other ids that do not resolve are documented as such: Update, Delete, EmojiReact, QuoteRequest and its answers,
Flag, Ignore and poll votes.
676 tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
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.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();
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
}
|