R4 server pieces: typed admin statistics, DELETE media, accounts by ids, hashtag history in search
Build / Build (push) Successful in 32s

Completion program R4 (the client catching up), the server's part.

- /clientapi/admin/statistics/hosts/{host}, /events and /server answer typed views (ViewHostDetail, ViewHostInstance,
  ViewHostDay, ViewInteractionEvent, ViewServerDay in ClientModels) instead of an anonymous object and raw entities:
  the instance's public description, never its raw instance API document; the events without their actor hash. The
  JSON names stay, so lib/interop.sh's stats_check reads them as before.
- DELETE /api/v1/media/{id} (Mastodon 4.4): a persona's own upload goes to the trash while nothing holds it; a posted
  or scheduled one answers 422.
- GET /api/v1/accounts?id[]= (Mastodon 4.3): up to 40 accounts, those that exist.
- /api/v2/search's hashtags carry their week of use (TagsController.History), not an empty history.

Verified: dotnet build PrivaPub.sln -c Release; the pasture: gts 64/64 (statistics included); by hand, an unattached
upload deleted (then 404), a posted one refused (422), accounts by ids, a hashtag's history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-17 03:23:06 +02:00
1 parent 1f3bb27e17
commit f67e2ae35e
5 files changed
+155 -12

No files matched your search

@@ -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<IActionResult> Many(CancellationToken token)
{
var accounts = new List<object>();
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<IActionResult> Get(string id, CancellationToken token)
{
@@ -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<IActionResult> Delete(string id, CancellationToken token)
{
var attachment = await DB.Default.Find<MediaAttachment>().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<IActionResult> Update(string id, CancellationToken token)
@@ -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<object>() });
results.Hashtags.Add(new { name = tag, url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag)}", history = await TagsController.History(_dbEntities, tag, token) });
return Json(results);
}
}
+37 -11
View File
@@ -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<object> Host(string host, int days, CancellationToken token)
public async Task<ViewHostDetail> Host(string host, int days, CancellationToken token)
{
host = Interactions.Host(host);
var instance = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == host).ExecuteFirstAsync(token);
@@ -136,20 +136,41 @@ namespace PrivaPub.Domain.Statistics
var events = await DB.Default.Find<InteractionEvent>().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<string, long>()),
Accounts = d.Accounts, Live = d.Live
}).ToList(),
Events = events.Select(View).ToList(),
Attribution = Attribution
};
}
public async Task<List<InteractionEvent>> 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<List<ViewInteractionEvent>> 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<InteractionEvent>()
return (await DB.Default.Find<InteractionEvent>()
.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<ViewCrawler> Crawler(bool enabled, int revisitDays, CancellationToken token)
@@ -183,10 +204,15 @@ namespace PrivaPub.Domain.Statistics
};
}
public async Task<List<ServerDay>> Server(int days, CancellationToken token)
public async Task<List<ViewServerDay>> Server(int days, CancellationToken token)
{
var since = DateTime.UtcNow.Date.AddDays(1 - Math.Clamp(days, 1, 3660));
return await DB.Default.Find<ServerDay>().Match(d => d.Day >= since).Sort(d => d.Day, Order.Ascending).ExecuteAsync(token);
return (await DB.Default.Find<ServerDay>().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<string, long> Outcomes(IReadOnlyDictionary<string, long> counters, string channel)