2026-10-07 11:03:25 +02:00
|
|
|
using MongoDB.Driver;
|
2026-10-04 03:48:32 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 11:03:25 +02:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 03:48:32 +02:00
|
|
|
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);
|
|
|
|
|
}
|
2026-10-17 04:02:47 +02:00
|
|
|
|
|
|
|
|
// 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";
|
2026-10-04 03:48:32 +02:00
|
|
|
}
|
|
|
|
|
}
|