InteractionEvent records one interaction with a remote server: its channel (recv, in, out, http, preview, crawl), activity and object type, outcome and reason, status, latency, wait, bytes, attempt, audience, local actor kind, inbox, signature, features and the object's age. IInteractionLedger.Record never blocks and never throws: events go into a bounded channel of 10k, a full channel drops and counts, and a hosted service writes batches of up to 1000 every two seconds. Privacy, as decided by the owner: - no persona, root, group or activity id, inbox URL, actor URI or sender IP is stored; - distinct accounts are counted with an HMAC keyed by a per-day salt (InteractionSalt, upserted so restarts agree, never created for a past day); - the local actor kind survives only on public and unlisted traffic; - a host claimed by an unverified sender is kept only if it is already known. Traffic caused by reading is only counted per day (InstanceDay.Reads, ServerDay). Indexes: a 90-day TTL on events, unique day rows, and a TTL safety net on salts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
45 lines
1.4 KiB
C#
45 lines
1.4 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Models.Statistics;
|
|
|
|
using System.Collections.Concurrent;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
|
|
namespace PrivaPub.Infrastructure.Statistics
|
|
{
|
|
public class InteractionSalts
|
|
{
|
|
readonly ConcurrentDictionary<DateTime, byte[]> _known = new();
|
|
|
|
public async Task<byte[]> For(DateTime day, CancellationToken token)
|
|
{
|
|
day = day.Date;
|
|
if (_known.TryGetValue(day, out var cached))
|
|
return cached;
|
|
InteractionSalt salt;
|
|
if (day == DateTime.UtcNow.Date)
|
|
salt = await DB.Default.UpdateAndGet<InteractionSalt>()
|
|
.Match(s => s.Day == day)
|
|
.Modify(b => b.SetOnInsert(s => s.Key, Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))))
|
|
.Modify(b => b.SetOnInsert(s => s.ExpiresAt, day.AddHours(30)))
|
|
.Option(o => o.IsUpsert = true)
|
|
.ExecuteAsync(token);
|
|
else
|
|
salt = await DB.Default.Find<InteractionSalt>().Match(s => s.Day == day).ExecuteFirstAsync(token);
|
|
if (salt?.Key == default)
|
|
return default;
|
|
var key = Convert.FromBase64String(salt.Key);
|
|
foreach (var old in _known.Keys.Where(k => k < day.AddDays(-1)))
|
|
_known.TryRemove(old, out _);
|
|
_known[day] = key;
|
|
return key;
|
|
}
|
|
|
|
public static string Hash(byte[] salt, string actorUri) =>
|
|
salt == default || string.IsNullOrEmpty(actorUri)
|
|
? default
|
|
: Convert.ToBase64String(HMACSHA256.HashData(salt, Encoding.UTF8.GetBytes(actorUri))[..12]).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
|
}
|
|
}
|