using LightlessSync.API.Routes; using LightlessSyncShared.Services; using LightlessSyncShared.Utils.Configuration; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.WebUtilities; using System.Security.Cryptography; using System.Text; using System.Globalization; using System.Linq; namespace LightlessSyncStaticFilesServer.Services; public class CDNDownloadUrlService { private readonly IConfigurationService _staticConfig; private readonly IConfigurationService _globalConfig; private readonly ILogger _logger; public CDNDownloadUrlService(IConfigurationService staticConfig, IConfigurationService globalConfig, ILogger logger) { _staticConfig = staticConfig; _globalConfig = globalConfig; _logger = logger; } public bool DirectDownloadsEnabled => _staticConfig.GetValueOrDefault(nameof(StaticFilesServerConfiguration.EnableDirectDownloads), false); public Uri? TryCreateDirectDownloadUri(Uri? baseUri, string hash) { if (!DirectDownloadsEnabled || baseUri == null) { return null; } if (!IsSupportedHash(hash)) { _logger.LogDebug("Skipping direct download link generation for invalid hash {hash}", hash); return null; } var normalizedHash = hash.ToUpperInvariant(); var lifetimeSeconds = Math.Max(5, _staticConfig.GetValueOrDefault(nameof(StaticFilesServerConfiguration.DirectDownloadTokenLifetimeSeconds), 300)); var expiresAt = DateTimeOffset.UtcNow.AddSeconds(lifetimeSeconds); var signature = CreateSignature(normalizedHash, expiresAt.ToUnixTimeSeconds()); var directPath = $"{LightlessFiles.ServerFiles}/{LightlessFiles.ServerFiles_DirectDownload}/{normalizedHash}"; var builder = new UriBuilder(new Uri(baseUri, directPath)); var query = new QueryBuilder { { "expires", expiresAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) }, { "signature", signature } }; builder.Query = query.ToQueryString().Value!.TrimStart('?'); return builder.Uri; } public bool TryValidateSignature(string hash, long expiresUnixSeconds, string signature) { if (!DirectDownloadsEnabled) { return false; } if (string.IsNullOrEmpty(signature) || !IsSupportedHash(hash)) { return false; } var normalizedHash = hash.ToUpperInvariant(); DateTimeOffset expiresAt; try { expiresAt = DateTimeOffset.FromUnixTimeSeconds(expiresUnixSeconds); } catch (ArgumentOutOfRangeException) { return false; } if (expiresAt < DateTimeOffset.UtcNow) { return false; } var expected = CreateSignature(normalizedHash, expiresAt.ToUnixTimeSeconds()); return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature)); } private string CreateSignature(string hash, long expiresUnixSeconds) { var signingKey = _globalConfig.GetValue(nameof(LightlessConfigurationBase.Jwt)); using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(signingKey)); var payload = Encoding.UTF8.GetBytes($"{hash}:{expiresUnixSeconds}"); return WebEncoders.Base64UrlEncode(hmac.ComputeHash(payload)); } private static bool IsSupportedHash(string hash) { return hash.Length == 40 && hash.All(char.IsAsciiLetterOrDigit); } }