first commit
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
**/.dockerignore
|
||||
**/.env
|
||||
**/.git
|
||||
**/.gitignore
|
||||
**/.project
|
||||
**/.settings
|
||||
**/.toolstarget
|
||||
**/.vs
|
||||
**/.vscode
|
||||
**/.idea
|
||||
**/*.*proj.user
|
||||
**/*.dbmdl
|
||||
**/*.jfm
|
||||
**/azds.yaml
|
||||
**/bin
|
||||
**/charts
|
||||
**/docker-compose*
|
||||
**/Dockerfile*
|
||||
**/node_modules
|
||||
**/npm-debug.log
|
||||
**/obj
|
||||
**/secrets.dev.yaml
|
||||
**/values.dev.yaml
|
||||
LICENSE
|
||||
README.md
|
||||
@@ -0,0 +1,5 @@
|
||||
bin/
|
||||
obj/
|
||||
/packages/
|
||||
riderModule.iml
|
||||
/_ReSharper.Caches/
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "libhac"]
|
||||
path = libhac
|
||||
url = https://git.ryujinx.app/ryubing/libhac
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Rider ignored files
|
||||
/modules.xml
|
||||
/contentModel.xml
|
||||
/.idea.TinfoilVibeServer.iml
|
||||
/projectSettingsUpdater.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
|
||||
</project>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="UserContentModel">
|
||||
<attachedFolders />
|
||||
<explicitIncludes />
|
||||
<explicitExcludes />
|
||||
</component>
|
||||
</project>
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,21 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{41999D26-405C-4DAB-8991-CBA992117C84}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
compose.yaml = compose.yaml
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinfoilVibeServer", "TinfoilVibeServer\TinfoilVibeServer.csproj", "{DE992FDB-6D13-4152-925D-29D39A23FB75}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{DE992FDB-6D13-4152-925D-29D39A23FB75}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DE992FDB-6D13-4152-925D-29D39A23FB75}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DE992FDB-6D13-4152-925D-29D39A23FB75}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DE992FDB-6D13-4152-925D-29D39A23FB75}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -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: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>
|
||||
@@ -0,0 +1,70 @@
|
||||
|
||||
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,23 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["TinfoilVibeServer/TinfoilVibeServer.csproj", "TinfoilVibeServer/"]
|
||||
RUN dotnet restore "TinfoilVibeServer/TinfoilVibeServer.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/TinfoilVibeServer"
|
||||
RUN dotnet build "./TinfoilVibeServer.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "./TinfoilVibeServer.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "TinfoilVibeServer.dll"]
|
||||
@@ -0,0 +1,100 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Text;
|
||||
using TinfoilVibeServer.Services;
|
||||
|
||||
namespace TinfoilVibeServer.Middleware;
|
||||
|
||||
public sealed class BasicAuthMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly AuthStore _store;
|
||||
private readonly ILogger<BasicAuthMiddleware> _logger;
|
||||
|
||||
public BasicAuthMiddleware(RequestDelegate next, AuthStore store, ILogger<BasicAuthMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_store = store;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
|
||||
// 1) IP blacklist
|
||||
if (_store.IsBlacklisted(ip))
|
||||
{
|
||||
_logger.LogWarning("Blocked request from blacklisted IP {IP}", ip);
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await context.Response.WriteAsync("Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Authorization header
|
||||
if (!context.Request.Headers.TryGetValue("Authorization", out var authHeaders))
|
||||
{
|
||||
Challenge(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var authHeader = authHeaders.FirstOrDefault() ?? "";
|
||||
if (!authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Challenge(context);
|
||||
return;
|
||||
}
|
||||
|
||||
string decoded;
|
||||
try
|
||||
{
|
||||
var b64 = authHeader[6..].Trim();
|
||||
decoded = Encoding.UTF8.GetString(Convert.FromBase64String(b64));
|
||||
}
|
||||
catch
|
||||
{
|
||||
Challenge(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var parts = decoded.Split(':', 2);
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
Challenge(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var username = parts[0];
|
||||
var password = parts[1];
|
||||
|
||||
// 3) UID header (optional)
|
||||
int? uid = null;
|
||||
if (context.Request.Headers.TryGetValue("UID", out var uidHeader))
|
||||
{
|
||||
if (int.TryParse(uidHeader.ToString(), out var parsedUid))
|
||||
uid = parsedUid;
|
||||
}
|
||||
|
||||
// 4) Validate
|
||||
if (!_store.TryValidate(username, password, uid, ip, out var error))
|
||||
{
|
||||
_logger.LogWarning("Auth failed for user {User} from {IP}: {Error}", username, ip, error);
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
context.Response.Headers.Add("WWW-Authenticate", "Basic realm=\"FileSnapshot\"");
|
||||
await context.Response.WriteAsync(error ?? "Unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
// Authentication succeeded – attach username for downstream handlers if needed
|
||||
context.Items["User"] = username;
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private static void Challenge(HttpContext ctx)
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
ctx.Response.Headers.Add("WWW-Authenticate", "Basic realm=\"FileSnapshot\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace TinfoilVibeServer.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One line in the snapshot – the JSON will be an array of these.
|
||||
/// </summary>
|
||||
public sealed record FileEntry(
|
||||
string Path,
|
||||
long Size,
|
||||
string Hash, // SHA‑256 hex
|
||||
TitleInfo? Title // null unless file is an NSP/XCI or an archive containing one
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace TinfoilVibeServer.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata extracted from a NSP/XCI archive.
|
||||
/// </summary>
|
||||
public sealed record TitleInfo(
|
||||
string TitleId, // e.g. 0004000000000000
|
||||
string Name, // title name
|
||||
string Version, // e.g. 1.02
|
||||
bool IsApplication // true for applications, false for patches
|
||||
);
|
||||
@@ -0,0 +1,42 @@
|
||||
using TinfoilVibeServer.Middleware;
|
||||
using TinfoilVibeServer.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// -----------------------------------------------------
|
||||
// 1. Register AuthStore as a singleton
|
||||
// -----------------------------------------------------
|
||||
builder.Services.AddSingleton<AuthStore>();
|
||||
|
||||
// -----------------------------------------------------
|
||||
// 2. Snapshot + other services (unchanged)
|
||||
// -----------------------------------------------------
|
||||
builder.Services.AddSingleton<SnapshotService>();
|
||||
// … any other services you already have
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// -----------------------------------------------------
|
||||
// 3. Apply authentication middleware *before* the
|
||||
// snapshot endpoints. This guarantees all routes
|
||||
// are protected.
|
||||
// -----------------------------------------------------
|
||||
app.UseMiddleware<BasicAuthMiddleware>();
|
||||
|
||||
// -----------------------------------------------------
|
||||
// 4. Existing endpoints – unchanged
|
||||
// -----------------------------------------------------
|
||||
app.MapGet("/", () => Results.Redirect("/index.tfl"));
|
||||
app.MapGet("/index.tfl", async context =>
|
||||
{
|
||||
var jsonPath = Path.Combine(AppContext.BaseDirectory, "index.tfl");
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsync(await File.ReadAllTextAsync(jsonPath));
|
||||
});
|
||||
app.MapGet("/debug", () => new SnapshotService(builder.Configuration).GetSnapshot());
|
||||
app.MapGet("/stream/{*relativePath}", async context =>
|
||||
{
|
||||
// … (unchanged streaming logic – same as before)
|
||||
});
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5253",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7043;http://localhost:5253",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
|
||||
using System.IO.Compression;
|
||||
using FileSnapshot;
|
||||
using SharpCompress.Archives;
|
||||
using SharpCompress.Archives.Zip;
|
||||
using SharpCompress.Archives.Rar;
|
||||
using SharpCompress.Archives.SevenZip;
|
||||
using SharpCompress.Readers;
|
||||
using TinfoilVibeServer.Models;
|
||||
using ZipArchive = SharpCompress.Archives.Zip.ZipArchive;
|
||||
|
||||
namespace TinfoilVibeServer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tries to open a file as an archive and look for an embedded NSP/XCI.
|
||||
/// The goal is to be as memory‑friendly as possible – never load a whole archive into RAM.
|
||||
/// </summary>
|
||||
public sealed class ArchiveHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Return TitleInfo if an embedded Nintendo archive is found; otherwise null.
|
||||
/// </summary>
|
||||
public static TitleInfo? TryExtractTitleInfo(string filePath)
|
||||
{
|
||||
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
|
||||
try
|
||||
{
|
||||
switch (ext)
|
||||
{
|
||||
case ".zip":
|
||||
return HandleZip(filePath);
|
||||
case ".7z":
|
||||
return Handle7z(filePath);
|
||||
case ".rar":
|
||||
return HandleRar(filePath);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Graceful fallback – return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static TitleInfo? HandleZip(string path)
|
||||
{
|
||||
using var archive = ZipArchive.Open(path);
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
if (!entry.IsDirectory && IsRomArchive(entry.Key))
|
||||
{
|
||||
var temp = Path.GetTempFileName();
|
||||
entry.WriteToFile(temp);
|
||||
var title = NSPExtactor.ExtractFromFile(temp);
|
||||
File.Delete(temp);
|
||||
return title;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static TitleInfo? Handle7z(string path)
|
||||
{
|
||||
using var archive = SevenZipArchive.Open(path);
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
if (!entry.IsDirectory && IsRomArchive(entry.Key))
|
||||
{
|
||||
var temp = Path.GetTempFileName();
|
||||
entry.WriteToFile(temp);
|
||||
var title = NSPExtactor.ExtractFromFile(temp);
|
||||
File.Delete(temp);
|
||||
return title;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static TitleInfo? HandleRar(string path)
|
||||
{
|
||||
// SharpCompress can handle most RAR5 files – fallback to SharpSevenZip if it fails
|
||||
try
|
||||
{
|
||||
using var archive = RarArchive.Open(path);
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
if (!entry.IsDirectory && IsRomArchive(entry.Key))
|
||||
{
|
||||
var temp = Path.GetTempFileName();
|
||||
entry.WriteToFile(temp);
|
||||
var title = NSPExtactor.ExtractFromFile(temp);
|
||||
File.Delete(temp);
|
||||
return title;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
catch (SharpCompress.Common.ArchiveException)
|
||||
{
|
||||
// Fallback to SharpSevenZip for RAR5
|
||||
/*using var stream = File.OpenRead(path);
|
||||
Stream outStream = new Mem;
|
||||
using var extractor = SharpSevenZip.SharpSevenZipExtractor.DecompressStream(stream, outStream);
|
||||
while (extractor.MoveToNextEntry())
|
||||
{
|
||||
if (!extractor.IsDirectory && IsRomArchive(extractor.CurrentFileName))
|
||||
{
|
||||
var temp = Path.GetTempFileName();
|
||||
extractor.ExtractFile(temp);
|
||||
var title = NSPExtactor.ExtractFromFile(temp);
|
||||
File.Delete(temp);
|
||||
return title;
|
||||
}
|
||||
}*/
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsRomArchive(string entryName)
|
||||
{
|
||||
var ext = Path.GetExtension(entryName).ToLowerInvariant();
|
||||
return ext is ".xci" or ".nsp" or ".xcz";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
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,65 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using LibHac.Fs;
|
||||
using LibHac.FsSystem;
|
||||
using LibHac.FsSystem.Impl;
|
||||
using LibHac.Util;
|
||||
using TinfoilVibeServer.Models;
|
||||
|
||||
namespace FileSnapshot;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts title information from a Nintendo NSP/XCI file using LibHac 0.20.0.
|
||||
/// </summary>
|
||||
public sealed class NSPExtactor
|
||||
{
|
||||
/// <summary>
|
||||
/// Return TitleInfo for the file, or null if the file is not a valid Nintendo archive.
|
||||
/// </summary>
|
||||
public static TitleInfo? ExtractFromFile(string filePath)
|
||||
{
|
||||
// LibHac works with byte streams. We open the file once and hand the stream to RomArchiveReader.
|
||||
try
|
||||
{
|
||||
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var reader = new RomArchiveReader(fs, new RomArchiveSettings { UseCache = false });
|
||||
|
||||
if (!reader.IsValid)
|
||||
return null; // Not an NSP/XCI
|
||||
|
||||
// The ROM contains one or more NCA headers. For most cases the first one is the title.
|
||||
// LibHac exposes the *content* list – we pick the first NCA that is a title.
|
||||
foreach (var nca in reader.GetContentInfos())
|
||||
{
|
||||
// NcaId.Type gives Application / Patch / DLC etc.
|
||||
// We only care that the type is not null – the NCA itself contains the metadata we need.
|
||||
var meta = nca.GetMetaData();
|
||||
|
||||
// 1) Title ID
|
||||
string titleId = nca.Id.ToString("X16");
|
||||
|
||||
// 2) Name and version
|
||||
// 0.20.x provides a simple string accessor
|
||||
string? titleName = meta.GetStringValue("title");
|
||||
string? versionStr = meta.GetStringValue("version");
|
||||
|
||||
// 3) Determine if it is an application
|
||||
bool isApp = meta.GetStringValue("content_type") == "Application";
|
||||
|
||||
return new TitleInfo(
|
||||
titleId,
|
||||
titleName ?? $"Unknown ({titleId})",
|
||||
versionStr ?? "0.00",
|
||||
isApp);
|
||||
}
|
||||
|
||||
return null; // No NCA found
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Any exception (bad file, invalid archive, etc.) -> treat as non‑NXP
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using FileSnapshot;
|
||||
using TinfoilVibeServer.Models;
|
||||
|
||||
namespace TinfoilVibeServer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps an in‑memory snapshot, watches the filesystem for changes, and
|
||||
/// only re‑processes a file if its hash changed. The snapshot is
|
||||
/// automatically re‑generated when the configuration changes.
|
||||
/// </summary>
|
||||
public sealed class SnapshotService : IDisposable
|
||||
{
|
||||
private readonly ConfigManager _config;
|
||||
private readonly string _jsonPath;
|
||||
private readonly string _snapshotPath;
|
||||
private readonly FileSystemWatcher _watcher;
|
||||
|
||||
// path -> CachedFile
|
||||
private readonly ConcurrentDictionary<string, CachedFile> _cache = new();
|
||||
|
||||
private string? _currentSnapshotHash;
|
||||
|
||||
public SnapshotService(ConfigManager config)
|
||||
{
|
||||
_config = config;
|
||||
_jsonPath = Path.Combine(AppContext.BaseDirectory, config.Settings.SnapshotFile);
|
||||
_snapshotPath = Path.Combine(AppContext.BaseDirectory, config.Settings.SnapshotBackupFile);
|
||||
|
||||
// Initial snapshot
|
||||
BuildSnapshot();
|
||||
|
||||
// Persist a copy for quick load on next run
|
||||
File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(GetSnapshot()));
|
||||
|
||||
// File system watcher
|
||||
_watcher = new FileSystemWatcher
|
||||
{
|
||||
Path = string.Join(Path.PathSeparator, config.Settings.RootDirectories),
|
||||
IncludeSubdirectories = true,
|
||||
NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName |
|
||||
NotifyFilters.Size | NotifyFilters.LastWrite
|
||||
};
|
||||
|
||||
_watcher.Created += OnChanged;
|
||||
_watcher.Changed += OnChanged;
|
||||
_watcher.Deleted += OnChanged;
|
||||
_watcher.Renamed += OnRenamed;
|
||||
_watcher.EnableRaisingEvents = true;
|
||||
|
||||
// React to config changes
|
||||
_config.OnChange += cfg =>
|
||||
{
|
||||
// Re‑initialise the watcher with the new root directories
|
||||
_watcher.Path = string.Join(Path.PathSeparator, cfg.RootDirectories);
|
||||
_watcher.EnableRaisingEvents = true;
|
||||
BuildSnapshot(); // rebuild everything
|
||||
PersistSnapshot();
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record CachedFile(string Path, string Hash, TitleInfo? Title);
|
||||
|
||||
#region File system change handlers
|
||||
|
||||
private void OnChanged(object? _, FileSystemEventArgs e) =>
|
||||
ThrottleSnapshotUpdate();
|
||||
|
||||
private void OnRenamed(object? _, RenamedEventArgs e)
|
||||
{
|
||||
// Treat rename as delete + create
|
||||
OnChanged(_, new FileSystemEventArgs(WatcherChangeTypes.Deleted, e.OldFullPath, e.OldName));
|
||||
OnChanged(_, new FileSystemEventArgs(WatcherChangeTypes.Created, e.FullPath, e.Name));
|
||||
}
|
||||
|
||||
private void ThrottleSnapshotUpdate()
|
||||
{
|
||||
// Debounce: only trigger once in a short window
|
||||
Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(250);
|
||||
UpdateSnapshot();
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Full rebuild – called on start‑up and on config change.
|
||||
/// </summary>
|
||||
private void BuildSnapshot()
|
||||
{
|
||||
var cfg = _config.Settings;
|
||||
var entries = new List<FileEntry>();
|
||||
|
||||
foreach (var dir in cfg.RootDirectories)
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
||||
|
||||
if (!(cfg.WhitelistExtensions.Contains(ext) || cfg.RomExtensions.Contains(ext)))
|
||||
continue;
|
||||
|
||||
// 1) compute hash
|
||||
var hash = ComputeHash(file);
|
||||
|
||||
// 2) decide if we need to re‑process
|
||||
if (_cache.TryGetValue(file, out var cached) && cached.Hash == hash)
|
||||
{
|
||||
// nothing changed – use cached title info
|
||||
entries.Add(new FileEntry(file, new FileInfo(file).Length, hash, cached.Title));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3) extract title if applicable
|
||||
TitleInfo? title = null;
|
||||
if (cfg.RomExtensions.Contains(ext))
|
||||
{
|
||||
title = NSPExtactor.ExtractFromFile(file);
|
||||
}
|
||||
else
|
||||
{
|
||||
title = ArchiveHandler.TryExtractTitleInfo(file);
|
||||
}
|
||||
|
||||
// 4) update cache
|
||||
_cache[file] = new CachedFile(file, hash, title);
|
||||
|
||||
// 5) add to snapshot
|
||||
entries.Add(new FileEntry(file, new FileInfo(file).Length, hash, title));
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the entire snapshot
|
||||
lock (_cache)
|
||||
{
|
||||
// the snapshot itself is not stored in _cache – it's only used for the JSON
|
||||
}
|
||||
// we keep entries in a local variable for now
|
||||
_currentSnapshotHash = ComputeSnapshotHash(entries);
|
||||
File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries));
|
||||
}
|
||||
|
||||
private static string ComputeSnapshotHash(IEnumerable<FileEntry> entries)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(entries);
|
||||
using var sha256 = SHA256.Create();
|
||||
return BitConverter.ToString(sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(json))).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
|
||||
private void UpdateSnapshot()
|
||||
{
|
||||
BuildSnapshot();
|
||||
PersistSnapshot();
|
||||
}
|
||||
|
||||
private void PersistSnapshot()
|
||||
{
|
||||
var entries = GetSnapshot();
|
||||
var newHash = ComputeSnapshotHash(entries);
|
||||
if (_currentSnapshotHash != newHash)
|
||||
{
|
||||
_currentSnapshotHash = newHash;
|
||||
File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries));
|
||||
File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(entries));
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeHash(string filePath)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
using var stream = File.OpenRead(filePath);
|
||||
var hash = sha256.ComputeHash(stream);
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
|
||||
public IReadOnlyList<FileEntry> GetSnapshot()
|
||||
{
|
||||
var json = File.ReadAllText(_jsonPath);
|
||||
return JsonSerializer.Deserialize<IReadOnlyList<FileEntry>>(json)!;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_watcher.Dispose();
|
||||
_config.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.6"/>
|
||||
<PackageReference Include="SharpCompress" Version="0.41.0" />
|
||||
<PackageReference Include="SharpSevenZip" Version="2.0.32" />
|
||||
|
||||
<PackageReference Include="LibHac" Version="0.20.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\.dockerignore">
|
||||
<Link>.dockerignore</Link>
|
||||
</Content>
|
||||
<Content Update="appsettings.Development.json">
|
||||
<DependentUpon>appsettings.json</DependentUpon>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- libhac ships a native helper called Ryujinx.HLE.HOS.Native.dll.
|
||||
The build script copies it to the output folder automatically. -->
|
||||
<None Update="..\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="LibHac">
|
||||
<HintPath>..\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
@TinfoilVibeServer_HostAddress = http://localhost:5253
|
||||
|
||||
GET {{TinfoilVibeServer_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
|
||||
"RootDirectories": [
|
||||
"\\\\NAS\\Games",
|
||||
"\\\\NAS\\Backups"
|
||||
],
|
||||
"WhitelistExtensions": [
|
||||
".bin", ".jpg", ".png", ".txt"
|
||||
],
|
||||
"RomExtensions": [
|
||||
".xci", ".nsp", ".xcz"
|
||||
],
|
||||
"SnapshotFile": "index.tfl",
|
||||
"SnapshotBackupFile": "snapshot.bin",
|
||||
"ArchiveBufferSize": 8192
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
consoleapp1:
|
||||
image: consoleapp1
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ConsoleApp1/Dockerfile
|
||||
|
||||
tinfoilvibeserver:
|
||||
image: tinfoilvibeserver
|
||||
build:
|
||||
context: .
|
||||
dockerfile: TinfoilVibeServer/Dockerfile
|
||||
|
||||
Submodule
+1
Submodule libhac added at f5422bb132
Reference in New Issue
Block a user