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> 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> 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 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> BarredPersonas(CancellationToken token) { var roots = (await DB.Default.Find().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() : (await DB.Default.Find().Match(ra => roots.Contains(ra.RootId)).ExecuteAsync(token)).Select(ra => ra.AvatarId).ToList(); } public static async Task Users(CancellationToken token) { var barred = await BarredPersonas(token); return await DB.Default.CountAsync(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 ActiveUsers(int days, CancellationToken token) { var barred = await BarredPersonas(token); var since = DateTime.UtcNow.AddDays(-days); var authors = await DB.Default.Find() .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(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> which, CancellationToken token) { var totals = await DB.Default.Fluent() .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().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 LocalPosts(CancellationToken token) { var barred = await BarredPersonas(token); return await DB.Default.CountAsync(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> 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(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(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"; } }