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