Build / Build (push) Successful in 1m3s
A singleton ServerSettings (title, descriptions, contact, thumbnail, privacy policy, terms, rules, relays, media overrides, PublishActivity), cached by InstanceSettings and read by the instance API, NodeInfo, the instance actor, the web pages and the mailer; its defaults are the old literals, so nothing changes until an administrator edits. /clientapi/admin/settings (and /thumbnail), /announcements, /relays for administrators; /clientapi/moderator/log, the staff's log (AdminAction, written by domain blocks, backups, settings and the CLI; no IP, never a root paired with its personas). The Mastodon API's rules, extended description, privacy policy, terms of service, domain blocks (Published, Obfuscate), announcements (dismiss, reactions, streaming) and activity (only when published). Reports carry rule ids. Media overrides clamped to hard caps. Relays named by the settings reconcile next to the configured ones. Verified: interop.sh instance accounts relay with mastodon, relay, aoderelay and mailpit, 119 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
118 lines
5.8 KiB
C#
118 lines
5.8 KiB
C#
using MongoDB.Driver;
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Models.User;
|
|
|
|
using System.Linq.Expressions;
|
|
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
|
|
|
namespace PrivaPub.Domain.Privacy
|
|
{
|
|
// What the numbers count, in one place, so a persona's statuses_count, its outbox's totalItems, NodeInfo and the
|
|
// instance API agree (they used to count four different things).
|
|
public static class Counted
|
|
{
|
|
// Mastodon's statuses_count: every post that is neither deleted nor a direct message, reblogs included. Circle and
|
|
// located posts count too (owner decision 2026-10-04: "a good balance for the fediverse to work").
|
|
public static readonly Expression<Func<PostEntity, bool>> Post = p => !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct;
|
|
|
|
// a persona's own posts, or every post in a group, its remote members' included
|
|
public static Expression<Func<PostEntity, bool>> By(LocalActor actor)
|
|
{
|
|
var id = actor.Id;
|
|
return actor.Kind == LocalActorKind.Group
|
|
? p => p.GroupId == id && !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct
|
|
: p => p.GroupUserId == id && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct;
|
|
}
|
|
|
|
// replies_count counts only public and unlisted replies, as Mastodon does: a count must not tell anyone that a
|
|
// followers-only, direct or circle reply exists
|
|
public static bool Reply(PostEntity reply) => reply.Visibility is PostVisibility.Public or PostVisibility.Unlisted;
|
|
|
|
public static async Task<long> PostsBy(LocalActor actor, CancellationToken token) =>
|
|
await DB.Default.CountAsync(By(actor), token);
|
|
|
|
// Personas that do not count as this server's users, and are not found in search: those of a banned or deleted root.
|
|
public static async Task<List<string>> BarredPersonas(CancellationToken token)
|
|
{
|
|
var roots = (await DB.Default.Find<RootUser>().Match(u => u.IsBanned || u.DeletedAt != null).Project(u => u.Include(x => x.ID)).ExecuteAsync(token))
|
|
.Select(u => u.ID).ToList();
|
|
return roots.Count == 0
|
|
? new List<string>()
|
|
: (await DB.Default.Find<RootToAvatar>().Match(ra => roots.Contains(ra.RootId)).ExecuteAsync(token)).Select(ra => ra.AvatarId).ToList();
|
|
}
|
|
|
|
public static async Task<long> Users(CancellationToken token)
|
|
{
|
|
var barred = await BarredPersonas(token);
|
|
return await DB.Default.CountAsync<Avatar>(a => !a.DeletionAt.HasValue && !barred.Contains(a.ID), token);
|
|
}
|
|
|
|
// users who posted within the last days, as NodeInfo's activeMonth and activeHalfyear and the instance API mean it
|
|
public static async Task<long> ActiveUsers(int days, CancellationToken token)
|
|
{
|
|
var barred = await BarredPersonas(token);
|
|
var since = DateTime.UtcNow.AddDays(-days);
|
|
var authors = await DB.Default.Find<PostEntity, string>()
|
|
.Match(p => !p.IsFederatedCopy && p.CreationDate >= since && !barred.Contains(p.GroupUserId))
|
|
.Project(p => p.GroupUserId)
|
|
.ExecuteAsync(token);
|
|
var distinct = authors.Where(a => !string.IsNullOrEmpty(a)).Distinct().ToList();
|
|
return distinct.Count == 0 ? 0 : await DB.Default.CountAsync<Avatar>(a => distinct.Contains(a.ID) && !a.DeletionAt.HasValue, token);
|
|
}
|
|
|
|
// the media kept (not trashed) that which holds: their bytes and how many
|
|
public static async Task<(long Bytes, long Files)> Media(Expression<Func<Models.Media.MediaAttachment, bool>> which, CancellationToken token)
|
|
{
|
|
var totals = await DB.Default.Fluent<Models.Media.MediaAttachment>()
|
|
.Match(m => m.TrashedAt == null)
|
|
.Match(which)
|
|
.Group(m => true, g => new { Bytes = g.Sum(m => m.Size), Files = g.LongCount() })
|
|
.FirstOrDefaultAsync(token);
|
|
return totals == default ? (0, 0) : (totals.Bytes, totals.Files);
|
|
}
|
|
|
|
// a login's media: every persona's uploads and pictures
|
|
public static async Task<(long Bytes, long Files)> MediaOfRoot(string rootId, CancellationToken token)
|
|
{
|
|
var personas = (await DB.Default.Find<Models.User.RootToAvatar>().Match(r => r.RootId == rootId).ExecuteAsync(token)).Select(r => r.AvatarId).ToList();
|
|
return await Media(m => personas.Contains(m.OwnerAvatarId), token);
|
|
}
|
|
|
|
public static async Task<long> LocalPosts(CancellationToken token)
|
|
{
|
|
var barred = await BarredPersonas(token);
|
|
return await DB.Default.CountAsync<PostEntity>(f => f.Where(p => !p.IsFederatedCopy && !barred.Contains(p.GroupUserId)) & f.Where(Post), token);
|
|
}
|
|
|
|
// Mastodon's instance activity, week by week (from Monday 00:00 UTC), this week first: the local posts made in it,
|
|
// counted as LocalPosts counts them (by their ids, which carry when they were made, so the _id index serves it), and
|
|
// the logins made in it (RootUser.CreatedAt). Never personas by their creation: their dates are spread on purpose.
|
|
public static async Task<List<(DateTime Week, long Posts, long Registrations)>> Weekly(int weeks, CancellationToken token)
|
|
{
|
|
var barred = await BarredPersonas(token);
|
|
var today = DateTime.UtcNow.Date;
|
|
var monday = DateTime.SpecifyKind(today.AddDays(-(((int)today.DayOfWeek + 6) % 7)), DateTimeKind.Utc);
|
|
var counted = new List<(DateTime, long, long)>();
|
|
for (var i = 0; i < weeks; i++)
|
|
{
|
|
var start = monday.AddDays(-7 * i);
|
|
var end = start.AddDays(7);
|
|
var (low, high) = (IdAt(start), IdAt(end));
|
|
var posts = await DB.Default.CountAsync<PostEntity>(f => f.Gte(p => p.ID, low) & f.Lt(p => p.ID, high)
|
|
& f.Where(p => !p.IsFederatedCopy && !barred.Contains(p.GroupUserId)) & f.Where(Post), token);
|
|
var registrations = await DB.Default.CountAsync<RootUser>(u => u.CreatedAt >= start && u.CreatedAt < end, token);
|
|
counted.Add((start, posts, registrations));
|
|
}
|
|
return counted;
|
|
}
|
|
|
|
// the smallest ObjectId of a moment
|
|
static string IdAt(DateTime at) => ((uint)new DateTimeOffset(at, TimeSpan.Zero).ToUnixTimeSeconds()).ToString("x8") + "0000000000000000";
|
|
}
|
|
}
|