From 09e19249968bff95d3c5b97d5bd880e8bcab91c3 Mon Sep 17 00:00:00 2001 From: Huy Nguyen Date: Sun, 2 Nov 2025 20:24:58 +1030 Subject: [PATCH] Compiles but runs strange --- TinfoilVibeServer.sln | 6 + TinfoilVibeServer.sln.DotSettings | 3 + TinfoilVibeServer.sln.DotSettings.user | 21 +- .../Authentication/AuthSettings.cs | 10 + TinfoilVibeServer/Authentication/AuthStore.cs | 269 ++++++++++++++++++ .../Authentication/Credential.cs | 16 ++ TinfoilVibeServer/ConfigManager.cs | 70 ----- .../Controllers/IndexController.cs | 164 +++++++++++ .../Middleware/BasicAuthMiddleware.cs | 26 +- TinfoilVibeServer/Models/AppSettings.cs | 17 ++ TinfoilVibeServer/Models/FileEntry.cs | 4 +- TinfoilVibeServer/Models/IdHelper.cs | 48 ++++ TinfoilVibeServer/Models/IndexDto.cs | 15 + TinfoilVibeServer/Models/KeySetHolder.cs | 12 + TinfoilVibeServer/Models/NcaMetadataDto.cs | 11 + TinfoilVibeServer/Models/TitleInfo.cs | 4 +- TinfoilVibeServer/Models/TitleInfoDto.cs | 14 + TinfoilVibeServer/Program.cs | 57 ++-- TinfoilVibeServer/Services/ArchiveHandler.cs | 105 ++++--- TinfoilVibeServer/Services/AuthStore.cs | 211 -------------- TinfoilVibeServer/Services/ConfigManager.cs | 89 ++++++ .../Services/IndexBuilderService.cs | 78 +++++ TinfoilVibeServer/Services/NSPExtractor.cs | 128 ++++++--- TinfoilVibeServer/Services/SnapshotService.cs | 104 +++---- .../Services/TitleDatabaseService.cs | 248 ++++++++++++++++ TinfoilVibeServer/TinfoilVibeServer.csproj | 3 +- .../appsettings.Development.json | 3 +- TinfoilVibeServer/appsettings.json | 31 +- .../TinfoilVibeServerTest.csproj | 23 ++ TinfoilVibeServerTest/UnitTest1.cs | 15 + 30 files changed, 1301 insertions(+), 504 deletions(-) create mode 100644 TinfoilVibeServer.sln.DotSettings create mode 100644 TinfoilVibeServer/Authentication/AuthSettings.cs create mode 100644 TinfoilVibeServer/Authentication/AuthStore.cs create mode 100644 TinfoilVibeServer/Authentication/Credential.cs delete mode 100644 TinfoilVibeServer/ConfigManager.cs create mode 100644 TinfoilVibeServer/Controllers/IndexController.cs create mode 100644 TinfoilVibeServer/Models/AppSettings.cs create mode 100644 TinfoilVibeServer/Models/IdHelper.cs create mode 100644 TinfoilVibeServer/Models/IndexDto.cs create mode 100644 TinfoilVibeServer/Models/KeySetHolder.cs create mode 100644 TinfoilVibeServer/Models/NcaMetadataDto.cs create mode 100644 TinfoilVibeServer/Models/TitleInfoDto.cs delete mode 100644 TinfoilVibeServer/Services/AuthStore.cs create mode 100644 TinfoilVibeServer/Services/ConfigManager.cs create mode 100644 TinfoilVibeServer/Services/IndexBuilderService.cs create mode 100644 TinfoilVibeServer/Services/TitleDatabaseService.cs create mode 100644 TinfoilVibeServerTest/TinfoilVibeServerTest.csproj create mode 100644 TinfoilVibeServerTest/UnitTest1.cs 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..355a6ee --- /dev/null +++ b/TinfoilVibeServer.sln.DotSettings @@ -0,0 +1,3 @@ + + NSP + PFS \ No newline at end of file diff --git a/TinfoilVibeServer.sln.DotSettings.user b/TinfoilVibeServer.sln.DotSettings.user index bfb9262..142b5a7 100644 --- a/TinfoilVibeServer.sln.DotSettings.user +++ b/TinfoilVibeServer.sln.DotSettings.user @@ -1,3 +1,22 @@  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 + <AssemblyExplorer> + <Assembly Path="D:\Cloud\Git\TinfoilVibeServer\TinfoilVibeServer\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll" /> +</AssemblyExplorer> \ 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..1615931 --- /dev/null +++ b/TinfoilVibeServer/Authentication/AuthStore.cs @@ -0,0 +1,269 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using TinfoilVibeServer.Models; +using LibHac.Common; +using LibHac.Common.Keys; + +namespace TinfoilVibeServer.Authentication; + +/// +/// 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 sealed class AuthStore : IDisposable +{ + public readonly AuthSettings Settings; + + public readonly ConcurrentDictionary Credentials = new(); + public readonly ConcurrentDictionary> Fingerprints = new(); + public readonly ConcurrentDictionary FailedAttempts = new(); + public readonly HashSet BlacklistIPs = new(); + + private readonly object _sync = new(); + private readonly FileSystemWatcher _credentialsWatcher; + + public AuthStore() + { + Settings = new AuthSettings( + "credentials.json", + "fingerprints.json", + "blacklist.json", + 5 + ); + + LoadAll(); + + var directoryName = Path.GetDirectoryName(Settings.CredentialsFile); + _credentialsWatcher = new FileSystemWatcher + { + Path = (!string.IsNullOrEmpty(directoryName))?directoryName : AppContext.BaseDirectory, + Filter = Path.GetFileName(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() + { + // 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; + } + + // 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; + } + + // 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); + } + } + + #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(Settings.CredentialsFile)) + return; + + try + { + var txt = File.ReadAllText(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 + { + // ignore – malformed JSON or IO error – keep old state + } + } + + #endregion + + #region Authentication logic + + public bool IsBlacklisted(string ip) => BlacklistIPs.Contains(ip); + + 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(); + + 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); + 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; + } + } + + 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(); + FailedAttempts[username] = 0; + } + } + + #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(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 +} \ 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/IndexController.cs b/TinfoilVibeServer/Controllers/IndexController.cs new file mode 100644 index 0000000..8f59ce9 --- /dev/null +++ b/TinfoilVibeServer/Controllers/IndexController.cs @@ -0,0 +1,164 @@ +using System; +using System.IO; +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using TinfoilVibeServer.Models; +using TinfoilVibeServer.Services; + +namespace TinfoilVibeServer.Controllers; + +[ApiController] +[Route("[controller]")] +public sealed class IndexController : ControllerBase +{ + private readonly SnapshotService _snapshotService; + private readonly TitleDatabaseService _titleDb; + private readonly IConfiguration _configuration; + + public IndexController(SnapshotService snapshotService, + TitleDatabaseService titleDb, + IConfiguration configuration) + { + _snapshotService = snapshotService; + _titleDb = titleDb; + _configuration = configuration; + } + + /// + /// GET /index.json – returns the structure you asked for. + /// + [HttpGet("index.json")] + public IActionResult GetIndex() + { + var index = new IndexBuilderService( + _snapshotService, _titleDb, _configuration, + this.HttpContext.RequestServices.GetService(typeof(ILogger)) as Microsoft.Extensions.Logging.ILogger) + .Build(); + + return Ok(index); + } + + /// + /// GET /download?url=… – streams the requested NSP file. + /// The `url` value is the literal string that appears in the + /// index, e.g. “[Mario][0004000000000000][10000][Base].nsp”. + /// + [HttpGet("download")] + public IActionResult Download(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return BadRequest("Missing url query parameter."); + + // ---- 1️⃣ Parse the brackets -------------------------------- + // Expected format: [name][TitleId][v][patchOrApp].nsp + var match = System.Text.RegularExpressions.Regex.Match(url, + @"\[(?.*?)\]\[(?[0-9a-fA-F]{8}[0-9a-fA-F]{8})\]\[(?[0-9a-fA-F]+)\]\[(?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() + .FirstOrDefault(e => e.Title?.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)) + { + // 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 archivePath = Path.GetDirectoryName(entry.Path); + var innerFileName = Path.GetFileName(entry.Path); + var stream = StreamFromArchive(archivePath, innerFileName); + + if (stream == null) + return NotFound("Could not stream entry from archive."); + + return File(stream, "application/octet-stream", + Path.GetFileName(innerFileName)); + } + else + { + // Regular file – just serve it. + return PhysicalFile(entry.Path, "application/octet-stream", + Path.GetFileName(entry.Path)); + } + } + + 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. + path.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".7z", StringComparison.OrdinalIgnoreCase) || + path.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(string archivePath, string innerFileName) + { + // 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)) + { + using var zip = SharpCompress.Archives.Zip.ZipArchive.Open(archivePath); + var entry = zip.Entries + .FirstOrDefault(e => e.Key.Equals(innerFileName, + StringComparison.OrdinalIgnoreCase)); + if (entry != null) + return entry.OpenEntryStream(); + } + else if (archivePath.EndsWith(".7z", StringComparison.OrdinalIgnoreCase)) + { + using var sevenZip = SharpCompress.Archives.SevenZip.SevenZipArchive.Open(archivePath); + var entry = sevenZip.Entries + .FirstOrDefault(e => e.Key.Equals(innerFileName, + StringComparison.OrdinalIgnoreCase)); + if (entry != null) + return entry.OpenEntryStream(); + } + else if (archivePath.EndsWith(".rar", StringComparison.OrdinalIgnoreCase)) + { + using var rar = SharpCompress.Archives.Rar.RarArchive.Open(archivePath); + var entry = rar.Entries + .FirstOrDefault(e => 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. + } + return null; + } +} \ No newline at end of file diff --git a/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs b/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs index 35fa36b..1c59bc4 100644 --- a/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs +++ b/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs @@ -1,32 +1,28 @@ - -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, AuthStore store, ILogger logger) { var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; // 1) IP blacklist - if (_store.IsBlacklisted(ip)) + if (store.IsBlacklisted(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; @@ -77,9 +73,9 @@ 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\""); await context.Response.WriteAsync(error ?? "Unauthorized"); diff --git a/TinfoilVibeServer/Models/AppSettings.cs b/TinfoilVibeServer/Models/AppSettings.cs new file mode 100644 index 0000000..1d61982 --- /dev/null +++ b/TinfoilVibeServer/Models/AppSettings.cs @@ -0,0 +1,17 @@ +namespace TinfoilVibeServer.Models; + +/// +/// Top‑level configuration – maps directly to appsettings.json. +/// +public sealed record AppSettings( + string[] RootDirectories, + string[] WhitelistExtensions, + string[] RomExtensions, + string SnapshotFile, + string SnapshotBackupFile, + 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..0789279 100644 --- a/TinfoilVibeServer/Models/FileEntry.cs +++ b/TinfoilVibeServer/Models/FileEntry.cs @@ -6,6 +6,6 @@ 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 Hash, // SHA‑256 hex + NcaMetadataDto? Title // null unless file is an NSP/XCI or an archive containing one ); \ No newline at end of file diff --git a/TinfoilVibeServer/Models/IdHelper.cs b/TinfoilVibeServer/Models/IdHelper.cs new file mode 100644 index 0000000..b0a5b76 --- /dev/null +++ b/TinfoilVibeServer/Models/IdHelper.cs @@ -0,0 +1,48 @@ +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; + } +} \ 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/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..1401218 --- /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, + DateTime ReleaseDate, + string NSUID, + string Version); \ No newline at end of file diff --git a/TinfoilVibeServer/Program.cs b/TinfoilVibeServer/Program.cs index 0d64a40..bcf0984 100644 --- a/TinfoilVibeServer/Program.cs +++ b/TinfoilVibeServer/Program.cs @@ -1,42 +1,41 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using System.Text; +using System.Text.Json; +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) -// ----------------------------------------------------- +// ------------------------------------------------------------------- +// 1) Configuration – read appsettings.json once and expose it via +// ConfigManager (reloads on file change) +// ------------------------------------------------------------------- +builder.Services.AddSingleton(); builder.Services.AddSingleton(); -// … any other services you already have +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(provider => provider.GetRequiredService()).AddHttpClient(); +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()) + .GetSnapshot()); app.Run(); \ No newline at end of file diff --git a/TinfoilVibeServer/Services/ArchiveHandler.cs b/TinfoilVibeServer/Services/ArchiveHandler.cs index a3cbe67..abbe891 100644 --- a/TinfoilVibeServer/Services/ArchiveHandler.cs +++ b/TinfoilVibeServer/Services/ArchiveHandler.cs @@ -1,26 +1,24 @@ - -using System.IO.Compression; -using FileSnapshot; +using System.IO; using SharpCompress.Archives; using SharpCompress.Archives.Zip; -using SharpCompress.Archives.Rar; using SharpCompress.Archives.SevenZip; -using SharpCompress.Readers; +using SharpCompress.Archives.Rar; using TinfoilVibeServer.Models; -using ZipArchive = SharpCompress.Archives.Zip.ZipArchive; +using SharpSevenZip; 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. +/// Tries to open an archive and look for an embedded NSP/XCI entry. +/// The implementation streams the entry directly into LibHac, avoiding any +/// temporary files on disk. /// public sealed class ArchiveHandler { /// - /// Return TitleInfo if an embedded Nintendo archive is found; otherwise null. + /// Returns TitleInfo if an embedded Nintendo archive is found; otherwise null. /// - public static TitleInfo? TryExtractTitleInfo(string filePath) + public static NcaMetadataDto? TryExtractTitleInfo(string filePath) { var ext = Path.GetExtension(filePath).ToLowerInvariant(); @@ -40,48 +38,41 @@ public sealed class ArchiveHandler } catch { - // Graceful fallback – return null return null; } } - private static TitleInfo? HandleZip(string path) + private static NcaMetadataDto? 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; + using var src = entry.OpenEntryStream(); // already seekable + return NSPExtactor.ExtractFromStream(src); } } return null; } - private static TitleInfo? Handle7z(string path) + private static NcaMetadataDto? Handle7z(string path) { using var archive = SevenZipArchive.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; + using var src = entry.OpenEntryStream(); // not seekable + var seekable = MakeSeekable(src); + return NSPExtactor.ExtractFromStream(seekable); } } return null; } - private static TitleInfo? HandleRar(string path) + private static NcaMetadataDto? HandleRar(string path) { - // SharpCompress can handle most RAR5 files – fallback to SharpSevenZip if it fails try { using var archive = RarArchive.Open(path); @@ -89,39 +80,59 @@ public sealed class ArchiveHandler { if (!entry.IsDirectory && IsRomArchive(entry.Key)) { - var temp = Path.GetTempFileName(); - entry.WriteToFile(temp); - var title = NSPExtactor.ExtractFromFile(temp); - File.Delete(temp); - return title; + using var src = entry.OpenEntryStream(); // not seekable + var seekable = MakeSeekable(src); + return NSPExtactor.ExtractFromStream(seekable); } } return null; } - catch (SharpCompress.Common.ArchiveException) + catch (SharpCompress.Common.ExtractionException) { - // 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()) + // ---------- RAR5 fallback (SharpSevenZip) ---------- + // We decompress the entire archive into a MemoryStream + // and then feed that stream into SevenZipExtractor. + + using var inStream = File.OpenRead(path); // source stream + using var outStream = new MemoryStream(); // destination + + // Decompress – progress event can be null + + outStream.Position = 0; // rewind for reading + +// using var extractor = SharpSevenZipExtractor.OpenStream(outStream); + using var extractor = new SharpSevenZip.SharpSevenZipExtractor(inStream); + for (int i = 0; i < extractor.ArchiveFileData.Count; i++) { - if (!extractor.IsDirectory && IsRomArchive(extractor.CurrentFileName)) + var archiveFileInfo =extractor.ArchiveFileData[i]; + + if (!archiveFileInfo.IsDirectory && extractor.FileName != null && IsRomArchive(extractor.FileName)) { - var temp = Path.GetTempFileName(); - extractor.ExtractFile(temp); - var title = NSPExtactor.ExtractFromFile(temp); - File.Delete(temp); - return title; + var ms = new MemoryStream(); // extract a single entry + extractor.ExtractFile(extractor.FileName, ms); + ms.Position = 0; + return NSPExtactor.ExtractFromStream(ms); } - }*/ - return null; + } + + return null; // nothing found } } - private static bool IsRomArchive(string entryName) + /// + /// Turn a non‑seekable stream into a seekable one by buffering it into memory. + /// + private static Stream MakeSeekable(Stream nonSeekable) { - var ext = Path.GetExtension(entryName).ToLowerInvariant(); - return ext is ".xci" or ".nsp" or ".xcz"; + if (nonSeekable.CanSeek) + return nonSeekable; + + var ms = new MemoryStream(); + nonSeekable.CopyTo(ms); + ms.Position = 0; + return ms; } + + private static bool IsRomArchive(string name) => + Path.GetExtension(name).ToLowerInvariant() is ".xci" or ".nsp" or ".xcz"; } \ No newline at end of file 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..54ed4d2 --- /dev/null +++ b/TinfoilVibeServer/Services/ConfigManager.cs @@ -0,0 +1,89 @@ +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 sealed 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(), + SnapshotFile: "index.tfl", + SnapshotBackupFile: "snapshot.bin", + 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..7f2ddf2 --- /dev/null +++ b/TinfoilVibeServer/Services/IndexBuilderService.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using TinfoilVibeServer.Models; + +namespace TinfoilVibeServer.Services; + +/// +/// Builds the from the current snapshot +/// and reads the “Directories” & “SuccessMessage” values from +/// configuration. +/// +public sealed class IndexBuilderService +{ + private readonly SnapshotService _snapshotService; + private readonly TitleDatabaseService _titleDb; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public IndexBuilderService(SnapshotService snapshotService, + TitleDatabaseService titleDb, + IConfiguration configuration, + ILogger logger) + { + _snapshotService = snapshotService; + _titleDb = titleDb; + _configuration = configuration; + _logger = logger; + } + + /// + /// Build the IndexDto that is sent to the client. + /// + public IndexDto Build() + { + var snapshot = _snapshotService.GetSnapshot(); + + var files = snapshot + .Where(e => e.Title != null) // only NSP/XCI files + .Select(e => + { + var titleId = e.Title.TitleId; // 16‑digit hex + + // 1️⃣ Get the human readable name from the title DB. + var name = _titleDb.TryGetTitle(titleId, out var titleInfo) + ? titleInfo.Name + : "Unknown"; + + // 2️⃣ Parse the version string (e.g. “1.02” → 102) + int versionNumber = e.Title.Version; + + // 3️⃣ vProcessed = versionNumber * 0x10000 + var vProcessed = versionNumber * 0x10000; + + // 4️⃣ patchOrApplication + var patchOrApp = e.Title.IsApplication ? "Base" : "Update"; + + // 5️⃣ Build the URL string + var url = $"[{name}][{titleId}][{vProcessed:X}][{patchOrApp}].nsp"; + + return new FileDto( + Url: url, + Size: e.Size); + }) + .ToList(); + + // Directories & success message come straight from the config. + var directories = _configuration.GetSection("Directories") + .Get() ?? Array.Empty(); + + var success = _configuration["SuccessMessage"] ?? string.Empty; + + return new IndexDto(files, directories.ToList(), success); + } +} \ No newline at end of file diff --git a/TinfoilVibeServer/Services/NSPExtractor.cs b/TinfoilVibeServer/Services/NSPExtractor.cs index dff4a4f..1fe21f1 100644 --- a/TinfoilVibeServer/Services/NSPExtractor.cs +++ b/TinfoilVibeServer/Services/NSPExtractor.cs @@ -1,65 +1,107 @@ using System; using System.IO; -using System.Text.Json; -using LibHac.Fs; -using LibHac.FsSystem; -using LibHac.FsSystem.Impl; -using LibHac.Util; +using System.Linq; using TinfoilVibeServer.Models; -namespace FileSnapshot; +using LibHac.Fs; // OpenMode, StreamStorage, FileStorage +using LibHac.Fs.Fsa; // IFile +using LibHac.FsSystem; // PartitionFileSystem +using LibHac.Tools.FsSystem; // SearchOptions +using LibHac.Tools.FsSystem.NcaUtils; // Nca, NcaContentType +using LibHac.Common; // UniqueRef + +namespace TinfoilVibeServer.Services; /// -/// Extracts title information from a Nintendo NSP/XCI file using LibHac 0.20.0. +/// Extracts only the three fields you asked for from a full NSP/XCI container. /// public sealed class NSPExtactor { /// - /// Return TitleInfo for the file, or null if the file is not a valid Nintendo archive. + /// Convenience overload – read the NSP/XCI from disk. /// - public static TitleInfo? ExtractFromFile(string filePath) + public static NcaMetadataDto? ExtractFromFile(string filePath) + { + using var stream = File.OpenRead(filePath); + return ExtractFromStream(stream); + } + + /// + /// Core implementation – works on any seekable stream that contains a + /// full NSP/XCI container. + /// + public static NcaMetadataDto? ExtractFromStream(Stream stream) + { + if (!IsPFSFileSystem(stream)) + return null; + + stream.Seek(0, SeekOrigin.Begin); + + // Open the whole NSP container as a StreamStorage (LibHac.Fs). + using var storage = new StreamStorage(stream, false); + + // Build a PartitionFileSystem that can walk the PFS layout. + var partition = new PartitionFileSystem(); + partition.Initialize(storage).ThrowIfFailure(); + + // Enumerate all *.nca entries (recursively). + var ncaEntries = partition + .EnumerateEntries("*.nca", SearchOptions.RecurseSubdirectories) + .Where(e => e.Type == DirectoryEntryType.File) // <-- use the enum comparison + .ToList(); + + foreach (var dirEntry in ncaEntries) + { + // Open the NCA file as an IFile (LibHac.Fs.Fsa). + using var fileRef = new UniqueRef(); + var openResult = partition.OpenFile(ref fileRef.Ref, + dirEntry.FullPath.ToU8Span(), OpenMode.Read); + + if (openResult.IsFailure()) + continue; + + // Convert the IFile to an IStorage (FileStorage – LibHac.Fs). + using var ncaFile = fileRef.Release(); // IFile + using var ncaFileStorage = new FileStorage(ncaFile); + + // Feed the storage into the Nca constructor. + var nca = new Nca(KeySetHolder.KeySet, ncaFileStorage); + + // Only the meta NCA contains the title metadata. + if (nca.Header.ContentType != NcaContentType.Meta) + continue; + + string titleId = nca.Header.TitleId.ToString("X16"); + int version = nca.Header.Version; + bool isPatch = nca.IsPatch; + bool isApp = nca.IsProgram && !isPatch; + + return new NcaMetadataDto(titleId, version, isApp, isPatch); + } + + // No meta NCA found. + return null; + } + + /// + /// Check that the stream looks like a PFS0 file system. + /// + private static bool IsPFSFileSystem(Stream stream) { - // LibHac works with byte streams. We open the file once and hand the stream to RomArchiveReader. try { - using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); - using var reader = new RomArchiveReader(fs, new RomArchiveSettings { UseCache = false }); + if (!stream.CanSeek) return false; + stream.Seek(0, SeekOrigin.Begin); - if (!reader.IsValid) - return null; // Not an NSP/XCI + var storage = new StreamStorage(stream, false); + var partition = new PartitionFileSystem(); + partition.Initialize(storage).ThrowIfFailure(); - // 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()) - { - // 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 null; // No NCA found + return true; } catch { - // Any exception (bad file, invalid archive, etc.) -> treat as non‑NXP - return null; + return false; } } } \ No newline at end of file diff --git a/TinfoilVibeServer/Services/SnapshotService.cs b/TinfoilVibeServer/Services/SnapshotService.cs index 4d680a2..6f2879e 100644 --- a/TinfoilVibeServer/Services/SnapshotService.cs +++ b/TinfoilVibeServer/Services/SnapshotService.cs @@ -1,15 +1,13 @@ using System.Collections.Concurrent; using System.Security.Cryptography; using System.Text.Json; -using FileSnapshot; using TinfoilVibeServer.Models; namespace TinfoilVibeServer.Services; /// /// 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 { @@ -17,67 +15,47 @@ public sealed class SnapshotService : IDisposable 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) { _config = config; - _jsonPath = Path.Combine(AppContext.BaseDirectory, config.Settings.SnapshotFile); - _snapshotPath = Path.Combine(AppContext.BaseDirectory, config.Settings.SnapshotBackupFile); + _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 + BuildSnapshot(); // initial scan File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(GetSnapshot())); - // File system watcher _watcher = new FileSystemWatcher { - Path = string.Join(Path.PathSeparator, config.Settings.RootDirectories), + Path = string.Join(Path.PathSeparator, _config.Settings.RootDirectories), 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 + BuildSnapshot(); // rebuild everything PersistSnapshot(); }; } - private sealed record CachedFile(string Path, string Hash, TitleInfo? Title); + #region FileSystemWatcher - #region File system change handlers - - private void OnChanged(object? _, FileSystemEventArgs e) => - ThrottleSnapshotUpdate(); - - private void OnRenamed(object? _, RenamedEventArgs e) - { - // Treat rename as delete + create - OnChanged(_, new FileSystemEventArgs(WatcherChangeTypes.Deleted, e.OldFullPath, e.OldName)); - OnChanged(_, new FileSystemEventArgs(WatcherChangeTypes.Created, e.FullPath, e.Name)); - } + private void OnChanged(object? _, FileSystemEventArgs e) => ThrottleSnapshotUpdate(); + private void OnRenamed(object? _, RenamedEventArgs e) => ThrottleSnapshotUpdate(); private void ThrottleSnapshotUpdate() { - // Debounce: only trigger once in a short window Task.Run(async () => { await Task.Delay(250); @@ -87,9 +65,8 @@ public sealed class SnapshotService : IDisposable #endregion - /// - /// Full rebuild – called on start‑up and on config change. - /// + #region Snapshot logic + private void BuildSnapshot() { var cfg = _config.Settings; @@ -104,58 +81,33 @@ public sealed class SnapshotService : IDisposable 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 + // Cache hit? if (_cache.TryGetValue(file, out var cached) && cached.Hash == hash) { - // nothing changed – use cached title info entries.Add(new FileEntry(file, new FileInfo(file).Length, hash, cached.Title)); continue; } - // 3) extract title if applicable - TitleInfo? title = null; + // Extract title if possible + NcaMetadataDto? title = null; if (cfg.RomExtensions.Contains(ext)) - { title = NSPExtactor.ExtractFromFile(file); - } 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)); } } - // Replace the entire snapshot - lock (_cache) - { - // the snapshot itself is not stored in _cache – it's only used for the JSON - } - // we keep entries in a local variable for now _currentSnapshotHash = ComputeSnapshotHash(entries); File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries)); } - 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(); - } - - private void UpdateSnapshot() - { - BuildSnapshot(); - PersistSnapshot(); - } + private void UpdateSnapshot() => BuildSnapshot(); private void PersistSnapshot() { @@ -171,21 +123,35 @@ public sealed class SnapshotService : IDisposable private static string ComputeHash(string filePath) { - using var sha256 = SHA256.Create(); + using var sha = SHA256.Create(); using var stream = File.OpenRead(filePath); - var hash = sha256.ComputeHash(stream); + var hash = sha.ComputeHash(stream); return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); } + private static string ComputeSnapshotHash(IEnumerable entries) + { + var json = JsonSerializer.Serialize(entries); + using var sha = SHA256.Create(); + var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(json)); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + + #endregion + public IReadOnlyList GetSnapshot() { var json = File.ReadAllText(_jsonPath); return JsonSerializer.Deserialize>(json)!; } - - public void Dispose() + public void RebuildSnapshot() { - _watcher.Dispose(); - _config.Dispose(); + // Build a fresh snapshot and persist it. + BuildSnapshot(); // private method inside the same class + PersistSnapshot(); // private method inside the same class } + + public void Dispose() => _watcher.Dispose(); + + private sealed record CachedFile(string Path, string Hash, NcaMetadataDto? Title); } \ No newline at end of file diff --git a/TinfoilVibeServer/Services/TitleDatabaseService.cs b/TinfoilVibeServer/Services/TitleDatabaseService.cs new file mode 100644 index 0000000..b506120 --- /dev/null +++ b/TinfoilVibeServer/Services/TitleDatabaseService.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +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 + + // These come from appsettings.json (see 7. ConfigManager.cs). + private readonly string _countryCode; // e.g. “US” + private readonly string _language; // e.g. “en” + + #endregion + + #region Fields + + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpFactory; + private readonly string _cacheFolder; // Where the JSON is cached. + private readonly string _baseCacheFolder; // Directory that contains all NSP files to index + private readonly List _rootDirectories; // directories that contain game files + + // 1️⃣ Cache for the JSON data (key = TitleId) + private readonly ConcurrentDictionary _titleData + = new(); + + // 2️⃣ Reverse lookup: TitleId → real file‑system path + private readonly ConcurrentDictionary _titleIdToPath + = new(); + + // 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); + + #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, + ILogger logger, + IHttpClientFactory httpFactory) + { + // The following values must be present in appsettings.json. + _countryCode = configuration["TitleDb:CountryCode"]?.ToUpperInvariant() + ?? throw new ArgumentException("TitleDb:CountryCode not configured"); + _language = configuration["TitleDb:Language"]?.ToLowerInvariant() + ?? throw new ArgumentException("TitleDb:Language not configured"); + + _logger = logger; + _httpFactory = httpFactory; + + _cacheFolder = Path.Combine(AppContext.BaseDirectory, "titledb-cache"); + _baseCacheFolder = Path.Combine(AppContext.BaseDirectory, "titledb-data"); + _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") + }; + } + + #endregion + + #region IHostedService + + public Task StartAsync(CancellationToken cancellationToken) + { + // 1️⃣ Load the JSON (download if not cached). + LoadAndCacheTitleDb(cancellationToken).GetAwaiter().GetResult(); + + // 2️⃣ Scan the file‑system and build the title‑id → path map. + BuildFilesystemIndex(); + + _logger.LogInformation("Title database ready – {Count} entries loaded.", + _titleData.Count); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + => Task.CompletedTask; // nothing special to do on shutdown + + #endregion + + #region Public API + + /// + /// Return the TitleInfoDto for a known TitleId. + /// + public bool TryGetTitle(string titleId, out TitleInfoDto? title) + => _titleData.TryGetValue(titleId, out title); + + /// + /// If a file was indexed, return its full path. If the file was + /// indexed by extracting the TitleId from its contents, this will still + /// work. + /// + public bool TryGetFilePath(string titleId, out string? path) + => _titleIdToPath.TryGetValue(titleId, out path); + + /// + /// Convenience helper – look‑up the file path for a TitleId and return + /// it as a string. Returns null if the TitleId is unknown. + /// + public string? GetFilePathByTitleId(string titleId) + => _titleIdToPath.TryGetValue(titleId, out var p) ? p : null; + + #endregion + + #region Private helpers + + /// + /// 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/{_countryCode}.{_language}.json"; + + // Ensure the cache directory exists. + Directory.CreateDirectory(_cacheFolder); + var cacheFile = Path.Combine(_cacheFolder, $"{_countryCode}.{_language}.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 ReadTitleDbAsync(cacheFile, ct); + return; + } + } + + _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. + /// + private async Task ReadTitleDbAsync(string filePath, CancellationToken ct) + { + var json = await File.ReadAllTextAsync(filePath, ct); + // The JSON structure used by blawar is: + // { "entries": [ { "titleId":"0004000000000000", "name":"Mario", … }, … ] } + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (!root.TryGetProperty("entries", out var entries)) + { + _logger.LogWarning("Title database file {File} has no \"entries\" property", filePath); + return; + } + + foreach (var entry in entries.EnumerateArray()) + { + var dto = new TitleInfoDto( + TitleId: entry.GetProperty("titleId").GetString() ?? "", + Name: entry.GetProperty("name").GetString() ?? "", + Id: entry.GetProperty("id").GetString() ?? "", + ReleaseDate: entry.GetProperty("releaseDate").GetDateTime(), + NSUID: entry.GetProperty("nsuid").GetString() ?? "", + Version: entry.GetProperty("version").GetString() ?? "" + ); + if (!string.IsNullOrWhiteSpace(dto.TitleId)) + _titleData[dto.TitleId] = dto; + } + } + + /// + /// Scan the configured root directories and create the + /// _titleIdToPath map. If the file name does not contain a + /// TitleId, we extract it from the NSP file. + /// + private void BuildFilesystemIndex() + { + foreach (var root in _rootDirectories) + { + if (!Directory.Exists(root)) continue; + + foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + { + if (file.EndsWith(".nsp", StringComparison.OrdinalIgnoreCase)) + { + // 1️⃣ Does the file name already contain a TitleId? + var match = _titleIdRegex.Match(Path.GetFileName(file)); + string titleId; + if (match.Success) + { + titleId = match.Groups[1].Value + match.Groups[2].Value; + } + else + { + // 2️⃣ Extract the TitleId from the NSP using the extractor. + titleId = NSPExtactor.ExtractFromStream(File.OpenRead(file))?.TitleId; + if (string.IsNullOrWhiteSpace(titleId)) + { + _logger.LogWarning("Could not extract TitleId from {File}", file); + continue; + } + } + + // Normalise to 16‑digit hex (upper‑case). + titleId = titleId.ToUpperInvariant(); + _titleIdToPath[titleId] = file; + } + } + } + } + + #endregion +} \ No newline at end of file diff --git a/TinfoilVibeServer/TinfoilVibeServer.csproj b/TinfoilVibeServer/TinfoilVibeServer.csproj index 06fa2e5..d43ac7a 100644 --- a/TinfoilVibeServer/TinfoilVibeServer.csproj +++ b/TinfoilVibeServer/TinfoilVibeServer.csproj @@ -8,11 +8,10 @@ + - - 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..97ac829 100644 --- a/TinfoilVibeServer/appsettings.json +++ b/TinfoilVibeServer/appsettings.json @@ -7,17 +7,24 @@ }, "AllowedHosts": "*", - "RootDirectories": [ - "\\\\NAS\\Games", - "\\\\NAS\\Backups" - ], - "WhitelistExtensions": [ - ".bin", ".jpg", ".png", ".txt" - ], - "RomExtensions": [ - ".xci", ".nsp", ".xcz" - ], + "RootDirectories": [ "\\\\NAS\\Roms\\Switch", "Z:\\imgs\\roms\\Switch" ], + "WhitelistExtensions": [ ".bin", ".jpg", ".png", ".txt" ], + "RomExtensions": [ ".xci", ".nsp", ".xcz" ], "SnapshotFile": "index.tfl", "SnapshotBackupFile": "snapshot.bin", - "ArchiveBufferSize": 8192 -} + "CredentialsFile": "credentials.json", + "FingerprintsFile": "fingerprints.json", + "BlacklistFile": "blacklist.json", + "MaxFailedAttempts": 5, + "KeySetFile": "prod.keys", + "TitleDb": { + "CountryCode": "AU", + "Language": "en" + }, + "IndexDirectories": [ + "https://url1", + "sdmc:/url2", + "http://url3" + ], + "Success" : "Welcome to Tinfoil Vibe Server!" +} \ No newline at end of file diff --git a/TinfoilVibeServerTest/TinfoilVibeServerTest.csproj b/TinfoilVibeServerTest/TinfoilVibeServerTest.csproj new file mode 100644 index 0000000..d3517ca --- /dev/null +++ b/TinfoilVibeServerTest/TinfoilVibeServerTest.csproj @@ -0,0 +1,23 @@ + + + + net9.0 + latest + enable + enable + false + + + + + + + + + + + + + + + diff --git a/TinfoilVibeServerTest/UnitTest1.cs b/TinfoilVibeServerTest/UnitTest1.cs new file mode 100644 index 0000000..0429a91 --- /dev/null +++ b/TinfoilVibeServerTest/UnitTest1.cs @@ -0,0 +1,15 @@ +namespace TinfoilVibeServerTest; + +public class Tests +{ + [SetUp] + public void Setup() + { + } + + [Test] + public void Test1() + { + Assert.Pass(); + } +} \ No newline at end of file