diff --git a/PrivaPub.ClientModels/Admin/ViewStatistics.cs b/PrivaPub.ClientModels/Admin/ViewStatistics.cs index c2cdffb..b86d7dc 100644 --- a/PrivaPub.ClientModels/Admin/ViewStatistics.cs +++ b/PrivaPub.ClientModels/Admin/ViewStatistics.cs @@ -96,4 +96,96 @@ namespace PrivaPub.ClientModels.Admin public string CrawlError { get; set; } public string Software { get; set; } } + + // /clientapi/admin/statistics/hosts/{host}: one server, what it says of itself, its days and its latest events + public class ViewHostDetail + { + public string Host { get; set; } + public ViewHostInstance Instance { get; set; } + public List Days { get; set; } = []; + public List Events { get; set; } = []; + public string Attribution { get; set; } + } + + public class ViewHostInstance + { + public string Software { get; set; } + public string SoftwareVersion { get; set; } + public string NodeName { get; set; } + public string Title { get; set; } + public string NodeDescription { get; set; } + public bool? OpenRegistrations { get; set; } + public string RegistrationMode { get; set; } + public long? UsersTotal { get; set; } + public long? UsersActiveMonth { get; set; } + public long? LocalPosts { get; set; } + public List Languages { get; set; } = []; + public string Seen { get; set; } + public DateTime? FirstSeenAt { get; set; } + public DateTime? LastSeenAt { get; set; } + public DateTime? DescribedAt { get; set; } + public string DescriptionError { get; set; } + public DateTime? LastSuccessAt { get; set; } + public DateTime? LastFailureAt { get; set; } + public string LastError { get; set; } + public int ConsecutiveFailures { get; set; } + public DateTime? UnavailableUntil { get; set; } + public string Country { get; set; } + public string City { get; set; } + public int? Asn { get; set; } + public string AsnOrg { get; set; } + public string Cdn { get; set; } + public string InstanceApi { get; set; } + public string SourceUrl { get; set; } + } + + public class ViewHostDay + { + public DateTime Day { get; set; } + public Dictionary Counters { get; set; } = new(); + public Dictionary Latency { get; set; } = new(); + public Dictionary Bytes { get; set; } = new(); + public Dictionary Reads { get; set; } = new(); + public int Accounts { get; set; } + public bool Live { get; set; }//today's or a day not rolled up yet, folded from its events + } + + // one ledger entry: what was received, delivered or fetched, and how it ended; never who + public class ViewInteractionEvent + { + public DateTime At { get; set; } + public string Host { get; set; } + public string Channel { get; set; } + public string Activity { get; set; } + public string Object { get; set; } + public string Purpose { get; set; } + public string Trigger { get; set; } + public string Outcome { get; set; } + public string Reason { get; set; } + public int? Status { get; set; } + public int? LatencyMs { get; set; } + public int? WaitMs { get; set; } + public long? Bytes { get; set; } + public int? Attempt { get; set; } + public string Audience { get; set; } + public string LocalKind { get; set; } + public string Inbox { get; set; } + public string Signature { get; set; } + public List Features { get; set; } = []; + public long? AgeSeconds { get; set; } + } + + // /clientapi/admin/statistics/server: the server's own day, its client API and the documents it served + public class ViewServerDay + { + public DateTime Day { get; set; } + public Dictionary Client { get; set; } = new(); + public Dictionary ClientLatency { get; set; } = new(); + public Dictionary Served { get; set; } = new(); + public long LedgerWritten { get; set; } + public long LedgerDropped { get; set; } + public long LedgerFailed { get; set; } + public int Accounts { get; set; } + public int Hosts { get; set; } + } } diff --git a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs index aa8f58c..f897e6d 100644 --- a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs @@ -181,6 +181,17 @@ namespace PrivaPub.Api.Mastodon.Controllers ? await search.Followed(MyId, Params.Get("q"), Limit(), token) : await search.Find(Params.Get("q"), Params.Bool("resolve") == true && MyId != default, Limit(), token)); + // Mastodon 4.3: several accounts at once (at most 40), those that exist, in no promised order + [HttpGet("/api/v1/accounts"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] + public async Task Many(CancellationToken token) + { + var accounts = new List(); + foreach (var id in Params.List("id").Distinct().Take(40)) + if (await _mapper.Account(id, token) is { } account) + accounts.Add(account); + return Json(accounts); + } + [HttpGet("/api/v1/accounts/{id}"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task Get(string id, CancellationToken token) { diff --git a/PrivaPub/Api/Mastodon/Controllers/MediaController.cs b/PrivaPub/Api/Mastodon/Controllers/MediaController.cs index 5d09ac9..a7f4b44 100644 --- a/PrivaPub/Api/Mastodon/Controllers/MediaController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/MediaController.cs @@ -64,6 +64,20 @@ namespace PrivaPub.Api.Mastodon.Controllers }; } + // Mastodon 4.4: a persona's own upload, while nothing holds it (not posted, not scheduled, not a profile picture), goes to + // the trash as the janitor would take it + [HttpDelete("/api/v1/media/{id}"), Scope("write:media")] + public async Task Delete(string id, CancellationToken token) + { + var attachment = await DB.Default.Find().Match(m => m.ID == id && m.OwnerAvatarId == MyId && m.TrashedAt == null && m.ProfileOfAvatarId == null).ExecuteFirstAsync(token); + if (attachment == default) + return NotFoundError(); + if (attachment.PostId != null || attachment.ScheduledStatusId != null) + return Error(StatusCodes.Status422UnprocessableEntity, "Media attachment is currently used by a status"); + await _media.Trash(m => m.ID == attachment.ID && m.PostId == null && m.ScheduledStatusId == null, "deleted by its owner", token); + return Json(new { }); + } + // as on Mastodon, only media not posted yet: a posted one's description and focus change by editing its post (media_attributes) [HttpPut("/api/v1/media/{id}"), Scope("write:media")] public async Task Update(string id, CancellationToken token) diff --git a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs index 69a1172..2ab86a1 100644 --- a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs @@ -77,7 +77,7 @@ namespace PrivaPub.Api.Mastodon.Controllers if (type is null or "statuses" && MyId != default && !q.StartsWith('#') && !Handle().IsMatch(q)) results.Statuses = await Words(q, Math.Max(0, offset), Limit(), token); if (type is null or "hashtags" && TagsController.Normalise(q) is var tag && TagsController.IsHashtag(tag)) - results.Hashtags.Add(new { name = tag, url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag)}", history = Array.Empty() }); + results.Hashtags.Add(new { name = tag, url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag)}", history = await TagsController.History(_dbEntities, tag, token) }); return Json(results); } } diff --git a/PrivaPub/Domain/Statistics/StatisticsQueries.cs b/PrivaPub/Domain/Statistics/StatisticsQueries.cs index b75c7f2..200dd76 100644 --- a/PrivaPub/Domain/Statistics/StatisticsQueries.cs +++ b/PrivaPub/Domain/Statistics/StatisticsQueries.cs @@ -128,7 +128,7 @@ namespace PrivaPub.Domain.Statistics return new ViewHostsPage { Total = list.Count, Page = Math.Max(page, 1), Hosts = list.Skip((Math.Max(page, 1) - 1) * limit).Take(limit).ToList(), Attribution = Attribution }; } - public async Task Host(string host, int days, CancellationToken token) + public async Task Host(string host, int days, CancellationToken token) { host = Interactions.Host(host); var instance = await DB.Default.Find().Match(i => i.Host == host).ExecuteFirstAsync(token); @@ -136,20 +136,41 @@ namespace PrivaPub.Domain.Statistics var events = await DB.Default.Find().Match(e => e.Host == host).Sort(e => e.At, Order.Descending).Limit(100).ExecuteAsync(token); if (instance == default && hostDays.Count == 0 && events.Count == 0) return default; - return new + return new ViewHostDetail { Host = host, - Instance = instance, - Days = hostDays.Select(d => new { d.Day, d.Counters, d.Latency, d.Bytes, d.Reads, d.Accounts, d.Live }), - Events = events, - Attribution + Instance = instance == default ? default : new ViewHostInstance + { + Software = instance.Software, SoftwareVersion = instance.SoftwareVersion, NodeName = instance.NodeName, Title = instance.Title, + NodeDescription = instance.NodeDescription, OpenRegistrations = instance.OpenRegistrations, RegistrationMode = instance.RegistrationMode, + UsersTotal = instance.UsersTotal, UsersActiveMonth = instance.UsersActiveMonth, LocalPosts = instance.LocalPosts, + Languages = instance.Languages ?? [], Seen = instance.Seen, FirstSeenAt = instance.FirstSeenAt, LastSeenAt = instance.LastSeenAt, + DescribedAt = instance.DescribedAt, DescriptionError = instance.DescriptionError, LastSuccessAt = instance.LastSuccessAt, + LastFailureAt = instance.LastFailureAt, LastError = instance.LastError, ConsecutiveFailures = instance.ConsecutiveFailures, + UnavailableUntil = instance.UnavailableUntil, Country = instance.Geo?.Country, City = instance.Geo?.City, Asn = instance.Geo?.Asn, + AsnOrg = instance.Geo?.AsnOrg, Cdn = instance.Geo?.Cdn, InstanceApi = instance.InstanceApi, SourceUrl = instance.SourceUrl + }, + Days = hostDays.Select(d => new ViewHostDay + { + Day = d.Day, Counters = new(d.Counters), Latency = new(d.Latency), Bytes = new(d.Bytes), Reads = new(d.Reads ?? new Dictionary()), + Accounts = d.Accounts, Live = d.Live + }).ToList(), + Events = events.Select(View).ToList(), + Attribution = Attribution }; } - public async Task> Events(string host, string channel, string outcome, string reason, DateTime? before, int limit, CancellationToken token) + static ViewInteractionEvent View(InteractionEvent e) => new() + { + At = e.At, Host = e.Host, Channel = e.Channel, Activity = e.Activity, Object = e.Object, Purpose = e.Purpose, Trigger = e.Trigger, + Outcome = e.Outcome, Reason = e.Reason, Status = e.Status, LatencyMs = e.LatencyMs, WaitMs = e.WaitMs, Bytes = e.Bytes, Attempt = e.Attempt, + Audience = e.Audience, LocalKind = e.LocalKind, Inbox = e.Inbox, Signature = e.Signature, Features = e.Features ?? [], AgeSeconds = e.AgeSeconds + }; + + public async Task> Events(string host, string channel, string outcome, string reason, DateTime? before, int limit, CancellationToken token) { var hostName = host == default ? default : Interactions.Host(host); - return await DB.Default.Find() + return (await DB.Default.Find() .Match(e => hostName == null || e.Host == hostName) .Match(e => channel == null || e.Channel == channel) .Match(e => outcome == null || e.Outcome == outcome) @@ -157,7 +178,7 @@ namespace PrivaPub.Domain.Statistics .Match(e => before == null || e.At < before) .Sort(e => e.At, Order.Descending) .Limit(Math.Clamp(limit, 1, 200)) - .ExecuteAsync(token); + .ExecuteAsync(token)).Select(View).ToList(); } public async Task Crawler(bool enabled, int revisitDays, CancellationToken token) @@ -183,10 +204,15 @@ namespace PrivaPub.Domain.Statistics }; } - public async Task> Server(int days, CancellationToken token) + public async Task> Server(int days, CancellationToken token) { var since = DateTime.UtcNow.Date.AddDays(1 - Math.Clamp(days, 1, 3660)); - return await DB.Default.Find().Match(d => d.Day >= since).Sort(d => d.Day, Order.Ascending).ExecuteAsync(token); + return (await DB.Default.Find().Match(d => d.Day >= since).Sort(d => d.Day, Order.Ascending).ExecuteAsync(token)) + .Select(d => new ViewServerDay + { + Day = d.Day, Client = d.Client, ClientLatency = d.ClientLatency, Served = d.Served, LedgerWritten = d.LedgerWritten, + LedgerDropped = d.LedgerDropped, LedgerFailed = d.LedgerFailed, Accounts = d.Accounts, Hosts = d.Hosts + }).ToList(); } public static Dictionary Outcomes(IReadOnlyDictionary counters, string channel)