using System.Collections.Concurrent; using System.Security.Cryptography; using Microsoft.AspNetCore.WebUtilities; namespace AIStudio.Assistants.VisualBriefing; /// /// Issues and validates short-lived, non-guessable preview grants. /// public sealed class VisualBriefingPreviewTokenService { /// /// Defines the maximum preview-grant lifetime. /// private static readonly TimeSpan TOKEN_LIFETIME = TimeSpan.FromMinutes(2); /// /// Stores active grants by opaque token. /// private readonly ConcurrentDictionary grants = new(StringComparer.Ordinal); /// /// Issues a preview token bound to one briefing revision. /// /// The briefing identifier. /// The revision identifier. /// The opaque preview token. public string Issue(Guid briefingId, Guid revisionId) { this.RemoveExpired(); var token = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32)); this.grants[token] = new(briefingId, revisionId, DateTimeOffset.UtcNow.Add(TOKEN_LIFETIME)); return token; } /// /// Validates a token and its briefing/revision binding. /// /// The opaque preview token. /// The requested briefing identifier. /// The requested revision identifier. /// Whether the grant is valid and unexpired. public bool Validate(string? token, Guid briefingId, Guid revisionId) { if (string.IsNullOrWhiteSpace(token) || !this.grants.TryGetValue(token, out var grant)) return false; if (grant.ExpiresAtUtc <= DateTimeOffset.UtcNow) { this.grants.TryRemove(token, out _); return false; } return grant.BriefingId == briefingId && grant.RevisionId == revisionId; } /// /// Removes expired grants. /// private void RemoveExpired() { var now = DateTimeOffset.UtcNow; foreach (var (token, grant) in this.grants) if (grant.ExpiresAtUtc <= now) this.grants.TryRemove(token, out _); } /// /// Stores one token binding and expiry. /// /// The bound briefing identifier. /// The bound revision identifier. /// The token expiry. private sealed record PreviewGrant(Guid BriefingId, Guid RevisionId, DateTimeOffset ExpiresAtUtc); }