diff --git a/.idea/.idea.TinfoilVibeServer/.idea/vcs.xml b/.idea/.idea.TinfoilVibeServer/.idea/vcs.xml index 94a25f7..c656bff 100644 --- a/.idea/.idea.TinfoilVibeServer/.idea/vcs.xml +++ b/.idea/.idea.TinfoilVibeServer/.idea/vcs.xml @@ -2,5 +2,6 @@ + \ No newline at end of file diff --git a/Dependencies/LibHac.dll b/Dependencies/LibHac.dll new file mode 100644 index 0000000..3dddd7b Binary files /dev/null and b/Dependencies/LibHac.dll differ diff --git a/TinfoilVibeServer.sln b/TinfoilVibeServer.sln index 970b95a..77ad61c 100644 --- a/TinfoilVibeServer.sln +++ b/TinfoilVibeServer.sln @@ -7,6 +7,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinfoilVibeServer", "TinfoilVibeServer\TinfoilVibeServer.csproj", "{DE992FDB-6D13-4152-925D-29D39A23FB75}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinfoilVibeServerTest", "TinfoilVibeServerTest\TinfoilVibeServerTest.csproj", "{E0A5CACD-E3F9-4420-AA14-4C447CCF430A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -17,5 +19,9 @@ Global {DE992FDB-6D13-4152-925D-29D39A23FB75}.Debug|Any CPU.Build.0 = Debug|Any CPU {DE992FDB-6D13-4152-925D-29D39A23FB75}.Release|Any CPU.ActiveCfg = Release|Any CPU {DE992FDB-6D13-4152-925D-29D39A23FB75}.Release|Any CPU.Build.0 = Release|Any CPU + {E0A5CACD-E3F9-4420-AA14-4C447CCF430A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E0A5CACD-E3F9-4420-AA14-4C447CCF430A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E0A5CACD-E3F9-4420-AA14-4C447CCF430A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E0A5CACD-E3F9-4420-AA14-4C447CCF430A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/TinfoilVibeServer.sln.DotSettings b/TinfoilVibeServer.sln.DotSettings new file mode 100644 index 0000000..ba97206 --- /dev/null +++ b/TinfoilVibeServer.sln.DotSettings @@ -0,0 +1,4 @@ + + IP + NSP + PFS \ No newline at end of file diff --git a/TinfoilVibeServer.sln.DotSettings.user b/TinfoilVibeServer.sln.DotSettings.user index bfb9262..e06bf7e 100644 --- a/TinfoilVibeServer.sln.DotSettings.user +++ b/TinfoilVibeServer.sln.DotSettings.user @@ -1,3 +1,104 @@  True - True \ No newline at end of file + True + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + + <AssemblyExplorer> + <Assembly Path="D:\Cloud\Git\TinfoilVibeServer\TinfoilVibeServer\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll" /> + <Assembly Path="D:\Cloud\Git\TinfoilVibeServer\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll" /> +</AssemblyExplorer> + + <SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Solution /> +</SessionState> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Solution /> +</SessionState> \ No newline at end of file diff --git a/TinfoilVibeServer/Authentication/AuthSettings.cs b/TinfoilVibeServer/Authentication/AuthSettings.cs new file mode 100644 index 0000000..567411e --- /dev/null +++ b/TinfoilVibeServer/Authentication/AuthSettings.cs @@ -0,0 +1,10 @@ +namespace TinfoilVibeServer.Authentication; + +/// +/// Settings for AuthStore loaded from appsettings.json. +/// +public sealed record AuthSettings( + string CredentialsFile, + string FingerprintsFile, + string BlacklistFile, + int MaxFailedAttempts); \ No newline at end of file diff --git a/TinfoilVibeServer/Authentication/AuthStore.cs b/TinfoilVibeServer/Authentication/AuthStore.cs new file mode 100644 index 0000000..b58b59e --- /dev/null +++ b/TinfoilVibeServer/Authentication/AuthStore.cs @@ -0,0 +1,318 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using TinfoilVibeServer.Services; +using TinfoilVibeServer.Utilities; + +namespace TinfoilVibeServer.Authentication; + +public interface IAuthStore +{ + void Dispose(); + bool TryValidate(string username, + string password, + int? uid, + string ip, + out string? error); + + int IncrementFailed(string username, string ip); + bool IsIPBlacklisted(string ipAddress); +} + +/// +/// Holds authentication configuration and runtime state. +/// It watches credentials.json for changes and updates the in‑memory +/// user list (including the Verified flag) on the fly. +/// +public class AuthStore : IDisposable, IAuthStore +{ + private readonly ILogger _logger; + private readonly ConfigManager _configManager; + + public readonly ConcurrentDictionary Credentials = new(); + public readonly ConcurrentDictionary> Fingerprints = new(); + public readonly ConcurrentDictionary FailedAttempts = new(); + private readonly HashSet BlacklistIPs = new(); + + private readonly object _sync = new(); + private readonly FileSystemWatcher _credentialsWatcher; + + public AuthStore(ILogger logger, ConfigManager configManager) + { + _logger = logger; + _configManager = configManager; + + LoadAll(); + + var directoryName = Path.GetDirectoryName(_configManager.Settings.CredentialsFile); + _credentialsWatcher = new FileSystemWatcher + { + Path = (!string.IsNullOrEmpty(directoryName))?directoryName : AppContext.BaseDirectory, + Filter = Path.GetFileName(_configManager.Settings.CredentialsFile), + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.Attributes + }; + _credentialsWatcher.Changed += (_, _) => OnCredentialsChanged(); + _credentialsWatcher.EnableRaisingEvents = true; + } + + public void Dispose() + { + _credentialsWatcher?.Dispose(); + } + + #region Loading helpers + + private void LoadAll() + { + _logger.LogInformation("Loading authentication data from {File}", _configManager.Settings.CredentialsFile); + // credentials + if (File.Exists(_configManager.Settings.CredentialsFile)) + { + var txt = File.ReadAllText(_configManager.Settings.CredentialsFile); + var dict = JsonSerializer.Deserialize>(txt)!; + foreach (var kv in dict) + Credentials[kv.Key] = kv.Value; + } + else + { + FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.CredentialsFile))); + } + + // fingerprints + if (File.Exists(_configManager.Settings.FingerprintsFile)) + { + var txt = File.ReadAllText(_configManager.Settings.FingerprintsFile); + var dict = JsonSerializer.Deserialize>>(txt)!; + foreach (var kv in dict) + Fingerprints[kv.Key] = kv.Value; + } + else + { + FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.FingerprintsFile))); + } + + // blacklist + if (File.Exists(_configManager.Settings.BlacklistFile)) + { + var txt = File.ReadAllText(_configManager.Settings.BlacklistFile); + var arr = JsonSerializer.Deserialize(txt)!; + foreach (var ip in arr) + BlacklistIPs.Add(ip); + } + else + { + FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.BlacklistFile))); + } + _logger.LogInformation("Loaded {UserCount} users, {FpCount} fingerprints, {IpCount} IPs", + Credentials.Count, Fingerprints.Count, BlacklistIPs.Count); + } + + #endregion + + #region Watcher callbacks + + private void OnCredentialsChanged() + { + // Small debounce – the file may still be locked by the editor. + Task.Run(async () => + { + await Task.Delay(200); + ReloadCredentials(); + }); + } + + private void ReloadCredentials() + { + if (!File.Exists(_configManager.Settings.CredentialsFile)) + { + _logger.LogError("Credentials file {File} does not exist", _configManager.Settings.CredentialsFile); + return; + } + + try + { + var txt = File.ReadAllText(_configManager.Settings.CredentialsFile); + var newDict = JsonSerializer.Deserialize>(txt)!; + + lock (_sync) + { + // Update existing users & add new ones + foreach (var kv in newDict) + { + if (Credentials.TryGetValue(kv.Key, out var existing)) + { + existing.PasswordHash = kv.Value.PasswordHash; + existing.AllowedUidCount = kv.Value.AllowedUidCount; + existing.Verified = kv.Value.Verified; + } + else + { + Credentials[kv.Key] = kv.Value; + } + } + + // Remove users that were deleted from the file + var toRemove = Credentials.Keys.Except(newDict.Keys).ToList(); + foreach (var key in toRemove) + { + Credentials.TryRemove(key, out _); + Fingerprints.TryRemove(key, out _); + FailedAttempts.TryRemove(key, out _); + } + } + } + catch(Exception ex) + { + _logger.LogError(ex, "Failed to reload credentials from {File}", _configManager.Settings.CredentialsFile); + // ignore – malformed JSON or IO error – keep old state + } + } + + #endregion + + #region Authentication logic + + public bool TryValidate(string username, + string password, + int? uid, + string ip, + out string? error) + { + error = null; + lock (_sync) + { + if (!Credentials.TryGetValue(username, out var cred)) + { + // Create user on the fly + cred = new Credential(username, PasswordHash: ComputeHash(password), 1, Verified: false); + Credentials[username] = cred; + PersistCredentials(); + _logger.LogInformation("Created new user {Username} (verified={Verified})", username, cred.Verified); + + var list = Fingerprints.GetOrAdd(username, _ => new List()); + if (uid.HasValue && !list.Contains(uid.Value)) + { + if (list.Count < cred.AllowedUidCount) + list.Add(uid.Value); + PersistFingerprints(); + } + + error = "User not verified"; + IncrementFailed(username, ip); + return false; + } + + if (!VerifyPasswordHash(password, cred.PasswordHash)) + { + error = "Invalid password"; + IncrementFailed(username, ip); + _logger.LogWarning("Auth failed for {Username} from {IP} – {Error}", username, ip, error); + return false; + } + + if (!cred.Verified) + { + error = "User not verified"; + IncrementFailed(username, ip); + return false; + } + + if (uid.HasValue) + { + var list = Fingerprints.GetOrAdd(username, _ => new List()); + + if (!list.Contains(uid.Value)) + { + if (list.Count < cred.AllowedUidCount) + { + list.Add(uid.Value); + PersistFingerprints(); + } + else + { + error = $"UID limit ({cred.AllowedUidCount}) exceeded"; + IncrementFailed(username, ip); + return false; + } + } + } + + FailedAttempts[username] = 0; + return true; + } + } + + public int IncrementFailed(string username, string ip) + { + var newCount = FailedAttempts.GetOrAdd(username, 0) + 1; + lock (_sync) + { + FailedAttempts[username] = newCount; + } + _logger.LogInformation("Failed attempts for {Username} increased to {Count}", username, newCount); + + if (newCount < _configManager.Settings.MaxFailedAttempts+1) return newCount; + + BlacklistIPs.Add(ip); + PersistBlacklist(); + lock (_sync) + { + FailedAttempts[username] = 0; + } + _logger.LogWarning("IP {IP} blacklisted after {Count} failures", ip, newCount); + return newCount; + } + + #endregion + + #region Helpers + + public static string ComputeHash(string input) + { + using var sha = SHA256.Create(); + var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(input)); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + + private static bool VerifyPasswordHash(string plain, string storedHash) + => string.Equals(ComputeHash(plain), storedHash, StringComparison.OrdinalIgnoreCase); + + private void PersistCredentials() + { + var json = JsonSerializer.Serialize(Credentials, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(_configManager.Settings.CredentialsFile, json); + } + + private void PersistFingerprints() + { + var json = JsonSerializer.Serialize(Fingerprints, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(_configManager.Settings.FingerprintsFile, json); + } + + private void PersistBlacklist() + { + var json = JsonSerializer.Serialize(BlacklistIPs.ToArray(), new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(_configManager.Settings.BlacklistFile, json); + } + + #endregion + + #region Blacklist helpers + public bool IsIPBlacklisted(string ipAddress) + { + return BlacklistIPs.Contains(ipAddress); + } + + public bool UnbanIp(string ipAddress) + { + return BlacklistIPs.Remove(ipAddress); + } + + public bool BlacklistActive() + { + return BlacklistIPs.Count > 0; + } + + #endregion +} \ No newline at end of file diff --git a/TinfoilVibeServer/Authentication/Credential.cs b/TinfoilVibeServer/Authentication/Credential.cs new file mode 100644 index 0000000..d231f25 --- /dev/null +++ b/TinfoilVibeServer/Authentication/Credential.cs @@ -0,0 +1,16 @@ +namespace TinfoilVibeServer.Authentication; + +/// +/// User credential record. +/// +public sealed record Credential( + string Username, + string PasswordHash, + int AllowedUidCount, + bool Verified +) +{ + public string PasswordHash { get; set; } = PasswordHash; + public int AllowedUidCount{ get; set; } = AllowedUidCount; + public bool Verified { get; set; } = Verified; +}; \ No newline at end of file diff --git a/TinfoilVibeServer/ConfigManager.cs b/TinfoilVibeServer/ConfigManager.cs deleted file mode 100644 index aeed52d..0000000 --- a/TinfoilVibeServer/ConfigManager.cs +++ /dev/null @@ -1,70 +0,0 @@ - -using System.Text.Json; - -namespace TinfoilVibeServer; - -/// -/// Reads the JSON config file and raises an event whenever it changes. -/// -public sealed class ConfigManager : IDisposable -{ - private readonly string _configPath; - private readonly FileSystemWatcher _watcher; - private readonly object _sync = new(); - - public AppSettings Settings { get; private set; } - - public event Action? OnChange; - - public ConfigManager(string configPath) - { - _configPath = configPath; - Settings = Load(); - - _watcher = new FileSystemWatcher - { - Path = Path.GetDirectoryName(_configPath)!, - Filter = Path.GetFileName(_configPath), - NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.Attributes, - EnableRaisingEvents = true - }; - - _watcher.Changed += (_, _) => Reload(); - } - - private AppSettings Load() - { - var json = File.ReadAllText(_configPath); - return JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; - } - - private void Reload() - { - lock (_sync) - { - try - { - Settings = Load(); - OnChange?.Invoke(Settings); - } - catch (Exception ex) - { - Console.Error.WriteLine($"Failed to reload config: {ex.Message}"); - } - } - } - - public void Dispose() => _watcher.Dispose(); -} - -/// -/// POCO that matches appsettings.json. -/// -public sealed record AppSettings( - string[] RootDirectories, - string[] WhitelistExtensions, - string[] RomExtensions, - string SnapshotFile, - string SnapshotBackupFile, - int ArchiveBufferSize -); \ No newline at end of file diff --git a/TinfoilVibeServer/Controllers/CancelableFileResult.cs b/TinfoilVibeServer/Controllers/CancelableFileResult.cs new file mode 100644 index 0000000..ed73d01 --- /dev/null +++ b/TinfoilVibeServer/Controllers/CancelableFileResult.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Mvc; + +namespace TinfoilVibeServer.Controllers; + +public class CancelableFileResult : FileResult +{ + private readonly Stream _fileStream; + + public CancelableFileResult(string contentType, Stream fileStream) + : base(contentType) + { + _fileStream = fileStream ?? throw new ArgumentNullException(nameof(fileStream)); + } + + /// + /// Allows you to set a suggested download name. + /// It will be sent in a “Content‑Disposition” header. + /// + public string? FileDownloadName { get; set; } + + public override async Task ExecuteResultAsync(ActionContext context) + { + var response = context.HttpContext.Response; + + if (!string.IsNullOrEmpty(FileDownloadName)) + { + // Typical “attachment” disposition – most browsers honour it. + response.Headers.Append("Content-Disposition", + $"attachment; filename=\"{FileDownloadName}\""); + } + + // The request‑aborted token tells the stream copy to stop ASAP + var cancellationToken = context.HttpContext.RequestAborted; + + try + { + // Copy the file to the response body in 8 KiB chunks + await _fileStream.CopyToAsync( + response.Body, + bufferSize: 81920, // 80 KiB – default for Stream.CopyToAsync + cancellationToken); + } + catch (OperationCanceledException) + { + // The client disconnected – nothing to do. + // Swallowing keeps the API from returning a 500 error. + } + finally + { + await _fileStream.DisposeAsync(); + } + } +} \ No newline at end of file diff --git a/TinfoilVibeServer/Controllers/IndexController.cs b/TinfoilVibeServer/Controllers/IndexController.cs new file mode 100644 index 0000000..c85edd1 --- /dev/null +++ b/TinfoilVibeServer/Controllers/IndexController.cs @@ -0,0 +1,255 @@ +using System; +using System.IO; +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using SharpCompress.Readers; +using TinfoilVibeServer.Models; +using TinfoilVibeServer.Services; + +namespace TinfoilVibeServer.Controllers; + +[ApiController] +[Route("/")] +public sealed class IndexController : ControllerBase +{ + private readonly ISnapshotService _snapshotService; + private readonly TitleDatabaseService _titleDb; + private readonly IConfiguration _configuration; + private readonly IndexBuilderService _indexBuilderService; + + public IndexController(ISnapshotService snapshotService, + TitleDatabaseService titleDb, + IConfiguration configuration, IndexBuilderService indexBuilderService) + { + _snapshotService = snapshotService; + _titleDb = titleDb; + _configuration = configuration; + _indexBuilderService = indexBuilderService; + } + + // ------------------------------------------------------------ + // GET / + // ------------------------------------------------------------ + /// + /// Return the “index snapshot” – e.g. a JSON file that lists + /// every available resource. The snapshot could also be a + /// rendered Razor view – simply change the return type. + /// + public IActionResult Index() + { + if (HttpContext.Request.Headers.CacheControl == "no-cache") + { + _indexBuilderService.InvalidateIndex(this, EventArgs.Empty); + } + + var index = _indexBuilderService.Build(HttpContext); + + return Ok(index); + } + + // ------------------------------------------------------------ + // GET /{*path} + // ------------------------------------------------------------ + /// + /// Catch‑all action. Any URL that is **not** “/” is routed + /// here. The value of *path* is the relative location you + /// want to stream back to the client. + /// + /// The relative file path requested. + [HttpGet("{*path}")] + public async Task Download(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return BadRequest("Missing url query parameter."); + + // ---- 1️⃣ Parse the brackets -------------------------------- + // Expected format: [name][TitleId][v][patchOrApp].nsp + var match = System.Text.RegularExpressions.Regex.Match(path, + @"(?.*?)\[(?[0-9a-fA-F]{8}[0-9a-fA-F]{8})\]\[v(?[0-9]+)\]\[(?Base|Update)\]\.nsp", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + if (!match.Success) + return BadRequest("Url does not match the expected pattern."); + + var titleId = match.Groups["id"].Value.ToUpperInvariant(); + + // ---- 2️⃣ Find the file that contains this TitleId ------------ + var entry = _snapshotService.GetSnapshot().Files + .FirstOrDefault(e => { return e.Titles.FirstOrDefault(hash => hash.TitleId == titleId)?.TitleId == titleId; }); + + if (entry == null) + return NotFound("No file with that TitleId found."); + + // ---- 3️⃣ If the file is a normal NSP → send it ---------------- + + if (Path.GetExtension(entry.Path).Equals(".nsp", StringComparison.OrdinalIgnoreCase) + && !entry.Path.Contains(_snapshotService.GetArchivePathSeparator())) + { + if (System.IO.File.Exists(entry.Path)) + { + // 1️⃣ Open the file for async read + var fileStream = new FileStream( + entry.Path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 128 * 1024 * 1024, // 81920, // 80 KiB + useAsync: true); // <‑‑ VERY important for scalability + + // 2️⃣ Return a cancellation‑aware result + return new CancelableFileResult( + contentType: "application/octet-stream", + fileStream: fileStream) + { + FileDownloadName = Path.GetFileName(entry.Path) // optional but nice + }; + } + } + + // Check if it is inside an archive. + // If the path contains a slash that is not the root separator + // it might be an entry inside an archive; we simply stream it. + // - For normal files, we can use SendFileAsync. + // - For archives, we stream the entry using ArchiveHandler. + + if (IsInsideArchive(entry.Path)) + { + // Example: file is inside an archive – use ArchiveHandler + var innerFileName = entry.Path.Split(_snapshotService.GetArchivePathSeparator()).Last(); + var stream = StreamFromArchive(entry, titleId, out var streamContainer); + + if (stream == null) + return NotFound("Could not stream entry from archive."); + + var wrappedStream = new DependentStream(stream, streamContainer); + var contentDisposition = $"inline; filename=\"{innerFileName}\""; + + Response.ContentType = "application/octet-stream"; + Response.ContentLength = entry.Size; + Response.Headers["Content-Disposition"] = contentDisposition; + await using var entryStream = wrappedStream; + const int bufferSize = 10 * 1024 * 1024;// 81920; // 80 KB – default used by CopyToAsync + await entryStream.CopyToAsync(Response.Body, bufferSize, HttpContext.RequestAborted); + + // Once the copy completes, the `await using` disposes the archive. + return new EmptyResult(); // We already wrote the stream to Response.Body + } + + return NotFound("Requested URL does not reference an NSP file."); + } + + /// + /// Very light‑weight helper – decides whether the file path + /// represents a file inside an archive. + /// + private bool IsInsideArchive(string path) + { + // If the path contains a separator that is not a root separator + // (e.g. "Games/MyGame.nsp" is a regular file; "archive.7z/mygame.nsp" + // would be inside an archive). For simplicity we only check + // for common archive extensions. + var filePath = path.Split(_snapshotService.GetArchivePathSeparator()).First(); + return filePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) || + filePath.EndsWith(".7z", StringComparison.OrdinalIgnoreCase) || + filePath.EndsWith(".rar", StringComparison.OrdinalIgnoreCase); + } + + /// + /// If the NSP is inside an archive, this method opens the archive + /// and returns the entry stream. It is deliberately minimal – + /// if the archive can’t be opened we return null. + /// + private Stream? StreamFromArchive(FileEntry fileEntry, string titleId, out IDisposable? streamContainer) + { + // Example: file is inside an archive – use ArchiveHandler + var archivePath = fileEntry.Path.Split(_snapshotService.GetArchivePathSeparator()).First(); + _snapshotService.GetArchiveName(titleId); + var innerFileName = Path.GetFileName(fileEntry.Path.Split(_snapshotService.GetArchivePathSeparator()).Last()); + + // Use SharpCompress to open the archive and find the entry. + // Only the 3 archive types we support are handled. + try + { + // Check which archive type + if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + { + var zip = SharpCompress.Archives.Zip.ZipArchive.Open(archivePath, new ReaderOptions { LeaveStreamOpen = true }); + streamContainer = zip; + var entry = zip.Entries + .FirstOrDefault(e => e.Key != null && e.Key.Equals(innerFileName, + StringComparison.OrdinalIgnoreCase)); + if (entry != null) + return entry.OpenEntryStream(); + } + else if (archivePath.EndsWith(".7z", StringComparison.OrdinalIgnoreCase)) + { + var sevenZip = SharpCompress.Archives.SevenZip.SevenZipArchive.Open(archivePath, new ReaderOptions { LeaveStreamOpen = true }); + streamContainer = sevenZip; + var entry = sevenZip.Entries + .FirstOrDefault(e => e.Key != null && e.Key.Equals(innerFileName, + StringComparison.OrdinalIgnoreCase)); + if (entry != null) + return entry.OpenEntryStream(); + } + else if (archivePath.EndsWith(".rar", StringComparison.OrdinalIgnoreCase)) + { + var rar = SharpCompress.Archives.Rar.RarArchive.Open(archivePath, new ReaderOptions { LeaveStreamOpen = true }); + streamContainer = rar; + var entry = rar.Entries + .FirstOrDefault(e => e.Key != null && e.Key.Equals(innerFileName, + StringComparison.OrdinalIgnoreCase)); + if (entry != null) + return entry.OpenEntryStream(); + } + } + catch + { + // ignore – we will just return null and the controller + // will respond with 404. + } + + streamContainer = null; + return null; + } + + public class DependentStream : Stream + { + private readonly Stream _innerStream; + private readonly IDisposable? _parentContainer; + + public DependentStream(Stream innerStream, IDisposable? parentContainer) + { + _innerStream = innerStream; + _parentContainer = parentContainer; + } + + public override void Flush() => _innerStream.Flush(); + + public override int Read(byte[] buffer, int offset, int count) => _innerStream.Read(buffer, offset, count); + + public override long Seek(long offset, SeekOrigin origin) => _innerStream.Seek(offset, origin); + + public override void SetLength(long value) => _innerStream.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) => _innerStream.Write(buffer, offset, count); + + + public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) + { + return _innerStream.CopyToAsync(destination, bufferSize, cancellationToken); + } + + public override bool CanRead => _innerStream.CanRead; + public override bool CanSeek => _innerStream.CanSeek; + public override bool CanWrite => _innerStream.CanWrite; + public override long Length => _innerStream.Length; + public override long Position { get => _innerStream.Position; set => _innerStream.Position = value; } + protected override void Dispose(bool disposing) + { + _parentContainer?.Dispose(); + base.Dispose(disposing); + } + } + +} \ No newline at end of file diff --git a/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs b/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs index 35fa36b..1366725 100644 --- a/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs +++ b/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs @@ -1,32 +1,37 @@ - -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; -using System.Text; -using TinfoilVibeServer.Services; +using System.Text; +using TinfoilVibeServer.Authentication; namespace TinfoilVibeServer.Middleware; +/// +/// Minimal Basic‑Auth middleware that also checks UID, failure counters and a blacklist. +/// public sealed class BasicAuthMiddleware { private readonly RequestDelegate _next; - private readonly AuthStore _store; - private readonly ILogger _logger; - public BasicAuthMiddleware(RequestDelegate next, AuthStore store, ILogger logger) + public BasicAuthMiddleware(RequestDelegate next) { _next = next; - _store = store; - _logger = logger; } - public async Task InvokeAsync(HttpContext context) + public async Task InvokeAsync(HttpContext context, IAuthStore store, ILogger logger) { + // ------------- 1) Bypass auth for every path except “/” ---------------- + // PathString is a struct – compare its value directly. + if (!context.Request.Path.Equals("/", StringComparison.Ordinal)) + { + await _next(context); + return; + } + + logger.LogInformation("Incoming request from {IP} – {Method} {Path}", context.Connection.RemoteIpAddress, context.Request.Method, context.Request.Path); var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; // 1) IP blacklist - if (_store.IsBlacklisted(ip)) + if (store.IsIPBlacklisted(ip)) { - _logger.LogWarning("Blocked request from blacklisted IP {IP}", ip); + logger.LogWarning("Blocked request from blacklisted IP {IP}", ip); context.Response.StatusCode = StatusCodes.Status403Forbidden; await context.Response.WriteAsync("Forbidden"); return; @@ -35,7 +40,9 @@ public sealed class BasicAuthMiddleware // 2) Authorization header if (!context.Request.Headers.TryGetValue("Authorization", out var authHeaders)) { + logger.LogWarning("Missing Authorization header from {IP}", ip); Challenge(context); + logger.LogInformation("Sent 401 challenge to client"); return; } @@ -43,6 +50,7 @@ public sealed class BasicAuthMiddleware if (!authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) { Challenge(context); + logger.LogInformation("Sent 401 challenge to client"); return; } @@ -55,6 +63,7 @@ public sealed class BasicAuthMiddleware catch { Challenge(context); + logger.LogInformation("Sent 401 challenge to client"); return; } @@ -62,6 +71,7 @@ public sealed class BasicAuthMiddleware if (parts.Length != 2) { Challenge(context); + logger.LogInformation("Sent 401 challenge to client"); return; } @@ -77,24 +87,24 @@ public sealed class BasicAuthMiddleware } // 4) Validate - if (!_store.TryValidate(username, password, uid, ip, out var error)) + if (!store.TryValidate(username, password, uid, ip, out var error)) { - _logger.LogWarning("Auth failed for user {User} from {IP}: {Error}", username, ip, error); + logger.LogWarning("Auth failed for user {User} from {IP}: {Error}", username, ip, error); context.Response.StatusCode = StatusCodes.Status401Unauthorized; - context.Response.Headers.Add("WWW-Authenticate", "Basic realm=\"FileSnapshot\""); + context.Response.Headers.Append("WWW-Authenticate", "Basic realm=\"FileSnapshot\""); await context.Response.WriteAsync(error ?? "Unauthorized"); return; } // Authentication succeeded – attach username for downstream handlers if needed context.Items["User"] = username; - + logger.LogInformation("User {User} authenticated successfully (UID={UID})", username, uid); await _next(context); } private static void Challenge(HttpContext ctx) { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; - ctx.Response.Headers.Add("WWW-Authenticate", "Basic realm=\"FileSnapshot\""); + ctx.Response.Headers.Append("WWW-Authenticate", "Basic realm=\"FileSnapshot\""); } } \ No newline at end of file diff --git a/TinfoilVibeServer/Models/AppSettings.cs b/TinfoilVibeServer/Models/AppSettings.cs new file mode 100644 index 0000000..d4bfd6d --- /dev/null +++ b/TinfoilVibeServer/Models/AppSettings.cs @@ -0,0 +1,15 @@ +namespace TinfoilVibeServer.Models; + +/// +/// Top‑level configuration – maps directly to appsettings.json. +/// +public sealed record AppSettings( + string[] RootDirectories, + string[] WhitelistExtensions, + string[] RomExtensions, + string CredentialsFile, + string FingerprintsFile, + string BlacklistFile, + int MaxFailedAttempts, + string KeySetFile +); \ No newline at end of file diff --git a/TinfoilVibeServer/Models/FileEntry.cs b/TinfoilVibeServer/Models/FileEntry.cs index e921d48..d73f050 100644 --- a/TinfoilVibeServer/Models/FileEntry.cs +++ b/TinfoilVibeServer/Models/FileEntry.cs @@ -1,11 +1,13 @@ -namespace TinfoilVibeServer.Models; +using TinfoilVibeServer.Services; + +namespace TinfoilVibeServer.Models; /// /// One line in the snapshot – the JSON will be an array of these. /// public sealed record FileEntry( - string Path, - long Size, - string Hash, // SHA‑256 hex - TitleInfo? Title // null unless file is an NSP/XCI or an archive containing one + string Path, // nsp or archive path + long Size, // size of nsp or full archive + string Hash, // SHA‑256 hex of first NCA of first NCP in NSP or archive + List Titles // Details of all NSP Roms in the Path ); \ No newline at end of file diff --git a/TinfoilVibeServer/Models/IdHelper.cs b/TinfoilVibeServer/Models/IdHelper.cs new file mode 100644 index 0000000..ced038d --- /dev/null +++ b/TinfoilVibeServer/Models/IdHelper.cs @@ -0,0 +1,78 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace TinfoilVibeServer.Models; + + +public static class IdHelper +{ + public static string ToStrId(this int num) + { + return ToIdFromConvertedNumBytes(BitConverter.GetBytes(num)); + } + + public static string ToStrId(this uint num) + { + return ToIdFromConvertedNumBytes(BitConverter.GetBytes(num)); + } + + public static string ToStrId(this long num) + { + return ToIdFromConvertedNumBytes(BitConverter.GetBytes(num)); + } + + public static string ToStrId(this ulong num) + { + return ToIdFromConvertedNumBytes(BitConverter.GetBytes(num)); + } + + public static string ToStrId(this IEnumerable bytes) + { + return bytes.Aggregate("", (current, b) => current + b.ToString("X2")); + } + + public static string ToStrId(this Span bytes) + { + return bytes.ToArray().ToStrId(); + } + + private static string ToIdFromConvertedNumBytes(IEnumerable getBytes) + { + return ToStrId(getBytes.Reverse()); + } + + public static string GetTitleId(this FileInfo fileInfo) + { + var match = Regex.Match(fileInfo.Name, "^.*\\[(\\w{16})\\].*\\.nsp$"); + return match is { Length: > 0, Groups.Count: > 1 }?match.Groups[1].Value:string.Empty; + } + #region 1️⃣ From hex string → byte[] (big‑endian) + + /// + /// Turns an even‑length hex string into a byte array in **big‑endian** order + /// (the same order that the original ToStrId creates). + /// + private static byte[] HexStringToBytes(string hex) + { + if (string.IsNullOrWhiteSpace(hex)) + throw new ArgumentException("ID string cannot be empty", nameof(hex)); + + if (hex.Length % 2 != 0) + throw new ArgumentException("ID string must contain an even number of hex digits", nameof(hex)); + + var bytes = new byte[hex.Length / 2]; + for (var i = 0; i < bytes.Length; i++) + { + var chunk = hex.Substring(i * 2, 2); + bytes[i] = byte.Parse(chunk, NumberStyles.HexNumber, CultureInfo.InvariantCulture); + } + + return bytes; + } + + #endregion + + public static long ToLongFromStrId(this string hex) => + BitConverter.ToInt64(HexStringToBytes(hex).Reverse().ToArray(), 0); + +} \ No newline at end of file diff --git a/TinfoilVibeServer/Models/IndexBuilderSettings.cs b/TinfoilVibeServer/Models/IndexBuilderSettings.cs new file mode 100644 index 0000000..a2ade75 --- /dev/null +++ b/TinfoilVibeServer/Models/IndexBuilderSettings.cs @@ -0,0 +1,6 @@ +namespace TinfoilVibeServer.Models; + +public class IndexBuilderSettings +{ + public string ApiBaseUrl { get; set; } = "http://tinfoil.localhost"; +} \ No newline at end of file diff --git a/TinfoilVibeServer/Models/IndexDto.cs b/TinfoilVibeServer/Models/IndexDto.cs new file mode 100644 index 0000000..b3112da --- /dev/null +++ b/TinfoilVibeServer/Models/IndexDto.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace TinfoilVibeServer.Models; + +/// +/// The JSON object that will be returned for the “index” route. +/// +public sealed record IndexDto( + List Files, + List Directories, + string Success); + +public sealed record FileDto( + string Url, + long Size); \ No newline at end of file diff --git a/TinfoilVibeServer/Models/KeySetHolder.cs b/TinfoilVibeServer/Models/KeySetHolder.cs new file mode 100644 index 0000000..8edf33a --- /dev/null +++ b/TinfoilVibeServer/Models/KeySetHolder.cs @@ -0,0 +1,12 @@ +using LibHac.Common.Keys; + +namespace TinfoilVibeServer.Models; + +/// +/// A tiny static holder that contains the KeySet loaded from disk. +/// All parts of the application that need keys just read this property. +/// +public static class KeySetHolder +{ + public static KeySet KeySet { get; set; } = new KeySet(); +} \ No newline at end of file diff --git a/TinfoilVibeServer/Models/NcaMetadataDto.cs b/TinfoilVibeServer/Models/NcaMetadataDto.cs new file mode 100644 index 0000000..965abb1 --- /dev/null +++ b/TinfoilVibeServer/Models/NcaMetadataDto.cs @@ -0,0 +1,11 @@ +namespace TinfoilVibeServer.Models; + +/// +/// DTO that is returned by the extractor. +/// +public sealed record NcaMetadataDto( + string TitleId, // 16‑digit hex, e.g. 0004000000000000 + int Version, // header version – 0 = application, >0 = patch + bool IsApplication, // true if the NSP is an application + bool IsPatch // true if the NSP is a patch +); \ No newline at end of file diff --git a/TinfoilVibeServer/Models/SnapshotOptions.cs b/TinfoilVibeServer/Models/SnapshotOptions.cs new file mode 100644 index 0000000..46e651f --- /dev/null +++ b/TinfoilVibeServer/Models/SnapshotOptions.cs @@ -0,0 +1,88 @@ +using System.ComponentModel; + +namespace TinfoilVibeServer.Models; + +public sealed class SnapshotOptions : INotifyPropertyChanged +{ + private List _rootDirectories = new(); + public List RootDirectories + { + get => _rootDirectories; + set + { + if (_rootDirectories != value) + { + _rootDirectories = value; + OnPropertyChanged(nameof(RootDirectories)); + } + } + } + private List _archiveExtensions = new(); + public List ArchiveExtensions + { + get => _archiveExtensions; + set + { + if (_archiveExtensions != value) + { + _archiveExtensions = value; + OnPropertyChanged(nameof(_archiveExtensions)); + } + } + } + private List _romExtensions = new(); + public List RomExtensions + { + get => _romExtensions; + set + { + if (_romExtensions != value) + { + _romExtensions = value; + OnPropertyChanged(nameof(_romExtensions)); + } + } + } + private TimeSpan _cacheTtl = TimeSpan.FromHours(1); + public TimeSpan CacheTtl + { + get => _cacheTtl; + set + { + if (_cacheTtl != value) + { + _cacheTtl = value; + OnPropertyChanged(nameof(CacheTtl)); + } + } + } + + private string _snapshotFile = "snapshot.json"; + + public string SnapshotFile + { + get => _snapshotFile; + set + { + if (string.Equals(_snapshotFile,value, StringComparison.InvariantCultureIgnoreCase)) return; + _snapshotFile = value; + OnPropertyChanged(nameof(SnapshotFile)); + } + } + + private string _snapshotBackupFile = "snapshot.bak"; + public string SnapshotBackupFile + { + get => _snapshotBackupFile; + set + { + if (string.Equals(_snapshotBackupFile,value, StringComparison.InvariantCultureIgnoreCase)) return; + _snapshotBackupFile = value; + OnPropertyChanged(nameof(SnapshotBackupFile)); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + private void OnPropertyChanged(string propertyName) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); +} \ No newline at end of file diff --git a/TinfoilVibeServer/Models/TitleDbOptions.cs b/TinfoilVibeServer/Models/TitleDbOptions.cs new file mode 100644 index 0000000..08061cb --- /dev/null +++ b/TinfoilVibeServer/Models/TitleDbOptions.cs @@ -0,0 +1,8 @@ +namespace TinfoilVibeServer.Models; + +public class TitleDbOptions +{ + public int TtlSeconds { get; set; } = 60; // fallback + public string LanguageCode { get; set; } = "en"; + public string CountryCode { get; set; } = "US"; +} \ No newline at end of file diff --git a/TinfoilVibeServer/Models/TitleInfo.cs b/TinfoilVibeServer/Models/TitleInfo.cs index 30d45b6..c3ae29c 100644 --- a/TinfoilVibeServer/Models/TitleInfo.cs +++ b/TinfoilVibeServer/Models/TitleInfo.cs @@ -1,11 +1,11 @@ namespace TinfoilVibeServer.Models; /// -/// Metadata extracted from a NSP/XCI archive. +/// Metadata extracted from a Nintendo NSP/XCI archive. /// public sealed record TitleInfo( string TitleId, // e.g. 0004000000000000 string Name, // title name string Version, // e.g. 1.02 - bool IsApplication // true for applications, false for patches + bool IsApplication // true for applications, false for patches ); \ No newline at end of file diff --git a/TinfoilVibeServer/Models/TitleInfoDto.cs b/TinfoilVibeServer/Models/TitleInfoDto.cs new file mode 100644 index 0000000..0fcf21a --- /dev/null +++ b/TinfoilVibeServer/Models/TitleInfoDto.cs @@ -0,0 +1,14 @@ +namespace TinfoilVibeServer.Models; + +/// +/// One entry that is read from the JSON files on GitHub and can also be +/// constructed from an NSP. The key for the dictionary that stores these +/// objects is . +/// +public sealed record TitleInfoDto( + string TitleId, // 16‑digit hex – “0004000000000000” + string Name, + string Id, + int? ReleaseDate, + long NSUID, + string Version); \ No newline at end of file diff --git a/TinfoilVibeServer/Program.cs b/TinfoilVibeServer/Program.cs index 0d64a40..8dbe16a 100644 --- a/TinfoilVibeServer/Program.cs +++ b/TinfoilVibeServer/Program.cs @@ -1,42 +1,71 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using TinfoilVibeServer.Authentication; using TinfoilVibeServer.Middleware; using TinfoilVibeServer.Services; +using TinfoilVibeServer.Models; var builder = WebApplication.CreateBuilder(args); - -// ----------------------------------------------------- -// 1. Register AuthStore as a singleton -// ----------------------------------------------------- -builder.Services.AddSingleton(); - -// ----------------------------------------------------- -// 2. Snapshot + other services (unchanged) -// ----------------------------------------------------- +builder.Logging.ClearProviders(); +builder.Logging.AddConsole(); +builder.Logging.AddDebug(); +// ------------------------------------------------------------------- +// 1) Configuration – read appsettings.json once and expose it via +// ConfigManager (reloads on file change) +// ------------------------------------------------------------------- +builder.Services.AddMemoryCache(); +builder.Services.Configure(builder.Configuration.GetSection("TitleDb")); +builder.Services.Configure(builder.Configuration.GetSection("AuthSettings")); +builder.Services.Configure(builder.Configuration.GetSection("Snapshot")); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => +{ + var config = sp.GetRequiredService(); + var logger = sp.GetRequiredService>(); + var keySet = KeySetHolder.KeySet; // already loaded by ConfigManager + return new NSPExtractor(keySet, logger); +}); builder.Services.AddSingleton(); -// … any other services you already have +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(provider => provider.GetRequiredService()); +builder.Services.AddHostedService(provider => provider.GetRequiredService()).AddHttpClient(); +builder.Services.AddHostedService(provider => provider.GetRequiredService()); +builder.Services.AddControllers(); // add MVC +// ------------------------------------------------------------------- +// 2) Middleware – Basic‑Auth (verifies username, password, UID, blacklist) +// ------------------------------------------------------------------- var app = builder.Build(); -// ----------------------------------------------------- -// 3. Apply authentication middleware *before* the -// snapshot endpoints. This guarantees all routes -// are protected. -// ----------------------------------------------------- app.UseMiddleware(); +app.MapControllers(); // routes the /index.json & /download endpoints -// ----------------------------------------------------- -// 4. Existing endpoints – unchanged -// ----------------------------------------------------- -app.MapGet("/", () => Results.Redirect("/index.tfl")); -app.MapGet("/index.tfl", async context => -{ - var jsonPath = Path.Combine(AppContext.BaseDirectory, "index.tfl"); - context.Response.ContentType = "application/json"; - await context.Response.WriteAsync(await File.ReadAllTextAsync(jsonPath)); -}); -app.MapGet("/debug", () => new SnapshotService(builder.Configuration).GetSnapshot()); -app.MapGet("/stream/{*relativePath}", async context => -{ - // … (unchanged streaming logic – same as before) -}); +// ------------------------------------------------------------------- +// 3) End‑points +// ------------------------------------------------------------------- + +app.MapGet("/debug", () => new SnapshotService( + app.Services.GetRequiredService(), + app.Services.GetRequiredService>(), + app.Services.GetRequiredService(), + app.Services.GetRequiredService(), + app.Services.GetRequiredService>()) + .GetSnapshot()); +app.Lifetime.ApplicationStarted.Register(() => + app.Services.GetRequiredService>().LogInformation("Application started. Listening on {Urls}", string.Join(", ", app.Urls))); +app.Use(async (ctx, next) => +{ + var logger = app.Services.GetRequiredService>(); + var correlationId = ctx.Request.Headers["X-Correlation-ID"].FirstOrDefault() + ?? Guid.NewGuid().ToString(); + using (logger.BeginScope("CorrelationId:{CorrelationId}", correlationId)) + { + await next(); + } +}); app.Run(); \ No newline at end of file diff --git a/TinfoilVibeServer/Properties/launchSettings.json b/TinfoilVibeServer/Properties/launchSettings.json index c876394..ed97731 100644 --- a/TinfoilVibeServer/Properties/launchSettings.json +++ b/TinfoilVibeServer/Properties/launchSettings.json @@ -5,7 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "http://localhost:5253", + "applicationUrl": "http://192.168.1.145:80;http://tinfoil.localhost:8080;http://tinfoil.ecenshu.net", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/TinfoilVibeServer/Services/ArchiveHandler.cs b/TinfoilVibeServer/Services/ArchiveHandler.cs index a3cbe67..dd1ab9b 100644 --- a/TinfoilVibeServer/Services/ArchiveHandler.cs +++ b/TinfoilVibeServer/Services/ArchiveHandler.cs @@ -1,27 +1,50 @@ - -using System.IO.Compression; -using FileSnapshot; +using System.IO.Compression; using SharpCompress.Archives; using SharpCompress.Archives.Zip; using SharpCompress.Archives.Rar; using SharpCompress.Archives.SevenZip; -using SharpCompress.Readers; +using SharpCompress.Common; using TinfoilVibeServer.Models; +using TinfoilVibeServer.Utilities; using ZipArchive = SharpCompress.Archives.Zip.ZipArchive; namespace TinfoilVibeServer.Services; -/// -/// Tries to open a file as an archive and look for an embedded NSP/XCI. -/// The goal is to be as memory‑friendly as possible – never load a whole archive into RAM. -/// -public sealed class ArchiveHandler +public interface IArchiveHandler { /// /// Return TitleInfo if an embedded Nintendo archive is found; otherwise null. /// - public static TitleInfo? TryExtractTitleInfo(string filePath) + IEnumerable<(string, long, NcaMetadataWithHash)> TryExtractTitleInfos(string filePath); + + /// + /// Return TitleInfo if an embedded Nintendo archive is found; otherwise null. + /// + IEnumerable<(string, long, NcaMetadataWithHash)> TryExtractTitleInfos(Stream archiveStream, string archiveType); +} + +/// +/// Tries to open a file as an archive and look for an embedded NSP/XCI. +/// The extractor is injected so that the hash of the first stream can be accessed +/// while the file is being read. +/// +public sealed class ArchiveHandler : IArchiveHandler +{ + private readonly INSPExtractor _nspExtractor; + private readonly ILogger _logger; + + public ArchiveHandler(INSPExtractor nspExtractor, ILogger logger) { + _nspExtractor = nspExtractor; + _logger = logger; + } + + /// + /// Return TitleInfo if an embedded Nintendo archive is found; otherwise null. + /// + public IEnumerable<(string, long, NcaMetadataWithHash)> TryExtractTitleInfos(string filePath) + { + _logger.LogInformation("Examining archive {File} for embedded NSP", filePath); var ext = Path.GetExtension(filePath).ToLowerInvariant(); try @@ -35,34 +58,41 @@ public sealed class ArchiveHandler case ".rar": return HandleRar(filePath); default: - return null; + { + _logger.LogWarning("Unsupported archive type {Extension} – skipping", ext); + return []; + } } } - catch + catch (Exception ex) { + _logger.LogError("Error opening archive {File}: {Exception}", filePath, ex.Message); // Graceful fallback – return null - return null; + return []; } } - private static TitleInfo? HandleZip(string path) + public IEnumerable<(string, long, NcaMetadataWithHash)> TryExtractTitleInfos(Stream archiveStream, string archiveType) + { + throw new NotImplementedException(); + } + + private IEnumerable<(string, long, NcaMetadataWithHash)> HandleZip(string path) { using var archive = ZipArchive.Open(path); foreach (var entry in archive.Entries) { - if (!entry.IsDirectory && IsRomArchive(entry.Key)) - { - var temp = Path.GetTempFileName(); - entry.WriteToFile(temp); - var title = NSPExtactor.ExtractFromFile(temp); - File.Delete(temp); - return title; - } + if (entry.IsDirectory || entry.Key == null || !IsRomArchive(entry.Key)) continue; + + var temp = Path.GetTempFileName(); + entry.WriteToFile(temp); + var title = _nspExtractor.ExtractFromFile(temp); // instance call + File.Delete(temp); + if (title != null) yield return (entry.Key, entry.Size, title); } - return null; } - private static TitleInfo? Handle7z(string path) + private IEnumerable<(string, long, NcaMetadataWithHash)> Handle7z(string path) { using var archive = SevenZipArchive.Open(path); foreach (var entry in archive.Entries) @@ -71,55 +101,81 @@ public sealed class ArchiveHandler { var temp = Path.GetTempFileName(); entry.WriteToFile(temp); - var title = NSPExtactor.ExtractFromFile(temp); + var title = _nspExtractor.ExtractFromFile(temp); // instance call File.Delete(temp); - return title; + if (title != null) yield return (entry.Key, entry.Size, title); } } - return null; } - private static TitleInfo? HandleRar(string path) + private IEnumerable<(string, long, NcaMetadataWithHash)> HandleRar(string path) { - // SharpCompress can handle most RAR5 files – fallback to SharpSevenZip if it fails + var titles = new List<(string, long, NcaMetadataWithHash)>(); + var entryCount = 0; try { + // todo: handle and skip multipart archives using var archive = RarArchive.Open(path); + entryCount = archive.Entries.Count; foreach (var entry in archive.Entries) { - if (!entry.IsDirectory && IsRomArchive(entry.Key)) + if (entry.IsDirectory || entry.Key == null || !IsRomArchive(entry.Key)) continue; + { + try + { + using var streamWrapper = new SeekableBufferedStream(entry.OpenEntryStream(), entry.Size, 64 * 1024 * 1024, true); + var title = _nspExtractor.ExtractFromStream(streamWrapper); + if (title != null) titles.Add((entry.Key, entry.Size, title)); + } + catch (IncompleteArchiveException incompleteArchiveException) + { + _logger.LogWarning("Incomplete archive {Archive}: {Exception}", path, incompleteArchiveException.Message); + } + catch (Exception e) + { + if (e.Message.StartsWith("Failed to extract NSP")) + { + _logger.LogError("Failed to extract title info from archive {Archive}: {Exception}", path, e.Message); + } + else + { + throw; + } + } + } + } + } + catch (Exception exception) + { + if (titles.Count > 0 && titles.Count == entryCount) + { + // broken archive but managed to read some titles + _logger.LogInformation("Failed to fully process archive with SharpCompress, but found {Count} titles: {Exception}", titles.Count, exception.Message); + return titles; + } + + // Fallback to SharpSevenZip (if needed) + _logger.LogInformation( + "Failed to open archive with SharpCompress, falling back to SharpSevenZip {Exception}", + exception.Message); + using var archive = SevenZipArchive.Open(path); + foreach (var entry in archive.Entries) + { + if (entry is { IsDirectory: false, Key: not null } && IsRomArchive(entry.Key)) { var temp = Path.GetTempFileName(); entry.WriteToFile(temp); - var title = NSPExtactor.ExtractFromFile(temp); + var title = _nspExtractor.ExtractFromFile(temp); // instance call File.Delete(temp); - return title; + if (title != null) titles.Add((entry.Key, entry.Size, title)); } } - return null; - } - catch (SharpCompress.Common.ArchiveException) - { - // Fallback to SharpSevenZip for RAR5 - /*using var stream = File.OpenRead(path); - Stream outStream = new Mem; - using var extractor = SharpSevenZip.SharpSevenZipExtractor.DecompressStream(stream, outStream); - while (extractor.MoveToNextEntry()) - { - if (!extractor.IsDirectory && IsRomArchive(extractor.CurrentFileName)) - { - var temp = Path.GetTempFileName(); - extractor.ExtractFile(temp); - var title = NSPExtactor.ExtractFromFile(temp); - File.Delete(temp); - return title; - } - }*/ - return null; } + + return titles; } - private static bool IsRomArchive(string entryName) + private bool IsRomArchive(string entryName) { var ext = Path.GetExtension(entryName).ToLowerInvariant(); return ext is ".xci" or ".nsp" or ".xcz"; diff --git a/TinfoilVibeServer/Services/AuthStore.cs b/TinfoilVibeServer/Services/AuthStore.cs deleted file mode 100644 index a9be168..0000000 --- a/TinfoilVibeServer/Services/AuthStore.cs +++ /dev/null @@ -1,211 +0,0 @@ -using System.Collections.Concurrent; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; - -namespace TinfoilVibeServer.Services; - -/// -/// Configuration section used by the auth system. -/// -public sealed class AuthSettings -{ - public string CredentialsFile { get; init; } = "credentials.json"; - public string FingerprintsFile { get; init; } = "fingerprints.json"; - public string BlacklistFile { get; init; } = "blacklist.json"; - public int MaxFailedAttempts { get; init; } = 5; -} - -/// -/// One user record – stored in *credentials.json*. -/// -public sealed record Credential( - string Username, - string PasswordHash, // SHA‑256 hex - int AllowedUidCount = 1, - bool Verified = true); // new flag – defaults to true for pre‑existing users - -/// -/// Thread‑safe singleton that keeps the authentication state in memory -/// and writes it back to disk whenever it changes. -/// -public sealed class AuthStore -{ - private readonly AuthSettings _settings; - private readonly object _sync = new(); - - // In‑memory state - public ConcurrentDictionary Credentials { get; } = new(); - public ConcurrentDictionary> Fingerprints { get; } = new(); - public ConcurrentDictionary FailedAttempts { get; } = new(); - public HashSet BlacklistIPs { get; } = new(); - - public AuthStore(IConfiguration config) - { - _settings = new AuthSettings - { - CredentialsFile = config.GetValue("Authentication:CredentialsFile") ?? "credentials.json", - FingerprintsFile = config.GetValue("Authentication:FingerprintsFile") ?? "fingerprints.json", - BlacklistFile = config.GetValue("Authentication:BlacklistFile") ?? "blacklist.json", - MaxFailedAttempts = config.GetValue("Authentication:MaxFailedAttempts", 5) - }; - - LoadAll(); - } - - #region Loading / Persisting - - private void LoadAll() - { - // Load credentials - if (File.Exists(_settings.CredentialsFile)) - { - var txt = File.ReadAllText(_settings.CredentialsFile); - var dict = JsonSerializer.Deserialize>(txt)!; - foreach (var kv in dict) - Credentials[kv.Key] = kv.Value; - } - - // Load fingerprints - if (File.Exists(_settings.FingerprintsFile)) - { - var txt = File.ReadAllText(_settings.FingerprintsFile); - var dict = JsonSerializer.Deserialize>>(txt)!; - foreach (var kv in dict) - Fingerprints[kv.Key] = kv.Value; - } - - // Load blacklist - if (File.Exists(_settings.BlacklistFile)) - { - var txt = File.ReadAllText(_settings.BlacklistFile); - var arr = JsonSerializer.Deserialize(txt)!; - foreach (var ip in arr) - BlacklistIPs.Add(ip); - } - } - - private void PersistCredentials() - { - var json = JsonSerializer.Serialize(Credentials, new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(_settings.CredentialsFile, json); - } - - private void PersistFingerprints() - { - var json = JsonSerializer.Serialize(Fingerprints, new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(_settings.FingerprintsFile, json); - } - - private void PersistBlacklist() - { - var json = JsonSerializer.Serialize(BlacklistIPs.ToArray(), new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(_settings.BlacklistFile, json); - } - - #endregion - - public bool IsBlacklisted(string ip) => BlacklistIPs.Contains(ip); - - /// - /// Validates username/password/UID, updates fingerprints and blacklists as needed. - /// - /// true if the user is authenticated; otherwise false. - public bool TryValidate(string username, string password, int? uid, string ip, out string? error) - { - error = null; - lock (_sync) - { - // 1) User existence – create on‑the‑fly if missing - if (!Credentials.TryGetValue(username, out var cred)) - { - // Create a *new* user that is not yet verified - cred = new Credential(username, ComputeHash(password), 1, Verified: false); - Credentials[username] = cred; - PersistCredentials(); - - // Create empty fingerprint list (or pre‑add the first UID) - var list = Fingerprints.GetOrAdd(username, _ => new List()); - if (uid.HasValue && !list.Contains(uid.Value)) - { - if (list.Count < cred.AllowedUidCount) - list.Add(uid.Value); - PersistFingerprints(); - } - - error = "User not verified"; - IncrementFailed(username, ip); - return false; - } - - // 2) Password check - if (!VerifyPasswordHash(password, cred.PasswordHash)) - { - error = "Invalid password"; - IncrementFailed(username, ip); - return false; - } - - // 3) Verify flag – only verified users can pass - if (!cred.Verified) - { - error = "User not verified"; - IncrementFailed(username, ip); - return false; - } - - // 4) UID handling - if (uid.HasValue) - { - var list = Fingerprints.GetOrAdd(username, _ => new List()); - - if (!list.Contains(uid.Value)) - { - if (list.Count < cred.AllowedUidCount) - { - list.Add(uid.Value); - PersistFingerprints(); - } - else - { - error = $"UID limit ({cred.AllowedUidCount}) exceeded"; - IncrementFailed(username, ip); - return false; - } - } - } - - // 5) Success – reset counter - FailedAttempts[username] = 0; - return true; - } - } - - private void IncrementFailed(string username, string ip) - { - var newCount = FailedAttempts.GetOrAdd(username, 0) + 1; - FailedAttempts[username] = newCount; - - if (newCount >= _settings.MaxFailedAttempts) - { - BlacklistIPs.Add(ip); - PersistBlacklist(); - // reset counter for the next session - FailedAttempts[username] = 0; - } - } - - #region Helpers - - public static string ComputeHash(string input) - { - using var sha = SHA256.Create(); - var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(input)); - return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); - } - - private static bool VerifyPasswordHash(string plain, string storedHash) - => string.Equals(ComputeHash(plain), storedHash, StringComparison.OrdinalIgnoreCase); - - #endregion -} \ No newline at end of file diff --git a/TinfoilVibeServer/Services/ConfigManager.cs b/TinfoilVibeServer/Services/ConfigManager.cs new file mode 100644 index 0000000..9d75d05 --- /dev/null +++ b/TinfoilVibeServer/Services/ConfigManager.cs @@ -0,0 +1,87 @@ +using System.IO; +using System.Text.Json; +using LibHac.Common.Keys; +using TinfoilVibeServer.Models; + +namespace TinfoilVibeServer.Services; + +/// +/// Reads the JSON config file on startup, watches it for changes, and also +/// loads the KeySet from the file specified in the config. +/// +public class ConfigManager +{ + public AppSettings Settings { get; private set; } + + public event Action? OnChange; + + private readonly string _configPath; + private readonly FileSystemWatcher _watcher; + private readonly object _sync = new(); + + public ConfigManager() + { + _configPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + Load(); + + _watcher = new FileSystemWatcher + { + Path = Path.GetDirectoryName(_configPath)!, + Filter = Path.GetFileName(_configPath), + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.Attributes + }; + _watcher.Changed += (_, _) => Reload(); + _watcher.EnableRaisingEvents = true; + } + + private void Load() + { + if (!File.Exists(_configPath)) + { + Settings = new AppSettings( + RootDirectories: Array.Empty(), + WhitelistExtensions: Array.Empty(), + RomExtensions: Array.Empty(), + CredentialsFile: "credentials.json", + FingerprintsFile: "fingerprints.json", + BlacklistFile: "blacklist.json", + MaxFailedAttempts: 5, + KeySetFile: "keys.bin" + ); + return; + } + + var txt = File.ReadAllText(_configPath); + Settings = JsonSerializer.Deserialize(txt, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + + // --- Load the KeySet -------------------------------------------- + if (!string.IsNullOrWhiteSpace(Settings.KeySetFile)) + { + var keyFilePath = Path.Combine(AppContext.BaseDirectory, Settings.KeySetFile); + if (File.Exists(keyFilePath)) + { + // LibHac provides a static helper to load a key‑set file. + // If the file is not found or corrupt, we simply keep the + // default (empty) key set – the app will throw later + // when a title requires a missing key. + try + { + KeySetHolder.KeySet = ExternalKeyReader.ReadKeyFile(keyFilePath); + } + catch + { + KeySetHolder.KeySet = new KeySet(); // fallback + } + } + } + } + + private void Reload() + { + lock (_sync) + { + Load(); + OnChange?.Invoke(Settings); + } + } +} \ No newline at end of file diff --git a/TinfoilVibeServer/Services/IndexBuilderService.cs b/TinfoilVibeServer/Services/IndexBuilderService.cs new file mode 100644 index 0000000..de5d951 --- /dev/null +++ b/TinfoilVibeServer/Services/IndexBuilderService.cs @@ -0,0 +1,187 @@ +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.RegularExpressions; +using LibHac.Ncm; +using Microsoft.Extensions.Options; +using TinfoilVibeServer.Models; + +namespace TinfoilVibeServer.Services; + +// File: Services/IndexBuilderService.cs +// *** NEW *** +public sealed class IndexBuilderService: IHostedService +{ + private const string CacheFileName = "indexcache.json"; + + private readonly IOptions _options; + private readonly ISnapshotService _snapshotService; + private readonly TitleDatabaseService _titleDb; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + private readonly string _cachePath; + + private readonly SemaphoreSlim _lock = new(1, 1); + public IndexBuilderService( + IOptions options, + ISnapshotService snapshotService, + TitleDatabaseService titleDb, + IConfiguration configuration, + ILogger logger) + { + _options = options; + _snapshotService = snapshotService; + _titleDb = titleDb; + _configuration = configuration; + _logger = logger; + _cachePath = Path.Combine(AppContext.BaseDirectory, CacheFileName); + } + + public IndexDto Build(HttpContext httpContext) + { + // 1️⃣ Load cache if it exists + var cached = LoadCache(); + var snapshot = _snapshotService.GetSnapshot(); + + if (string.IsNullOrEmpty(snapshot.Hash) || snapshot.Files.Count == 0) + { + _snapshotService.BuildSnapshotAsync(); + snapshot = _snapshotService.GetSnapshot(); + } + + // 2️⃣ Re‑build only if the snapshot hash changed + string snapshotHash = ComputeSnapshotHash(snapshot.Files); + if (cached != null && cached.SnapshotHash == snapshotHash) + { + _logger.LogInformation("Using cached index (snapshot hash={Hash}", snapshotHash); + return cached.Index; + } + + _logger.LogInformation("Building index (snapshot size={Count})", snapshot.Files.Count); + // 3️⃣ Build new index from snapshot entries + var files = ParseSnapshotFiles(snapshot, new Uri(httpContext.Request.Scheme + "://" + httpContext.Request.Host + httpContext.Request.PathBase)); + + var directories = _configuration.GetSection("Directories") + .Get() ?? Array.Empty(); + + var success = _configuration["SuccessMessage"] ?? string.Empty; + + var index = new IndexDto(files.SelectMany(inner => inner).ToList(), directories.ToList(), success); + + // 4️⃣ Persist cache + PersistCache(snapshotHash, index); + + return index; + } + + private List> ParseSnapshotFiles(SnapshotService.ROMSnapshot snapshot, Uri baseUri) + { + var files = snapshot.Files + .Where(e => e.Titles.Count > 0) + .Select(e => + { + var fileDtos = new List(); + foreach (var title in e.Titles) + { + var titleId = title.TitleId; + var name = _titleDb.TryGetTitle(titleId, out var t) + ? t?.Name + : "Unknown"; + + var versionNumberParsed = (title.ContentMetaType == ContentMetaType.Application) ? title.Version : title.Version * 0x10000; + var patchOrApp = title.ContentMetaType == ContentMetaType.Application ? "Base" : "Update"; + if (title.ContentMetaType == ContentMetaType.Patch || + title.ContentMetaType == ContentMetaType.AddOnContent) + { + // Patch should use the application title name + name = _titleDb.TryGetTitle(title.ApplicationTitle, out var appTitle) + ? appTitle?.Name + : "Unknown"; + //_logger.LogInformation("Patch title {TitleId} uses application title {ApplicationTitle}", titleId, title.ApplicationTitle); + } + + // if still unknown, its probably a demo, use filename extraction + if (name == "Unknown") + { + var match = Regex.Match(Path.GetFileNameWithoutExtension(e.Path), "^(.+?)\\s*(?:\\[.*)?$", + RegexOptions.None); + if (match.Success) + { + name = match.Groups[1].Value; + _logger.LogInformation("Name not found for {TitleId}, using filename: {Name}", titleId, + name); + } + } + + var fileName =Uri.EscapeDataString($"{name}[{titleId}][v{versionNumberParsed}][{patchOrApp}].nsp"); + var url = $"{baseUri.ToString().TrimEnd('/')}/{fileName}"; + if (Uri.IsWellFormedUriString(url, UriKind.Absolute)) + { + fileDtos.Add(new FileDto(url, e.Size)); + } + else + { + _logger.LogWarning("Invalid URL for {TitleId}: {Url}", titleId, url); + } + } + + return fileDtos; + + }) + .ToList(); + return files; + } + + private IndexCache? LoadCache() + { + if (!File.Exists(_cachePath)) return null; + _lock.Wait(); + var json = File.ReadAllText(_cachePath); + _lock.Release(); + _logger.LogInformation("Loaded index cache from {Path}", _cachePath); + return JsonSerializer.Deserialize(json); + } + + private void PersistCache(string snapshotHash, IndexDto index) + { + var cache = new IndexCache(snapshotHash, index); + File.WriteAllText(_cachePath, JsonSerializer.Serialize(cache, new JsonSerializerOptions{WriteIndented=true})); + } + + private static string ComputeSnapshotHash(IEnumerable entries) + { + var json = JsonSerializer.Serialize(entries); + using var sha256 = SHA256.Create(); + return BitConverter.ToString(sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(json))).Replace("-", "").ToLowerInvariant(); + } + + // DTO for the cache file + private sealed record IndexCache(string SnapshotHash, IndexDto Index); + + #region IHostedService + + public Task StartAsync(CancellationToken cancellationToken) + { + var url = new Uri(_options.Value.ApiBaseUrl); + var host = url.Host; + Build(new DefaultHttpContext {HttpContext = { Request = { Host = new HostString(host), Scheme = url.Scheme, Path = new PathString(url.AbsolutePath)}}}); + _snapshotService.SnapshotRebuilt += InvalidateIndex; + return Task.CompletedTask; + } + + public void InvalidateIndex(object? sender, EventArgs e) + { + if (!File.Exists(_cachePath)) return; + + File.Delete(_cachePath); + _logger.LogInformation("Index cache cleared"); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _snapshotService.SnapshotRebuilt -= InvalidateIndex; + return Task.CompletedTask; // nothing special to do on shutdown + } + + + #endregion +} \ No newline at end of file diff --git a/TinfoilVibeServer/Services/NSPExtractor.cs b/TinfoilVibeServer/Services/NSPExtractor.cs index dff4a4f..1f3f6d6 100644 --- a/TinfoilVibeServer/Services/NSPExtractor.cs +++ b/TinfoilVibeServer/Services/NSPExtractor.cs @@ -1,65 +1,296 @@ -using System; -using System.IO; -using System.Text.Json; +using System.Security.Cryptography; +using LibHac.Common; using LibHac.Fs; +using LibHac.Fs.Fsa; using LibHac.FsSystem; -using LibHac.FsSystem.Impl; -using LibHac.Util; -using TinfoilVibeServer.Models; +using LibHac.Tools.FsSystem; +using LibHac.Tools.FsSystem.NcaUtils; +using LibHac.Common.Keys; +using LibHac.Ncm; +using LibHac.Tools.Fs; +using LibHac.Tools.Ncm; -namespace FileSnapshot; - -/// -/// Extracts title information from a Nintendo NSP/XCI file using LibHac 0.20.0. -/// -public sealed class NSPExtactor +namespace TinfoilVibeServer.Services { - /// - /// Return TitleInfo for the file, or null if the file is not a valid Nintendo archive. - /// - public static TitleInfo? ExtractFromFile(string filePath) + public interface INSPExtractor { - // LibHac works with byte streams. We open the file once and hand the stream to RomArchiveReader. - try + /// + /// Public convenience wrapper that opens the file on disk. + /// + NcaMetadataWithHash? ExtractFromFile(string filePath); + + /// + /// Core implementation – works on any seekable stream that contains a full NSP/XCI container. + /// + NcaMetadataWithHash? ExtractFromStream(Stream stream); + + string ExtractHashFromStream(Stream nspStream); + } + + /// + /// Extracts the TitleId, version, type *and* the SHA‑256 of the first NCA stream. + /// + public sealed class NSPExtractor : INSPExtractor + { + private readonly KeySet _keySet; + private readonly ILogger _logger; + + public NSPExtractor(KeySet keySet, ILogger logger) { - using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); - using var reader = new RomArchiveReader(fs, new RomArchiveSettings { UseCache = false }); + _keySet = keySet; + _logger = logger; + } - if (!reader.IsValid) - return null; // Not an NSP/XCI + /// + /// Public convenience wrapper that opens the file on disk. + /// + public NcaMetadataWithHash? ExtractFromFile(string filePath) + { + using var stream = File.OpenRead(filePath); + return ExtractFromStream(stream); + } - // The ROM contains one or more NCA headers. For most cases the first one is the title. - // LibHac exposes the *content* list – we pick the first NCA that is a title. - foreach (var nca in reader.GetContentInfos()) + /// + /// Core implementation – works on any seekable stream that contains a full NSP/XCI container. + /// + public NcaMetadataWithHash? ExtractFromStream(Stream stream) + { + if (!stream.CanSeek) return null; + stream.Seek(0, SeekOrigin.Begin); + + _logger.LogInformation("Extracting NSP/XCI from stream (length={Length})", stream.Length); + using var storage = new StreamStorage(stream, false); + if (IsPfs0FileSystem(stream)) { - // NcaId.Type gives Application / Patch / DLC etc. - // We only care that the type is not null – the NCA itself contains the metadata we need. - var meta = nca.GetMetaData(); - - // 1) Title ID - string titleId = nca.Id.ToString("X16"); - - // 2) Name and version - // 0.20.x provides a simple string accessor - string? titleName = meta.GetStringValue("title"); - string? versionStr = meta.GetStringValue("version"); - - // 3) Determine if it is an application - bool isApp = meta.GetStringValue("content_type") == "Application"; - - return new TitleInfo( - titleId, - titleName ?? $"Unknown ({titleId})", - versionStr ?? "0.00", - isApp); + return ExtractNSPFromStream(storage); } - return null; // No NCA found + if (IsXciFileSystem(stream)) + { + var xci = new Xci(_keySet, storage); + List ncaEntries; + if (xci.HasPartition(XciPartitionType.Secure)) + { + _logger.LogInformation("Processing as XCI"); + var partition = xci.OpenPartition(XciPartitionType.Secure); + ncaEntries = partition + .EnumerateEntries("*.cnmt.nca", SearchOptions.RecurseSubdirectories) + .Where(e => e.Type == DirectoryEntryType.File) + .ToList(); + byte[]? hash = null; + foreach (var dirEntry in ncaEntries) + { + using var fileRef = new UniqueRef(); + partition.OpenFile(ref fileRef.Ref, dirEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); + using var ncaFile = fileRef.Release(); + using var ncaFileStorage = new FileStorage(ncaFile); + + var nca = new Nca(_keySet, ncaFileStorage); + if (hash == null) + { + // Hash the *first* NCA stream – the stream we just opened + using var sha256 = SHA256.Create(); + using var ncaStream = ncaFile.AsStream(); + hash = sha256.ComputeHash(ncaStream); + } + + if (nca.Header.ContentType != NcaContentType.Meta) + continue; // only the meta NCA contains title metadata + + string titleId = nca.Header.TitleId.ToString("X16"); + var (contentMetaType, applicationTitleId, titleVersion) = GetMetaData(nca); + + _logger.LogInformation("Meta NCA found – TitleId={TitleId} Version={Version}", titleId, titleVersion); + // XCI can never be a patch? + return new NcaMetadataWithHash(titleId, applicationTitleId.ToString("X16"), titleVersion.Major, ContentMetaType.Application, BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant()); + } + } + } + + return null; // no meta NCA found } - catch + + private NcaMetadataWithHash? ExtractNSPFromStream(StreamStorage storage) { - // Any exception (bad file, invalid archive, etc.) -> treat as non‑NXP + List ncaEntries; + _logger.LogInformation("Processing as NSP"); + var partition = new PartitionFileSystem(); + partition.Initialize(storage).ThrowIfFailure(); + // Find the first *.nca that contains the meta header + ncaEntries = partition + .EnumerateEntries("*.nca", SearchOptions.RecurseSubdirectories) + .Where(e => e.Type == DirectoryEntryType.File) + .ToList(); + byte[]? hash = null; + foreach (var dirEntry in ncaEntries) + { + using var fileRef = new UniqueRef(); + partition.OpenFile(ref fileRef.Ref, dirEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); + using var ncaFile = fileRef.Release(); + using var ncaFileStorage = new FileStorage(ncaFile); + + var nca = new Nca(_keySet, ncaFileStorage); + if (hash == null) + { + // Hash the *first* NCA stream – the stream we just opened + using var sha256 = SHA256.Create(); + using var ncaStream = ncaFile.AsStream(); + hash = sha256.ComputeHash(ncaStream); + } + + if (nca.Header.ContentType != NcaContentType.Meta) + continue; // only the meta NCA contains title metadata + + _logger.LogInformation("Meta NCA found – TitleId={TitleId} Version={Version}", nca.Header.TitleId, nca.Header.Version); + string titleId = nca.Header.TitleId.ToString("X16"); + + var (contentMetaType,applicationTitle,titleVersion) = GetMetaData(nca); + if (contentMetaType != null) + return new NcaMetadataWithHash(titleId, applicationTitle.ToString("X16"), titleVersion.Minor, contentMetaType.Value, BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant()); + } + return null; } + + private static (ContentMetaType?,ulong, TitleVersion) GetMetaData(Nca nca) + { + if (nca.Header.ContentType != NcaContentType.Meta) return (null,0, new TitleVersion(0, true)); + using var openFileSystem = nca.OpenFileSystem(0, IntegrityCheckLevel.ErrorOnInvalid); + foreach (var entry in openFileSystem.EnumerateEntries("*.cnmt", SearchOptions.Default)) + { + using var fileRef = new UniqueRef(); + + var result = openFileSystem.OpenFile(ref fileRef.Ref, entry.FullPath.ToU8Span(), OpenMode.Read); + if (result.IsFailure()) continue; + using var nacpFile = fileRef.Release(); + using var asStream = nacpFile.AsStream(); + + var cnmt = new Cnmt(asStream); + var applicationTitle = cnmt.ApplicationTitleId; + return (cnmt.Type,applicationTitle, cnmt.TitleVersion); + } + + return (null,0, new TitleVersion(0, true)); + } + + /// + /// Quick sanity check that the stream looks like a PFS0 file system. + /// + private bool IsPfs0FileSystem(Stream stream) + { + try + { + if (!stream.CanSeek) return false; + stream.Seek(0, SeekOrigin.Begin); + + var storage = new StreamStorage(stream, true); + var partition = new PartitionFileSystem(); + partition.Initialize(storage).ThrowIfFailure(); + _logger.LogInformation("PFS0 found"); + return true; + } + catch + { + // ignored + } + + return false; + } + private bool IsXciFileSystem(Stream stream) + { + try + { + if (!stream.CanSeek) return false; + stream.Seek(0, SeekOrigin.Begin); + + var storage = new StreamStorage(stream, true); + try + { + var xciBlock = new Xci(_keySet, storage); + _logger.LogInformation("XCI found"); + return xciBlock.HasPartition(XciPartitionType.Secure); + } + catch + { + // ignored + } + return false; + } + catch (Exception e) + { + _logger.LogError("Failed to extract XCI: {Exception}", e.Message); + return false; + } + } + + public string ExtractHashFromStream(Stream nspStream) + { + if (!IsPfs0FileSystem(nspStream)) + return string.Empty; + + nspStream.Seek(0, SeekOrigin.Begin); + + using var storage = new StreamStorage(nspStream, true); + var partition = new PartitionFileSystem(); + partition.Initialize(storage).ThrowIfFailure(); + + // Find the first *.nca that contains the meta header + var ncaEntries = partition + .EnumerateEntries("*.nca", SearchOptions.RecurseSubdirectories) + .Where(e => e.Type == DirectoryEntryType.File) + .ToList(); + + foreach (var dirEntry in ncaEntries) + { + using var fileRef = new UniqueRef(); + partition.OpenFile(ref fileRef.Ref, dirEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); + using var ncaFile = fileRef.Release(); + using var ncaFileStorage = new FileStorage(ncaFile); + + try + { + var nca = new Nca(_keySet, ncaFileStorage); + if (nca.Header.ContentType != NcaContentType.Meta) + continue; // only the meta NCA contains title metadata + + // Hash the *first* NCA stream – the stream we just opened + using var ncaStream = ncaFile.AsStream(); + using var sha256 = SHA256.Create(); + var hash = sha256.ComputeHash(ncaStream); + var extractHashFromStream = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + _logger.LogInformation("Computed first‑stream hash {Hash} for {TitleId}", extractHashFromStream, + nca.Header.TitleId); + return extractHashFromStream; + } + catch (Exception e) + { + _logger.LogError("Failed to extract NSP: {Exception}", e.Message); + } + } + return string.Empty; + } + } + + /// + /// DTO returned by the extractor – contains all data the snapshot needs. + /// + public sealed class NcaMetadataWithHash + { + public string TitleId { get; } + public string ApplicationTitle { get; set; } + public int Version { get; } + + public ContentMetaType ContentMetaType { get; set; } + public string Hash { get; } + + public NcaMetadataWithHash(string titleId, string applicationTitle, int version, + ContentMetaType contentMetaType, string hash) + { + TitleId = titleId; + ApplicationTitle = applicationTitle; + Version = version; + ContentMetaType = contentMetaType; + Hash = hash; + } } } \ No newline at end of file diff --git a/TinfoilVibeServer/Services/ROMArchiveReader.cs b/TinfoilVibeServer/Services/ROMArchiveReader.cs new file mode 100644 index 0000000..9b1a716 --- /dev/null +++ b/TinfoilVibeServer/Services/ROMArchiveReader.cs @@ -0,0 +1,112 @@ +// File: Services/RomArchiveReader.cs +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using SharpCompress.Archives; +using SharpCompress.Common; + +namespace TinfoilVibeServer.Services +{ + /// + /// Reads a ROM archive (zip / 7z / rar) from a stream. + /// + public sealed class RomArchiveReader : IDisposable + { + private readonly ZipArchive? _zipArchive; + private readonly IArchive? _sharpArchive; + private readonly Stream? _archiveStream; // the stream actually handed to SharpCompress + + public RomArchiveReader(string path) : this(File.OpenRead(path), path) { } + /// + /// Opens an archive from a stream. + /// The *fileName* parameter is only used to decide which archive format + /// to open; it can be null if the caller already knows the format. + /// + public RomArchiveReader(Stream stream, string? fileName = null) + { + if (stream == null) throw new ArgumentNullException(nameof(stream)); + + var ext = fileName?.ToLowerInvariant() ?? string.Empty; + + switch (ext) + { + case ".zip": + // System.IO.Compression can use the stream directly + _zipArchive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false); + break; + + case ".7z": + case ".rar": + // SharpCompress requires a seekable stream; copy if necessary + if (!stream.CanSeek) + { + var ms = new MemoryStream(); + stream.CopyTo(ms); + ms.Position = 0; + _archiveStream = ms; + stream.Dispose(); // original non‑seekable stream no longer needed + } + else + { + _archiveStream = stream; + } + _sharpArchive = ArchiveFactory.Open(_archiveStream); + break; + + default: + throw new NotSupportedException($"Archive type '{ext}' is not supported."); + } + } + + /// + /// Enumerates every file entry inside the archive. + /// + public IEnumerable GetEntries() + { + if (_zipArchive != null) + { + foreach (var entry in _zipArchive.Entries) + { + if (entry.FullName.EndsWith("/", StringComparison.Ordinal)) + continue; // skip directories + + // ZipArchiveEntry.Open returns a seekable stream that must be disposed by the caller + yield return new RomArchiveEntry(entry.FullName, entry.Open()); + } + } + else if (_sharpArchive != null) + { + foreach (var entry in _sharpArchive.Entries) + { + if (entry.IsDirectory) + continue; + + // SharpCompress gives us a stream that must be disposed by the caller + yield return new RomArchiveEntry(entry.Key, entry.OpenEntryStream()); + } + } + } + + /// + /// Back‑compat wrapper used by SnapshotService. + /// + public IEnumerable GetContentInfos() => GetEntries(); + + /// + /// Disposes the underlying archive objects and the stream(s). + /// + public void Dispose() + { + _zipArchive?.Dispose(); + _sharpArchive?.Dispose(); + _archiveStream?.Dispose(); + } + + /// + /// Lightweight container that holds the entry name and the opened stream. + /// The caller must dispose Stream after it is done. + /// + public sealed record RomArchiveEntry(string Name, Stream Stream); + } +} \ No newline at end of file diff --git a/TinfoilVibeServer/Services/SnapshotService.cs b/TinfoilVibeServer/Services/SnapshotService.cs index 4d680a2..5d50f91 100644 --- a/TinfoilVibeServer/Services/SnapshotService.cs +++ b/TinfoilVibeServer/Services/SnapshotService.cs @@ -1,175 +1,645 @@ using System.Collections.Concurrent; using System.Security.Cryptography; using System.Text.Json; -using FileSnapshot; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; using TinfoilVibeServer.Models; +using TinfoilVibeServer.Utilities; namespace TinfoilVibeServer.Services; +public interface ISnapshotService +{ + event EventHandler SnapshotRebuilt; // raised after a rebuild + void RebuildSnapshot(); + SnapshotService.ROMSnapshot GetSnapshot(); + + Task AddToSnapshotAsync(FileEntry entry); + Task BuildSnapshotAsync(); + void GetArchiveName(string titleId); + char GetArchivePathSeparator(); +} /// /// Keeps an in‑memory snapshot, watches the filesystem for changes, and -/// only re‑processes a file if its hash changed. The snapshot is -/// automatically re‑generated when the configuration changes. +/// only re‑processes a file if its hash changed. /// -public sealed class SnapshotService : IDisposable +public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedService { - private readonly ConfigManager _config; + #region FileSystemWatcher + private readonly List _watchers = new(); + #endregion + + private readonly SnapshotOptions _options; + private readonly INSPExtractor _nspExtractor; + private readonly IArchiveHandler _archiveHandler; + private readonly ILogger _logger; private readonly string _jsonPath; private readonly string _snapshotPath; - private readonly FileSystemWatcher _watcher; - - // path -> CachedFile - private readonly ConcurrentDictionary _cache = new(); - - private string? _currentSnapshotHash; - - public SnapshotService(ConfigManager config) + private readonly ConcurrentDictionary _cache = new(); + private readonly ConcurrentDictionary _hashCache = new(); + // Archive full path -> FileEntry.Path + private readonly ConcurrentDictionary _archiveLookup = new(); + // hash -> file size + private readonly ConcurrentDictionary _sizeLookup = new(); + private readonly IMemoryCache _debouncerCache; + public event EventHandler? SnapshotRebuilt; + public event EventHandler? SnapshotRebuilding; + + private readonly SemaphoreSlim _snapshotFileSemaphore = new(1,1); + private const char ArchivePathSeparator = '|'; + public char GetArchivePathSeparator() => ArchivePathSeparator; + + public SnapshotService( + IMemoryCache debouncerCache, + IOptionsMonitor options, + INSPExtractor nspExtractor, + IArchiveHandler archiveHandler, + ILogger logger) { - _config = config; - _jsonPath = Path.Combine(AppContext.BaseDirectory, config.Settings.SnapshotFile); - _snapshotPath = Path.Combine(AppContext.BaseDirectory, config.Settings.SnapshotBackupFile); - - // Initial snapshot - BuildSnapshot(); - - // Persist a copy for quick load on next run - File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(GetSnapshot())); - - // File system watcher - _watcher = new FileSystemWatcher + _options = options.CurrentValue; + _debouncerCache = debouncerCache; + _nspExtractor = nspExtractor; + _archiveHandler = archiveHandler; + _logger = logger; + _jsonPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotFile); + + // Debounce timer for persisting snapshot + long debounceTime = 200; + var entryOptions = new MemoryCacheEntryOptions() + .SetSlidingExpiration(TimeSpan.FromSeconds(debounceTime)).RegisterPostEvictionCallback((key, value, reason, + state) => + { + + _logger.LogInformation("Should persist the snapshot {Key}, {Reason}", key, reason); + }); // <‑‑ sliding! + FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_jsonPath))); + if (!File.Exists(_jsonPath)) { - Path = string.Join(Path.PathSeparator, config.Settings.RootDirectories), + _snapshotFileSemaphore.Wait(); + File.WriteAllText(_jsonPath, "[]"); + _snapshotFileSemaphore.Release(); + } + _snapshotPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotBackupFile); + FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_snapshotPath))); + // 1️⃣ Register for *property* changes + _options.PropertyChanged += (s, e) => OnOptionsChanged(e.PropertyName); + + foreach (var path in _options.RootDirectories) + { + AddWatchDirectory(path); + } + } + // --------- Private helpers --------- + private void OnOptionsChanged(string propertyName) + { + if (propertyName == nameof(SnapshotOptions.RootDirectories)) + { + var fileSystemWatchers = _watchers.Where(watcher => !_options.RootDirectories.Contains(watcher.Path)); + foreach (var watcher in fileSystemWatchers) + { + watcher.EnableRaisingEvents = false; + watcher.Dispose(); + _watchers.Remove(watcher); + } + + var newWatchedDirectories = _options.RootDirectories.Where(newWatchedDirectory => + !_watchers.Any(watcher => + string.Equals(watcher.Path, newWatchedDirectory, StringComparison.OrdinalIgnoreCase))); + + foreach (var newWatchedDirectory in newWatchedDirectories) + { + AddWatchDirectory(newWatchedDirectory); + + } + + BuildSnapshotAsync(); // rebuild everything + PersistSnapshotAsync(); + } + } + + + #region FileSystemWatcher + private void AddWatchDirectory(string path) + { + if (!Directory.Exists(path)) return; + var watcher = new FileSystemWatcher + { + Path = path, IncludeSubdirectories = true, NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.Size | NotifyFilters.LastWrite }; - - _watcher.Created += OnChanged; - _watcher.Changed += OnChanged; - _watcher.Deleted += OnChanged; - _watcher.Renamed += OnRenamed; - _watcher.EnableRaisingEvents = true; - - // React to config changes - _config.OnChange += cfg => - { - // Re‑initialise the watcher with the new root directories - _watcher.Path = string.Join(Path.PathSeparator, cfg.RootDirectories); - _watcher.EnableRaisingEvents = true; - BuildSnapshot(); // rebuild everything - PersistSnapshot(); - }; + watcher.Created += OnChanged; + watcher.Changed += OnChanged; + watcher.Deleted += OnChanged; + watcher.Renamed += OnRenamed; + watcher.EnableRaisingEvents = true; + _logger.LogInformation("Watching {Path}", path); + _watchers.Add(watcher); } - private sealed record CachedFile(string Path, string Hash, TitleInfo? Title); - - #region File system change handlers - - private void OnChanged(object? _, FileSystemEventArgs e) => - ThrottleSnapshotUpdate(); - - private void OnRenamed(object? _, RenamedEventArgs e) + private void RemoveWatchDirectory(string path) { - // Treat rename as delete + create - OnChanged(_, new FileSystemEventArgs(WatcherChangeTypes.Deleted, e.OldFullPath, e.OldName)); - OnChanged(_, new FileSystemEventArgs(WatcherChangeTypes.Created, e.FullPath, e.Name)); + var fileSystemWatchers = _watchers.FirstOrDefault(watcher => watcher.Path == path); + if (fileSystemWatchers == null) return; + fileSystemWatchers.EnableRaisingEvents = false; + fileSystemWatchers.Dispose(); + _logger.LogInformation("Stopped watching {Path}", path); + _watchers.Remove(fileSystemWatchers); } - private void ThrottleSnapshotUpdate() + private void OnChanged(object? _, FileSystemEventArgs e) => ThrottleSnapshotUpdate(e); + private void OnRenamed(object? _, RenamedEventArgs e) => ThrottleSnapshotUpdate(e); + + private void ThrottleSnapshotUpdate(FileSystemEventArgs fileSystemEventArgs) { - // Debounce: only trigger once in a short window - Task.Run(async () => + SnapshotRebuilding?.Invoke(this, fileSystemEventArgs); + using var cacheEntry = _debouncerCache.CreateEntry(fileSystemEventArgs.FullPath) + //.SetAbsoluteExpiration(TimeSpan.FromMilliseconds(DebounceMs)) + .SetValue(fileSystemEventArgs) + .SetOptions(new MemoryCacheEntryOptions + { + PostEvictionCallbacks = + { + new PostEvictionCallbackRegistration + { + EvictionCallback = + (key, value, reason, state) => + { + if (reason != EvictionReason.Expired) return; + + if (value is FileSystemEventArgs args) + { + if (IsFileLocked(args.FullPath)) + { + _logger.LogInformation("File {FilePath} is locked, skipping snapshot update", args.FullPath); + using var rebounce = _debouncerCache.CreateEntry(args.FullPath) + .SetAbsoluteExpiration(TimeSpan.FromMilliseconds(DebounceMs)) + .SetValue(args); + } + } + + RebuildSnapshot(); + } + } + } + }); + cacheEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(DebounceMs); + + _logger.LogDebug("File system event {EventType} on {Path} at {Time}", fileSystemEventArgs.ChangeType, + fileSystemEventArgs.FullPath, DateTime.Now.ToString("HH:mm:ss")); + } + private static bool IsFileLocked(string filePath) + { + FileStream? stream = null; + var file = new FileInfo(filePath); + + try { - await Task.Delay(250); - UpdateSnapshot(); - }); + stream = file.Open(FileMode.Open, FileAccess.ReadWrite, FileShare.None); + } + catch (IOException) + { + return true; + } + finally + { + stream?.Close(); + } + return false; + } + + private const int DebounceMs = 400; + private readonly JsonSerializerOptions _jsonSerializerOptions = new() { IncludeFields = true }; + private int SnapshotFileLockTimeout { get; } = 1000; + + private void DebounceElapsed() + { + UpdateSnapshot(); } #endregion - /// - /// Full rebuild – called on start‑up and on config change. - /// - private void BuildSnapshot() + #region Snapshot logic + + public Task AddToSnapshotAsync(FileEntry entry) { - var cfg = _config.Settings; - var entries = new List(); - - foreach (var dir in cfg.RootDirectories) + // Update lookup tables + _cache[entry.Path] = new SnapshotEntry(entry.Path, entry.Hash, entry.Size, entry.Titles); + _hashCache[entry.Hash] = entry.Path; + _sizeLookup[entry.Hash] = entry.Size; + if (entry.Path.Contains(ArchivePathSeparator)) { - foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories)) + var filename = entry.Path.Split(ArchivePathSeparator)[0]; + _archiveLookup[filename] = entry.Path; + } + + foreach (var ncaMetadataWithHash in entry.Titles) + { + _hashCache[ncaMetadataWithHash.Hash] = entry.Path; + _sizeLookup[ncaMetadataWithHash.Hash] = entry.Size; + _logger.LogInformation("Added entry {titleId} to snapshot (hash={hash})", ncaMetadataWithHash.TitleId, ncaMetadataWithHash.Hash); + } + // Persist snapshot to disk + PersistSnapshotAsync(); + return Task.CompletedTask; + } + + /// Builds _cache and _hashCache based on directory configuration + public Task BuildSnapshotAsync() + { + _logger.LogInformation("Building snapshot"); + var index = LoadSnapshotIndex(); + var latestModifiedUtcParallel = FileSystemExtensions.GetLatestModifiedUtcParallel(_options.RootDirectories); + var fileInfo = new FileInfo(_snapshotPath); + bool snapshotVerified = true; + if (latestModifiedUtcParallel.HasValue && latestModifiedUtcParallel.Value < fileInfo.LastWriteTimeUtc) + { + if (index.Count != 0) { - var ext = Path.GetExtension(file).ToLowerInvariant(); - - if (!(cfg.WhitelistExtensions.Contains(ext) || cfg.RomExtensions.Contains(ext))) - continue; - - // 1) compute hash - var hash = ComputeHash(file); - - // 2) decide if we need to re‑process - if (_cache.TryGetValue(file, out var cached) && cached.Hash == hash) + // directory may have been added with older roms, verify that the snapshot is still up to date + foreach (var dir in _options.RootDirectories) { - // nothing changed – use cached title info - entries.Add(new FileEntry(file, new FileInfo(file).Length, hash, cached.Title)); - continue; + // check first entry is in index + var entry = BuildSnapshot(dir).FirstOrDefault(); + if (entry != null) + { + if (!index.TryGetValue(entry.Path, out var cached)) + { + snapshotVerified = false; + _logger.LogInformation("Snapshot does not contain first entry in directory {Directory}", dir); + } + } } - // 3) extract title if applicable - TitleInfo? title = null; - if (cfg.RomExtensions.Contains(ext)) + if (snapshotVerified) { - title = NSPExtactor.ExtractFromFile(file); + _logger.LogInformation("Snapshot is up to date"); + return Task.CompletedTask; } - else - { - title = ArchiveHandler.TryExtractTitleInfo(file); - } - - // 4) update cache - _cache[file] = new CachedFile(file, hash, title); - - // 5) add to snapshot - entries.Add(new FileEntry(file, new FileInfo(file).Length, hash, title)); } + else + { + _logger.LogInformation("Snapshot is up to date but index is empty"); + } + } + _logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", _options.RootDirectories.Count); + var entries = new List(); + + var snapshotChanged = false; + foreach (var dir in _options.RootDirectories) + { + _ = Task.Run(() => + { + _logger.LogInformation("Rebuilding directory {Directory}", dir); + var buildSnapshot = BuildSnapshot(dir); + var fileEntries = buildSnapshot.ToList(); + snapshotChanged = snapshotChanged || fileEntries.Count != 0; + entries.AddRange(fileEntries.Where(entry => entry != null)!); + }); } // Replace the entire snapshot - lock (_cache) + ComputeSnapshotHash(entries); + if (snapshotChanged) { - // the snapshot itself is not stored in _cache – it's only used for the JSON + _logger.LogInformation("Snapshot rebuilt"); + SnapshotRebuilt?.Invoke(this, EventArgs.Empty); } - // we keep entries in a local variable for now - _currentSnapshotHash = ComputeSnapshotHash(entries); - File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries)); + return Task.CompletedTask; + } + + public void GetArchiveName(string titleId) + { + ; + } + + // Returns List of FileEntry that do not have a hash in the cache + // Each entry that has not been added to the lookup table is added to the cache + private IEnumerable BuildSnapshot(string dir) + { + FileEntry entry; + if (!Directory.Exists(dir)) yield break; + foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories)) + { + string hash = string.Empty; + var ext = Path.GetExtension(file).ToLowerInvariant(); + + if (!(_options.ArchiveExtensions.Contains(ext) || _options.RomExtensions.Contains(ext))) + continue; + + if (_cache.ContainsKey(file) || _hashCache.ContainsKey(hash)) + { + continue; + } + + // 3) extract title if applicable + var titles = new List<(string, long, NcaMetadataWithHash)>(); + if (_options.RomExtensions.Contains(ext)) + { + using var nspStream = File.OpenRead(file); + hash = ComputeFirstStreamHash(nspStream); + + if (_hashCache.ContainsKey(hash)) + { + continue; + } + + var nspStreamLength = nspStream.Length; + var title = _nspExtractor.ExtractFromStream(nspStream); + if (title != null) + { + var archiveEntry = new FileEntry(file, nspStreamLength, hash, [title]); + AddToSnapshotAsync(archiveEntry); + titles.Add((title.TitleId, nspStreamLength, title)); + yield return archiveEntry; + } + } + else + { + if (_options.ArchiveExtensions.Contains(ext)) + { + if (_archiveLookup.ContainsKey(file)) continue; + hash = ComputeFirstStreamHash(file); + if (_hashCache.ContainsKey(hash)) + { + yield return null; + } + + IEnumerable<(string, long, NcaMetadataWithHash)>? titlesEnumerable = null; + try + { + titlesEnumerable = _archiveHandler.TryExtractTitleInfos(file); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to extract title info from archive {Archive}", file); + } + + if (titlesEnumerable == null) continue; + + titles = titlesEnumerable.ToList(); + foreach (var title in titles) + { + var archiveEntry = new FileEntry(file + ArchivePathSeparator + title.Item1, title.Item2, title.Item3.Hash, [title.Item3]); + AddToSnapshotAsync(archiveEntry); + yield return archiveEntry; + } + /*var fileEntry = new FileEntry(file, new FileInfo(file).Length, hash, titles.Select((tuple, i) => tuple.Item3).ToList()); + AddToSnapshotAsync(fileEntry); + yield return fileEntry;*/ + } + else + { + continue; + } + } + + if (titles.Count == 0) + { + _logger.LogInformation("Failed to process {File}", file); + } + else + { + _logger.LogInformation("Added {File} to snapshot (hash={Hash})", file, hash); + yield return new FileEntry(file, titles.Select((tuple, i) => tuple.Item2).FirstOrDefault(), hash, titles.Select((tuple, i) => tuple.Item3).ToList()); + } + } + } + + private string ComputeFirstStreamHash(Stream nspStream) + { + return _nspExtractor.ExtractHashFromStream(nspStream); + } + + private void UpdateSnapshot() => BuildSnapshotAsync(); + + IEnumerable GetEntries() + { + foreach (var snapshotEntry in _cache) + { + _sizeLookup.TryGetValue(snapshotEntry.Value.Hash, out var size); + var fileEntry = new FileEntry(snapshotEntry.Key, snapshotEntry.Value.Size, snapshotEntry.Value.Hash, snapshotEntry.Value.NcaMetadataWithHash); + yield return fileEntry; + } + } + private Task PersistSnapshotAsync() + { + if (_debouncerCache.TryGetValue(_jsonPath, out var value)) + { + _logger.LogInformation("Sliding debounce in progress, skipping snapshot persistence"); + return Task.CompletedTask; + } + var snapshot = GetSnapshot(); + var entries = GetEntries(); + var fileEntries = entries.ToList(); + var newHash = ComputeSnapshotHash(fileEntries); + if (snapshot.Hash == newHash) return Task.CompletedTask; + + _logger.LogInformation("Snapshot hash changed – persisting new snapshot"); + using var debouncedPersistence = _debouncerCache.CreateEntry(_jsonPath); + debouncedPersistence.SlidingExpiration = TimeSpan.FromMilliseconds(DebounceMs); + debouncedPersistence.Value = fileEntries; + debouncedPersistence.PostEvictionCallbacks.Add(new PostEvictionCallbackRegistration + { + EvictionCallback = (key, entriesCallback, reason, state) => + { + if (entriesCallback is IEnumerable entriesToPersist && key is string filePath) + { + if (_snapshotFileSemaphore.Wait(SnapshotFileLockTimeout)) + { + if (IsFileLocked(filePath)) + { + _logger.LogInformation("File {FilePath} is locked, skipping snapshot persistence", filePath); + } + else + { + File.WriteAllText(filePath, + JsonSerializer.Serialize(entriesToPersist, _jsonSerializerOptions)); + _snapshotFileSemaphore.Release(); + _logger.LogInformation("Persisted snapshot"); + SnapshotRebuilt?.Invoke(this, EventArgs.Empty); + } + } + else + { + _logger.LogInformation("Failed to persist file {FilePath} due to timeout", filePath); + } + } + } + }); + return Task.CompletedTask; + } + + + private static string ComputeHash(string filePath) + { + using var sha = SHA256.Create(); + using var stream = File.OpenRead(filePath); + var hash = sha.ComputeHash(stream); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); } private static string ComputeSnapshotHash(IEnumerable entries) { var json = JsonSerializer.Serialize(entries); - using var sha256 = SHA256.Create(); - return BitConverter.ToString(sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(json))).Replace("-", "").ToLowerInvariant(); + using var sha = SHA256.Create(); + var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(json)); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); } - - private void UpdateSnapshot() + /// + /// From filesystem cache, load each entry and build the lookups + /// + /// + private Dictionary LoadSnapshotIndex() { - BuildSnapshot(); - PersistSnapshot(); - } - - private void PersistSnapshot() - { - var entries = GetSnapshot(); - var newHash = ComputeSnapshotHash(entries); - if (_currentSnapshotHash != newHash) + if (!File.Exists(_jsonPath)) return new Dictionary(); + _snapshotFileSemaphore.Wait(); + var json = File.ReadAllText(_jsonPath); + _snapshotFileSemaphore.Release(); + var entries = JsonSerializer.Deserialize>(json, _jsonSerializerOptions)!; + try { - _currentSnapshotHash = newHash; - File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries)); - File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(entries)); + var fileEntries = new Dictionary(); + // Reindex the cache + foreach (var fileEntry in entries) + { + if (_hashCache.TryGetValue(fileEntry.Hash, out var value)) + { + _logger.LogWarning("Duplicate hash found in snapshot: {Hash}, {OldPath}, {newPath}", fileEntry.Hash, value, fileEntry.Path); + } + + if (_options.RomExtensions.Contains(Path.GetExtension(fileEntry.Path))) + { + if (fileEntry.Path.Contains(ArchivePathSeparator)) + { + var filename = fileEntry.Path.Split(ArchivePathSeparator)[0]; + _cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileEntry.Titles); + _archiveLookup[filename] = fileEntry.Path; + } + else + { + _cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileEntry.Titles); + fileEntries.TryAdd(fileEntry.Path, fileEntry); + _hashCache[fileEntry.Hash] = fileEntry.Path; + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (fileEntry.Titles == null) continue; + foreach (var ncaMetadataWithHash in fileEntry.Titles) + { + _hashCache[ncaMetadataWithHash.Hash] = fileEntry.Path; + } + } + } + } + return fileEntries; + } + catch (ArgumentException e) + { + _logger.LogError(e, "Failed to load snapshot"); + return new(); } } - private static string ComputeHash(string filePath) + + public void RebuildSnapshot() + { + // 1️⃣ Flush the old in‑memory snapshot + _cache.Clear(); + _hashCache.Clear(); + _archiveLookup.Clear(); + _sizeLookup.Clear(); + //_failedAttempts.Clear(); // if you keep per‑user counters + + // 2️⃣ Re‑build from disk again + BuildSnapshotAsync().Wait(); // synchronous – we already own the lock + PersistSnapshotAsync().Wait(); // same + SnapshotRebuilt?.Invoke(this, EventArgs.Empty); + } + #endregion + + public ROMSnapshot GetSnapshot() + { + if (!File.Exists(_jsonPath)) return new ROMSnapshot(); + + if (_snapshotFileSemaphore.Wait(SnapshotFileLockTimeout)) + { + try + { + var json = File.ReadAllText(_jsonPath); + var hash = ComputeHash(_jsonPath); + var romSnapshot = new ROMSnapshot + { + Hash = hash, + Files = JsonSerializer.Deserialize>(json, _jsonSerializerOptions)! + }; + return romSnapshot; + } + catch (Exception e) + { + _logger.LogError(e, "Failed to load snapshot"); + } + finally + { + _snapshotFileSemaphore.Release(); + } + } + else + { + _logger.LogWarning("Failed to load snapshot due to timeout"); + } + + return new ROMSnapshot(); + } + + public void Dispose() + { + foreach (var watcher in _watchers) + { + watcher.Dispose(); + } + } + + private sealed record SnapshotEntry(string Path, string Hash, long Size, List NcaMetadataWithHash); + + // File: TinfoilVibeServer/Services/SnapshotService.cs (inside SnapshotService class) + + private string ComputeFirstStreamHash(string filePath) + { + // Only treat NSP/XCI/XCZ as “first‑stream” files + var ext = Path.GetExtension(filePath).ToLowerInvariant(); + if (ext is not ".nsp" and not ".xci" and not ".xcz") + { + // Open the NSP/XCI with LibHac and read the first stream. + // The first stream is the first entry returned by GetContentInfos(). + try + { + using var reader = new RomArchiveReader(filePath); + + var first = reader.GetEntries().FirstOrDefault(); + if (first == null) return ComputeFullHash(filePath); + + using var firstStream = first.Stream; + var hash = _nspExtractor.ExtractHashFromStream(firstStream); + return hash; + } + catch + { + // On error, fall back to the full file hash + using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs); + return ncaMetadataWithHash?.Hash ?? string.Empty; + } + } + else + { + using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs); + return ncaMetadataWithHash?.Hash ?? string.Empty; + } + + } + + private static string ComputeFullHash(string filePath) { using var sha256 = SHA256.Create(); using var stream = File.OpenRead(filePath); @@ -177,15 +647,26 @@ public sealed class SnapshotService : IDisposable return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); } - public IReadOnlyList GetSnapshot() + public class ROMSnapshot { - var json = File.ReadAllText(_jsonPath); - return JsonSerializer.Deserialize>(json)!; + public string? Hash { get; set; } + public IReadOnlyList Files { get; set; } = new List(); } - public void Dispose() + public async Task StartAsync(CancellationToken cancellationToken) { - _watcher.Dispose(); - _config.Dispose(); + _logger.LogInformation("Starting snapshot service"); + _ = Task.Run(async () => + { + await BuildSnapshotAsync(); + await PersistSnapshotAsync(); + }, cancellationToken); // initial scan + new Timer(_ => DebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + Dispose(); + return Task.CompletedTask; } } \ No newline at end of file diff --git a/TinfoilVibeServer/Services/TitleDatabaseService.cs b/TinfoilVibeServer/Services/TitleDatabaseService.cs new file mode 100644 index 0000000..53bab92 --- /dev/null +++ b/TinfoilVibeServer/Services/TitleDatabaseService.cs @@ -0,0 +1,320 @@ +using System.Text.RegularExpressions; +using System.Text.Json; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using TinfoilVibeServer.Models; + +namespace TinfoilVibeServer.Services; + +/// +/// * Loads the title‑database JSON that lives on GitHub. +/// * Caches the JSON file on disk (in a configurable “cache” folder). +/// * Builds a dictionary that maps a 16‑digit hex TitleId → the full +/// filesystem path of the NSP that contains it (for later look‑ups). +/// * Provides a convenient look‑up API (via GetTitleByTitleId). +/// +public sealed class TitleDatabaseService : IHostedService +{ + #region Configuration keys + private const string CacheKey = "TitleDb"; + #endregion + + #region Fields + + private readonly IOptionsMonitor _options; + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpFactory; + private readonly INSPExtractor _nspExtractor; + private readonly string _cacheFolder; // Where the JSON is cached. + private readonly List _rootDirectories; // directories that contain game files + + private readonly IMemoryCache _cache; + private readonly ISnapshotService _snapshotService; + + private readonly Dictionary _titleIdToPath = new Dictionary(); + + // Regex to find a 16‑digit hex TitleId in a filename + private static readonly Regex TitleIdRegex = new( + @"([0-9a-fA-F]{8})([0-9a-fA-F]{8})", RegexOptions.Compiled); + private readonly SemaphoreSlim _reloadLock = new(1, 1); // protects reload logic + private Task? _reloadTask; // the currently running or last finished reload + + #endregion + + #region ctor + + /// + /// Register as a singleton IHostedService. + /// The constructor receives the values that are needed to build + /// the GitHub URL (CountryCode + Language) and the root + /// directories that contain the NSP files. + /// + public TitleDatabaseService( + IConfiguration configuration, + IOptionsMonitor options, + ILogger logger, + ISnapshotService snapshotService, + IHttpClientFactory httpFactory, + INSPExtractor nspExtractor, + IMemoryCache cache) + { + _options = options; + _logger = logger; + _snapshotService = snapshotService; + _httpFactory = httpFactory; + _nspExtractor = nspExtractor; + _cache = cache; + + _cacheFolder = Path.Combine(AppContext.BaseDirectory, "titledb-cache"); + _rootDirectories = new List + { + // You can extend this list – it is the set of directories that + // are scanned when the service starts up. + Path.Combine(AppContext.BaseDirectory, "Games") + }; + // Reload cache immediately when a snapshot rebuild occurs + _snapshotService.SnapshotRebuilt += SnapshotServiceOnSnapshotRebuilt; + _options.OnChange(OptionsChanged); + } + + private void OptionsChanged(TitleDbOptions arg1, string? arg2) + { + // todo: handle ttl changes + // todo: handle country code change + // todo: handle language change + } + + #endregion + + #region IHostedService + + public async Task StartAsync(CancellationToken cancellationToken) + { + // 1️⃣ Load the JSON (download if not cached). + await ReloadCacheAsync(); + + _logger.LogInformation("Title database ready – {Count} entries loaded.", + GetAllAsync().Result.Count); + } + + public Task StopAsync(CancellationToken cancellationToken) + => Task.CompletedTask; // nothing special to do on shutdown + + #endregion + + /* ---------------------------------------------------------------- */ + /* 1️⃣ Cache loading / reloading – sliding expiration + /* ---------------------------------------------------------------- */ + private async Task ReloadCacheAsync() + { + var ttlSec = _options.CurrentValue.TtlSeconds; + _logger.LogInformation("Reloading title database cache (TTL={TTL}s).", ttlSec); + + var entryOptions = new MemoryCacheEntryOptions() + .SetSlidingExpiration(TimeSpan.FromSeconds(ttlSec)) + .RegisterPostEvictionCallback((key, value, reason, state) => { _logger.LogInformation("Cache eviction: {Key} ({Reason})", key, reason); }); + + var dict = await LoadFromDiskAsync() ?? new Dictionary(); + _cache.Set(CacheKey, dict, entryOptions); + _logger.LogInformation("Title DB reloaded – {Count} items cached (TTL={TTL}s).", + dict.Count, ttlSec); + } + + private async Task?> LoadFromDiskAsync() + { + var cacheFile = Path.Combine(_cacheFolder, $"{_options.CurrentValue.CountryCode}.{_options.CurrentValue.LanguageCode}.json"); + if (!File.Exists(cacheFile)) + { + _logger.LogInformation("Cache miss – downloading title DB from {Url}", cacheFile); + await LoadAndCacheTitleDb(CancellationToken.None); + } + + return await ReadTitleDbAsync(cacheFile, CancellationToken.None); + } + + /* ---------------------------------------------------------------- */ + /* 2️⃣ Public API – every call slides the cache + /* ---------------------------------------------------------------- */ + public async Task> GetAllAsync() + { + await EnsureCacheLoadedAsync().ConfigureAwait(false); + + // The cache entry is guaranteed to exist now + _cache.TryGetValue(CacheKey, out Dictionary? dict); + return dict!; + } + + public async Task GetAsync(string titleId) + { + var all = await GetAllAsync(); // slides the entry + all.TryGetValue(titleId, out var dto); + return dto; + } + + /* ---------------------------------------------------------------- */ + /* 3️⃣ Persist to disk & notify snapshot service + /* ---------------------------------------------------------------- */ + private async Task PersistAsync(Dictionary dict) + { + // Trigger a rebuild so SnapshotService (and any other listeners) + // can pick up the new snapshot. + //_snapshotService.RebuildSnapshot(); + await Task.CompletedTask; + } + + /* ---------------------------------------------------------------- */ + /* 4️⃣ Dispose + /* ---------------------------------------------------------------- */ + public void Dispose() + { + _snapshotService.SnapshotRebuilt -= SnapshotServiceOnSnapshotRebuilt; + _reloadLock.Dispose(); + } + + private async void SnapshotServiceOnSnapshotRebuilt(object? o, EventArgs eventArgs) + { + try + { + await EnsureCacheLoadedAsync(); + } + catch (Exception e) + { + _logger.LogCritical(e, "Failed to reload title database cache"); + } + } + + #region Public API + + /// + /// Return the TitleInfoDto for a known TitleId. + /// + public bool TryGetTitle(string titleId, out TitleInfoDto? title) + { + title = GetAsync(titleId).GetAwaiter().GetResult(); + return title != null; + } + #endregion + + #region Private helpers + /// + /// Makes sure the cache is loaded. If a reload is already in progress, + /// the caller simply awaits the same task. Otherwise it starts a new reload. + /// + private async Task EnsureCacheLoadedAsync() + { + // Fast path – a cache entry exists, no work needed + if (_cache.TryGetValue(CacheKey, out _)) + return; + + // Fast path – a reload is already underway + var existingTask = _reloadTask; + if (existingTask != null) + { + await existingTask.ConfigureAwait(false); + return; + } + + // Slow path – we need to start the reload + await _reloadLock.WaitAsync().ConfigureAwait(false); + try + { + // Double‑check after acquiring the lock + if (_cache.TryGetValue(CacheKey, out _)) + return; + + // If another thread started the reload while we were waiting, use it + existingTask = _reloadTask; + if (existingTask != null) + { + await existingTask.ConfigureAwait(false); + return; + } + + // Create the shared task + _reloadTask = ReloadCacheAsync(); + await _reloadTask.ConfigureAwait(false); + } + finally + { + _reloadTask = null; // reset for the next miss + _reloadLock.Release(); + } + } + + /// + /// Downloads the JSON file from GitHub (raw) if it does not exist + /// or the cached copy is older than the remote one. + /// + private async Task LoadAndCacheTitleDb(CancellationToken ct) + { + // Build the raw URL + var rawUrl = $"https://raw.githubusercontent.com/blawar/titledb/refs/heads/master/{_options.CurrentValue.CountryCode}.{_options.CurrentValue.LanguageCode}.json"; + + // Ensure the cache directory exists. + Directory.CreateDirectory(_cacheFolder); + var cacheFile = Path.Combine(_cacheFolder, $"{_options.CurrentValue.CountryCode}.{_options.CurrentValue.LanguageCode}.json"); + + // If the file exists & is recent – no download needed. + if (File.Exists(cacheFile)) + { + var fi = new FileInfo(cacheFile); + // If the file is newer than 24h – use it. + if (fi.LastWriteTimeUtc > DateTime.UtcNow.AddHours(-24)) + { + _logger.LogInformation("Using cached title database {File}", cacheFile); + await LoadAndCacheTitleDb(ct); + return; + } + _logger.LogInformation("Cache miss – downloading title DB from {Url}", cacheFile); + } + + _logger.LogInformation("Downloading title database from {Url}", rawUrl); + var client = _httpFactory.CreateClient(); + using var response = await client.GetAsync(rawUrl, ct); + response.EnsureSuccessStatusCode(); + await using var fs = new FileStream(cacheFile, FileMode.Create, FileAccess.Write, FileShare.None); + await response.Content.CopyToAsync(fs, ct); + + _logger.LogInformation("Title database cached to {File}", cacheFile); + await ReadTitleDbAsync(cacheFile, ct); + } + + /// + /// Read the JSON file and populate _titleData. + /// Also remap titleId for XCI files based on cnmts.json + /// + private async Task> ReadTitleDbAsync(string filePath, CancellationToken ct) + { + var json = await File.ReadAllTextAsync(filePath, ct); + + var titleInfoDtos = JsonSerializer.Deserialize>( + json, + new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }) ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + + _logger.LogInformation("Loaded {Count} titles from the database.", titleInfoDtos.Count); + + var titleData = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i =0; i< titleInfoDtos.Values.Count; i++) + { + var entry = titleInfoDtos.Values.ElementAt(i); + var key = titleInfoDtos.Keys.ElementAt(i); + if (!string.IsNullOrWhiteSpace(key)) + { + if (entry.Id != null) + { + if (entry.Id.Length == 16) + { + titleData[entry.Id] = entry; + continue; + } + } + } + } + return titleData; + } + #endregion +} \ No newline at end of file diff --git a/TinfoilVibeServer/TinfoilVibeServer.csproj b/TinfoilVibeServer/TinfoilVibeServer.csproj index 06fa2e5..89f3096 100644 --- a/TinfoilVibeServer/TinfoilVibeServer.csproj +++ b/TinfoilVibeServer/TinfoilVibeServer.csproj @@ -8,11 +8,12 @@ - + + + - - + @@ -22,18 +23,32 @@ appsettings.json + + + LibHac.dll + Always + - + + - ..\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll + ..\Dependencies\LibHac.dll + + + + + + + + diff --git a/TinfoilVibeServer/Utilities/FileSystemExtensions.cs b/TinfoilVibeServer/Utilities/FileSystemExtensions.cs new file mode 100644 index 0000000..457b56c --- /dev/null +++ b/TinfoilVibeServer/Utilities/FileSystemExtensions.cs @@ -0,0 +1,124 @@ +namespace TinfoilVibeServer.Utilities; + +public static class FileSystemExtensions +{ + /// + /// Returns the most recent last‑write time (UTC) of any file under the supplied + /// root directories, traversing all sub‑directories. If no files are found, + /// null is returned. + /// + /// + /// A collection of absolute paths that must point to existing directories. + /// Paths that do not exist or are inaccessible are silently skipped. + /// + /// + /// The UTC of the newest file, or null if there are none. + /// + public static DateTime? GetLatestModifiedUtc(IEnumerable rootDirectories) + { + if (rootDirectories == null) throw new ArgumentNullException(nameof(rootDirectories)); + + // We keep a mutable variable because we don't want to materialise the entire + // sequence into memory. + DateTime? latest = null; + + foreach (var root in rootDirectories) + { + if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root)) + continue; // skip bad paths + + try + { + // Enumerate lazily and process each file as soon as it’s yielded. + foreach (var filePath in Directory.EnumerateFiles( + root, + "*", + SearchOption.AllDirectories)) + { + try + { + // Using FileSystemInfo to fetch only the property we need. + var fsi = new FileInfo(filePath); + var lastWrite = fsi.LastWriteTimeUtc; + + if (!latest.HasValue || lastWrite > latest.Value) + latest = lastWrite; + } + catch (FileNotFoundException) // file vanished while we were enumerating + { + // ignore and keep going + } + catch (UnauthorizedAccessException) + { + // file exists but we can’t read its attributes – skip it + } + } + } + catch (UnauthorizedAccessException) + { + // the root directory itself is inaccessible – skip it + } + } + + return latest; + } + + /// + /// Parallelised version that may be faster on very large directory trees. + /// + public static DateTime? GetLatestModifiedUtcParallel(IEnumerable rootDirectories) + { + if (rootDirectories == null) throw new ArgumentNullException(nameof(rootDirectories)); + + // Flatten all file paths into a single stream first (this is the only + // part that needs to be thread‑safe). + var allFiles = rootDirectories + .Where(r => !string.IsNullOrWhiteSpace(r) && Directory.Exists(r)) + .SelectMany(r => Directory.EnumerateFiles(r, "*", SearchOption.AllDirectories)) + .ToArray(); // materialise once, then parallelise + + // Now fetch the dates in parallel. The LINQ overload of Max() that takes + // an async selector is not available, so we just use Parallel.ForEach. + DateTime? latest = null; + var lockObj = new object(); + + Parallel.ForEach(allFiles, filePath => + { + try + { + var lastWrite = new FileInfo(filePath).LastWriteTimeUtc; + lock (lockObj) + { + if (!latest.HasValue || lastWrite > latest.Value) + latest = lastWrite; + } + } + catch (Exception) + { + // swallow all exceptions – the caller only cares about the max date + } + }); + + return latest; + } + + /// + /// Creates the directory (and all missing parent directories) if it does not already exist. + /// + /// Absolute or relative path to the directory to create. + /// Thrown if is null. + /// Thrown if is empty or contains only whitespace. + /// Thrown if the caller does not have permission. + /// Thrown if a file exists at the target path or the directory cannot be created. + public static void EnsureDirectoryExists(string path) + { + if (path is null) + throw new ArgumentNullException(nameof(path)); + + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("Path must not be empty or whitespace.", nameof(path)); + + // Directory.CreateDirectory is already idempotent – it only creates missing parts. + Directory.CreateDirectory(path); + } +} diff --git a/TinfoilVibeServer/Utilities/SeekableBufferedStream.cs b/TinfoilVibeServer/Utilities/SeekableBufferedStream.cs new file mode 100644 index 0000000..e942ca8 --- /dev/null +++ b/TinfoilVibeServer/Utilities/SeekableBufferedStream.cs @@ -0,0 +1,251 @@ +using System.Buffers; + +namespace TinfoilVibeServer.Utilities; + +/// +/// A read‑only, seekable wrapper around a non‑seekable stream. +/// It buffers the source data on demand in chunks so that you can seek +/// back and forth without reading the whole source at once. +/// +public sealed class SeekableBufferedStream : Stream +{ + private const int DefaultChunkSize = 128 * 1024 * 1024; // 128 MiB + + private readonly Stream _source; + private readonly ArrayPool _pool; + private readonly int _chunkSize; + private readonly bool _disposeSource; + + // Buffer block – holds a rented byte[] and the number of bytes actually read. + private readonly struct BufferBlock + { + public readonly byte[] Data; + public readonly int Length; + public BufferBlock(byte[] data, int length) { Data = data; Length = length; } + } + + private readonly List _blocks = new(); + private readonly long _specifiedLength = 0; + private long _bufferedLength; // total number of bytes buffered so far + private long _position; // current logical position in the stream + private bool _eof; // true when the source stream has been exhausted + + #region ctor / dispose + + /// + /// Creates a new instance. + /// + /// The underlying source stream. Must be readable. + /// Length of underlying stream if known before using + /// Size of each buffer chunk (bytes). 128 MiB by default. + /// If true, disposing this wrapper will also dispose the source stream. + public SeekableBufferedStream(Stream source, long specifiedLength = 0, int chunkSize = DefaultChunkSize, bool disposeSource = false) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + if (!source.CanRead) throw new ArgumentException("Source stream must be readable.", nameof(source)); + if (chunkSize <= 0) throw new ArgumentOutOfRangeException(nameof(chunkSize), "Chunk size must be positive."); + if (specifiedLength <= 0) throw new ArgumentOutOfRangeException(nameof(specifiedLength), "Specified length must be positive."); + + _source = source; + _specifiedLength = specifiedLength; + _pool = ArrayPool.Shared; + _chunkSize = chunkSize; + _disposeSource = disposeSource; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + foreach (var block in _blocks) + _pool.Return(block.Data, clearArray: true); + _blocks.Clear(); + + if (_disposeSource) + _source.Dispose(); + } + base.Dispose(disposing); + } + + #endregion + + #region helpers + + /// + /// Ensures that at least bytes are buffered. + /// Reads from the source stream until the requested offset is reached or EOF is hit. + /// + private void EnsureBuffered(long requiredOffset) + { + if (_eof || _bufferedLength >= requiredOffset) + return; + + while (_bufferedLength < requiredOffset && !_eof) + { + var buf = _pool.Rent(_chunkSize); + int read = _source.Read(buf, 0, _chunkSize); + if (read == 0) + { + _eof = true; + _pool.Return(buf, clearArray: true); + break; + } + + _blocks.Add(new BufferBlock(buf, read)); + _bufferedLength += read; + } + } + + /// + /// Finds the block that contains and the offset inside that block. + /// + private void GetBlockAndOffset(long pos, out int blockIndex, out int offsetInBlock) + { + long accumulated = 0; + for (int i = 0; i < _blocks.Count; i++) + { + int blockLen = _blocks[i].Length; + if (pos < accumulated + blockLen) + { + blockIndex = i; + offsetInBlock = (int)(pos - accumulated); + return; + } + accumulated += blockLen; + } + + // This should never happen because we always call EnsureBuffered before accessing. + throw new InvalidOperationException("Requested position is outside buffered range."); + } + + #endregion + + #region Stream overrides + + public override bool CanRead => true; + public override bool CanSeek => true; + public override bool CanWrite => false; + public override long Length + { + get + { + // If we were given a length, we can return that. + if (_specifiedLength > 0) return _specifiedLength; + + // If we already hit EOF, we know the length. + if (_eof) return _bufferedLength; + + // If the underlying stream is seekable, we can ask it directly. + if (_source.CanSeek) + return _source.Length; + + // Otherwise we need to drain the source to discover its length. + while (!_eof) + EnsureBuffered(_bufferedLength + _chunkSize); + return _bufferedLength; + } + } + + public override long Position + { + get => _position; + set + { + if (value < 0) throw new ArgumentOutOfRangeException(nameof(value)); + if (value > Length) throw new ArgumentOutOfRangeException(nameof(value)); + _position = value; + } + } + + public override int Read(byte[] buffer, int offset, int count) + { + if (buffer == null) throw new ArgumentNullException(nameof(buffer)); + if (offset < 0 || count < 0 || offset + count > buffer.Length) + throw new ArgumentOutOfRangeException(); + + // If we are already at or beyond the logical end, nothing to read. + if (_position >= Length) + return 0; + + // We will read at most `count` bytes but not past the logical end. + long maxRead = Math.Min(count, Length - _position); + EnsureBuffered(_position + maxRead); + + int bytesRead = 0; + while (bytesRead < maxRead) + { + GetBlockAndOffset(_position, out int blockIdx, out int blockOffset); + var block = _blocks[blockIdx]; + int available = block.Length - blockOffset; + int toCopy = (int)Math.Min(available, maxRead - bytesRead); + + Buffer.BlockCopy(block.Data, blockOffset, buffer, offset + bytesRead, toCopy); + + _position += toCopy; + bytesRead += toCopy; + } + + return bytesRead; + } + + public override long Seek(long offset, SeekOrigin origin) + { + long newPos = origin switch + { + SeekOrigin.Begin => offset, + SeekOrigin.Current => _position + offset, + SeekOrigin.End => Length + offset, + _ => throw new ArgumentException("Invalid SeekOrigin", nameof(origin)) + }; + + if (newPos < 0) throw new IOException("Attempted to seek before the beginning of the stream."); + + // Make sure we have buffered data up to the new position. + EnsureBuffered(newPos); + _position = newPos; + return _position; + } + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Flush() { /* No-op – read‑only stream */ } + + public override void Write(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + public override void WriteByte(byte value) => throw new NotSupportedException(); + + #endregion + + #region async helpers (optional) + + public override async ValueTask ReadAsync(Memory destination, CancellationToken cancellationToken = default) + { + // If we are already at or beyond the logical end, nothing to read. + if (_position >= Length) + return 0; + + long maxRead = Math.Min(destination.Length, Length - _position); + EnsureBuffered(_position + maxRead); + + int bytesRead = 0; + while (bytesRead < maxRead) + { + GetBlockAndOffset(_position, out int blockIdx, out int blockOffset); + var block = _blocks[blockIdx]; + int available = block.Length - blockOffset; + int toCopy = (int)Math.Min(available, maxRead - bytesRead); + + // We copy synchronously – no async source involved + destination.Slice(bytesRead, toCopy).Span + .CopyTo(block.Data.AsSpan(blockOffset, toCopy)); + + _position += toCopy; + bytesRead += toCopy; + } + + return bytesRead; + } + + #endregion +} \ No newline at end of file diff --git a/TinfoilVibeServer/appsettings.Development.json b/TinfoilVibeServer/appsettings.Development.json index 0c208ae..5ad5de7 100644 --- a/TinfoilVibeServer/appsettings.Development.json +++ b/TinfoilVibeServer/appsettings.Development.json @@ -4,5 +4,6 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } - } + }, + "RootDirectories": [ "D:\\Cloud\\Git\\TinfoilWebServer\\TinfoilWebServer.Test\\data", "Z:\\imgs\\roms\\Switch" ] } diff --git a/TinfoilVibeServer/appsettings.json b/TinfoilVibeServer/appsettings.json index ecd2905..dbf7aac 100644 --- a/TinfoilVibeServer/appsettings.json +++ b/TinfoilVibeServer/appsettings.json @@ -7,17 +7,33 @@ }, "AllowedHosts": "*", - "RootDirectories": [ - "\\\\NAS\\Games", - "\\\\NAS\\Backups" + "KeySetFile": "prod.keys", + "CredentialsFile": "credentials.json", + "FingerprintsFile": "fingerprints.json", + "BlacklistFile": "blacklist.json", + "MaxFailedAttempts": 5, + "Snapshot" : { + "RootDirectories": [ "Z:\\downloads\\roms\\switch", "Z:\\imgs\\roms\\Switch" ], + "ArchiveExtensions": [ ".zip", ".rar", ".7z" ], + "RomExtensions": [ ".xci", ".nsp", ".xcz" ], + "CacheTtl": 60, + "SnapshotFile": "index.tfl", + "SnapshotBackupFile": "snapshot.bin" + }, + "IndexBuilder": { + "ApiBaseUrl": "http://tinfoil.localhost:80" + }, + "TitleDb": { + "CountryCode": "AU", + "Language": "en", + "TtlSeconds" : 90, + "SnapshotFile" : "snapshot.json" + }, + + "IndexDirectories": [ + "https://url1", + "sdmc:/url2", + "http://url3" ], - "WhitelistExtensions": [ - ".bin", ".jpg", ".png", ".txt" - ], - "RomExtensions": [ - ".xci", ".nsp", ".xcz" - ], - "SnapshotFile": "index.tfl", - "SnapshotBackupFile": "snapshot.bin", - "ArchiveBufferSize": 8192 -} + "Success" : "Welcome to Tinfoil Vibe Server!" +} \ No newline at end of file diff --git a/TinfoilVibeServerTest/Tests/AuthStoreTests.cs b/TinfoilVibeServerTest/Tests/AuthStoreTests.cs new file mode 100644 index 0000000..9633b35 --- /dev/null +++ b/TinfoilVibeServerTest/Tests/AuthStoreTests.cs @@ -0,0 +1,111 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using TinfoilVibeServer.Authentication; +using TinfoilVibeServer.Services; + +// <-- adjust namespace + +namespace TinfoilVibeServerTest.Tests +{ + [TestFixture] + public class AuthStoreTests + { + private Mock> _loggerMock; + private AuthStore _authStore; + + [SetUp] + public void SetUp() + { + _loggerMock = new Mock>(); + // Assume Settings is static and can be patched for tests + MockConfigManager = new Mock(); + _authStore = new AuthStore(_loggerMock.Object, MockConfigManager.Object); + } + + public Mock MockConfigManager { get; set; } + + [TearDown] + public void TearDown() + { + _authStore.Dispose(); + } + + [Test] + public void LoadAll_ShouldPopulateCollections() + { + // Act + var users = _authStore.Credentials.Count; + var fprs = _authStore.Fingerprints.Count; + + // Assert + Assert.That(users, Is.GreaterThan(0), "At least one user must be loaded"); + Assert.That(fprs, Is.GreaterThanOrEqualTo(0)); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Loaded")), + null, + (Func)It.IsAny()), + Times.AtLeastOnce); + } + + [Test] + public void TryValidate_NewUser_ShouldCreateAndVerify() + { + // Arrange + var newUser = "newuser"; + var ip = "127.0.0.1"; + var password = ""; + var uid = null as int?; + // Act + var result = _authStore.TryValidate(newUser, password, uid, ip, out var cred); + + // Assert + Assert.That(result, Is.False, "New user should be not be verified automatically"); + Assert.That(_authStore.Credentials[newUser], Is.Not.Null); + Assert.That(_authStore.Credentials[newUser].Verified, Is.False); + + // New user should now exist + Assert.That(_authStore.Credentials.Any(u => u.Value.Username == newUser), Is.True); + } + + [Test] + public void IncrementFailed_BeforeBlacklist_ShouldNotBlacklist() + { + // Arrange + var ip = "203.0.113.5"; + var cred = new Credential("dummy", "hash", 1, false); + _authStore.UnbanIp(ip); // ensure clean + + // Act + var counter = _authStore.IncrementFailed(cred.Username, ip); + + // Assert + Assert.That(counter, Is.EqualTo(1)); + Assert.That(_authStore.IsIPBlacklisted(ip), Is.False); + } + + [Test] + public void IncrementFailed_ExceedingThreshold_ShouldBlacklist() + { + // Arrange + var ip = "203.0.113.5"; + var cred = new Credential("dummy", "hash", MockConfigManager.Object.Settings.MaxFailedAttempts, false); + int threshold = MockConfigManager.Object.Settings.MaxFailedAttempts; + + // Simulate threshold failures + for (int i = 0; i < threshold; i++) + _authStore.IncrementFailed(cred.Username, ip); + + // Act + int final = _authStore.IncrementFailed(cred.Username, ip); + + // Assert + Assert.That(final, Is.EqualTo(threshold + 1)); + Assert.That(_authStore.IsIPBlacklisted(ip), Is.True); + } + } +} \ No newline at end of file diff --git a/TinfoilVibeServerTest/Tests/BasicAuthMiddlewareTests.cs b/TinfoilVibeServerTest/Tests/BasicAuthMiddlewareTests.cs new file mode 100644 index 0000000..362a1dd --- /dev/null +++ b/TinfoilVibeServerTest/Tests/BasicAuthMiddlewareTests.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using TinfoilVibeServer.Authentication; +using TinfoilVibeServer.Middleware; + +namespace TinfoilVibeServerTest.Tests +{ + [TestFixture] + public class BasicAuthMiddlewareTests + { + private Mock> _loggerMock; + private Mock _authMock; + private BasicAuthMiddleware _middleware; + private RequestDelegate _next; + + [SetUp] + public void SetUp() + { + _loggerMock = new Mock>(); + _authMock = new Mock(); + _next = (HttpContext ctx) => Task.CompletedTask; + + _middleware = new BasicAuthMiddleware(_next); + } + + private HttpContext CreateContext(string authHeader = "", string ip = "127.0.0.1", int? uid = null) + { + var ctx = new DefaultHttpContext(); + ctx.Connection.RemoteIpAddress = IPAddress.Parse(ip); + + if (!string.IsNullOrEmpty(authHeader)) + { + ctx.Request.Headers["Authorization"] = authHeader; + } + + if (uid!= null) + { + ctx.Request.Headers["UID"] = uid.ToString(); + } + + return ctx; + } + + [Test] + public async Task InvokeAsync_NoAuthHeader_ShouldReturn401() + { + // Arrange + var ctx = CreateContext(); + + // Act + await _middleware.InvokeAsync(ctx, _authMock.Object, _loggerMock.Object); + + // Assert + Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status401Unauthorized)); + _loggerMock.Verify(l => l.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Missing Authorization header")), + null, + It.IsAny>()), Times.Once); + } + + [Test] + public async Task InvokeAsync_BlacklistedIP_ShouldReturn403() + { + // Arrange + var ctx = CreateContext("Basic dXNlcjpwYXNz"); + + _authMock.Setup(a => a.IsIPBlacklisted("127.0.0.1")).Returns(true); + + // Act + + await _middleware.InvokeAsync(ctx, _authMock.Object, _loggerMock.Object); + + // Assert + Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status403Forbidden)); + } + + [Test] + public async Task InvokeAsync_ValidCredentials_ShouldCallNext() + { + // Arrange + var user = "alice"; + var pw = "secret"; + var uid = 1234; + var header = $"Basic {Convert.ToBase64String(Encoding.ASCII.GetBytes($"{user}:{pw}"))}"; + + var ip = "127.0.0.1"; + var ctx = CreateContext(header,ip, uid); + + string? error; + _authMock.Setup(a => + a.TryValidate(user, pw, uid, ip, out error)) + .Returns(true); + + bool nextCalled = false; + _next = (HttpContext _) => { nextCalled = true; return Task.CompletedTask; }; + _middleware = new BasicAuthMiddleware(_next); + + // Act + await _middleware.InvokeAsync(ctx, _authMock.Object, _loggerMock.Object); + + // Assert + Assert.That(nextCalled, Is.True); + Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status200OK)); + } + } +} \ No newline at end of file diff --git a/TinfoilVibeServerTest/Tests/SnapshotServiceTests.cs b/TinfoilVibeServerTest/Tests/SnapshotServiceTests.cs new file mode 100644 index 0000000..1ff2273 --- /dev/null +++ b/TinfoilVibeServerTest/Tests/SnapshotServiceTests.cs @@ -0,0 +1,144 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using LibHac.Ncm; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using TinfoilVibeServer.Models; +using TinfoilVibeServer.Services; +using TinfoilVibeServer.Utilities; + +namespace TinfoilVibeServerTest.Tests +{ + [TestFixture] + public class SnapshotServiceTests + { + private Mock> _loggerMock; + private SnapshotService _service; + private Mock _nspExtractorMock; + private Mock _archiveHander; + private Mock> _mockOptions; + private SnapshotOptions _options; + private MemoryCache _memoryCache; + + [SetUp] + public void SetUp() + { + _mockOptions = new Mock>(); + _options = new SnapshotOptions() + { + RomExtensions = [".nsp"], + RootDirectories = ["TestData/ROMS"], + SnapshotFile = "TestData/snapshot.json", + SnapshotBackupFile = "TestData/snapshot.bak" + }; + /*// ensure ROM test directory has test files removed + foreach (var file in Directory.GetFiles("TestData/ROMS")) + { + File.Delete(file); + }*/ + _mockOptions.Setup(m => m.CurrentValue).Returns(_options); + _loggerMock = new Mock>(); + _archiveHander = new Mock(); + _nspExtractorMock = new Mock(); + var memoryCacheOptions = Options.Create(new MemoryCacheOptions()); + _memoryCache = new MemoryCache(memoryCacheOptions); + + + _nspExtractorMock.Setup(extractor => extractor.ExtractHashFromStream(It.IsAny())).Returns("HASH"); + _nspExtractorMock.Setup(extractor => extractor.ExtractFromStream(It.IsAny())).Returns( + new NcaMetadataWithHash(titleId: "0000000000000000","0000000000000000", version: 1, ContentMetaType.Application, "HASH")); + //Settings.RootDirs = new List { "TestData/Root1", "TestData/Root2" }; + _service = new SnapshotService(_memoryCache, _mockOptions.Object, _nspExtractorMock.Object, _archiveHander.Object, _loggerMock.Object); + } + + [TearDown] + public void TearDown() + { + _service.Dispose(); + _memoryCache.Dispose(); + } + + [Test] + public async Task BuildSnapshot_WhenFilesChanged_ShouldPersist() + { + // Arrange + var initialHash = _service.GetSnapshot()?.Hash; + var rebuilding = false; + var rebuilt = false; + CancellationTokenSource snapshotRebuilding = new(); + _service.SnapshotRebuilding += (sender, args) => + { + rebuilding = true; + snapshotRebuilding.Cancel(); + }; + CancellationTokenSource snapshotPersisting = new(); + _service.SnapshotRebuilt+= (sender, args) => + { + rebuilt = true; + snapshotPersisting.Cancel(); + // Assert + var newHash = _service.GetSnapshot()?.Hash; + Assert.That(newHash, Is.Not.EqualTo(initialHash)); + }; + Timer timer = new(state => + { + snapshotPersisting.Cancel(); + snapshotRebuilding.Cancel(); + }, null, 20*1000, 0); + await File.WriteAllTextAsync(_options.SnapshotFile, "[]", snapshotPersisting.Token); + // Add a file to Root1 + var newFile = Path.Combine(_options.RootDirectories.First(), "new.nsp"); + + // Act + await File.WriteAllTextAsync(newFile,"TEST"); + + Task.Delay(4000).Wait(); + try + { + while (_memoryCache.Count > 0) + { + Task.Delay(200).Wait(snapshotRebuilding.Token); + } + } + catch (OperationCanceledException) + { + Assert.That(rebuilding, Is.True); + } + + try + { + while (_memoryCache.Count > 0) + { + Task.Delay(200).Wait(snapshotPersisting.Token); + } + } + catch (OperationCanceledException e) + { + Assert.That(rebuilt, Is.True); + } + } + + [Test] + public async Task BuildSnapshot_NoChange_ShouldNotPersist() + { + // Act + _service.BuildSnapshotAsync(); + + // Act again – snapshot should be identical + _service.BuildSnapshotAsync(); + + // Assert + _loggerMock.Verify( + l => l.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("persisting new snapshot")), + null, + It.IsAny>()), Times.Never); + } + } +} \ No newline at end of file diff --git a/TinfoilVibeServerTest/TinfoilVibeServerTest.csproj b/TinfoilVibeServerTest/TinfoilVibeServerTest.csproj new file mode 100644 index 0000000..06cc596 --- /dev/null +++ b/TinfoilVibeServerTest/TinfoilVibeServerTest.csproj @@ -0,0 +1,46 @@ + + + + net9.0 + latest + enable + enable + false + true + opencover + ../coverage/ + **/Program.cs + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + ..\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll + + + +