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