From fa9d535a3436f3592c44af2999b7a02ca7b968fa Mon Sep 17 00:00:00 2001 From: Huy Nguyen Date: Mon, 3 Nov 2025 16:25:07 +1030 Subject: [PATCH] First Commit 2nd run --- .idea/.idea.TinfoilVibeServer/.idea/vcs.xml | 1 + TinfoilVibeServer.sln | 6 + TinfoilVibeServer.sln.DotSettings.user | 7 +- .../Config/GameDirectoryOptions.cs | 11 + TinfoilVibeServer/ConfigManager.cs | 70 ------ .../Configuration/AppSettings.cs | 10 + .../Middleware/BasicAuthMiddleware.cs | 100 -------- TinfoilVibeServer/Models/FileEntry.cs | 11 - TinfoilVibeServer/Models/GameSnapshot.cs | 18 ++ TinfoilVibeServer/Models/TitleInfo.cs | 11 - .../Persistence/ISnapshotRepository.cs | 12 + .../Persistence/SnapshotRepository.cs | 34 +++ TinfoilVibeServer/Program.cs | 85 ++++--- .../Repositories/SnapshotRepository.cs | 32 +++ TinfoilVibeServer/Services/ArchiveHandler.cs | 127 ---------- TinfoilVibeServer/Services/AuthStore.cs | 211 ---------------- .../Services/GameDirectoryWatcherService.cs | 229 ++++++++++++++++++ TinfoilVibeServer/Services/ILibHacParser.cs | 14 ++ TinfoilVibeServer/Services/KeySetHolder.cs | 8 + TinfoilVibeServer/Services/LibHacParser.cs | 59 +++++ TinfoilVibeServer/Services/NSPExtractor.cs | 172 +++++++++---- TinfoilVibeServer/Services/NcaMetadataDto.cs | 8 + TinfoilVibeServer/Services/SnapshotService.cs | 191 --------------- TinfoilVibeServer/TinfoilVibeServer.csproj | 18 +- TinfoilVibeServer/appsettings.json | 44 ++-- .../TinfoilVibeServerTests.csproj | 23 ++ 26 files changed, 691 insertions(+), 821 deletions(-) create mode 100644 TinfoilVibeServer/Config/GameDirectoryOptions.cs delete mode 100644 TinfoilVibeServer/ConfigManager.cs create mode 100644 TinfoilVibeServer/Configuration/AppSettings.cs delete mode 100644 TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs delete mode 100644 TinfoilVibeServer/Models/FileEntry.cs create mode 100644 TinfoilVibeServer/Models/GameSnapshot.cs delete mode 100644 TinfoilVibeServer/Models/TitleInfo.cs create mode 100644 TinfoilVibeServer/Persistence/ISnapshotRepository.cs create mode 100644 TinfoilVibeServer/Persistence/SnapshotRepository.cs create mode 100644 TinfoilVibeServer/Repositories/SnapshotRepository.cs delete mode 100644 TinfoilVibeServer/Services/ArchiveHandler.cs delete mode 100644 TinfoilVibeServer/Services/AuthStore.cs create mode 100644 TinfoilVibeServer/Services/GameDirectoryWatcherService.cs create mode 100644 TinfoilVibeServer/Services/ILibHacParser.cs create mode 100644 TinfoilVibeServer/Services/KeySetHolder.cs create mode 100644 TinfoilVibeServer/Services/LibHacParser.cs create mode 100644 TinfoilVibeServer/Services/NcaMetadataDto.cs delete mode 100644 TinfoilVibeServer/Services/SnapshotService.cs create mode 100644 TinfoilVibeServerTests/TinfoilVibeServerTests.csproj 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/TinfoilVibeServer.sln b/TinfoilVibeServer.sln index 970b95a..071d091 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}") = "TinfoilVibeServerTests", "TinfoilVibeServerTests\TinfoilVibeServerTests.csproj", "{EB041FA0-F87C-4F24-9E39-9BAF3D3776D8}" +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 + {EB041FA0-F87C-4F24-9E39-9BAF3D3776D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB041FA0-F87C-4F24-9E39-9BAF3D3776D8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB041FA0-F87C-4F24-9E39-9BAF3D3776D8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB041FA0-F87C-4F24-9E39-9BAF3D3776D8}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/TinfoilVibeServer.sln.DotSettings.user b/TinfoilVibeServer.sln.DotSettings.user index bfb9262..d493d4b 100644 --- a/TinfoilVibeServer.sln.DotSettings.user +++ b/TinfoilVibeServer.sln.DotSettings.user @@ -1,3 +1,8 @@  True - True \ No newline at end of file + True + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded \ No newline at end of file diff --git a/TinfoilVibeServer/Config/GameDirectoryOptions.cs b/TinfoilVibeServer/Config/GameDirectoryOptions.cs new file mode 100644 index 0000000..43d35a5 --- /dev/null +++ b/TinfoilVibeServer/Config/GameDirectoryOptions.cs @@ -0,0 +1,11 @@ +// File: TinfoilVibeServer/Config/GameDirectoriesOptions.cs + +namespace TinfoilVibeServer.Config; + +public sealed class GameDirectoriesOptions +{ + /// + /// Paths to scan for Switch ROMs and archives. Values may change at runtime. + /// + public IList Paths { get; set; } = new List(); +} \ 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/Configuration/AppSettings.cs b/TinfoilVibeServer/Configuration/AppSettings.cs new file mode 100644 index 0000000..c0bc554 --- /dev/null +++ b/TinfoilVibeServer/Configuration/AppSettings.cs @@ -0,0 +1,10 @@ +// Configuration/AppSettings.cs +namespace TinfoilVibeServer.Configuration +{ + public class AppSettings + { + public List GameDirectories { get; set; } = new(); + public List ValidExtensions { get; set; } = new() { ".nsp", ".xci", ".zip", ".rar", ".7z" }; + public string SnapshotPath { get; set; } = "snapshot.json"; + } +} \ No newline at end of file diff --git a/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs b/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs deleted file mode 100644 index 35fa36b..0000000 --- a/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs +++ /dev/null @@ -1,100 +0,0 @@ - -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; -using System.Text; -using TinfoilVibeServer.Services; - -namespace TinfoilVibeServer.Middleware; - -public sealed class BasicAuthMiddleware -{ - private readonly RequestDelegate _next; - private readonly AuthStore _store; - private readonly ILogger _logger; - - public BasicAuthMiddleware(RequestDelegate next, AuthStore store, ILogger logger) - { - _next = next; - _store = store; - _logger = logger; - } - - public async Task InvokeAsync(HttpContext context) - { - var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; - - // 1) IP blacklist - if (_store.IsBlacklisted(ip)) - { - _logger.LogWarning("Blocked request from blacklisted IP {IP}", ip); - context.Response.StatusCode = StatusCodes.Status403Forbidden; - await context.Response.WriteAsync("Forbidden"); - return; - } - - // 2) Authorization header - if (!context.Request.Headers.TryGetValue("Authorization", out var authHeaders)) - { - Challenge(context); - return; - } - - var authHeader = authHeaders.FirstOrDefault() ?? ""; - if (!authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) - { - Challenge(context); - return; - } - - string decoded; - try - { - var b64 = authHeader[6..].Trim(); - decoded = Encoding.UTF8.GetString(Convert.FromBase64String(b64)); - } - catch - { - Challenge(context); - return; - } - - var parts = decoded.Split(':', 2); - if (parts.Length != 2) - { - Challenge(context); - return; - } - - var username = parts[0]; - var password = parts[1]; - - // 3) UID header (optional) - int? uid = null; - if (context.Request.Headers.TryGetValue("UID", out var uidHeader)) - { - if (int.TryParse(uidHeader.ToString(), out var parsedUid)) - uid = parsedUid; - } - - // 4) Validate - if (!_store.TryValidate(username, password, uid, ip, out var 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"); - return; - } - - // Authentication succeeded – attach username for downstream handlers if needed - context.Items["User"] = username; - - await _next(context); - } - - private static void Challenge(HttpContext ctx) - { - ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; - ctx.Response.Headers.Add("WWW-Authenticate", "Basic realm=\"FileSnapshot\""); - } -} \ No newline at end of file diff --git a/TinfoilVibeServer/Models/FileEntry.cs b/TinfoilVibeServer/Models/FileEntry.cs deleted file mode 100644 index e921d48..0000000 --- a/TinfoilVibeServer/Models/FileEntry.cs +++ /dev/null @@ -1,11 +0,0 @@ -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 -); \ No newline at end of file diff --git a/TinfoilVibeServer/Models/GameSnapshot.cs b/TinfoilVibeServer/Models/GameSnapshot.cs new file mode 100644 index 0000000..c65a8d8 --- /dev/null +++ b/TinfoilVibeServer/Models/GameSnapshot.cs @@ -0,0 +1,18 @@ +using System; + +namespace TinfoilVibeServer.Models +{ + public class GameSnapshot + { + public string TitleId { get; set; } // 16‑hex string + public int Version { get; set; } + public bool IsApplication { get; set; } + public bool IsPatch { get; set; } + + public string FullPath { get; set; } // Path on disk (file or archive) + public string InsidePath { get; set; } // Path inside an archive (empty if none) + public string FirstNcaHash { get; set; } // SHA‑256 of the first NCA stream + + public DateTime LastModified { get; set; } // For snapshot refresh logic + } +} \ No newline at end of file diff --git a/TinfoilVibeServer/Models/TitleInfo.cs b/TinfoilVibeServer/Models/TitleInfo.cs deleted file mode 100644 index 30d45b6..0000000 --- a/TinfoilVibeServer/Models/TitleInfo.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace TinfoilVibeServer.Models; - -/// -/// Metadata extracted from a 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 -); \ No newline at end of file diff --git a/TinfoilVibeServer/Persistence/ISnapshotRepository.cs b/TinfoilVibeServer/Persistence/ISnapshotRepository.cs new file mode 100644 index 0000000..d0c1816 --- /dev/null +++ b/TinfoilVibeServer/Persistence/ISnapshotRepository.cs @@ -0,0 +1,12 @@ +// File: TinfoilVibeServer/Persistence/ISnapshotRepository.cs +using System.Collections.Generic; +using System.Threading.Tasks; +using TinfoilVibeServer.Models; + +namespace TinfoilVibeServer.Persistence; + +public interface ISnapshotRepository +{ + Task> LoadAsync(); + Task PersistAsync(IReadOnlyDictionary snapshot); +} \ No newline at end of file diff --git a/TinfoilVibeServer/Persistence/SnapshotRepository.cs b/TinfoilVibeServer/Persistence/SnapshotRepository.cs new file mode 100644 index 0000000..a7f666e --- /dev/null +++ b/TinfoilVibeServer/Persistence/SnapshotRepository.cs @@ -0,0 +1,34 @@ +// File: TinfoilVibeServer/Persistence/SnapshotRepository.cs +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using TinfoilVibeServer.Models; + +namespace TinfoilVibeServer.Persistence; + +public sealed class SnapshotRepository : ISnapshotRepository +{ + private readonly string _path = "snapshot.json"; + + public async Task> LoadAsync() + { + if (!File.Exists(_path)) + { + return new Dictionary(); + } + + await using var stream = File.OpenRead(_path); + var snapshot = await JsonSerializer.DeserializeAsync>(stream, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + return snapshot ?? new Dictionary(); + } + + public async Task PersistAsync(IReadOnlyDictionary snapshot) + { + await using var stream = File.Create(_path); + await JsonSerializer.SerializeAsync(stream, snapshot, + new JsonSerializerOptions { WriteIndented = true }); + } +} \ No newline at end of file diff --git a/TinfoilVibeServer/Program.cs b/TinfoilVibeServer/Program.cs index 0d64a40..f13e1be 100644 --- a/TinfoilVibeServer/Program.cs +++ b/TinfoilVibeServer/Program.cs @@ -1,42 +1,53 @@ -using TinfoilVibeServer.Middleware; +// File: TinfoilVibeServer/Program.cs + +using LibHac.Common.Keys; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Configuration; +using TinfoilVibeServer.Config; +using TinfoilVibeServer.Models; using TinfoilVibeServer.Services; +using TinfoilVibeServer.Persistence; -var builder = WebApplication.CreateBuilder(args); +namespace TinfoilVibeServer; -// ----------------------------------------------------- -// 1. Register AuthStore as a singleton -// ----------------------------------------------------- -builder.Services.AddSingleton(); - -// ----------------------------------------------------- -// 2. Snapshot + other services (unchanged) -// ----------------------------------------------------- -builder.Services.AddSingleton(); -// … any other services you already have - -var app = builder.Build(); - -// ----------------------------------------------------- -// 3. Apply authentication middleware *before* the -// snapshot endpoints. This guarantees all routes -// are protected. -// ----------------------------------------------------- -app.UseMiddleware(); - -// ----------------------------------------------------- -// 4. Existing endpoints – unchanged -// ----------------------------------------------------- -app.MapGet("/", () => Results.Redirect("/index.tfl")); -app.MapGet("/index.tfl", async context => +internal static class Program { - 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) -}); + public static void Main(string[] args) + { + var host = Host.CreateDefaultBuilder(args) + .ConfigureAppConfiguration((ctx, cfg) => + { + cfg.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); + cfg.AddEnvironmentVariables(); + cfg.AddCommandLine(args); + }) + .ConfigureServices((ctx, services) => + { + // Configuration POCO + services.Configure(ctx.Configuration.GetSection("GameDirectories")); -app.Run(); \ No newline at end of file + // Snapshot persistence + services.AddSingleton(); + + // LibHac parser + services.AddSingleton(); + + // Main service + services.AddHostedService(); + KeySetHolder.KeySet = ExternalKeyReader.ReadKeyFile(ctx.Configuration.GetSection("KeySet").Get()); + }) + .ConfigureLogging((ctx, logging) => + { + logging.ClearProviders(); + logging.AddConsole(options => + { + options.TimestampFormat = "[yyyy-MM-dd HH:mm:ss] "; + }); + }) + .Build(); + + host.Run(); + } +} \ No newline at end of file diff --git a/TinfoilVibeServer/Repositories/SnapshotRepository.cs b/TinfoilVibeServer/Repositories/SnapshotRepository.cs new file mode 100644 index 0000000..59229e6 --- /dev/null +++ b/TinfoilVibeServer/Repositories/SnapshotRepository.cs @@ -0,0 +1,32 @@ +// Repositories/SnapshotRepository.cs +using System.IO; +using System.Text.Json; +using TinfoilVibeServer.Models; + +namespace TinfoilVibeServer.Repositories +{ + public class SnapshotRepository + { + private readonly string _path; + + public SnapshotRepository(string path) + { + _path = path; + } + + public List Load() + { + if (!File.Exists(_path)) + return new(); + + var json = File.ReadAllText(_path); + return JsonSerializer.Deserialize>(json) ?? new(); + } + + public void Persist(IEnumerable entries) + { + var json = JsonSerializer.Serialize(entries, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(_path, json); + } + } +} \ No newline at end of file diff --git a/TinfoilVibeServer/Services/ArchiveHandler.cs b/TinfoilVibeServer/Services/ArchiveHandler.cs deleted file mode 100644 index a3cbe67..0000000 --- a/TinfoilVibeServer/Services/ArchiveHandler.cs +++ /dev/null @@ -1,127 +0,0 @@ - -using System.IO.Compression; -using FileSnapshot; -using SharpCompress.Archives; -using SharpCompress.Archives.Zip; -using SharpCompress.Archives.Rar; -using SharpCompress.Archives.SevenZip; -using SharpCompress.Readers; -using TinfoilVibeServer.Models; -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 -{ - /// - /// Return TitleInfo if an embedded Nintendo archive is found; otherwise null. - /// - public static TitleInfo? TryExtractTitleInfo(string filePath) - { - var ext = Path.GetExtension(filePath).ToLowerInvariant(); - - try - { - switch (ext) - { - case ".zip": - return HandleZip(filePath); - case ".7z": - return Handle7z(filePath); - case ".rar": - return HandleRar(filePath); - default: - return null; - } - } - catch - { - // Graceful fallback – return null - return null; - } - } - - private static TitleInfo? 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; - } - } - return null; - } - - private static TitleInfo? 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; - } - } - return null; - } - - private static TitleInfo? HandleRar(string path) - { - // SharpCompress can handle most RAR5 files – fallback to SharpSevenZip if it fails - try - { - using var archive = RarArchive.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; - } - } - 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; - } - } - - private static bool IsRomArchive(string entryName) - { - var ext = Path.GetExtension(entryName).ToLowerInvariant(); - return ext 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/GameDirectoryWatcherService.cs b/TinfoilVibeServer/Services/GameDirectoryWatcherService.cs new file mode 100644 index 0000000..d91fe5d --- /dev/null +++ b/TinfoilVibeServer/Services/GameDirectoryWatcherService.cs @@ -0,0 +1,229 @@ +// File: TinfoilVibeServer/Services/GameDirectoryWatcherService.cs + +using Microsoft.Extensions.Options; +using SharpCompress.Archives; +using TinfoilVibeServer.Config; +using TinfoilVibeServer.Persistence; +using TinfoilVibeServer.Models; + +namespace TinfoilVibeServer.Services; + +/// +/// 1. Loads persisted snapshot on start. +/// 2. Scans configured directories for Switch ROMs or archives, extracts metadata, and keeps snapshot up‑to‑date. +/// 3. Watches directories for changes and updates snapshot incrementally. +/// +public sealed class GameDirectoryWatcherService : BackgroundService +{ + private readonly ILogger _log; + private readonly IOptionsMonitor _options; + private readonly ISnapshotRepository _repo; + private readonly ILibHacParser _parser; + private readonly Dictionary _snapshot = new(); + private readonly List _watchers = new(); + + // Valid extensions for Switch ROMs (case‑insensitive). + private static readonly string[] _romExtensions = { ".nsp", ".xci" }; + + // Archive extensions that we can open with SharpCompress. + private static readonly string[] _archiveExtensions = { ".zip", ".rar", ".7z", ".tar", ".gz" }; + + public GameDirectoryWatcherService( + ILogger log, + IOptionsMonitor options, + ISnapshotRepository repo, + ILibHacParser parser) + { + _log = log; + _options = options; + _repo = repo; + _parser = parser; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // 1. Load persisted snapshot + var persisted = await _repo.LoadAsync(); + foreach (var kvp in persisted) + { + _snapshot[kvp.Key] = kvp.Value; + } + + // 2. Initial scan + await ScanAllDirectoriesAsync(stoppingToken); + + // 3. Setup watchers + foreach (var dir in _options.CurrentValue.Paths) + { + var watcher = new FileSystemWatcher(dir) + { + IncludeSubdirectories = true, + NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite + }; + + watcher.Created += async (_, e) => await HandleCreatedAsync(e.FullPath, stoppingToken); + watcher.Changed += async (_, e) => await HandleChangedAsync(e.FullPath, stoppingToken); + watcher.Deleted += (_, e) => HandleDeleted(e.FullPath); + watcher.Renamed += async (_, e) => await HandleRenamedAsync(e.OldFullPath, e.FullPath, stoppingToken); + + watcher.EnableRaisingEvents = true; + _watchers.Add(watcher); + } + + // Keep the service alive until cancellation + await Task.Delay(Timeout.Infinite, stoppingToken); + } + + private async Task ScanAllDirectoriesAsync(CancellationToken token) + { + foreach (var dir in _options.CurrentValue.Paths) + { + if (!Directory.Exists(dir)) + { + _log.LogWarning("Configured directory does not exist: {Dir}", dir); + continue; + } + + await foreach (var file in Directory.EnumerateFiles(dir, "*.*", SearchOption.AllDirectories).ToAsyncEnumerable().WithCancellation(token)) + { + await ProcessFileAsync(file, token); + _log.LogInformation("Processed file: {File}", file); + } + } + + await PersistSnapshotAsync(); + } + + private async Task ProcessFileAsync(string fullPath, CancellationToken token) + { + var ext = Path.GetExtension(fullPath).ToLowerInvariant(); + + if (_romExtensions.Contains(ext)) + { + await ProcessRomAsync(fullPath, string.Empty, token); + } + else if (_archiveExtensions.Contains(ext)) + { + await ProcessArchiveAsync(fullPath, token); + } + } + + private async Task ProcessRomAsync(string fullPath, string insidePath, CancellationToken token) + { + try + { + await using var stream = File.OpenRead(fullPath); + var entry = await _parser.ExtractAsync(stream, fullPath, insidePath); + + if (entry == null) + { + _log.LogDebug("No valid NCA found in {Path}", fullPath); + return; + } + + await UpsertSnapshotAsync(entry, token); + } + catch (Exception ex) + { + _log.LogError(ex, "Failed to process ROM {Path}", fullPath); + } + } + + private async Task ProcessArchiveAsync(string archivePath, CancellationToken token) + { + try + { + using var archive = ArchiveFactory.Open(archivePath); + foreach (var entry in archive.Entries.Where(e => !e.IsDirectory)) + { + var ext = Path.GetExtension(entry.Key).ToLowerInvariant(); + if (_romExtensions.Contains(ext)) + { + await using var entryStream = entry.OpenEntryStream(); + await ProcessRomAsync(archivePath, entry.Key, token); + } + } + } + catch (Exception ex) + { + _log.LogWarning(ex, "SharpCompress failed on {Archive}. Trying SharpSevenZip fallback.", archivePath); + await ProcessArchiveWithSharpSevenZipAsync(archivePath, token); + } + } + + private async Task ProcessArchiveWithSharpSevenZipAsync(string archivePath, CancellationToken token) + { + // Placeholder – actual implementation requires SharpSevenZip integration. + // The idea is to open the archive, enumerate entries, and for each ROM + // call ProcessRomAsync with the archive path and internal entry path. + _log.LogDebug("SharpSevenZip fallback not yet implemented for {Archive}", archivePath); + } + + private async Task UpsertSnapshotAsync(SnapshotEntry entry, CancellationToken token) + { + if (_snapshot.TryGetValue(entry.TitleId, out var existing)) + { + // File exists – compare paths and hashes + if (existing.FullPath == entry.FullPath && + existing.Hash.SequenceEqual(entry.Hash)) + { + // No change + return; + } + + _log.LogInformation("Updating snapshot for TitleId {TitleId} – path change or hash update.", entry.TitleId); + _snapshot[entry.TitleId] = entry; + } + else + { + _log.LogInformation("Adding new entry to snapshot: {TitleId}", entry.TitleId); + _snapshot[entry.TitleId] = entry; + } + + await PersistSnapshotAsync(); + } + + private void HandleDeleted(string fullPath) + { + var titleId = _snapshot.Values.FirstOrDefault(e => e.FullPath == fullPath)?.TitleId; + if (titleId != null) + { + _log.LogInformation("Removing deleted file from snapshot: {TitleId} ({Path})", titleId, fullPath); + _snapshot.Remove(titleId); + _repo.PersistAsync(_snapshot).ConfigureAwait(false); + } + } + + private async Task HandleCreatedAsync(string fullPath, CancellationToken token) + { + _log.LogInformation("Detected new file: {Path}", fullPath); + await ProcessFileAsync(fullPath, token); + } + + private async Task HandleChangedAsync(string fullPath, CancellationToken token) + { + _log.LogInformation("Detected changed file: {Path}", fullPath); + await ProcessFileAsync(fullPath, token); + } + + private async Task HandleRenamedAsync(string oldFullPath, string newFullPath, CancellationToken token) + { + _log.LogInformation("Detected renamed file: {Old} → {New}", oldFullPath, newFullPath); + // Treat as delete + create + HandleDeleted(oldFullPath); + await ProcessFileAsync(newFullPath, token); + } + + private async Task PersistSnapshotAsync() + { + try + { + await _repo.PersistAsync(_snapshot); + _log.LogDebug("Snapshot persisted with {Count} entries.", _snapshot.Count); + } + catch (Exception ex) + { + _log.LogError(ex, "Failed to persist snapshot"); + } + } +} \ No newline at end of file diff --git a/TinfoilVibeServer/Services/ILibHacParser.cs b/TinfoilVibeServer/Services/ILibHacParser.cs new file mode 100644 index 0000000..a090f97 --- /dev/null +++ b/TinfoilVibeServer/Services/ILibHacParser.cs @@ -0,0 +1,14 @@ +// File: TinfoilVibeServer/Services/ILibHacParser.cs +using System.IO; +using System.Threading.Tasks; +using TinfoilVibeServer.Models; + +namespace TinfoilVibeServer.Services; + +public interface ILibHacParser +{ + /// + /// Reads a ROM stream (NSP/XCI/…​) and extracts metadata. + /// + Task ExtractAsync(Stream romStream, string fullPath, string insidePath); +} \ No newline at end of file diff --git a/TinfoilVibeServer/Services/KeySetHolder.cs b/TinfoilVibeServer/Services/KeySetHolder.cs new file mode 100644 index 0000000..6339141 --- /dev/null +++ b/TinfoilVibeServer/Services/KeySetHolder.cs @@ -0,0 +1,8 @@ +using LibHac.Common.Keys; + +namespace TinfoilVibeServer.Models; + +public class KeySetHolder +{ + public static KeySet KeySet { get; set; } +} \ No newline at end of file diff --git a/TinfoilVibeServer/Services/LibHacParser.cs b/TinfoilVibeServer/Services/LibHacParser.cs new file mode 100644 index 0000000..225dbd2 --- /dev/null +++ b/TinfoilVibeServer/Services/LibHacParser.cs @@ -0,0 +1,59 @@ +// File: TinfoilVibeServer/Services/LibHacParser.cs +using System; +using System.IO; +using System.Linq; +using System.Buffers; +using System.Threading.Tasks; +using LibHac.Fs; +using LibHac.Fs.Fsa; +using LibHac.FsSystem; +using LibHac.Ncm; +using LibHac.Tools.FsSystem; +using LibHac.Tools.FsSystem.NcaUtils; +using TinfoilVibeServer.Models; + +namespace TinfoilVibeServer.Services; + +/// +/// Implements the LibHac extraction logic copied from the provided NSPExtractor.cs [4]. +/// +public sealed class LibHacParser : ILibHacParser +{ + private readonly NSPExtactor _nspExtractor; + + public LibHacParser(NSPExtactor nspExtractor) + { + _nspExtractor = nspExtractor; + } + /// + /// Reads the stream of a single Switch ROM (NSP or XCI) and returns metadata. + /// + public async Task ExtractAsync(Stream romStream, string fullPath, string insidePath) + { + var extractFromStream = _nspExtractor.ExtractFromStream(romStream); + // Build snapshot entry + if (extractFromStream?.TitleId == null) return null; + + var entry = new SnapshotEntry + { + TitleId = extractFromStream.TitleId, + Version = extractFromStream.Version, + IsApp = extractFromStream.ContentMetaType == ContentMetaType.Application, + IsPatch = extractFromStream.ContentMetaType == ContentMetaType.Patch, + IsDlc = extractFromStream.ContentMetaType == ContentMetaType.AddOnContent, + FullPath = fullPath, + InsidePath = insidePath, + Hash = await ComputeHashAsync(romStream), + LastModified = File.GetLastWriteTimeUtc(fullPath) + }; + + return entry; + } + + private static async Task ComputeHashAsync(Stream stream) + { + stream.Seek(0, SeekOrigin.Begin); + using var sha256 = System.Security.Cryptography.SHA256.Create(); + return await sha256.ComputeHashAsync(stream); + } +} \ No newline at end of file diff --git a/TinfoilVibeServer/Services/NSPExtractor.cs b/TinfoilVibeServer/Services/NSPExtractor.cs index dff4a4f..91e0c26 100644 --- a/TinfoilVibeServer/Services/NSPExtractor.cs +++ b/TinfoilVibeServer/Services/NSPExtractor.cs @@ -1,65 +1,143 @@ -using System; -using System.IO; -using System.Text.Json; +using LibHac.Common; +using LibHac.Common.Keys; 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.Ncm; +using LibHac.Tools.Ncm; +using Microsoft.Extensions.Options; -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. + /// Extracts the TitleId, version and patch/application flag from an NSP/XCI file. /// - public static TitleInfo? ExtractFromFile(string filePath) + public sealed class NSPExtactor { - // LibHac works with byte streams. We open the file once and hand the stream to RomArchiveReader. - try + private readonly KeySetProvider _keySetProvider; + + public NSPExtactor(KeySetProvider keySetProvider) { - using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); - using var reader = new RomArchiveReader(fs, new RomArchiveSettings { UseCache = false }); + _keySetProvider = keySetProvider; + } + public NcaMetadataDto? ExtractFromFile(string filePath) + { + using var stream = File.OpenRead(filePath); + return ExtractFromStream(stream); + } - if (!reader.IsValid) - return null; // Not an NSP/XCI + public NcaMetadataDto? ExtractFromStream(Stream stream) + { + if (!IsPfsFileSystem(stream)) + return null; - // 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()) + stream.Seek(0, SeekOrigin.Begin); + + // 0‑19.0.0 stream wrapper + using var storage = new StreamStorage(stream, false); + + var partition = new PartitionFileSystem(); + partition.Initialize(storage).ThrowIfFailure(); + + // Find the first Meta‑NCA inside the NSP/XCI + foreach (var entry in partition.EnumerateEntries("*.nca", + SearchOptions.RecurseSubdirectories) + .Where(e => e.Type == DirectoryEntryType.File)) { - // 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(); + using var fileRef = new UniqueRef(); + partition.OpenFile(ref fileRef.Ref, entry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); + using var file = fileRef.Release(); + using var fileStorage = new FileStorage(file); + var nca = new Nca(_keySetProvider.Get(), fileStorage); - // 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); + // Meta‑NCA contains the TitleId & version + if (nca.Header.ContentType == NcaContentType.Meta) + { + var titleId = nca.Header.TitleId.ToString("X16"); + int version = nca.Header.Version; + var contentMetaType = GetMetaDataType(nca); + if (contentMetaType != null) return new NcaMetadataDto(titleId, version, contentMetaType.Value); + } } - return null; // No NCA found - } - catch - { - // Any exception (bad file, invalid archive, etc.) -> treat as non‑NXP return null; } + + private static ContentMetaType? GetMetaDataType(Nca nca) + { + if (nca.Header.ContentType != NcaContentType.Meta) return null; + 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); + return cnmt.Type; + } + + return null; + } + + private static bool IsPfsFileSystem(Stream stream) + { + try + { + if (!stream.CanSeek) return false; + stream.Seek(0, SeekOrigin.Begin); + + var storage = new StreamStorage(stream, false); + var partition = new PartitionFileSystem(); + partition.Initialize(storage).ThrowIfFailure(); + + return true; + } + catch + { + return false; + } + } } + + public class KeySetProvider + { + private readonly KeySet _keySet; + + public KeySetProvider(IOptions nspExtractorSettings) + { + _keySet = ExternalKeyReader.ReadKeyFile(nspExtractorSettings.Value.KeyFilePath); + } + + public KeySet Get() => _keySet; + } + + public class NSPExtractorSettings + { + public string KeyFilePath { get; set; } = string.Empty; + } + + /// + /// Simple DTO that matches the information extracted by NSPExtactor. + /// + public sealed class NcaMetadataDto + { + public string TitleId { get; } + public int Version { get; } + public ContentMetaType ContentMetaType { get; } + + public NcaMetadataDto(string titleId, int version, ContentMetaType contentMetaType) + { + TitleId = titleId; + Version = version; + ContentMetaType = contentMetaType; + } + } + + } \ No newline at end of file diff --git a/TinfoilVibeServer/Services/NcaMetadataDto.cs b/TinfoilVibeServer/Services/NcaMetadataDto.cs new file mode 100644 index 0000000..2ac196d --- /dev/null +++ b/TinfoilVibeServer/Services/NcaMetadataDto.cs @@ -0,0 +1,8 @@ +namespace TinfoilVibeServer.Models; + +public class NcaMetadataDto(string titleId, int version, bool isApp) +{ + public string TitleId { get; set; } = titleId; + public int Version { get; set; } = version; + public bool IsApp { get; set; } = isApp; +} \ No newline at end of file diff --git a/TinfoilVibeServer/Services/SnapshotService.cs b/TinfoilVibeServer/Services/SnapshotService.cs deleted file mode 100644 index 4d680a2..0000000 --- a/TinfoilVibeServer/Services/SnapshotService.cs +++ /dev/null @@ -1,191 +0,0 @@ -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. -/// -public sealed class SnapshotService : IDisposable -{ - private readonly ConfigManager _config; - 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); - - // Initial snapshot - BuildSnapshot(); - - // Persist a copy for quick load on next run - File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(GetSnapshot())); - - // File system watcher - _watcher = new FileSystemWatcher - { - 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 - PersistSnapshot(); - }; - } - - 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) - { - // 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 ThrottleSnapshotUpdate() - { - // Debounce: only trigger once in a short window - Task.Run(async () => - { - await Task.Delay(250); - UpdateSnapshot(); - }); - } - - #endregion - - /// - /// Full rebuild – called on start‑up and on config change. - /// - private void BuildSnapshot() - { - var cfg = _config.Settings; - var entries = new List(); - - foreach (var dir in cfg.RootDirectories) - { - foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories)) - { - 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) - { - // 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; - 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 PersistSnapshot() - { - var entries = GetSnapshot(); - var newHash = ComputeSnapshotHash(entries); - if (_currentSnapshotHash != newHash) - { - _currentSnapshotHash = newHash; - File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries)); - File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(entries)); - } - } - - private static string ComputeHash(string filePath) - { - using var sha256 = SHA256.Create(); - using var stream = File.OpenRead(filePath); - var hash = sha256.ComputeHash(stream); - return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); - } - - public IReadOnlyList GetSnapshot() - { - var json = File.ReadAllText(_jsonPath); - return JsonSerializer.Deserialize>(json)!; - } - - public void Dispose() - { - _watcher.Dispose(); - _config.Dispose(); - } -} \ No newline at end of file diff --git a/TinfoilVibeServer/TinfoilVibeServer.csproj b/TinfoilVibeServer/TinfoilVibeServer.csproj index 06fa2e5..db91a59 100644 --- a/TinfoilVibeServer/TinfoilVibeServer.csproj +++ b/TinfoilVibeServer/TinfoilVibeServer.csproj @@ -12,7 +12,9 @@ - + + + @@ -22,6 +24,9 @@ appsettings.json + + Always + @@ -36,4 +41,15 @@ + + + + + + + + x86\Models\SnapshotEntry.cs + + + diff --git a/TinfoilVibeServer/appsettings.json b/TinfoilVibeServer/appsettings.json index ecd2905..fe8acc5 100644 --- a/TinfoilVibeServer/appsettings.json +++ b/TinfoilVibeServer/appsettings.json @@ -1,23 +1,39 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } + "Serilog": { + "Using": [ + "Serilog.Sinks.Console" + ], + "MinimumLevel": "Information", + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}" + } + } + ] }, "AllowedHosts": "*", - - "RootDirectories": [ - "\\\\NAS\\Games", - "\\\\NAS\\Backups" - ], + "GameDirectories": { + "Paths": [ + "\\\\NAS\\Games", + "Z:\\imgs\\roms\\Switch" + ] + }, + "SnapshotPath": "snapshot.json", "WhitelistExtensions": [ - ".bin", ".jpg", ".png", ".txt" + ".bin", + ".jpg", + ".png", + ".txt" ], "RomExtensions": [ - ".xci", ".nsp", ".xcz" + ".xci", + ".nsp", + ".xcz" ], "SnapshotFile": "index.tfl", "SnapshotBackupFile": "snapshot.bin", - "ArchiveBufferSize": 8192 -} + "ArchiveBufferSize": 8192, + "KeySet": "prod.keys" +} \ No newline at end of file diff --git a/TinfoilVibeServerTests/TinfoilVibeServerTests.csproj b/TinfoilVibeServerTests/TinfoilVibeServerTests.csproj new file mode 100644 index 0000000..d3517ca --- /dev/null +++ b/TinfoilVibeServerTests/TinfoilVibeServerTests.csproj @@ -0,0 +1,23 @@ + + + + net9.0 + latest + enable + enable + false + + + + + + + + + + + + + + +