using System.Security.Cryptography; using System.Text; using Microsoft.AspNetCore.WebUtilities; namespace Nuuru.Server.Services { public sealed class SignedUrlResult { public string Url { get; init; } = string.Empty; public DateTimeOffset ExpiresAt { get; init; } } public interface ISignedUrlService { SignedUrlResult CreateSignedUrl(string relativePath, int expiresInSeconds); bool IsValid(string relativePath, long expiresUnixSeconds, string signature); } public sealed class SignedUrlService : ISignedUrlService { private readonly byte[] _key; private readonly TimeSpan _defaultTtl; private readonly int _minTtlSeconds; private readonly int _maxTtlSeconds; public SignedUrlService(IConfiguration configuration) { var signingKey = configuration["MediaSigning:Key"] ?? configuration["Jwt:Key"]; if (string.IsNullOrWhiteSpace(signingKey)) { throw new InvalidOperationException("MediaSigning:Key (or fallback Jwt:Key) is not configured."); } _key = Encoding.UTF8.GetBytes(signingKey); _defaultTtl = TimeSpan.FromSeconds(configuration.GetValue("MediaSigning:DefaultTtlSeconds", 900)); _minTtlSeconds = configuration.GetValue("MediaSigning:MinTtlSeconds", 60); _maxTtlSeconds = configuration.GetValue("MediaSigning:MaxTtlSeconds", 3600); } public SignedUrlResult CreateSignedUrl(string relativePath, int expiresInSeconds) { var normalizedPath = NormalizeRelativePath(relativePath); var normalizedExpiresInSeconds = NormalizeTtlSeconds(expiresInSeconds); var expiresAt = DateTimeOffset.UtcNow.AddSeconds(normalizedExpiresInSeconds); var expiresUnixSeconds = expiresAt.ToUnixTimeSeconds(); var signature = ComputeSignature(normalizedPath, expiresUnixSeconds); var signedUrl = $"{normalizedPath}?expires={expiresUnixSeconds}&sig={signature}"; return new SignedUrlResult { Url = signedUrl, ExpiresAt = expiresAt }; } public bool IsValid(string relativePath, long expiresUnixSeconds, string signature) { if (string.IsNullOrWhiteSpace(signature)) { return false; } var nowUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (expiresUnixSeconds <= nowUnixSeconds) { return false; } byte[] providedBytes; try { providedBytes = WebEncoders.Base64UrlDecode(signature); } catch { return false; } var normalizedPath = NormalizeRelativePath(relativePath); var expectedSignature = ComputeSignature(normalizedPath, expiresUnixSeconds); var expectedBytes = WebEncoders.Base64UrlDecode(expectedSignature); return providedBytes.Length == expectedBytes.Length && CryptographicOperations.FixedTimeEquals(providedBytes, expectedBytes); } private int NormalizeTtlSeconds(int expiresInSeconds) { if (expiresInSeconds <= 0) { expiresInSeconds = (int)_defaultTtl.TotalSeconds; } return Math.Clamp(expiresInSeconds, _minTtlSeconds, _maxTtlSeconds); } private string ComputeSignature(string relativePath, long expiresUnixSeconds) { var payload = $"{relativePath}:{expiresUnixSeconds}"; var signatureBytes = HMACSHA256.HashData(_key, Encoding.UTF8.GetBytes(payload)); return WebEncoders.Base64UrlEncode(signatureBytes); } private static string NormalizeRelativePath(string relativePath) { if (string.IsNullOrWhiteSpace(relativePath)) { throw new ArgumentException("Path cannot be null or empty.", nameof(relativePath)); } var trimmed = relativePath.Trim(); if (!trimmed.StartsWith('/')) { trimmed = $"/{trimmed}"; } // Sign the path only; query params are controlled by the signer. var queryIndex = trimmed.IndexOf('?'); if (queryIndex >= 0) { trimmed = trimmed[..queryIndex]; } return trimmed; } } }