diff --git a/TinfoilVibeServer.sln.DotSettings b/TinfoilVibeServer.sln.DotSettings
index 355a6ee..ba97206 100644
--- a/TinfoilVibeServer.sln.DotSettings
+++ b/TinfoilVibeServer.sln.DotSettings
@@ -1,3 +1,4 @@
+ IP
NSP
PFS
\ No newline at end of file
diff --git a/TinfoilVibeServer.sln.DotSettings.user b/TinfoilVibeServer.sln.DotSettings.user
index dc418dc..103bd5f 100644
--- a/TinfoilVibeServer.sln.DotSettings.user
+++ b/TinfoilVibeServer.sln.DotSettings.user
@@ -3,28 +3,52 @@
True
ForceIncluded
ForceIncluded
+ ForceIncluded
+ ForceIncluded
ForceIncluded
ForceIncluded
+ ForceIncluded
+ ForceIncluded
ForceIncluded
ForceIncluded
ForceIncluded
ForceIncluded
ForceIncluded
ForceIncluded
+ ForceIncluded
+ ForceIncluded
ForceIncluded
ForceIncluded
ForceIncluded
ForceIncluded
+ ForceIncluded
+ ForceIncluded
ForceIncluded
+ ForceIncluded
+ ForceIncluded
+ ForceIncluded
ForceIncluded
ForceIncluded
+ ForceIncluded
+ ForceIncluded
ForceIncluded
ForceIncluded
ForceIncluded
+ ForceIncluded
+ ForceIncluded
+ ForceIncluded
ForceIncluded
ForceIncluded
ForceIncluded
+ ForceIncluded
ForceIncluded
<AssemblyExplorer>
<Assembly Path="D:\Cloud\Git\TinfoilVibeServer\TinfoilVibeServer\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll" />
-</AssemblyExplorer>
\ No newline at end of file
+</AssemblyExplorer>
+
+ <SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
+ <Solution />
+</SessionState>
+ <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
+ <Solution />
+</SessionState>
\ No newline at end of file
diff --git a/TinfoilVibeServer/Authentication/AuthStore.cs b/TinfoilVibeServer/Authentication/AuthStore.cs
index 2fd3518..b58b59e 100644
--- a/TinfoilVibeServer/Authentication/AuthStore.cs
+++ b/TinfoilVibeServer/Authentication/AuthStore.cs
@@ -1,53 +1,55 @@
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
+using System.Collections.Concurrent;
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;
+using TinfoilVibeServer.Services;
+using TinfoilVibeServer.Utilities;
namespace TinfoilVibeServer.Authentication;
+public interface IAuthStore
+{
+ void Dispose();
+ bool TryValidate(string username,
+ string password,
+ int? uid,
+ string ip,
+ out string? error);
+
+ int IncrementFailed(string username, string ip);
+ bool IsIPBlacklisted(string ipAddress);
+}
+
///
/// Holds authentication configuration and runtime state.
/// It watches credentials.json for changes and updates the in‑memory
/// user list (including the Verified flag) on the fly.
///
-public sealed class AuthStore : IDisposable
+public class AuthStore : IDisposable, IAuthStore
{
private readonly ILogger _logger;
- public readonly AuthSettings Settings;
+ private readonly ConfigManager _configManager;
public readonly ConcurrentDictionary Credentials = new();
public readonly ConcurrentDictionary> Fingerprints = new();
public readonly ConcurrentDictionary FailedAttempts = new();
- public readonly HashSet BlacklistIPs = new();
+ private readonly HashSet BlacklistIPs = new();
private readonly object _sync = new();
private readonly FileSystemWatcher _credentialsWatcher;
- public AuthStore(ILogger logger)
+ public AuthStore(ILogger logger, ConfigManager configManager)
{
_logger = logger;
- Settings = new AuthSettings(
- "credentials.json",
- "fingerprints.json",
- "blacklist.json",
- 5
- );
-
+ _configManager = configManager;
+
LoadAll();
- var directoryName = Path.GetDirectoryName(Settings.CredentialsFile);
+ var directoryName = Path.GetDirectoryName(_configManager.Settings.CredentialsFile);
_credentialsWatcher = new FileSystemWatcher
{
Path = (!string.IsNullOrEmpty(directoryName))?directoryName : AppContext.BaseDirectory,
- Filter = Path.GetFileName(Settings.CredentialsFile),
+ Filter = Path.GetFileName(_configManager.Settings.CredentialsFile),
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.Attributes
};
_credentialsWatcher.Changed += (_, _) => OnCredentialsChanged();
@@ -63,33 +65,45 @@ public sealed class AuthStore : IDisposable
private void LoadAll()
{
- _logger.LogInformation("Loading authentication data from {File}", Settings.CredentialsFile);
+ _logger.LogInformation("Loading authentication data from {File}", _configManager.Settings.CredentialsFile);
// credentials
- if (File.Exists(Settings.CredentialsFile))
+ if (File.Exists(_configManager.Settings.CredentialsFile))
{
- var txt = File.ReadAllText(Settings.CredentialsFile);
+ var txt = File.ReadAllText(_configManager.Settings.CredentialsFile);
var dict = JsonSerializer.Deserialize>(txt)!;
foreach (var kv in dict)
Credentials[kv.Key] = kv.Value;
}
+ else
+ {
+ FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.CredentialsFile)));
+ }
// fingerprints
- if (File.Exists(Settings.FingerprintsFile))
+ if (File.Exists(_configManager.Settings.FingerprintsFile))
{
- var txt = File.ReadAllText(Settings.FingerprintsFile);
+ var txt = File.ReadAllText(_configManager.Settings.FingerprintsFile);
var dict = JsonSerializer.Deserialize>>(txt)!;
foreach (var kv in dict)
Fingerprints[kv.Key] = kv.Value;
}
+ else
+ {
+ FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.FingerprintsFile)));
+ }
// blacklist
- if (File.Exists(Settings.BlacklistFile))
+ if (File.Exists(_configManager.Settings.BlacklistFile))
{
- var txt = File.ReadAllText(Settings.BlacklistFile);
+ var txt = File.ReadAllText(_configManager.Settings.BlacklistFile);
var arr = JsonSerializer.Deserialize(txt)!;
foreach (var ip in arr)
BlacklistIPs.Add(ip);
}
+ else
+ {
+ FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.BlacklistFile)));
+ }
_logger.LogInformation("Loaded {UserCount} users, {FpCount} fingerprints, {IpCount} IPs",
Credentials.Count, Fingerprints.Count, BlacklistIPs.Count);
}
@@ -110,15 +124,15 @@ public sealed class AuthStore : IDisposable
private void ReloadCredentials()
{
- if (!File.Exists(Settings.CredentialsFile))
+ if (!File.Exists(_configManager.Settings.CredentialsFile))
{
- _logger.LogError("Credentials file {File} does not exist", Settings.CredentialsFile);
+ _logger.LogError("Credentials file {File} does not exist", _configManager.Settings.CredentialsFile);
return;
}
try
{
- var txt = File.ReadAllText(Settings.CredentialsFile);
+ var txt = File.ReadAllText(_configManager.Settings.CredentialsFile);
var newDict = JsonSerializer.Deserialize>(txt)!;
lock (_sync)
@@ -150,7 +164,7 @@ public sealed class AuthStore : IDisposable
}
catch(Exception ex)
{
- _logger.LogError(ex, "Failed to reload credentials from {File}", Settings.CredentialsFile);
+ _logger.LogError(ex, "Failed to reload credentials from {File}", _configManager.Settings.CredentialsFile);
// ignore – malformed JSON or IO error – keep old state
}
}
@@ -159,8 +173,6 @@ public sealed class AuthStore : IDisposable
#region Authentication logic
- public bool IsBlacklisted(string ip) => BlacklistIPs.Contains(ip);
-
public bool TryValidate(string username,
string password,
int? uid,
@@ -231,18 +243,25 @@ public sealed class AuthStore : IDisposable
}
}
- private void IncrementFailed(string username, string ip)
+ public int IncrementFailed(string username, string ip)
{
var newCount = FailedAttempts.GetOrAdd(username, 0) + 1;
- FailedAttempts[username] = newCount;
+ lock (_sync)
+ {
+ FailedAttempts[username] = newCount;
+ }
_logger.LogInformation("Failed attempts for {Username} increased to {Count}", username, newCount);
- if (newCount < Settings.MaxFailedAttempts) return;
+ if (newCount < _configManager.Settings.MaxFailedAttempts+1) return newCount;
BlacklistIPs.Add(ip);
PersistBlacklist();
- FailedAttempts[username] = 0;
+ lock (_sync)
+ {
+ FailedAttempts[username] = 0;
+ }
_logger.LogWarning("IP {IP} blacklisted after {Count} failures", ip, newCount);
+ return newCount;
}
#endregion
@@ -262,19 +281,37 @@ public sealed class AuthStore : IDisposable
private void PersistCredentials()
{
var json = JsonSerializer.Serialize(Credentials, new JsonSerializerOptions { WriteIndented = true });
- File.WriteAllText(Settings.CredentialsFile, json);
+ File.WriteAllText(_configManager.Settings.CredentialsFile, json);
}
private void PersistFingerprints()
{
var json = JsonSerializer.Serialize(Fingerprints, new JsonSerializerOptions { WriteIndented = true });
- File.WriteAllText(Settings.FingerprintsFile, json);
+ File.WriteAllText(_configManager.Settings.FingerprintsFile, json);
}
private void PersistBlacklist()
{
var json = JsonSerializer.Serialize(BlacklistIPs.ToArray(), new JsonSerializerOptions { WriteIndented = true });
- File.WriteAllText(Settings.BlacklistFile, json);
+ File.WriteAllText(_configManager.Settings.BlacklistFile, json);
+ }
+
+ #endregion
+
+ #region Blacklist helpers
+ public bool IsIPBlacklisted(string ipAddress)
+ {
+ return BlacklistIPs.Contains(ipAddress);
+ }
+
+ public bool UnbanIp(string ipAddress)
+ {
+ return BlacklistIPs.Remove(ipAddress);
+ }
+
+ public bool BlacklistActive()
+ {
+ return BlacklistIPs.Count > 0;
}
#endregion
diff --git a/TinfoilVibeServer/Controllers/IndexController.cs b/TinfoilVibeServer/Controllers/IndexController.cs
index c4d6301..f8c6387 100644
--- a/TinfoilVibeServer/Controllers/IndexController.cs
+++ b/TinfoilVibeServer/Controllers/IndexController.cs
@@ -37,6 +37,10 @@ public sealed class IndexController : ControllerBase
///
public IActionResult Index()
{
+ if (HttpContext.Request.Headers.CacheControl == "no-cache")
+ {
+ _snapshotService.RebuildSnapshot();
+ }
var index = _indexBuilderService.Build();
return Ok(index);
@@ -69,7 +73,7 @@ public sealed class IndexController : ControllerBase
var titleId = match.Groups["id"].Value.ToUpperInvariant();
// ---- 2️⃣ Find the file that contains this TitleId ------------
- var entry = _snapshotService.GetSnapshot()
+ var entry = _snapshotService.GetSnapshot().Files
.FirstOrDefault(e => e.Title?.TitleId == titleId);
if (entry == null)
diff --git a/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs b/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs
index 337c663..a0f245c 100644
--- a/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs
+++ b/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs
@@ -15,13 +15,13 @@ public sealed class BasicAuthMiddleware
_next = next;
}
- public async Task InvokeAsync(HttpContext context, AuthStore store, ILogger logger)
+ public async Task InvokeAsync(HttpContext context, IAuthStore store, ILogger logger)
{
logger.LogInformation("Incoming request from {IP} – {Method} {Path}", context.Connection.RemoteIpAddress, context.Request.Method, context.Request.Path);
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
// 1) IP blacklist
- if (store.IsBlacklisted(ip))
+ if (store.IsIPBlacklisted(ip))
{
logger.LogWarning("Blocked request from blacklisted IP {IP}", ip);
context.Response.StatusCode = StatusCodes.Status403Forbidden;
diff --git a/TinfoilVibeServer/Models/AppSettings.cs b/TinfoilVibeServer/Models/AppSettings.cs
index 1d61982..d4bfd6d 100644
--- a/TinfoilVibeServer/Models/AppSettings.cs
+++ b/TinfoilVibeServer/Models/AppSettings.cs
@@ -7,8 +7,6 @@ public sealed record AppSettings(
string[] RootDirectories,
string[] WhitelistExtensions,
string[] RomExtensions,
- string SnapshotFile,
- string SnapshotBackupFile,
string CredentialsFile,
string FingerprintsFile,
string BlacklistFile,
diff --git a/TinfoilVibeServer/Models/SnapshotOptions.cs b/TinfoilVibeServer/Models/SnapshotOptions.cs
new file mode 100644
index 0000000..417060a
--- /dev/null
+++ b/TinfoilVibeServer/Models/SnapshotOptions.cs
@@ -0,0 +1,88 @@
+using System.ComponentModel;
+
+namespace TinfoilVibeServer.Models;
+
+public sealed class SnapshotOptions : INotifyPropertyChanged
+{
+ private List _rootDirectories = new();
+ public List RootDirectories
+ {
+ get => _rootDirectories;
+ set
+ {
+ if (_rootDirectories != value)
+ {
+ _rootDirectories = value;
+ OnPropertyChanged(nameof(RootDirectories));
+ }
+ }
+ }
+ private List _whitelistExtensions = new();
+ public List WhitelistExtensions
+ {
+ get => _whitelistExtensions;
+ set
+ {
+ if (_whitelistExtensions != value)
+ {
+ _whitelistExtensions = value;
+ OnPropertyChanged(nameof(_whitelistExtensions));
+ }
+ }
+ }
+ private List _romExtensions = new();
+ public List RomExtensions
+ {
+ get => _romExtensions;
+ set
+ {
+ if (_romExtensions != value)
+ {
+ _romExtensions = value;
+ OnPropertyChanged(nameof(_romExtensions));
+ }
+ }
+ }
+ private TimeSpan _cacheTtl = TimeSpan.FromHours(1);
+ public TimeSpan CacheTtl
+ {
+ get => _cacheTtl;
+ set
+ {
+ if (_cacheTtl != value)
+ {
+ _cacheTtl = value;
+ OnPropertyChanged(nameof(CacheTtl));
+ }
+ }
+ }
+
+ private string _snapshotFile = "snapshot.json";
+
+ public string SnapshotFile
+ {
+ get => _snapshotFile;
+ set
+ {
+ if (string.Equals(_snapshotFile,value, StringComparison.InvariantCultureIgnoreCase)) return;
+ _snapshotFile = value;
+ OnPropertyChanged(nameof(SnapshotFile));
+ }
+ }
+
+ private string _snapshotBackupFile = "snapshot.bak";
+ public string SnapshotBackupFile
+ {
+ get => _snapshotBackupFile;
+ set
+ {
+ if (string.Equals(_snapshotBackupFile,value, StringComparison.InvariantCultureIgnoreCase)) return;
+ _snapshotBackupFile = value;
+ OnPropertyChanged(nameof(SnapshotBackupFile));
+ }
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ private void OnPropertyChanged(string propertyName) =>
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Program.cs b/TinfoilVibeServer/Program.cs
index a801d7a..b2196ba 100644
--- a/TinfoilVibeServer/Program.cs
+++ b/TinfoilVibeServer/Program.cs
@@ -1,3 +1,4 @@
+using Microsoft.Extensions.Options;
using TinfoilVibeServer.Authentication;
using TinfoilVibeServer.Middleware;
using TinfoilVibeServer.Services;
@@ -13,19 +14,20 @@ builder.Logging.AddDebug();
// -------------------------------------------------------------------
builder.Services.AddMemoryCache();
builder.Services.Configure(builder.Configuration.GetSection("TitleDb"));
+builder.Services.Configure(builder.Configuration.GetSection("AuthSettings"));
+builder.Services.Configure(builder.Configuration.GetSection("Snapshot"));
builder.Services.AddSingleton();
-builder.Services.AddSingleton(sp =>
+builder.Services.AddSingleton(sp =>
{
var config = sp.GetRequiredService();
- var logger = sp.GetRequiredService>();
+ var logger = sp.GetRequiredService>();
var keySet = KeySetHolder.KeySet; // already loaded by ConfigManager
return new NSPExtractor(keySet, logger);
});
builder.Services.AddSingleton();
-builder.Services.AddSingleton(sp =>
- new AuthStore(sp.GetRequiredService>()));
+builder.Services.AddSingleton();
builder.Services.AddSingleton();
-builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddHostedService(provider => provider.GetRequiredService()).AddHttpClient();
builder.Services.AddHostedService(provider => provider.GetRequiredService());
@@ -45,9 +47,9 @@ app.MapControllers(); // routes the /index.json & /download endpoints
app.MapGet("/debug", () => new SnapshotService(
- app.Services.GetRequiredService(),
- app.Services.GetRequiredService(),
- app.Services.GetRequiredService(),
+ app.Services.GetRequiredService>(),
+ app.Services.GetRequiredService(),
+ app.Services.GetRequiredService(),
app.Services.GetRequiredService>())
.GetSnapshot());
app.Lifetime.ApplicationStarted.Register(() =>
diff --git a/TinfoilVibeServer/Services/ArchiveHandler.cs b/TinfoilVibeServer/Services/ArchiveHandler.cs
index 4202f7f..434c0d0 100644
--- a/TinfoilVibeServer/Services/ArchiveHandler.cs
+++ b/TinfoilVibeServer/Services/ArchiveHandler.cs
@@ -8,17 +8,25 @@ using ZipArchive = SharpCompress.Archives.Zip.ZipArchive;
namespace TinfoilVibeServer.Services;
+public interface IArchiveHandler
+{
+ ///
+ /// Return TitleInfo if an embedded Nintendo archive is found; otherwise null.
+ ///
+ NcaMetadataWithHash? TryExtractTitleInfo(string filePath);
+}
+
///
/// Tries to open a file as an archive and look for an embedded NSP/XCI.
/// The extractor is injected so that the hash of the first stream can be accessed
/// while the file is being read.
///
-public sealed class ArchiveHandler
+public sealed class ArchiveHandler : IArchiveHandler
{
- private readonly NSPExtractor _nspExtractor;
+ private readonly INSPExtractor _nspExtractor;
private readonly ILogger _logger;
- public ArchiveHandler(NSPExtractor nspExtractor, ILogger logger)
+ public ArchiveHandler(INSPExtractor nspExtractor, ILogger logger)
{
_nspExtractor = nspExtractor;
_logger = logger;
diff --git a/TinfoilVibeServer/Services/ConfigManager.cs b/TinfoilVibeServer/Services/ConfigManager.cs
index 54ed4d2..9d75d05 100644
--- a/TinfoilVibeServer/Services/ConfigManager.cs
+++ b/TinfoilVibeServer/Services/ConfigManager.cs
@@ -9,7 +9,7 @@ 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 class ConfigManager
{
public AppSettings Settings { get; private set; }
@@ -42,8 +42,6 @@ public sealed class ConfigManager
RootDirectories: Array.Empty(),
WhitelistExtensions: Array.Empty(),
RomExtensions: Array.Empty(),
- SnapshotFile: "index.tfl",
- SnapshotBackupFile: "snapshot.bin",
CredentialsFile: "credentials.json",
FingerprintsFile: "fingerprints.json",
BlacklistFile: "blacklist.json",
diff --git a/TinfoilVibeServer/Services/IndexBuilderService.cs b/TinfoilVibeServer/Services/IndexBuilderService.cs
index 26b26d9..9d32d6e 100644
--- a/TinfoilVibeServer/Services/IndexBuilderService.cs
+++ b/TinfoilVibeServer/Services/IndexBuilderService.cs
@@ -41,18 +41,23 @@ public sealed class IndexBuilderService: IHostedService
// 1️⃣ Load cache if it exists
var cached = LoadCache();
var snapshot = _snapshotService.GetSnapshot();
+ if (string.IsNullOrEmpty(snapshot.Hash))
+ {
+ _snapshotService.BuildSnapshot();
+ snapshot = _snapshotService.GetSnapshot();
+ }
// 2️⃣ Re‑build only if the snapshot hash changed
- string snapshotHash = ComputeSnapshotHash(snapshot);
+ string snapshotHash = ComputeSnapshotHash(snapshot.Files);
if (cached != null && cached.SnapshotHash == snapshotHash)
{
_logger.LogInformation("Using cached index (snapshot hash={Hash}", snapshotHash);
return cached.Index;
}
- _logger.LogInformation("Building index (snapshot size={Count})", snapshot.Count);
+ _logger.LogInformation("Building index (snapshot size={Count})", snapshot.Files.Count);
// 3️⃣ Build new index from snapshot entries
- var files = snapshot
+ var files = snapshot.Files
.Where(e => e.Title != null)
.Select(e =>
{
@@ -63,7 +68,11 @@ public sealed class IndexBuilderService: IHostedService
var vProcessed = e.Title.Version * 0x10000;
var patchOrApp = e.Title.ContentMetaType == ContentMetaType.Application ? "Base" : "Update";
- var url = $"{name}[{titleId}][{vProcessed:X}][{patchOrApp}].nsp";
+ if (e.Title.ContentMetaType == ContentMetaType.Patch)
+ {
+ name = _titleDb.TryGetTitle(e.Title.ApplicationTitle, out var appTitle) ? appTitle.Name : "Unknown";
+ }
+ var url = $"{name}[{titleId}][{vProcessed}][{patchOrApp}].nsp";
return new FileDto(url, e.Size);
})
diff --git a/TinfoilVibeServer/Services/NSPExtractor.cs b/TinfoilVibeServer/Services/NSPExtractor.cs
index 5d4ca52..4bd4487 100644
--- a/TinfoilVibeServer/Services/NSPExtractor.cs
+++ b/TinfoilVibeServer/Services/NSPExtractor.cs
@@ -18,15 +18,30 @@ using TinfoilVibeServer.Models;
namespace TinfoilVibeServer.Services
{
+ public interface INSPExtractor
+ {
+ ///
+ /// Public convenience wrapper that opens the file on disk.
+ ///
+ NcaMetadataWithHash? ExtractFromFile(string filePath);
+
+ ///
+ /// Core implementation – works on any seekable stream that contains a full NSP/XCI container.
+ ///
+ NcaMetadataWithHash? ExtractFromStream(Stream stream);
+
+ string ExtractHashFromStream(Stream nspStream);
+ }
+
///
/// Extracts the TitleId, version, type *and* the SHA‑256 of the first NCA stream.
///
- public sealed class NSPExtractor
+ public sealed class NSPExtractor : INSPExtractor
{
private readonly KeySet _keySet;
- private readonly ILogger _logger;
+ private readonly ILogger _logger;
- public NSPExtractor(KeySet keySet, ILogger logger)
+ public NSPExtractor(KeySet keySet, ILogger logger)
{
_keySet = keySet;
_logger = logger;
@@ -84,16 +99,16 @@ namespace TinfoilVibeServer.Services
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(ncaStream);
- var contentMetaType = GetMetaDataType(nca);
+ var (contentMetaType,applicationTitle) = GetMetaDataType(nca);
if (contentMetaType != null)
- return new NcaMetadataWithHash(titleId, version, contentMetaType.Value, BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant());
+ return new NcaMetadataWithHash(titleId, applicationTitle.ToString("X16"), version, contentMetaType.Value, BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant());
}
return null; // no meta NCA found
}
- private static ContentMetaType? GetMetaDataType(Nca nca)
+ private static (ContentMetaType?,ulong) GetMetaDataType(Nca nca)
{
- if (nca.Header.ContentType != NcaContentType.Meta) return null;
+ if (nca.Header.ContentType != NcaContentType.Meta) return (null,0);
using var openFileSystem = nca.OpenFileSystem(0, IntegrityCheckLevel.ErrorOnInvalid);
foreach (var entry in openFileSystem.EnumerateEntries("*.cnmt", SearchOptions.Default))
{
@@ -105,10 +120,11 @@ namespace TinfoilVibeServer.Services
using var asStream = nacpFile.AsStream();
var cnmt = new Cnmt(asStream);
- return cnmt.Type;
+ var applicationTitle = cnmt.ApplicationTitleId;
+ return (cnmt.Type,applicationTitle);
}
- return null;
+ return (null,0);
}
///
/// Quick sanity check that the stream looks like a PFS0 file system.
@@ -186,14 +202,16 @@ namespace TinfoilVibeServer.Services
public sealed class NcaMetadataWithHash
{
public string TitleId { get; }
+ public string ApplicationTitle { get; set; }
public int Version { get; }
public ContentMetaType ContentMetaType { get; set; }
public string Hash { get; }
- public NcaMetadataWithHash(string titleId, int version, ContentMetaType contentMetaType, string hash)
+ public NcaMetadataWithHash(string titleId, string applicationTitle, int version, ContentMetaType contentMetaType, string hash)
{
TitleId = titleId;
+ ApplicationTitle = applicationTitle;
Version = version;
ContentMetaType = contentMetaType;
Hash = hash;
diff --git a/TinfoilVibeServer/Services/SnapshotService.cs b/TinfoilVibeServer/Services/SnapshotService.cs
index d3d4dd3..c787d1b 100644
--- a/TinfoilVibeServer/Services/SnapshotService.cs
+++ b/TinfoilVibeServer/Services/SnapshotService.cs
@@ -1,14 +1,17 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text.Json;
+using Microsoft.Extensions.Options;
using TinfoilVibeServer.Models;
+using TinfoilVibeServer.Utilities;
namespace TinfoilVibeServer.Services;
public interface ISnapshotService
{
event EventHandler SnapshotRebuilt; // raised after a rebuild
void RebuildSnapshot();
- IReadOnlyList GetSnapshot();
+ SnapshotService.ROMSnapshot GetSnapshot();
+ void BuildSnapshot();
}
///
@@ -17,38 +20,54 @@ public interface ISnapshotService
///
public sealed class SnapshotService : IDisposable, ISnapshotService
{
- private readonly ConfigManager _config;
- private readonly NSPExtractor _nspExtractor;
- private readonly ArchiveHandler _archiveHandler;
+ private readonly SnapshotOptions _options;
+ private readonly INSPExtractor _nspExtractor;
+ private readonly IArchiveHandler _archiveHandler;
private readonly ILogger _logger;
private readonly string _jsonPath;
private readonly string _snapshotPath;
private readonly List _watchers = new();
private readonly ConcurrentDictionary _cache = new();
private string? _currentSnapshotHash;
+ private readonly Timer _debounceTimer;
public event EventHandler? SnapshotRebuilt;
- public SnapshotService(ConfigManager config, NSPExtractor nspExtractor, ArchiveHandler archiveHandler, ILogger logger)
+ public SnapshotService(
+ IOptionsMonitor options,
+ INSPExtractor nspExtractor,
+ IArchiveHandler archiveHandler,
+ ILogger logger)
{
- _config = config;
+ _options = options.CurrentValue;
_nspExtractor = nspExtractor;
_archiveHandler = archiveHandler;
_logger = logger;
- _jsonPath = Path.Combine(AppContext.BaseDirectory, _config.Settings.SnapshotFile);
- _snapshotPath = Path.Combine(AppContext.BaseDirectory, _config.Settings.SnapshotBackupFile);
-
+ _jsonPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotFile);
+ FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(_jsonPath));
+ if (!File.Exists(_jsonPath))
+ {
+ File.WriteAllText(_jsonPath, "[]");
+ }
+ _snapshotPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotBackupFile);
+ FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(_snapshotPath));
+ // 1️⃣ Register for *property* changes
+ _options.PropertyChanged += (s, e) => OnOptionsChanged(e.PropertyName);
+
BuildSnapshot(); // initial scan
File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(GetSnapshot()));
+ _debounceTimer = new Timer(_ => DebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite);
- foreach (var path in _config.Settings.RootDirectories)
+ foreach (var path in _options.RootDirectories)
{
InitializeFileSystemWatcher(path);
}
-
-
- _config.OnChange += cfg =>
+ }
+ // --------- Private helpers ---------
+ private void OnOptionsChanged(string propertyName)
+ {
+ if (propertyName == nameof(SnapshotOptions.RootDirectories))
{
- var fileSystemWatchers = _watchers.Where(watcher => !cfg.RootDirectories.Contains(watcher.Path));
+ var fileSystemWatchers = _watchers.Where(watcher => !_options.RootDirectories.Contains(watcher.Path));
foreach (var watcher in fileSystemWatchers)
{
watcher.EnableRaisingEvents = false;
@@ -56,7 +75,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
_watchers.Remove(watcher);
}
- var newWatchedDirectories = cfg.RootDirectories.Where(newWatchedDirectory =>
+ var newWatchedDirectories = _options.RootDirectories.Where(newWatchedDirectory =>
!_watchers.Any(watcher =>
string.Equals(watcher.Path, newWatchedDirectory, StringComparison.OrdinalIgnoreCase)));
@@ -65,11 +84,11 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
InitializeFileSystemWatcher(newWatchedDirectory);
}
+
BuildSnapshot(); // rebuild everything
PersistSnapshot();
- };
+ }
}
-
private void InitializeFileSystemWatcher(string path)
{
if (!Directory.Exists(path)) return;
@@ -96,42 +115,65 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
private void ThrottleSnapshotUpdate(FileSystemEventArgs fileSystemEventArgs)
{
- Task.Run(async () =>
+ lock (_lock)
+ {
+ _debounceTimer.Change(_debounceMs, Timeout.Infinite); // reset the timer
+ _logger.LogDebug("File system event {EventType} on {Path} at {Time}", fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath, DateTime.Now.ToString("HH:mm:ss"));
+ }
+ /*Task.Run(async () =>
{
await Task.Delay(250);
_logger.LogDebug("File system event {EventType} on {Path}", fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath);
UpdateSnapshot();
- });
+ });*/
+ }
+
+ private readonly object _lock = new object();
+ private int _debounceMs = 200;
+
+ private void DebounceElapsed()
+ {
+ UpdateSnapshot();
}
#endregion
#region Snapshot logic
- private void BuildSnapshot()
+ public void BuildSnapshot()
{
- var cfg = _config.Settings;
- _logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", cfg.RootDirectories.Length);
- var entries = new List();
var index = LoadSnapshotIndex();
-
+ var latestModifiedUtcParallel = FileSystemExtensions.GetLatestModifiedUtcParallel(_options.RootDirectories);
+ var fileInfo = new FileInfo(_snapshotPath);
+ if (latestModifiedUtcParallel.HasValue && latestModifiedUtcParallel.Value < fileInfo.LastWriteTimeUtc)
+ {
+ _logger.LogInformation("Snapshot is up to date");
+ return;
+ }
+ _logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", _options.RootDirectories.Count);
+ var entries = new List();
+
var snapshotChanged = false;
- foreach (var dir in cfg.RootDirectories)
+ foreach (var dir in _options.RootDirectories)
{
if (!Directory.Exists(dir)) continue;
foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
{
var ext = Path.GetExtension(file).ToLowerInvariant();
- if (!(cfg.WhitelistExtensions.Contains(ext) || cfg.RomExtensions.Contains(ext)))
+ if (!(_options.WhitelistExtensions.Contains(ext) || _options.RomExtensions.Contains(ext)))
continue;
- if (index.ContainsKey(file)) continue;
+ if (index.TryGetValue(file, out var value))
+ {
+ entries.Add(value);
+ continue;
+ }
// 3) extract title if applicable
string hash;
NcaMetadataWithHash? title = null;
- if (cfg.RomExtensions.Contains(ext))
+ if (_options.RomExtensions.Contains(ext))
{
using var nspStream = File.OpenRead(file);
hash = ComputeFirstStreamHash(nspStream);
@@ -170,6 +212,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries));
if (snapshotChanged)
{
+ _logger.LogInformation("Snapshot rebuilt");
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
}
}
@@ -183,14 +226,14 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
private void PersistSnapshot()
{
- var entries = GetSnapshot();
- var newHash = ComputeSnapshotHash(entries);
+ var snapshot = GetSnapshot();
+ var newHash = ComputeSnapshotHash(snapshot.Files);
if (_currentSnapshotHash != newHash)
{
_logger.LogInformation("Snapshot hash changed – persisting new snapshot");
_currentSnapshotHash = newHash;
- File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries));
- File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(entries));
+ File.WriteAllText(_jsonPath, JsonSerializer.Serialize(snapshot.Files));
+ File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(snapshot.Files));
}
}
@@ -219,10 +262,18 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
}
#endregion
- public IReadOnlyList GetSnapshot()
+ public ROMSnapshot GetSnapshot()
{
+ if (!File.Exists(_jsonPath)) return new();
var json = File.ReadAllText(_jsonPath);
- return JsonSerializer.Deserialize>(json, new JsonSerializerOptions(){IncludeFields = true})!;
+ var hash = ComputeHash(_jsonPath);
+ var romSnapshot = new ROMSnapshot()
+ {
+ Hash = hash,
+ Files = JsonSerializer.Deserialize>(json,
+ new JsonSerializerOptions() { IncludeFields = true })!
+ };
+ return romSnapshot;
}
public void RebuildSnapshot()
@@ -285,4 +336,10 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
var hash = sha256.ComputeHash(stream);
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
+
+ public class ROMSnapshot
+ {
+ public string Hash { get; set; }
+ public IReadOnlyList Files { get; set; } = new List();
+ }
}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Services/TitleDatabaseService.cs b/TinfoilVibeServer/Services/TitleDatabaseService.cs
index 0c08766..6194130 100644
--- a/TinfoilVibeServer/Services/TitleDatabaseService.cs
+++ b/TinfoilVibeServer/Services/TitleDatabaseService.cs
@@ -24,7 +24,7 @@ public sealed class TitleDatabaseService : IHostedService
private readonly IOptionsMonitor _options;
private readonly ILogger _logger;
private readonly IHttpClientFactory _httpFactory;
- private readonly NSPExtractor _nspExtractor;
+ private readonly INSPExtractor _nspExtractor;
private readonly string _cacheFolder; // Where the JSON is cached.
private readonly List _rootDirectories; // directories that contain game files
@@ -62,7 +62,7 @@ public sealed class TitleDatabaseService : IHostedService
ILogger logger,
ISnapshotService snapshotService,
IHttpClientFactory httpFactory,
- NSPExtractor nspExtractor,
+ INSPExtractor nspExtractor,
IMemoryCache cache)
{
_options = options;
diff --git a/TinfoilVibeServer/TinfoilVibeServer.csproj b/TinfoilVibeServer/TinfoilVibeServer.csproj
index fe4bf34..5740f29 100644
--- a/TinfoilVibeServer/TinfoilVibeServer.csproj
+++ b/TinfoilVibeServer/TinfoilVibeServer.csproj
@@ -9,7 +9,7 @@
-
+
diff --git a/TinfoilVibeServer/Utilities/FileSystemExtensions.cs b/TinfoilVibeServer/Utilities/FileSystemExtensions.cs
new file mode 100644
index 0000000..457b56c
--- /dev/null
+++ b/TinfoilVibeServer/Utilities/FileSystemExtensions.cs
@@ -0,0 +1,124 @@
+namespace TinfoilVibeServer.Utilities;
+
+public static class FileSystemExtensions
+{
+ ///
+ /// Returns the most recent last‑write time (UTC) of any file under the supplied
+ /// root directories, traversing all sub‑directories. If no files are found,
+ /// null is returned.
+ ///
+ ///
+ /// A collection of absolute paths that must point to existing directories.
+ /// Paths that do not exist or are inaccessible are silently skipped.
+ ///
+ ///
+ /// The UTC of the newest file, or null if there are none.
+ ///
+ public static DateTime? GetLatestModifiedUtc(IEnumerable rootDirectories)
+ {
+ if (rootDirectories == null) throw new ArgumentNullException(nameof(rootDirectories));
+
+ // We keep a mutable variable because we don't want to materialise the entire
+ // sequence into memory.
+ DateTime? latest = null;
+
+ foreach (var root in rootDirectories)
+ {
+ if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
+ continue; // skip bad paths
+
+ try
+ {
+ // Enumerate lazily and process each file as soon as it’s yielded.
+ foreach (var filePath in Directory.EnumerateFiles(
+ root,
+ "*",
+ SearchOption.AllDirectories))
+ {
+ try
+ {
+ // Using FileSystemInfo to fetch only the property we need.
+ var fsi = new FileInfo(filePath);
+ var lastWrite = fsi.LastWriteTimeUtc;
+
+ if (!latest.HasValue || lastWrite > latest.Value)
+ latest = lastWrite;
+ }
+ catch (FileNotFoundException) // file vanished while we were enumerating
+ {
+ // ignore and keep going
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // file exists but we can’t read its attributes – skip it
+ }
+ }
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // the root directory itself is inaccessible – skip it
+ }
+ }
+
+ return latest;
+ }
+
+ ///
+ /// Parallelised version that may be faster on very large directory trees.
+ ///
+ public static DateTime? GetLatestModifiedUtcParallel(IEnumerable rootDirectories)
+ {
+ if (rootDirectories == null) throw new ArgumentNullException(nameof(rootDirectories));
+
+ // Flatten all file paths into a single stream first (this is the only
+ // part that needs to be thread‑safe).
+ var allFiles = rootDirectories
+ .Where(r => !string.IsNullOrWhiteSpace(r) && Directory.Exists(r))
+ .SelectMany(r => Directory.EnumerateFiles(r, "*", SearchOption.AllDirectories))
+ .ToArray(); // materialise once, then parallelise
+
+ // Now fetch the dates in parallel. The LINQ overload of Max() that takes
+ // an async selector is not available, so we just use Parallel.ForEach.
+ DateTime? latest = null;
+ var lockObj = new object();
+
+ Parallel.ForEach(allFiles, filePath =>
+ {
+ try
+ {
+ var lastWrite = new FileInfo(filePath).LastWriteTimeUtc;
+ lock (lockObj)
+ {
+ if (!latest.HasValue || lastWrite > latest.Value)
+ latest = lastWrite;
+ }
+ }
+ catch (Exception)
+ {
+ // swallow all exceptions – the caller only cares about the max date
+ }
+ });
+
+ return latest;
+ }
+
+ ///
+ /// Creates the directory (and all missing parent directories) if it does not already exist.
+ ///
+ /// Absolute or relative path to the directory to create.
+ /// Thrown if is null.
+ /// Thrown if is empty or contains only whitespace.
+ /// Thrown if the caller does not have permission.
+ /// Thrown if a file exists at the target path or the directory cannot be created.
+ public static void EnsureDirectoryExists(string path)
+ {
+ if (path is null)
+ throw new ArgumentNullException(nameof(path));
+
+ if (string.IsNullOrWhiteSpace(path))
+ throw new ArgumentException("Path must not be empty or whitespace.", nameof(path));
+
+ // Directory.CreateDirectory is already idempotent – it only creates missing parts.
+ Directory.CreateDirectory(path);
+ }
+}
diff --git a/TinfoilVibeServer/appsettings.json b/TinfoilVibeServer/appsettings.json
index 3e7e63a..da1fc0d 100644
--- a/TinfoilVibeServer/appsettings.json
+++ b/TinfoilVibeServer/appsettings.json
@@ -7,16 +7,19 @@
},
"AllowedHosts": "*",
- "RootDirectories": [ "\\\\NAS\\Roms\\Switch", "Z:\\imgs\\roms\\Switch" ],
- "WhitelistExtensions": [ ".bin", ".jpg", ".png", ".txt" ],
- "RomExtensions": [ ".xci", ".nsp", ".xcz" ],
- "SnapshotFile": "index.tfl",
- "SnapshotBackupFile": "snapshot.bin",
+ "KeySetFile": "prod.keys",
"CredentialsFile": "credentials.json",
"FingerprintsFile": "fingerprints.json",
"BlacklistFile": "blacklist.json",
"MaxFailedAttempts": 5,
- "KeySetFile": "prod.keys",
+ "Snapshot" : {
+ "RootDirectories": [ "\\\\NAS\\Roms\\Switch", "Z:\\imgs\\roms\\Switch" ],
+ "WhitelistExtensions": [ ".bin", ".jpg", ".png", ".txt" ],
+ "RomExtensions": [ ".xci", ".nsp", ".xcz" ],
+ "CacheTtl": 60,
+ "SnapshotFile": "index.tfl",
+ "SnapshotBackupFile": "snapshot.bin"
+ },
"TitleDb": {
"CountryCode": "AU",
"Language": "en",
diff --git a/TinfoilVibeServerTest/Tests/AuthStoreTests.cs b/TinfoilVibeServerTest/Tests/AuthStoreTests.cs
new file mode 100644
index 0000000..9633b35
--- /dev/null
+++ b/TinfoilVibeServerTest/Tests/AuthStoreTests.cs
@@ -0,0 +1,111 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Moq;
+using TinfoilVibeServer.Authentication;
+using TinfoilVibeServer.Services;
+
+// <-- adjust namespace
+
+namespace TinfoilVibeServerTest.Tests
+{
+ [TestFixture]
+ public class AuthStoreTests
+ {
+ private Mock> _loggerMock;
+ private AuthStore _authStore;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _loggerMock = new Mock>();
+ // Assume Settings is static and can be patched for tests
+ MockConfigManager = new Mock();
+ _authStore = new AuthStore(_loggerMock.Object, MockConfigManager.Object);
+ }
+
+ public Mock MockConfigManager { get; set; }
+
+ [TearDown]
+ public void TearDown()
+ {
+ _authStore.Dispose();
+ }
+
+ [Test]
+ public void LoadAll_ShouldPopulateCollections()
+ {
+ // Act
+ var users = _authStore.Credentials.Count;
+ var fprs = _authStore.Fingerprints.Count;
+
+ // Assert
+ Assert.That(users, Is.GreaterThan(0), "At least one user must be loaded");
+ Assert.That(fprs, Is.GreaterThanOrEqualTo(0));
+
+ _loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Information,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString().Contains("Loaded")),
+ null,
+ (Func)It.IsAny