Compiles but runs strange
This commit is contained in:
@@ -7,6 +7,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinfoilVibeServer", "TinfoilVibeServer\TinfoilVibeServer.csproj", "{DE992FDB-6D13-4152-925D-29D39A23FB75}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinfoilVibeServer", "TinfoilVibeServer\TinfoilVibeServer.csproj", "{DE992FDB-6D13-4152-925D-29D39A23FB75}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinfoilVibeServerTest", "TinfoilVibeServerTest\TinfoilVibeServerTest.csproj", "{E0A5CACD-E3F9-4420-AA14-4C447CCF430A}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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}.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.ActiveCfg = Release|Any CPU
|
||||||
{DE992FDB-6D13-4152-925D-29D39A23FB75}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||||
|
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=NSP/@EntryIndexedValue">NSP</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=PFS/@EntryIndexedValue">PFS</s:String></wpf:ResourceDictionary>
|
||||||
@@ -1,3 +1,22 @@
|
|||||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||||
<s:Boolean x:Key="/Default/AddReferences/RecentPaths/=D_003A_005CCloud_005CGit_005CLibHac_005Csrc_005CLibHac_005Cbin_005CRelease_005Cnet8_002E0_005CLibHac_002Edll/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/AddReferences/RecentPaths/=D_003A_005CCloud_005CGit_005CLibHac_005Csrc_005CLibHac_005Cbin_005CRelease_005Cnet8_002E0_005CLibHac_002Edll/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/AddReferences/RecentPaths/=D_003A_005CCloud_005CGit_005CTinfoilVibeServer_005CTinfoilVibeServer_005Clibhac_005Csrc_005CLibHac_005Cbin_005CRelease_005Cnet8_002E0_005CLibHac_002Edll/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
<s:Boolean x:Key="/Default/AddReferences/RecentPaths/=D_003A_005CCloud_005CGit_005CTinfoilVibeServer_005CTinfoilVibeServer_005Clibhac_005Csrc_005CLibHac_005Cbin_005CRelease_005Cnet8_002E0_005CLibHac_002Edll/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAbstractWritableArchive_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F22ddbc5388b085f3bf3bfcf15c58a6938df55f53cc27a762c1e0d3e974da87_003FAbstractWritableArchive_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAppContext_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F574233347f5bab7d6529a8c4744fb8a213de24439a69fd5c4316ea962144_003FAppContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExceptionDispatchInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbd1d5c50194fea68ff3559c160230b0ab50f5acf4ce3061bffd6d62958e2182_003FExceptionDispatchInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F85a5735906a3a06f39a1422a28e0353e3e317f2d923dcc5731fef07dd436f9_003FFileInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemWatcher_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F771dac726bcbf51f1839c45bef2a78c4d834c4bc5420482d9cc3c38eb97535_003FFileSystemWatcher_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemWatcher_002EWin32_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F52fdc6a26ac6d933b95a413e4fb7bc90d22adb6b85734e5eb08036ab03a_003FFileSystemWatcher_002EWin32_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFile_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F3f31e7e8aa33de883c2ccfa62a9c81bfc246c36e825b489476f9472032e512_003FFile_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpResponseMessage_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F4cfeb8b377bc81e1fbb5f7d7a02492cb6ac23e88c8c9d7155944f0716f3d4b_003FHttpResponseMessage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonSerializerOptions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F5ef7f3c9db445621211faddebc3c1c9bb48a942f1b8cba4caa2501466f85f_003FJsonSerializerOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonSerializer_002EWrite_002EString_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbd49cb1d7ce3d716547551bbff70eb2084ebe04c491a927531363631f3e46330_003FJsonSerializer_002EWrite_002EString_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APath_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fcf5011822fd54235e86fdc54ee3baa876b4876e5549223ffeadca5607e59f6af_003FPath_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProviderServiceExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F621526284d3e4868b98948aff942206525763e57e194a27623b66c384466_003FServiceProviderServiceExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProvider_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fce37be1a06b16c6faa02038d2cc477dd3bca5b217ceeb41c5f2ad45c1bf9_003FServiceProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASharpSevenZipExtractor_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fb6d4f34f86579010d492ef5548eca93df749a4e4634ba9c3dee6fbd6c6d74_003FSharpSevenZipExtractor_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002ESerialization_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6f4af9413b11943bfdd164b58f2c3faa476fb6ffee3bb2d63ea7882b139c1_003FThrowHelper_002ESerialization_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AZipArchive_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F39ececcf144e1f9c884152723ed93931cd232485eaf2824bf5beb526f1f321b_003FZipArchive_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/Environment/AssemblyExplorer/XmlDocument/@EntryValue"><AssemblyExplorer>
|
||||||
|
<Assembly Path="D:\Cloud\Git\TinfoilVibeServer\TinfoilVibeServer\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll" />
|
||||||
|
</AssemblyExplorer></s:String></wpf:ResourceDictionary>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace TinfoilVibeServer.Authentication;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Settings for AuthStore loaded from appsettings.json.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AuthSettings(
|
||||||
|
string CredentialsFile,
|
||||||
|
string FingerprintsFile,
|
||||||
|
string BlacklistFile,
|
||||||
|
int MaxFailedAttempts);
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AuthStore : IDisposable
|
||||||
|
{
|
||||||
|
public readonly AuthSettings Settings;
|
||||||
|
|
||||||
|
public readonly ConcurrentDictionary<string, Credential> Credentials = new();
|
||||||
|
public readonly ConcurrentDictionary<string, List<int>> Fingerprints = new();
|
||||||
|
public readonly ConcurrentDictionary<string, int> FailedAttempts = new();
|
||||||
|
public readonly HashSet<string> 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<Dictionary<string, Credential>>(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<Dictionary<string, List<int>>>(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<string[]>(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<Dictionary<string, Credential>>(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<int>());
|
||||||
|
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<int>());
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace TinfoilVibeServer.Authentication;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User credential record.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads the JSON config file and raises an event whenever it changes.
|
|
||||||
/// </summary>
|
|
||||||
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<AppSettings>? 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<AppSettings>(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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// POCO that matches appsettings.json.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record AppSettings(
|
|
||||||
string[] RootDirectories,
|
|
||||||
string[] WhitelistExtensions,
|
|
||||||
string[] RomExtensions,
|
|
||||||
string SnapshotFile,
|
|
||||||
string SnapshotBackupFile,
|
|
||||||
int ArchiveBufferSize
|
|
||||||
);
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GET /index.json – returns the structure you asked for.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("index.json")]
|
||||||
|
public IActionResult GetIndex()
|
||||||
|
{
|
||||||
|
var index = new IndexBuilderService(
|
||||||
|
_snapshotService, _titleDb, _configuration,
|
||||||
|
this.HttpContext.RequestServices.GetService(typeof(ILogger<IndexBuilderService>)) as Microsoft.Extensions.Logging.ILogger<IndexBuilderService>)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
return Ok(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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”.
|
||||||
|
/// </summary>
|
||||||
|
[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,
|
||||||
|
@"\[(?<name>.*?)\]\[(?<id>[0-9a-fA-F]{8}[0-9a-fA-F]{8})\]\[(?<v>[0-9a-fA-F]+)\]\[(?<app>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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Very light‑weight helper – decides whether the file path
|
||||||
|
/// represents a file inside an archive.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +1,28 @@
|
|||||||
|
using System.Text;
|
||||||
using Microsoft.AspNetCore.Http;
|
using TinfoilVibeServer.Authentication;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using System.Text;
|
|
||||||
using TinfoilVibeServer.Services;
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Middleware;
|
namespace TinfoilVibeServer.Middleware;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal Basic‑Auth middleware that also checks UID, failure counters and a blacklist.
|
||||||
|
/// </summary>
|
||||||
public sealed class BasicAuthMiddleware
|
public sealed class BasicAuthMiddleware
|
||||||
{
|
{
|
||||||
private readonly RequestDelegate _next;
|
private readonly RequestDelegate _next;
|
||||||
private readonly AuthStore _store;
|
|
||||||
private readonly ILogger<BasicAuthMiddleware> _logger;
|
|
||||||
|
|
||||||
public BasicAuthMiddleware(RequestDelegate next, AuthStore store, ILogger<BasicAuthMiddleware> logger)
|
public BasicAuthMiddleware(RequestDelegate next)
|
||||||
{
|
{
|
||||||
_next = next;
|
_next = next;
|
||||||
_store = store;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task InvokeAsync(HttpContext context)
|
public async Task InvokeAsync(HttpContext context, AuthStore store, ILogger<BasicAuthMiddleware> logger)
|
||||||
{
|
{
|
||||||
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
|
|
||||||
// 1) IP blacklist
|
// 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;
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||||
await context.Response.WriteAsync("Forbidden");
|
await context.Response.WriteAsync("Forbidden");
|
||||||
return;
|
return;
|
||||||
@@ -77,9 +73,9 @@ public sealed class BasicAuthMiddleware
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4) Validate
|
// 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.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
context.Response.Headers.Add("WWW-Authenticate", "Basic realm=\"FileSnapshot\"");
|
context.Response.Headers.Add("WWW-Authenticate", "Basic realm=\"FileSnapshot\"");
|
||||||
await context.Response.WriteAsync(error ?? "Unauthorized");
|
await context.Response.WriteAsync(error ?? "Unauthorized");
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace TinfoilVibeServer.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Top‑level configuration – maps directly to appsettings.json.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AppSettings(
|
||||||
|
string[] RootDirectories,
|
||||||
|
string[] WhitelistExtensions,
|
||||||
|
string[] RomExtensions,
|
||||||
|
string SnapshotFile,
|
||||||
|
string SnapshotBackupFile,
|
||||||
|
string CredentialsFile,
|
||||||
|
string FingerprintsFile,
|
||||||
|
string BlacklistFile,
|
||||||
|
int MaxFailedAttempts,
|
||||||
|
string KeySetFile
|
||||||
|
);
|
||||||
@@ -6,6 +6,6 @@
|
|||||||
public sealed record FileEntry(
|
public sealed record FileEntry(
|
||||||
string Path,
|
string Path,
|
||||||
long Size,
|
long Size,
|
||||||
string Hash, // SHA‑256 hex
|
string Hash, // SHA‑256 hex
|
||||||
TitleInfo? Title // null unless file is an NSP/XCI or an archive containing one
|
NcaMetadataDto? Title // null unless file is an NSP/XCI or an archive containing one
|
||||||
);
|
);
|
||||||
@@ -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<byte> bytes)
|
||||||
|
{
|
||||||
|
return bytes.Aggregate("", (current, b) => current + b.ToString("X2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ToStrId(this Span<byte> bytes)
|
||||||
|
{
|
||||||
|
return bytes.ToArray().ToStrId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToIdFromConvertedNumBytes(IEnumerable<byte> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace TinfoilVibeServer.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The JSON object that will be returned for the “index” route.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record IndexDto(
|
||||||
|
List<FileDto> Files,
|
||||||
|
List<string> Directories,
|
||||||
|
string Success);
|
||||||
|
|
||||||
|
public sealed record FileDto(
|
||||||
|
string Url,
|
||||||
|
long Size);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using LibHac.Common.Keys;
|
||||||
|
|
||||||
|
namespace TinfoilVibeServer.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A tiny static holder that contains the KeySet loaded from disk.
|
||||||
|
/// All parts of the application that need keys just read this property.
|
||||||
|
/// </summary>
|
||||||
|
public static class KeySetHolder
|
||||||
|
{
|
||||||
|
public static KeySet KeySet { get; set; } = new KeySet();
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace TinfoilVibeServer.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO that is returned by the extractor.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
);
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
namespace TinfoilVibeServer.Models;
|
namespace TinfoilVibeServer.Models;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Metadata extracted from a NSP/XCI archive.
|
/// Metadata extracted from a Nintendo NSP/XCI archive.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record TitleInfo(
|
public sealed record TitleInfo(
|
||||||
string TitleId, // e.g. 0004000000000000
|
string TitleId, // e.g. 0004000000000000
|
||||||
string Name, // title name
|
string Name, // title name
|
||||||
string Version, // e.g. 1.02
|
string Version, // e.g. 1.02
|
||||||
bool IsApplication // true for applications, false for patches
|
bool IsApplication // true for applications, false for patches
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace TinfoilVibeServer.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="TitleId"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record TitleInfoDto(
|
||||||
|
string TitleId, // 16‑digit hex – “0004000000000000”
|
||||||
|
string Name,
|
||||||
|
string Id,
|
||||||
|
DateTime ReleaseDate,
|
||||||
|
string NSUID,
|
||||||
|
string Version);
|
||||||
@@ -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.Middleware;
|
||||||
using TinfoilVibeServer.Services;
|
using TinfoilVibeServer.Services;
|
||||||
|
using TinfoilVibeServer.Models;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// -----------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// 1. Register AuthStore as a singleton
|
// 1) Configuration – read appsettings.json once and expose it via
|
||||||
// -----------------------------------------------------
|
// ConfigManager (reloads on file change)
|
||||||
builder.Services.AddSingleton<AuthStore>();
|
// -------------------------------------------------------------------
|
||||||
|
builder.Services.AddSingleton<ConfigManager>();
|
||||||
// -----------------------------------------------------
|
|
||||||
// 2. Snapshot + other services (unchanged)
|
|
||||||
// -----------------------------------------------------
|
|
||||||
builder.Services.AddSingleton<SnapshotService>();
|
builder.Services.AddSingleton<SnapshotService>();
|
||||||
// … any other services you already have
|
builder.Services.AddSingleton<AuthStore>();
|
||||||
|
builder.Services.AddSingleton<TitleDatabaseService>();
|
||||||
|
builder.Services.AddHostedService<TitleDatabaseService>(provider => provider.GetRequiredService<TitleDatabaseService>()).AddHttpClient();
|
||||||
|
builder.Services.AddControllers(); // add MVC
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// 2) Middleware – Basic‑Auth (verifies username, password, UID, blacklist)
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// -----------------------------------------------------
|
|
||||||
// 3. Apply authentication middleware *before* the
|
|
||||||
// snapshot endpoints. This guarantees all routes
|
|
||||||
// are protected.
|
|
||||||
// -----------------------------------------------------
|
|
||||||
app.UseMiddleware<BasicAuthMiddleware>();
|
app.UseMiddleware<BasicAuthMiddleware>();
|
||||||
|
app.MapControllers(); // routes the /index.json & /download endpoints
|
||||||
|
|
||||||
// -----------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// 4. Existing endpoints – unchanged
|
// 3) End‑points
|
||||||
// -----------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
app.MapGet("/", () => Results.Redirect("/index.tfl"));
|
|
||||||
app.MapGet("/index.tfl", async context =>
|
|
||||||
{
|
app.MapGet("/debug", () => new SnapshotService(app.Services.GetRequiredService<ConfigManager>())
|
||||||
var jsonPath = Path.Combine(AppContext.BaseDirectory, "index.tfl");
|
.GetSnapshot());
|
||||||
context.Response.ContentType = "application/json";
|
|
||||||
await context.Response.WriteAsync(await File.ReadAllTextAsync(jsonPath));
|
|
||||||
});
|
|
||||||
app.MapGet("/debug", () => new SnapshotService(builder.Configuration).GetSnapshot());
|
|
||||||
app.MapGet("/stream/{*relativePath}", async context =>
|
|
||||||
{
|
|
||||||
// … (unchanged streaming logic – same as before)
|
|
||||||
});
|
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
@@ -1,26 +1,24 @@
|
|||||||
|
using System.IO;
|
||||||
using System.IO.Compression;
|
|
||||||
using FileSnapshot;
|
|
||||||
using SharpCompress.Archives;
|
using SharpCompress.Archives;
|
||||||
using SharpCompress.Archives.Zip;
|
using SharpCompress.Archives.Zip;
|
||||||
using SharpCompress.Archives.Rar;
|
|
||||||
using SharpCompress.Archives.SevenZip;
|
using SharpCompress.Archives.SevenZip;
|
||||||
using SharpCompress.Readers;
|
using SharpCompress.Archives.Rar;
|
||||||
using TinfoilVibeServer.Models;
|
using TinfoilVibeServer.Models;
|
||||||
using ZipArchive = SharpCompress.Archives.Zip.ZipArchive;
|
using SharpSevenZip;
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Services;
|
namespace TinfoilVibeServer.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tries to open a file as an archive and look for an embedded NSP/XCI.
|
/// Tries to open an archive and look for an embedded NSP/XCI entry.
|
||||||
/// The goal is to be as memory‑friendly as possible – never load a whole archive into RAM.
|
/// The implementation streams the entry directly into LibHac, avoiding any
|
||||||
|
/// temporary files on disk.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ArchiveHandler
|
public sealed class ArchiveHandler
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Return TitleInfo if an embedded Nintendo archive is found; otherwise null.
|
/// Returns TitleInfo if an embedded Nintendo archive is found; otherwise null.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static TitleInfo? TryExtractTitleInfo(string filePath)
|
public static NcaMetadataDto? TryExtractTitleInfo(string filePath)
|
||||||
{
|
{
|
||||||
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||||||
|
|
||||||
@@ -40,48 +38,41 @@ public sealed class ArchiveHandler
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Graceful fallback – return null
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TitleInfo? HandleZip(string path)
|
private static NcaMetadataDto? HandleZip(string path)
|
||||||
{
|
{
|
||||||
using var archive = ZipArchive.Open(path);
|
using var archive = ZipArchive.Open(path);
|
||||||
foreach (var entry in archive.Entries)
|
foreach (var entry in archive.Entries)
|
||||||
{
|
{
|
||||||
if (!entry.IsDirectory && IsRomArchive(entry.Key))
|
if (!entry.IsDirectory && IsRomArchive(entry.Key))
|
||||||
{
|
{
|
||||||
var temp = Path.GetTempFileName();
|
using var src = entry.OpenEntryStream(); // already seekable
|
||||||
entry.WriteToFile(temp);
|
return NSPExtactor.ExtractFromStream(src);
|
||||||
var title = NSPExtactor.ExtractFromFile(temp);
|
|
||||||
File.Delete(temp);
|
|
||||||
return title;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TitleInfo? Handle7z(string path)
|
private static NcaMetadataDto? Handle7z(string path)
|
||||||
{
|
{
|
||||||
using var archive = SevenZipArchive.Open(path);
|
using var archive = SevenZipArchive.Open(path);
|
||||||
foreach (var entry in archive.Entries)
|
foreach (var entry in archive.Entries)
|
||||||
{
|
{
|
||||||
if (!entry.IsDirectory && IsRomArchive(entry.Key))
|
if (!entry.IsDirectory && IsRomArchive(entry.Key))
|
||||||
{
|
{
|
||||||
var temp = Path.GetTempFileName();
|
using var src = entry.OpenEntryStream(); // not seekable
|
||||||
entry.WriteToFile(temp);
|
var seekable = MakeSeekable(src);
|
||||||
var title = NSPExtactor.ExtractFromFile(temp);
|
return NSPExtactor.ExtractFromStream(seekable);
|
||||||
File.Delete(temp);
|
|
||||||
return title;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
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
|
try
|
||||||
{
|
{
|
||||||
using var archive = RarArchive.Open(path);
|
using var archive = RarArchive.Open(path);
|
||||||
@@ -89,39 +80,59 @@ public sealed class ArchiveHandler
|
|||||||
{
|
{
|
||||||
if (!entry.IsDirectory && IsRomArchive(entry.Key))
|
if (!entry.IsDirectory && IsRomArchive(entry.Key))
|
||||||
{
|
{
|
||||||
var temp = Path.GetTempFileName();
|
using var src = entry.OpenEntryStream(); // not seekable
|
||||||
entry.WriteToFile(temp);
|
var seekable = MakeSeekable(src);
|
||||||
var title = NSPExtactor.ExtractFromFile(temp);
|
return NSPExtactor.ExtractFromStream(seekable);
|
||||||
File.Delete(temp);
|
|
||||||
return title;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
catch (SharpCompress.Common.ArchiveException)
|
catch (SharpCompress.Common.ExtractionException)
|
||||||
{
|
{
|
||||||
// Fallback to SharpSevenZip for RAR5
|
// ---------- RAR5 fallback (SharpSevenZip) ----------
|
||||||
/*using var stream = File.OpenRead(path);
|
// We decompress the entire archive into a MemoryStream
|
||||||
Stream outStream = new Mem;
|
// and then feed that stream into SevenZipExtractor.
|
||||||
using var extractor = SharpSevenZip.SharpSevenZipExtractor.DecompressStream(stream, outStream);
|
|
||||||
while (extractor.MoveToNextEntry())
|
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();
|
var ms = new MemoryStream(); // extract a single entry
|
||||||
extractor.ExtractFile(temp);
|
extractor.ExtractFile(extractor.FileName, ms);
|
||||||
var title = NSPExtactor.ExtractFromFile(temp);
|
ms.Position = 0;
|
||||||
File.Delete(temp);
|
return NSPExtactor.ExtractFromStream(ms);
|
||||||
return title;
|
|
||||||
}
|
}
|
||||||
}*/
|
}
|
||||||
return null;
|
|
||||||
|
return null; // nothing found
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsRomArchive(string entryName)
|
/// <summary>
|
||||||
|
/// Turn a non‑seekable stream into a seekable one by buffering it into memory.
|
||||||
|
/// </summary>
|
||||||
|
private static Stream MakeSeekable(Stream nonSeekable)
|
||||||
{
|
{
|
||||||
var ext = Path.GetExtension(entryName).ToLowerInvariant();
|
if (nonSeekable.CanSeek)
|
||||||
return ext is ".xci" or ".nsp" or ".xcz";
|
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";
|
||||||
}
|
}
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Configuration section used by the auth system.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// One user record – stored in *credentials.json*.
|
|
||||||
/// </summary>
|
|
||||||
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
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Thread‑safe singleton that keeps the authentication state in memory
|
|
||||||
/// and writes it back to disk whenever it changes.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class AuthStore
|
|
||||||
{
|
|
||||||
private readonly AuthSettings _settings;
|
|
||||||
private readonly object _sync = new();
|
|
||||||
|
|
||||||
// In‑memory state
|
|
||||||
public ConcurrentDictionary<string, Credential> Credentials { get; } = new();
|
|
||||||
public ConcurrentDictionary<string, List<int>> Fingerprints { get; } = new();
|
|
||||||
public ConcurrentDictionary<string, int> FailedAttempts { get; } = new();
|
|
||||||
public HashSet<string> BlacklistIPs { get; } = new();
|
|
||||||
|
|
||||||
public AuthStore(IConfiguration config)
|
|
||||||
{
|
|
||||||
_settings = new AuthSettings
|
|
||||||
{
|
|
||||||
CredentialsFile = config.GetValue<string>("Authentication:CredentialsFile") ?? "credentials.json",
|
|
||||||
FingerprintsFile = config.GetValue<string>("Authentication:FingerprintsFile") ?? "fingerprints.json",
|
|
||||||
BlacklistFile = config.GetValue<string>("Authentication:BlacklistFile") ?? "blacklist.json",
|
|
||||||
MaxFailedAttempts = config.GetValue<int>("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<Dictionary<string, Credential>>(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<Dictionary<string, List<int>>>(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<string[]>(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);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates username/password/UID, updates fingerprints and blacklists as needed.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>true if the user is authenticated; otherwise false.</returns>
|
|
||||||
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<int>());
|
|
||||||
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<int>());
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LibHac.Common.Keys;
|
||||||
|
using TinfoilVibeServer.Models;
|
||||||
|
|
||||||
|
namespace TinfoilVibeServer.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the JSON config file on startup, watches it for changes, and also
|
||||||
|
/// loads the KeySet from the file specified in the config.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ConfigManager
|
||||||
|
{
|
||||||
|
public AppSettings Settings { get; private set; }
|
||||||
|
|
||||||
|
public event Action<AppSettings>? 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<string>(),
|
||||||
|
WhitelistExtensions: Array.Empty<string>(),
|
||||||
|
RomExtensions: Array.Empty<string>(),
|
||||||
|
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<AppSettings>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the <see cref="IndexDto"/> from the current snapshot
|
||||||
|
/// and reads the “Directories” & “SuccessMessage” values from
|
||||||
|
/// configuration.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class IndexBuilderService
|
||||||
|
{
|
||||||
|
private readonly SnapshotService _snapshotService;
|
||||||
|
private readonly TitleDatabaseService _titleDb;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly ILogger<IndexBuilderService> _logger;
|
||||||
|
|
||||||
|
public IndexBuilderService(SnapshotService snapshotService,
|
||||||
|
TitleDatabaseService titleDb,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILogger<IndexBuilderService> logger)
|
||||||
|
{
|
||||||
|
_snapshotService = snapshotService;
|
||||||
|
_titleDb = titleDb;
|
||||||
|
_configuration = configuration;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the IndexDto that is sent to the client.
|
||||||
|
/// </summary>
|
||||||
|
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<string[]>() ?? Array.Empty<string>();
|
||||||
|
|
||||||
|
var success = _configuration["SuccessMessage"] ?? string.Empty;
|
||||||
|
|
||||||
|
return new IndexDto(files, directories.ToList(), success);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,65 +1,107 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text.Json;
|
using System.Linq;
|
||||||
using LibHac.Fs;
|
|
||||||
using LibHac.FsSystem;
|
|
||||||
using LibHac.FsSystem.Impl;
|
|
||||||
using LibHac.Util;
|
|
||||||
using TinfoilVibeServer.Models;
|
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;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class NSPExtactor
|
public sealed class NSPExtactor
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Return TitleInfo for the file, or null if the file is not a valid Nintendo archive.
|
/// Convenience overload – read the NSP/XCI from disk.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static TitleInfo? ExtractFromFile(string filePath)
|
public static NcaMetadataDto? ExtractFromFile(string filePath)
|
||||||
|
{
|
||||||
|
using var stream = File.OpenRead(filePath);
|
||||||
|
return ExtractFromStream(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Core implementation – works on any seekable stream that contains a
|
||||||
|
/// full NSP/XCI container.
|
||||||
|
/// </summary>
|
||||||
|
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<IFile>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check that the stream looks like a PFS0 file system.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsPFSFileSystem(Stream stream)
|
||||||
{
|
{
|
||||||
// LibHac works with byte streams. We open the file once and hand the stream to RomArchiveReader.
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
if (!stream.CanSeek) return false;
|
||||||
using var reader = new RomArchiveReader(fs, new RomArchiveSettings { UseCache = false });
|
stream.Seek(0, SeekOrigin.Begin);
|
||||||
|
|
||||||
if (!reader.IsValid)
|
var storage = new StreamStorage(stream, false);
|
||||||
return null; // Not an NSP/XCI
|
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.
|
return true;
|
||||||
// LibHac exposes the *content* list – we pick the first NCA that is a title.
|
|
||||||
foreach (var nca in reader.GetContentInfos())
|
|
||||||
{
|
|
||||||
// NcaId.Type gives Application / Patch / DLC etc.
|
|
||||||
// We only care that the type is not null – the NCA itself contains the metadata we need.
|
|
||||||
var meta = nca.GetMetaData();
|
|
||||||
|
|
||||||
// 1) Title ID
|
|
||||||
string titleId = nca.Id.ToString("X16");
|
|
||||||
|
|
||||||
// 2) Name and version
|
|
||||||
// 0.20.x provides a simple string accessor
|
|
||||||
string? titleName = meta.GetStringValue("title");
|
|
||||||
string? versionStr = meta.GetStringValue("version");
|
|
||||||
|
|
||||||
// 3) Determine if it is an application
|
|
||||||
bool isApp = meta.GetStringValue("content_type") == "Application";
|
|
||||||
|
|
||||||
return new TitleInfo(
|
|
||||||
titleId,
|
|
||||||
titleName ?? $"Unknown ({titleId})",
|
|
||||||
versionStr ?? "0.00",
|
|
||||||
isApp);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null; // No NCA found
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Any exception (bad file, invalid archive, etc.) -> treat as non‑NXP
|
return false;
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using FileSnapshot;
|
|
||||||
using TinfoilVibeServer.Models;
|
using TinfoilVibeServer.Models;
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Services;
|
namespace TinfoilVibeServer.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Keeps an in‑memory snapshot, watches the filesystem for changes, and
|
/// Keeps an in‑memory snapshot, watches the filesystem for changes, and
|
||||||
/// only re‑processes a file if its hash changed. The snapshot is
|
/// only re‑processes a file if its hash changed.
|
||||||
/// automatically re‑generated when the configuration changes.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SnapshotService : IDisposable
|
public sealed class SnapshotService : IDisposable
|
||||||
{
|
{
|
||||||
@@ -17,67 +15,47 @@ public sealed class SnapshotService : IDisposable
|
|||||||
private readonly string _jsonPath;
|
private readonly string _jsonPath;
|
||||||
private readonly string _snapshotPath;
|
private readonly string _snapshotPath;
|
||||||
private readonly FileSystemWatcher _watcher;
|
private readonly FileSystemWatcher _watcher;
|
||||||
|
|
||||||
// path -> CachedFile
|
|
||||||
private readonly ConcurrentDictionary<string, CachedFile> _cache = new();
|
private readonly ConcurrentDictionary<string, CachedFile> _cache = new();
|
||||||
|
|
||||||
private string? _currentSnapshotHash;
|
private string? _currentSnapshotHash;
|
||||||
|
|
||||||
public SnapshotService(ConfigManager config)
|
public SnapshotService(ConfigManager config)
|
||||||
{
|
{
|
||||||
_config = config;
|
_config = config;
|
||||||
_jsonPath = Path.Combine(AppContext.BaseDirectory, config.Settings.SnapshotFile);
|
_jsonPath = Path.Combine(AppContext.BaseDirectory, _config.Settings.SnapshotFile);
|
||||||
_snapshotPath = Path.Combine(AppContext.BaseDirectory, config.Settings.SnapshotBackupFile);
|
_snapshotPath = Path.Combine(AppContext.BaseDirectory, _config.Settings.SnapshotBackupFile);
|
||||||
|
|
||||||
// Initial snapshot
|
BuildSnapshot(); // initial scan
|
||||||
BuildSnapshot();
|
|
||||||
|
|
||||||
// Persist a copy for quick load on next run
|
|
||||||
File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(GetSnapshot()));
|
File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(GetSnapshot()));
|
||||||
|
|
||||||
// File system watcher
|
|
||||||
_watcher = new FileSystemWatcher
|
_watcher = new FileSystemWatcher
|
||||||
{
|
{
|
||||||
Path = string.Join(Path.PathSeparator, config.Settings.RootDirectories),
|
Path = string.Join(Path.PathSeparator, _config.Settings.RootDirectories),
|
||||||
IncludeSubdirectories = true,
|
IncludeSubdirectories = true,
|
||||||
NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName |
|
NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName |
|
||||||
NotifyFilters.Size | NotifyFilters.LastWrite
|
NotifyFilters.Size | NotifyFilters.LastWrite
|
||||||
};
|
};
|
||||||
|
|
||||||
_watcher.Created += OnChanged;
|
_watcher.Created += OnChanged;
|
||||||
_watcher.Changed += OnChanged;
|
_watcher.Changed += OnChanged;
|
||||||
_watcher.Deleted += OnChanged;
|
_watcher.Deleted += OnChanged;
|
||||||
_watcher.Renamed += OnRenamed;
|
_watcher.Renamed += OnRenamed;
|
||||||
_watcher.EnableRaisingEvents = true;
|
_watcher.EnableRaisingEvents = true;
|
||||||
|
|
||||||
// React to config changes
|
|
||||||
_config.OnChange += cfg =>
|
_config.OnChange += cfg =>
|
||||||
{
|
{
|
||||||
// Re‑initialise the watcher with the new root directories
|
|
||||||
_watcher.Path = string.Join(Path.PathSeparator, cfg.RootDirectories);
|
_watcher.Path = string.Join(Path.PathSeparator, cfg.RootDirectories);
|
||||||
_watcher.EnableRaisingEvents = true;
|
_watcher.EnableRaisingEvents = true;
|
||||||
BuildSnapshot(); // rebuild everything
|
BuildSnapshot(); // rebuild everything
|
||||||
PersistSnapshot();
|
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) => ThrottleSnapshotUpdate();
|
||||||
private void OnChanged(object? _, FileSystemEventArgs e) =>
|
|
||||||
ThrottleSnapshotUpdate();
|
|
||||||
|
|
||||||
private void OnRenamed(object? _, RenamedEventArgs e)
|
|
||||||
{
|
|
||||||
// Treat rename as delete + create
|
|
||||||
OnChanged(_, new FileSystemEventArgs(WatcherChangeTypes.Deleted, e.OldFullPath, e.OldName));
|
|
||||||
OnChanged(_, new FileSystemEventArgs(WatcherChangeTypes.Created, e.FullPath, e.Name));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ThrottleSnapshotUpdate()
|
private void ThrottleSnapshotUpdate()
|
||||||
{
|
{
|
||||||
// Debounce: only trigger once in a short window
|
|
||||||
Task.Run(async () =>
|
Task.Run(async () =>
|
||||||
{
|
{
|
||||||
await Task.Delay(250);
|
await Task.Delay(250);
|
||||||
@@ -87,9 +65,8 @@ public sealed class SnapshotService : IDisposable
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
/// <summary>
|
#region Snapshot logic
|
||||||
/// Full rebuild – called on start‑up and on config change.
|
|
||||||
/// </summary>
|
|
||||||
private void BuildSnapshot()
|
private void BuildSnapshot()
|
||||||
{
|
{
|
||||||
var cfg = _config.Settings;
|
var cfg = _config.Settings;
|
||||||
@@ -104,58 +81,33 @@ public sealed class SnapshotService : IDisposable
|
|||||||
if (!(cfg.WhitelistExtensions.Contains(ext) || cfg.RomExtensions.Contains(ext)))
|
if (!(cfg.WhitelistExtensions.Contains(ext) || cfg.RomExtensions.Contains(ext)))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// 1) compute hash
|
|
||||||
var hash = ComputeHash(file);
|
var hash = ComputeHash(file);
|
||||||
|
|
||||||
// 2) decide if we need to re‑process
|
// Cache hit?
|
||||||
if (_cache.TryGetValue(file, out var cached) && cached.Hash == hash)
|
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));
|
entries.Add(new FileEntry(file, new FileInfo(file).Length, hash, cached.Title));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) extract title if applicable
|
// Extract title if possible
|
||||||
TitleInfo? title = null;
|
NcaMetadataDto? title = null;
|
||||||
if (cfg.RomExtensions.Contains(ext))
|
if (cfg.RomExtensions.Contains(ext))
|
||||||
{
|
|
||||||
title = NSPExtactor.ExtractFromFile(file);
|
title = NSPExtactor.ExtractFromFile(file);
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
title = ArchiveHandler.TryExtractTitleInfo(file);
|
title = ArchiveHandler.TryExtractTitleInfo(file);
|
||||||
}
|
|
||||||
|
|
||||||
// 4) update cache
|
|
||||||
_cache[file] = new CachedFile(file, hash, title);
|
_cache[file] = new CachedFile(file, hash, title);
|
||||||
|
|
||||||
// 5) add to snapshot
|
|
||||||
entries.Add(new FileEntry(file, new FileInfo(file).Length, hash, title));
|
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);
|
_currentSnapshotHash = ComputeSnapshotHash(entries);
|
||||||
File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries));
|
File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ComputeSnapshotHash(IEnumerable<FileEntry> entries)
|
private void UpdateSnapshot() => BuildSnapshot();
|
||||||
{
|
|
||||||
var json = JsonSerializer.Serialize(entries);
|
|
||||||
using var sha256 = SHA256.Create();
|
|
||||||
return BitConverter.ToString(sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(json))).Replace("-", "").ToLowerInvariant();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateSnapshot()
|
|
||||||
{
|
|
||||||
BuildSnapshot();
|
|
||||||
PersistSnapshot();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void PersistSnapshot()
|
private void PersistSnapshot()
|
||||||
{
|
{
|
||||||
@@ -171,21 +123,35 @@ public sealed class SnapshotService : IDisposable
|
|||||||
|
|
||||||
private static string ComputeHash(string filePath)
|
private static string ComputeHash(string filePath)
|
||||||
{
|
{
|
||||||
using var sha256 = SHA256.Create();
|
using var sha = SHA256.Create();
|
||||||
using var stream = File.OpenRead(filePath);
|
using var stream = File.OpenRead(filePath);
|
||||||
var hash = sha256.ComputeHash(stream);
|
var hash = sha.ComputeHash(stream);
|
||||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ComputeSnapshotHash(IEnumerable<FileEntry> 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<FileEntry> GetSnapshot()
|
public IReadOnlyList<FileEntry> GetSnapshot()
|
||||||
{
|
{
|
||||||
var json = File.ReadAllText(_jsonPath);
|
var json = File.ReadAllText(_jsonPath);
|
||||||
return JsonSerializer.Deserialize<IReadOnlyList<FileEntry>>(json)!;
|
return JsonSerializer.Deserialize<IReadOnlyList<FileEntry>>(json)!;
|
||||||
}
|
}
|
||||||
|
public void RebuildSnapshot()
|
||||||
public void Dispose()
|
|
||||||
{
|
{
|
||||||
_watcher.Dispose();
|
// Build a fresh snapshot and persist it.
|
||||||
_config.Dispose();
|
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);
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// * 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 <c>GetTitleByTitleId</c>).
|
||||||
|
/// </summary>
|
||||||
|
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<TitleDatabaseService> _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<string> _rootDirectories; // directories that contain game files
|
||||||
|
|
||||||
|
// 1️⃣ Cache for the JSON data (key = TitleId)
|
||||||
|
private readonly ConcurrentDictionary<string, TitleInfoDto> _titleData
|
||||||
|
= new();
|
||||||
|
|
||||||
|
// 2️⃣ Reverse lookup: TitleId → real file‑system path
|
||||||
|
private readonly ConcurrentDictionary<string, string> _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
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public TitleDatabaseService(
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILogger<TitleDatabaseService> 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<string>
|
||||||
|
{
|
||||||
|
// 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
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Return the <c>TitleInfoDto</c> for a known <c>TitleId</c>.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryGetTitle(string titleId, out TitleInfoDto? title)
|
||||||
|
=> _titleData.TryGetValue(titleId, out title);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryGetFilePath(string titleId, out string? path)
|
||||||
|
=> _titleIdToPath.TryGetValue(titleId, out path);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convenience helper – look‑up the file path for a TitleId and return
|
||||||
|
/// it as a string. Returns null if the TitleId is unknown.
|
||||||
|
/// </summary>
|
||||||
|
public string? GetFilePathByTitleId(string titleId)
|
||||||
|
=> _titleIdToPath.TryGetValue(titleId, out var p) ? p : null;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Private helpers
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Downloads the JSON file from GitHub (raw) if it does not exist
|
||||||
|
/// or the cached copy is older than the remote one.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read the JSON file and populate <c>_titleData</c>.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scan the configured root directories and create the
|
||||||
|
/// <c>_titleIdToPath</c> map. If the file name does not contain a
|
||||||
|
/// TitleId, we extract it from the NSP file.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -8,11 +8,10 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.6"/>
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.6"/>
|
||||||
<PackageReference Include="SharpCompress" Version="0.41.0" />
|
<PackageReference Include="SharpCompress" Version="0.41.0" />
|
||||||
<PackageReference Include="SharpSevenZip" Version="2.0.32" />
|
<PackageReference Include="SharpSevenZip" Version="2.0.32" />
|
||||||
|
|
||||||
<PackageReference Include="LibHac" Version="0.20.0" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -4,5 +4,6 @@
|
|||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"RootDirectories": [ "D:\\Cloud\\Git\\TinfoilWebServer\\TinfoilWebServer.Test\\data", "Z:\\imgs\\roms\\Switch" ]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,24 @@
|
|||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
|
|
||||||
"RootDirectories": [
|
"RootDirectories": [ "\\\\NAS\\Roms\\Switch", "Z:\\imgs\\roms\\Switch" ],
|
||||||
"\\\\NAS\\Games",
|
"WhitelistExtensions": [ ".bin", ".jpg", ".png", ".txt" ],
|
||||||
"\\\\NAS\\Backups"
|
"RomExtensions": [ ".xci", ".nsp", ".xcz" ],
|
||||||
],
|
|
||||||
"WhitelistExtensions": [
|
|
||||||
".bin", ".jpg", ".png", ".txt"
|
|
||||||
],
|
|
||||||
"RomExtensions": [
|
|
||||||
".xci", ".nsp", ".xcz"
|
|
||||||
],
|
|
||||||
"SnapshotFile": "index.tfl",
|
"SnapshotFile": "index.tfl",
|
||||||
"SnapshotBackupFile": "snapshot.bin",
|
"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!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.2"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="NUnit" Version="4.2.2"/>
|
||||||
|
<PackageReference Include="NUnit.Analyzers" Version="4.4.0"/>
|
||||||
|
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="NUnit.Framework"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace TinfoilVibeServerTest;
|
||||||
|
|
||||||
|
public class Tests
|
||||||
|
{
|
||||||
|
[SetUp]
|
||||||
|
public void Setup()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Test1()
|
||||||
|
{
|
||||||
|
Assert.Pass();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user