commit d1d2c9f41e7a019bd8c1657bd2b803ddad6987e9 Author: Huy Nguyen Date: Sat Nov 1 19:18:39 2025 +1030 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd967fc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..add57be --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..2972ae4 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "libhac"] + path = libhac + url = https://git.ryujinx.app/ryubing/libhac diff --git a/.idea/.idea.TinfoilVibeServer/.idea/.gitignore b/.idea/.idea.TinfoilVibeServer/.idea/.gitignore new file mode 100644 index 0000000..13ef20a --- /dev/null +++ b/.idea/.idea.TinfoilVibeServer/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/modules.xml +/contentModel.xml +/.idea.TinfoilVibeServer.iml +/projectSettingsUpdater.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.TinfoilVibeServer/.idea/encodings.xml b/.idea/.idea.TinfoilVibeServer/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/.idea/.idea.TinfoilVibeServer/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.TinfoilVibeServer/.idea/indexLayout.xml b/.idea/.idea.TinfoilVibeServer/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/.idea/.idea.TinfoilVibeServer/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.TinfoilVibeServer/.idea/vcs.xml b/.idea/.idea.TinfoilVibeServer/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/.idea.TinfoilVibeServer/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/TinfoilVibeServer.sln b/TinfoilVibeServer.sln new file mode 100644 index 0000000..970b95a --- /dev/null +++ b/TinfoilVibeServer.sln @@ -0,0 +1,21 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{41999D26-405C-4DAB-8991-CBA992117C84}" + ProjectSection(SolutionItems) = preProject + compose.yaml = compose.yaml + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinfoilVibeServer", "TinfoilVibeServer\TinfoilVibeServer.csproj", "{DE992FDB-6D13-4152-925D-29D39A23FB75}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {DE992FDB-6D13-4152-925D-29D39A23FB75}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {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 + EndGlobalSection +EndGlobal diff --git a/TinfoilVibeServer.sln.DotSettings.user b/TinfoilVibeServer.sln.DotSettings.user new file mode 100644 index 0000000..bfb9262 --- /dev/null +++ b/TinfoilVibeServer.sln.DotSettings.user @@ -0,0 +1,3 @@ + + True + True \ No newline at end of file diff --git a/TinfoilVibeServer/ConfigManager.cs b/TinfoilVibeServer/ConfigManager.cs new file mode 100644 index 0000000..aeed52d --- /dev/null +++ b/TinfoilVibeServer/ConfigManager.cs @@ -0,0 +1,70 @@ + +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/Dockerfile b/TinfoilVibeServer/Dockerfile new file mode 100644 index 0000000..4903678 --- /dev/null +++ b/TinfoilVibeServer/Dockerfile @@ -0,0 +1,23 @@ +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["TinfoilVibeServer/TinfoilVibeServer.csproj", "TinfoilVibeServer/"] +RUN dotnet restore "TinfoilVibeServer/TinfoilVibeServer.csproj" +COPY . . +WORKDIR "/src/TinfoilVibeServer" +RUN dotnet build "./TinfoilVibeServer.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./TinfoilVibeServer.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "TinfoilVibeServer.dll"] diff --git a/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs b/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs new file mode 100644 index 0000000..35fa36b --- /dev/null +++ b/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs @@ -0,0 +1,100 @@ + +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 new file mode 100644 index 0000000..e921d48 --- /dev/null +++ b/TinfoilVibeServer/Models/FileEntry.cs @@ -0,0 +1,11 @@ +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/TitleInfo.cs b/TinfoilVibeServer/Models/TitleInfo.cs new file mode 100644 index 0000000..30d45b6 --- /dev/null +++ b/TinfoilVibeServer/Models/TitleInfo.cs @@ -0,0 +1,11 @@ +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/Program.cs b/TinfoilVibeServer/Program.cs new file mode 100644 index 0000000..0d64a40 --- /dev/null +++ b/TinfoilVibeServer/Program.cs @@ -0,0 +1,42 @@ +using TinfoilVibeServer.Middleware; +using TinfoilVibeServer.Services; + +var builder = WebApplication.CreateBuilder(args); + +// ----------------------------------------------------- +// 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 => +{ + 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) +}); + +app.Run(); \ No newline at end of file diff --git a/TinfoilVibeServer/Properties/launchSettings.json b/TinfoilVibeServer/Properties/launchSettings.json new file mode 100644 index 0000000..c876394 --- /dev/null +++ b/TinfoilVibeServer/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5253", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7043;http://localhost:5253", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/TinfoilVibeServer/Services/ArchiveHandler.cs b/TinfoilVibeServer/Services/ArchiveHandler.cs new file mode 100644 index 0000000..a3cbe67 --- /dev/null +++ b/TinfoilVibeServer/Services/ArchiveHandler.cs @@ -0,0 +1,127 @@ + +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 new file mode 100644 index 0000000..a9be168 --- /dev/null +++ b/TinfoilVibeServer/Services/AuthStore.cs @@ -0,0 +1,211 @@ +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/NSPExtractor.cs b/TinfoilVibeServer/Services/NSPExtractor.cs new file mode 100644 index 0000000..dff4a4f --- /dev/null +++ b/TinfoilVibeServer/Services/NSPExtractor.cs @@ -0,0 +1,65 @@ +using System; +using System.IO; +using System.Text.Json; +using LibHac.Fs; +using LibHac.FsSystem; +using LibHac.FsSystem.Impl; +using LibHac.Util; +using TinfoilVibeServer.Models; + +namespace FileSnapshot; + +/// +/// Extracts title information from a Nintendo NSP/XCI file using LibHac 0.20.0. +/// +public sealed class NSPExtactor +{ + /// + /// Return TitleInfo for the file, or null if the file is not a valid Nintendo archive. + /// + public static TitleInfo? ExtractFromFile(string filePath) + { + // 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 (!reader.IsValid) + return null; // Not an NSP/XCI + + // 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 + } + catch + { + // Any exception (bad file, invalid archive, etc.) -> treat as non‑NXP + return null; + } + } +} \ No newline at end of file diff --git a/TinfoilVibeServer/Services/SnapshotService.cs b/TinfoilVibeServer/Services/SnapshotService.cs new file mode 100644 index 0000000..4d680a2 --- /dev/null +++ b/TinfoilVibeServer/Services/SnapshotService.cs @@ -0,0 +1,191 @@ +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 new file mode 100644 index 0000000..06fa2e5 --- /dev/null +++ b/TinfoilVibeServer/TinfoilVibeServer.csproj @@ -0,0 +1,39 @@ + + + + net9.0 + enable + enable + Linux + + + + + + + + + + + + + .dockerignore + + + appsettings.json + + + + + + + + + + + ..\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll + + + + diff --git a/TinfoilVibeServer/TinfoilVibeServer.http b/TinfoilVibeServer/TinfoilVibeServer.http new file mode 100644 index 0000000..61720c8 --- /dev/null +++ b/TinfoilVibeServer/TinfoilVibeServer.http @@ -0,0 +1,6 @@ +@TinfoilVibeServer_HostAddress = http://localhost:5253 + +GET {{TinfoilVibeServer_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/TinfoilVibeServer/appsettings.Development.json b/TinfoilVibeServer/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/TinfoilVibeServer/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/TinfoilVibeServer/appsettings.json b/TinfoilVibeServer/appsettings.json new file mode 100644 index 0000000..ecd2905 --- /dev/null +++ b/TinfoilVibeServer/appsettings.json @@ -0,0 +1,23 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + + "RootDirectories": [ + "\\\\NAS\\Games", + "\\\\NAS\\Backups" + ], + "WhitelistExtensions": [ + ".bin", ".jpg", ".png", ".txt" + ], + "RomExtensions": [ + ".xci", ".nsp", ".xcz" + ], + "SnapshotFile": "index.tfl", + "SnapshotBackupFile": "snapshot.bin", + "ArchiveBufferSize": 8192 +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..934a314 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,13 @@ +services: + consoleapp1: + image: consoleapp1 + build: + context: . + dockerfile: ConsoleApp1/Dockerfile + + tinfoilvibeserver: + image: tinfoilvibeserver + build: + context: . + dockerfile: TinfoilVibeServer/Dockerfile + diff --git a/libhac b/libhac new file mode 160000 index 0000000..f5422bb --- /dev/null +++ b/libhac @@ -0,0 +1 @@ +Subproject commit f5422bb13267a2897f70c209c7cf55af9d7595b6