Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53ba636258 | |||
| a25f5f602e | |||
| b9370eb2d5 | |||
| 7c6fba9b3f | |||
| 4eb8324056 | |||
| 6cb78c91fa | |||
| a9184acd23 | |||
| fae1979e04 | |||
| 36f2f0c2f9 | |||
| 35d4eccdfd | |||
| a81f67536f | |||
| ce68860175 | |||
| becc41a5f0 |
@@ -0,0 +1,100 @@
|
|||||||
|
name: Build & Push Docker image
|
||||||
|
|
||||||
|
on:
|
||||||
|
# Trigger on pushes to main or release branches, and on manual workflow dispatch
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- 'release/**'
|
||||||
|
- 'beta/**'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: production
|
||||||
|
steps:
|
||||||
|
- name: Convert to lowercase
|
||||||
|
id: github_repository_to_lowercase
|
||||||
|
run: |
|
||||||
|
# Grab the value (you can also use `${{ github.ref }}`, `${{ secrets.MY_SECRET }}`, etc.)
|
||||||
|
raw_value="${{ github.repository }}"
|
||||||
|
# Convert to lower case
|
||||||
|
lower_value=$(echo "$raw_value" | tr '[:upper:]' '[:lower:]')
|
||||||
|
# Export it to the workflow environment
|
||||||
|
# echo "MY_LOWER=$lower_value" >> $GITHUB_ENV
|
||||||
|
# If you want to use it as an output of this step:
|
||||||
|
echo "lowercase=$lower_value" >> $GITHUB_OUTPUT
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 1. Checkout repository
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- name: Checkout source
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # needed for git rev‑parse and tag generation
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 2. Set up Docker Buildx (optional, but recommended for multi‑arch)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 3. Log in to the Gitea container registry
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- name: Log in to Gitea registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ secrets.REGISTRY_HOST }} # e.g. registry.example.com
|
||||||
|
username: ${{ secrets.REGISTRY_USER }} # e.g. admin
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }} # e.g. <api‑token>
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 4. Build the Docker image
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- name: Build image
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: TinfoilVibeServer/Dockerfile
|
||||||
|
# do not push yet
|
||||||
|
push: false
|
||||||
|
tags: |
|
||||||
|
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.sha }}
|
||||||
|
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.ref_name }}
|
||||||
|
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:latest
|
||||||
|
build-args: |
|
||||||
|
# Add any build args here
|
||||||
|
# ARG_NAME=VALUE
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 5. Push the image to the registry
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- name: Push image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: TinfoilVibeServer/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.sha }}
|
||||||
|
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.ref_name }}
|
||||||
|
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:latest
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 6. (Optional) Clean up local Docker cache
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- name: Docker system prune
|
||||||
|
run: docker system prune -f
|
||||||
|
if: ${{ always() }}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 7. Output useful info
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- name: Show pushed image tags
|
||||||
|
run: |
|
||||||
|
echo "Pushed image tags:"
|
||||||
|
echo "- ${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.sha }}"
|
||||||
|
echo "- ${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.ref_name }}"
|
||||||
|
echo "- ${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:latest"
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
name: ci
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_linux:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout source
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Convert to lowercase
|
||||||
|
id: github_repository_to_lowercase
|
||||||
|
run: |
|
||||||
|
# Grab the value (you can also use `${{ github.ref }}`, `${{ secrets.MY_SECRET }}`, etc.)
|
||||||
|
raw_value="${{ github.repository }}"
|
||||||
|
# Convert to lower case
|
||||||
|
lower_value=$(echo "$raw_value" | tr '[:upper:]' '[:lower:]')
|
||||||
|
# Export it to the workflow environment
|
||||||
|
# echo "MY_LOWER=$lower_value" >> $GITHUB_ENV
|
||||||
|
# If you want to use it as an output of this step:
|
||||||
|
echo "lowercase=$lower_value" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: TinfoilVibeServer/Dockerfile
|
||||||
|
# do not push yet
|
||||||
|
push: false
|
||||||
|
tags: |
|
||||||
|
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.sha }}
|
||||||
|
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.ref_name }}
|
||||||
|
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:latest
|
||||||
|
build-args: |
|
||||||
|
# Add any build args here
|
||||||
|
# ARG_NAME=VALUE
|
||||||
-1
@@ -2,6 +2,5 @@
|
|||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
<mapping directory="$PROJECT_DIR$/libhac" vcs="Git" />
|
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -7,8 +7,6 @@ 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}") = "TinfoilVibeServerTests", "TinfoilVibeServerTests\TinfoilVibeServerTests.csproj", "{EB041FA0-F87C-4F24-9E39-9BAF3D3776D8}"
|
|
||||||
EndProject
|
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -19,9 +17,5 @@ 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
|
||||||
{EB041FA0-F87C-4F24-9E39-9BAF3D3776D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{EB041FA0-F87C-4F24-9E39-9BAF3D3776D8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{EB041FA0-F87C-4F24-9E39-9BAF3D3776D8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{EB041FA0-F87C-4F24-9E39-9BAF3D3776D8}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -1,8 +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">
|
<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>
|
<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:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AContains_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F67987bd98adb31db56e1521f051c645ef14c5c8767f3826e274fb9087354cee_003FContains_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AContentMetaType_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F7df76fe8248d4d00bc92ef3a1d9ce9a0189a00_003F0e_003Fde2efd6d_003FContentMetaType_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHashAlgorithm_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fce7580e8e0f6a2637f8ec8ab41c5fd2f845ad94ea18c76d554db62248d8954_003FHashAlgorithm_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AResult_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F13_003Faf246217_003FResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASafeFileHandle_002EWindows_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6ef8e7d549f6bfb5b9eafaaa0ff48d8cbfc564f32be26323b5f60e63aef4dd1_003FSafeFileHandle_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
// File: TinfoilVibeServer/Config/GameDirectoriesOptions.cs
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Config;
|
|
||||||
|
|
||||||
public sealed class GameDirectoriesOptions
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Paths to scan for Switch ROMs and archives. Values may change at runtime.
|
|
||||||
/// </summary>
|
|
||||||
public IList<string> Paths { get; set; } = new List<string>();
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
// Configuration/AppSettings.cs
|
|
||||||
namespace TinfoilVibeServer.Configuration
|
|
||||||
{
|
|
||||||
public class AppSettings
|
|
||||||
{
|
|
||||||
public List<string> GameDirectories { get; set; } = new();
|
|
||||||
public List<string> ValidExtensions { get; set; } = new() { ".nsp", ".xci", ".zip", ".rar", ".7z" };
|
|
||||||
public string SnapshotPath { get; set; } = "snapshot.json";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Models
|
|
||||||
{
|
|
||||||
public class GameSnapshot
|
|
||||||
{
|
|
||||||
public string TitleId { get; set; } // 16‑hex string
|
|
||||||
public int Version { get; set; }
|
|
||||||
public bool IsApplication { get; set; }
|
|
||||||
public bool IsPatch { get; set; }
|
|
||||||
|
|
||||||
public string FullPath { get; set; } // Path on disk (file or archive)
|
|
||||||
public string InsidePath { get; set; } // Path inside an archive (empty if none)
|
|
||||||
public string FirstNcaHash { get; set; } // SHA‑256 of the first NCA stream
|
|
||||||
|
|
||||||
public DateTime LastModified { get; set; } // For snapshot refresh logic
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
// File: TinfoilVibeServer/Persistence/ISnapshotRepository.cs
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using TinfoilVibeServer.Models;
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Persistence;
|
|
||||||
|
|
||||||
public interface ISnapshotRepository
|
|
||||||
{
|
|
||||||
Task<IReadOnlyDictionary<string, SnapshotEntry>> LoadAsync();
|
|
||||||
Task PersistAsync(IReadOnlyDictionary<string, SnapshotEntry> snapshot);
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
// File: TinfoilVibeServer/Persistence/SnapshotRepository.cs
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using TinfoilVibeServer.Models;
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Persistence;
|
|
||||||
|
|
||||||
public sealed class SnapshotRepository : ISnapshotRepository
|
|
||||||
{
|
|
||||||
private readonly string _path = "snapshot.json";
|
|
||||||
|
|
||||||
public async Task<IReadOnlyDictionary<string, SnapshotEntry>> LoadAsync()
|
|
||||||
{
|
|
||||||
if (!File.Exists(_path))
|
|
||||||
{
|
|
||||||
return new Dictionary<string, SnapshotEntry>();
|
|
||||||
}
|
|
||||||
|
|
||||||
await using var stream = File.OpenRead(_path);
|
|
||||||
var snapshot = await JsonSerializer.DeserializeAsync<Dictionary<string, SnapshotEntry>>(stream,
|
|
||||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
|
||||||
|
|
||||||
return snapshot ?? new Dictionary<string, SnapshotEntry>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task PersistAsync(IReadOnlyDictionary<string, SnapshotEntry> snapshot)
|
|
||||||
{
|
|
||||||
await using var stream = File.Create(_path);
|
|
||||||
await JsonSerializer.SerializeAsync(stream, snapshot,
|
|
||||||
new JsonSerializerOptions { WriteIndented = true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +1,42 @@
|
|||||||
// File: TinfoilVibeServer/Program.cs
|
using TinfoilVibeServer.Middleware;
|
||||||
|
|
||||||
using LibHac.Common.Keys;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using TinfoilVibeServer.Config;
|
|
||||||
using TinfoilVibeServer.Models;
|
|
||||||
using TinfoilVibeServer.Services;
|
using TinfoilVibeServer.Services;
|
||||||
using TinfoilVibeServer.Persistence;
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer;
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
internal static class Program
|
// -----------------------------------------------------
|
||||||
{
|
// 1. Register AuthStore as a singleton
|
||||||
public static void Main(string[] args)
|
// -----------------------------------------------------
|
||||||
{
|
builder.Services.AddSingleton<AuthStore>();
|
||||||
var host = Host.CreateDefaultBuilder(args)
|
|
||||||
.ConfigureAppConfiguration((ctx, cfg) =>
|
|
||||||
{
|
|
||||||
cfg.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
|
|
||||||
cfg.AddEnvironmentVariables();
|
|
||||||
cfg.AddCommandLine(args);
|
|
||||||
})
|
|
||||||
.ConfigureServices((ctx, services) =>
|
|
||||||
{
|
|
||||||
// Configuration POCO
|
|
||||||
services.Configure<GameDirectoriesOptions>(ctx.Configuration.GetSection("GameDirectories"));
|
|
||||||
|
|
||||||
// Snapshot persistence
|
// -----------------------------------------------------
|
||||||
services.AddSingleton<ISnapshotRepository, SnapshotRepository>();
|
// 2. Snapshot + other services (unchanged)
|
||||||
|
// -----------------------------------------------------
|
||||||
|
builder.Services.AddSingleton<SnapshotService>();
|
||||||
|
// … any other services you already have
|
||||||
|
|
||||||
// LibHac parser
|
var app = builder.Build();
|
||||||
services.AddSingleton<ILibHacParser, LibHacParser>();
|
|
||||||
|
|
||||||
// Main service
|
// -----------------------------------------------------
|
||||||
services.AddHostedService<GameDirectoryWatcherService>();
|
// 3. Apply authentication middleware *before* the
|
||||||
KeySetHolder.KeySet = ExternalKeyReader.ReadKeyFile(ctx.Configuration.GetSection("KeySet").Get<string>());
|
// snapshot endpoints. This guarantees all routes
|
||||||
})
|
// are protected.
|
||||||
.ConfigureLogging((ctx, logging) =>
|
// -----------------------------------------------------
|
||||||
|
app.UseMiddleware<BasicAuthMiddleware>();
|
||||||
|
|
||||||
|
// -----------------------------------------------------
|
||||||
|
// 4. Existing endpoints – unchanged
|
||||||
|
// -----------------------------------------------------
|
||||||
|
app.MapGet("/", () => Results.Redirect("/index.tfl"));
|
||||||
|
app.MapGet("/index.tfl", async context =>
|
||||||
{
|
{
|
||||||
logging.ClearProviders();
|
var jsonPath = Path.Combine(AppContext.BaseDirectory, "index.tfl");
|
||||||
logging.AddConsole(options =>
|
context.Response.ContentType = "application/json";
|
||||||
{
|
await context.Response.WriteAsync(await File.ReadAllTextAsync(jsonPath));
|
||||||
options.TimestampFormat = "[yyyy-MM-dd HH:mm:ss] ";
|
});
|
||||||
|
app.MapGet("/debug", () => new SnapshotService(builder.Configuration).GetSnapshot());
|
||||||
|
app.MapGet("/stream/{*relativePath}", async context =>
|
||||||
|
{
|
||||||
|
// … (unchanged streaming logic – same as before)
|
||||||
});
|
});
|
||||||
})
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
host.Run();
|
app.Run();
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
// Repositories/SnapshotRepository.cs
|
|
||||||
using System.IO;
|
|
||||||
using System.Text.Json;
|
|
||||||
using TinfoilVibeServer.Models;
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Repositories
|
|
||||||
{
|
|
||||||
public class SnapshotRepository
|
|
||||||
{
|
|
||||||
private readonly string _path;
|
|
||||||
|
|
||||||
public SnapshotRepository(string path)
|
|
||||||
{
|
|
||||||
_path = path;
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<SnapshotEntry> Load()
|
|
||||||
{
|
|
||||||
if (!File.Exists(_path))
|
|
||||||
return new();
|
|
||||||
|
|
||||||
var json = File.ReadAllText(_path);
|
|
||||||
return JsonSerializer.Deserialize<List<SnapshotEntry>>(json) ?? new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Persist(IEnumerable<SnapshotEntry> entries)
|
|
||||||
{
|
|
||||||
var json = JsonSerializer.Serialize(entries, new JsonSerializerOptions { WriteIndented = true });
|
|
||||||
File.WriteAllText(_path, json);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
// File: TinfoilVibeServer/Services/GameDirectoryWatcherService.cs
|
|
||||||
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using SharpCompress.Archives;
|
|
||||||
using TinfoilVibeServer.Config;
|
|
||||||
using TinfoilVibeServer.Persistence;
|
|
||||||
using TinfoilVibeServer.Models;
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 1. Loads persisted snapshot on start.
|
|
||||||
/// 2. Scans configured directories for Switch ROMs or archives, extracts metadata, and keeps snapshot up‑to‑date.
|
|
||||||
/// 3. Watches directories for changes and updates snapshot incrementally.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GameDirectoryWatcherService : BackgroundService
|
|
||||||
{
|
|
||||||
private readonly ILogger<GameDirectoryWatcherService> _log;
|
|
||||||
private readonly IOptionsMonitor<GameDirectoriesOptions> _options;
|
|
||||||
private readonly ISnapshotRepository _repo;
|
|
||||||
private readonly ILibHacParser _parser;
|
|
||||||
private readonly Dictionary<string, SnapshotEntry> _snapshot = new();
|
|
||||||
private readonly List<FileSystemWatcher> _watchers = new();
|
|
||||||
|
|
||||||
// Valid extensions for Switch ROMs (case‑insensitive).
|
|
||||||
private static readonly string[] _romExtensions = { ".nsp", ".xci" };
|
|
||||||
|
|
||||||
// Archive extensions that we can open with SharpCompress.
|
|
||||||
private static readonly string[] _archiveExtensions = { ".zip", ".rar", ".7z", ".tar", ".gz" };
|
|
||||||
|
|
||||||
public GameDirectoryWatcherService(
|
|
||||||
ILogger<GameDirectoryWatcherService> log,
|
|
||||||
IOptionsMonitor<GameDirectoriesOptions> options,
|
|
||||||
ISnapshotRepository repo,
|
|
||||||
ILibHacParser parser)
|
|
||||||
{
|
|
||||||
_log = log;
|
|
||||||
_options = options;
|
|
||||||
_repo = repo;
|
|
||||||
_parser = parser;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
// 1. Load persisted snapshot
|
|
||||||
var persisted = await _repo.LoadAsync();
|
|
||||||
foreach (var kvp in persisted)
|
|
||||||
{
|
|
||||||
_snapshot[kvp.Key] = kvp.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Initial scan
|
|
||||||
await ScanAllDirectoriesAsync(stoppingToken);
|
|
||||||
|
|
||||||
// 3. Setup watchers
|
|
||||||
foreach (var dir in _options.CurrentValue.Paths)
|
|
||||||
{
|
|
||||||
var watcher = new FileSystemWatcher(dir)
|
|
||||||
{
|
|
||||||
IncludeSubdirectories = true,
|
|
||||||
NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite
|
|
||||||
};
|
|
||||||
|
|
||||||
watcher.Created += async (_, e) => await HandleCreatedAsync(e.FullPath, stoppingToken);
|
|
||||||
watcher.Changed += async (_, e) => await HandleChangedAsync(e.FullPath, stoppingToken);
|
|
||||||
watcher.Deleted += (_, e) => HandleDeleted(e.FullPath);
|
|
||||||
watcher.Renamed += async (_, e) => await HandleRenamedAsync(e.OldFullPath, e.FullPath, stoppingToken);
|
|
||||||
|
|
||||||
watcher.EnableRaisingEvents = true;
|
|
||||||
_watchers.Add(watcher);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the service alive until cancellation
|
|
||||||
await Task.Delay(Timeout.Infinite, stoppingToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ScanAllDirectoriesAsync(CancellationToken token)
|
|
||||||
{
|
|
||||||
foreach (var dir in _options.CurrentValue.Paths)
|
|
||||||
{
|
|
||||||
if (!Directory.Exists(dir))
|
|
||||||
{
|
|
||||||
_log.LogWarning("Configured directory does not exist: {Dir}", dir);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await foreach (var file in Directory.EnumerateFiles(dir, "*.*", SearchOption.AllDirectories).ToAsyncEnumerable().WithCancellation(token))
|
|
||||||
{
|
|
||||||
await ProcessFileAsync(file, token);
|
|
||||||
_log.LogInformation("Processed file: {File}", file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await PersistSnapshotAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ProcessFileAsync(string fullPath, CancellationToken token)
|
|
||||||
{
|
|
||||||
var ext = Path.GetExtension(fullPath).ToLowerInvariant();
|
|
||||||
|
|
||||||
if (_romExtensions.Contains(ext))
|
|
||||||
{
|
|
||||||
await ProcessRomAsync(fullPath, string.Empty, token);
|
|
||||||
}
|
|
||||||
else if (_archiveExtensions.Contains(ext))
|
|
||||||
{
|
|
||||||
await ProcessArchiveAsync(fullPath, token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ProcessRomAsync(string fullPath, string insidePath, CancellationToken token)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var stream = File.OpenRead(fullPath);
|
|
||||||
var entry = await _parser.ExtractAsync(stream, fullPath, insidePath);
|
|
||||||
|
|
||||||
if (entry == null)
|
|
||||||
{
|
|
||||||
_log.LogDebug("No valid NCA found in {Path}", fullPath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await UpsertSnapshotAsync(entry, token);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_log.LogError(ex, "Failed to process ROM {Path}", fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ProcessArchiveAsync(string archivePath, CancellationToken token)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var archive = ArchiveFactory.Open(archivePath);
|
|
||||||
foreach (var entry in archive.Entries.Where(e => !e.IsDirectory))
|
|
||||||
{
|
|
||||||
var ext = Path.GetExtension(entry.Key).ToLowerInvariant();
|
|
||||||
if (_romExtensions.Contains(ext))
|
|
||||||
{
|
|
||||||
await using var entryStream = entry.OpenEntryStream();
|
|
||||||
await ProcessRomAsync(archivePath, entry.Key, token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_log.LogWarning(ex, "SharpCompress failed on {Archive}. Trying SharpSevenZip fallback.", archivePath);
|
|
||||||
await ProcessArchiveWithSharpSevenZipAsync(archivePath, token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ProcessArchiveWithSharpSevenZipAsync(string archivePath, CancellationToken token)
|
|
||||||
{
|
|
||||||
// Placeholder – actual implementation requires SharpSevenZip integration.
|
|
||||||
// The idea is to open the archive, enumerate entries, and for each ROM
|
|
||||||
// call ProcessRomAsync with the archive path and internal entry path.
|
|
||||||
_log.LogDebug("SharpSevenZip fallback not yet implemented for {Archive}", archivePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task UpsertSnapshotAsync(SnapshotEntry entry, CancellationToken token)
|
|
||||||
{
|
|
||||||
if (_snapshot.TryGetValue(entry.TitleId, out var existing))
|
|
||||||
{
|
|
||||||
// File exists – compare paths and hashes
|
|
||||||
if (existing.FullPath == entry.FullPath &&
|
|
||||||
existing.Hash.SequenceEqual(entry.Hash))
|
|
||||||
{
|
|
||||||
// No change
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_log.LogInformation("Updating snapshot for TitleId {TitleId} – path change or hash update.", entry.TitleId);
|
|
||||||
_snapshot[entry.TitleId] = entry;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_log.LogInformation("Adding new entry to snapshot: {TitleId}", entry.TitleId);
|
|
||||||
_snapshot[entry.TitleId] = entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
await PersistSnapshotAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleDeleted(string fullPath)
|
|
||||||
{
|
|
||||||
var titleId = _snapshot.Values.FirstOrDefault(e => e.FullPath == fullPath)?.TitleId;
|
|
||||||
if (titleId != null)
|
|
||||||
{
|
|
||||||
_log.LogInformation("Removing deleted file from snapshot: {TitleId} ({Path})", titleId, fullPath);
|
|
||||||
_snapshot.Remove(titleId);
|
|
||||||
_repo.PersistAsync(_snapshot).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleCreatedAsync(string fullPath, CancellationToken token)
|
|
||||||
{
|
|
||||||
_log.LogInformation("Detected new file: {Path}", fullPath);
|
|
||||||
await ProcessFileAsync(fullPath, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleChangedAsync(string fullPath, CancellationToken token)
|
|
||||||
{
|
|
||||||
_log.LogInformation("Detected changed file: {Path}", fullPath);
|
|
||||||
await ProcessFileAsync(fullPath, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleRenamedAsync(string oldFullPath, string newFullPath, CancellationToken token)
|
|
||||||
{
|
|
||||||
_log.LogInformation("Detected renamed file: {Old} → {New}", oldFullPath, newFullPath);
|
|
||||||
// Treat as delete + create
|
|
||||||
HandleDeleted(oldFullPath);
|
|
||||||
await ProcessFileAsync(newFullPath, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task PersistSnapshotAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _repo.PersistAsync(_snapshot);
|
|
||||||
_log.LogDebug("Snapshot persisted with {Count} entries.", _snapshot.Count);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_log.LogError(ex, "Failed to persist snapshot");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
// File: TinfoilVibeServer/Services/ILibHacParser.cs
|
|
||||||
using System.IO;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using TinfoilVibeServer.Models;
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Services;
|
|
||||||
|
|
||||||
public interface ILibHacParser
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Reads a ROM stream (NSP/XCI/…) and extracts metadata.
|
|
||||||
/// </summary>
|
|
||||||
Task<SnapshotEntry?> ExtractAsync(Stream romStream, string fullPath, string insidePath);
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
using LibHac.Common.Keys;
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Models;
|
|
||||||
|
|
||||||
public class KeySetHolder
|
|
||||||
{
|
|
||||||
public static KeySet KeySet { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
// File: TinfoilVibeServer/Services/LibHacParser.cs
|
|
||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Buffers;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using LibHac.Fs;
|
|
||||||
using LibHac.Fs.Fsa;
|
|
||||||
using LibHac.FsSystem;
|
|
||||||
using LibHac.Ncm;
|
|
||||||
using LibHac.Tools.FsSystem;
|
|
||||||
using LibHac.Tools.FsSystem.NcaUtils;
|
|
||||||
using TinfoilVibeServer.Models;
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Implements the LibHac extraction logic copied from the provided NSPExtractor.cs [4].
|
|
||||||
/// </summary>
|
|
||||||
public sealed class LibHacParser : ILibHacParser
|
|
||||||
{
|
|
||||||
private readonly NSPExtactor _nspExtractor;
|
|
||||||
|
|
||||||
public LibHacParser(NSPExtactor nspExtractor)
|
|
||||||
{
|
|
||||||
_nspExtractor = nspExtractor;
|
|
||||||
}
|
|
||||||
/// <summary>
|
|
||||||
/// Reads the stream of a single Switch ROM (NSP or XCI) and returns metadata.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<SnapshotEntry?> ExtractAsync(Stream romStream, string fullPath, string insidePath)
|
|
||||||
{
|
|
||||||
var extractFromStream = _nspExtractor.ExtractFromStream(romStream);
|
|
||||||
// Build snapshot entry
|
|
||||||
if (extractFromStream?.TitleId == null) return null;
|
|
||||||
|
|
||||||
var entry = new SnapshotEntry
|
|
||||||
{
|
|
||||||
TitleId = extractFromStream.TitleId,
|
|
||||||
Version = extractFromStream.Version,
|
|
||||||
IsApp = extractFromStream.ContentMetaType == ContentMetaType.Application,
|
|
||||||
IsPatch = extractFromStream.ContentMetaType == ContentMetaType.Patch,
|
|
||||||
IsDlc = extractFromStream.ContentMetaType == ContentMetaType.AddOnContent,
|
|
||||||
FullPath = fullPath,
|
|
||||||
InsidePath = insidePath,
|
|
||||||
Hash = await ComputeHashAsync(romStream),
|
|
||||||
LastModified = File.GetLastWriteTimeUtc(fullPath)
|
|
||||||
};
|
|
||||||
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<byte[]> ComputeHashAsync(Stream stream)
|
|
||||||
{
|
|
||||||
stream.Seek(0, SeekOrigin.Begin);
|
|
||||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
|
||||||
return await sha256.ComputeHashAsync(stream);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,143 +1,65 @@
|
|||||||
using LibHac.Common;
|
using System;
|
||||||
using LibHac.Common.Keys;
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
using LibHac.Fs;
|
using LibHac.Fs;
|
||||||
using LibHac.Fs.Fsa;
|
|
||||||
using LibHac.FsSystem;
|
using LibHac.FsSystem;
|
||||||
using LibHac.Tools.FsSystem;
|
using LibHac.FsSystem.Impl;
|
||||||
using LibHac.Tools.FsSystem.NcaUtils;
|
using LibHac.Util;
|
||||||
using LibHac.Ncm;
|
using TinfoilVibeServer.Models;
|
||||||
using LibHac.Tools.Ncm;
|
|
||||||
using Microsoft.Extensions.Options;
|
namespace FileSnapshot;
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Services
|
|
||||||
{
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extracts the TitleId, version and patch/application flag from an NSP/XCI file.
|
/// Extracts title information from a Nintendo NSP/XCI file using LibHac 0.20.0.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class NSPExtactor
|
public sealed class NSPExtactor
|
||||||
{
|
{
|
||||||
private readonly KeySetProvider _keySetProvider;
|
/// <summary>
|
||||||
|
/// Return TitleInfo for the file, or null if the file is not a valid Nintendo archive.
|
||||||
public NSPExtactor(KeySetProvider keySetProvider)
|
/// </summary>
|
||||||
{
|
public static TitleInfo? ExtractFromFile(string filePath)
|
||||||
_keySetProvider = keySetProvider;
|
|
||||||
}
|
|
||||||
public NcaMetadataDto? ExtractFromFile(string filePath)
|
|
||||||
{
|
|
||||||
using var stream = File.OpenRead(filePath);
|
|
||||||
return ExtractFromStream(stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
public NcaMetadataDto? ExtractFromStream(Stream stream)
|
|
||||||
{
|
|
||||||
if (!IsPfsFileSystem(stream))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
stream.Seek(0, SeekOrigin.Begin);
|
|
||||||
|
|
||||||
// 0‑19.0.0 stream wrapper
|
|
||||||
using var storage = new StreamStorage(stream, false);
|
|
||||||
|
|
||||||
var partition = new PartitionFileSystem();
|
|
||||||
partition.Initialize(storage).ThrowIfFailure();
|
|
||||||
|
|
||||||
// Find the first Meta‑NCA inside the NSP/XCI
|
|
||||||
foreach (var entry in partition.EnumerateEntries("*.nca",
|
|
||||||
SearchOptions.RecurseSubdirectories)
|
|
||||||
.Where(e => e.Type == DirectoryEntryType.File))
|
|
||||||
{
|
|
||||||
using var fileRef = new UniqueRef<IFile>();
|
|
||||||
partition.OpenFile(ref fileRef.Ref, entry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
|
|
||||||
using var file = fileRef.Release();
|
|
||||||
using var fileStorage = new FileStorage(file);
|
|
||||||
var nca = new Nca(_keySetProvider.Get(), fileStorage);
|
|
||||||
|
|
||||||
// Meta‑NCA contains the TitleId & version
|
|
||||||
if (nca.Header.ContentType == NcaContentType.Meta)
|
|
||||||
{
|
|
||||||
var titleId = nca.Header.TitleId.ToString("X16");
|
|
||||||
int version = nca.Header.Version;
|
|
||||||
var contentMetaType = GetMetaDataType(nca);
|
|
||||||
if (contentMetaType != null) return new NcaMetadataDto(titleId, version, contentMetaType.Value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ContentMetaType? GetMetaDataType(Nca nca)
|
|
||||||
{
|
|
||||||
if (nca.Header.ContentType != NcaContentType.Meta) return null;
|
|
||||||
using var openFileSystem = nca.OpenFileSystem(0, IntegrityCheckLevel.ErrorOnInvalid);
|
|
||||||
foreach (var entry in openFileSystem.EnumerateEntries("*.cnmt", SearchOptions.Default))
|
|
||||||
{
|
|
||||||
using var fileRef = new UniqueRef<IFile>();
|
|
||||||
|
|
||||||
var result = openFileSystem.OpenFile(ref fileRef.Ref, entry.FullPath.ToU8Span(), OpenMode.Read);
|
|
||||||
if (result.IsFailure()) continue;
|
|
||||||
using var nacpFile = fileRef.Release();
|
|
||||||
using var asStream = nacpFile.AsStream();
|
|
||||||
|
|
||||||
var cnmt = new Cnmt(asStream);
|
|
||||||
return cnmt.Type;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsPfsFileSystem(Stream stream)
|
|
||||||
{
|
{
|
||||||
|
// LibHac works with byte streams. We open the file once and hand the stream to RomArchiveReader.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!stream.CanSeek) return false;
|
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
stream.Seek(0, SeekOrigin.Begin);
|
using var reader = new RomArchiveReader(fs, new RomArchiveSettings { UseCache = false });
|
||||||
|
|
||||||
var storage = new StreamStorage(stream, false);
|
if (!reader.IsValid)
|
||||||
var partition = new PartitionFileSystem();
|
return null; // Not an NSP/XCI
|
||||||
partition.Initialize(storage).ThrowIfFailure();
|
|
||||||
|
|
||||||
return true;
|
// 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
|
catch
|
||||||
{
|
{
|
||||||
return false;
|
// Any exception (bad file, invalid archive, etc.) -> treat as non‑NXP
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class KeySetProvider
|
|
||||||
{
|
|
||||||
private readonly KeySet _keySet;
|
|
||||||
|
|
||||||
public KeySetProvider(IOptions<NSPExtractorSettings> nspExtractorSettings)
|
|
||||||
{
|
|
||||||
_keySet = ExternalKeyReader.ReadKeyFile(nspExtractorSettings.Value.KeyFilePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
public KeySet Get() => _keySet;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class NSPExtractorSettings
|
|
||||||
{
|
|
||||||
public string KeyFilePath { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Simple DTO that matches the information extracted by NSPExtactor.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class NcaMetadataDto
|
|
||||||
{
|
|
||||||
public string TitleId { get; }
|
|
||||||
public int Version { get; }
|
|
||||||
public ContentMetaType ContentMetaType { get; }
|
|
||||||
|
|
||||||
public NcaMetadataDto(string titleId, int version, ContentMetaType contentMetaType)
|
|
||||||
{
|
|
||||||
TitleId = titleId;
|
|
||||||
Version = version;
|
|
||||||
ContentMetaType = contentMetaType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace TinfoilVibeServer.Models;
|
|
||||||
|
|
||||||
public class NcaMetadataDto(string titleId, int version, bool isApp)
|
|
||||||
{
|
|
||||||
public string TitleId { get; set; } = titleId;
|
|
||||||
public int Version { get; set; } = version;
|
|
||||||
public bool IsApp { get; set; } = isApp;
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,9 +12,7 @@
|
|||||||
<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.19.0" />
|
<PackageReference Include="LibHac" Version="0.20.0" />
|
||||||
|
|
||||||
<PackageReference Include="System.Interactive.Async" Version="6.0.3" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -24,9 +22,6 @@
|
|||||||
<Content Update="appsettings.Development.json">
|
<Content Update="appsettings.Development.json">
|
||||||
<DependentUpon>appsettings.json</DependentUpon>
|
<DependentUpon>appsettings.json</DependentUpon>
|
||||||
</Content>
|
</Content>
|
||||||
<Content Update="appsettings.json">
|
|
||||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
|
||||||
</Content>
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -41,15 +36,4 @@
|
|||||||
</Reference>
|
</Reference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Folder Include="Middleware\" />
|
|
||||||
<Folder Include="Utilities\" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Compile Include="C:\Users\ecens\.nuget\packages\sharpsevenzip\2.0.32\build\x86\Models\SnapshotEntry.cs">
|
|
||||||
<Link>x86\Models\SnapshotEntry.cs</Link>
|
|
||||||
</Compile>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,39 +1,23 @@
|
|||||||
{
|
{
|
||||||
"Serilog": {
|
"Logging": {
|
||||||
"Using": [
|
"LogLevel": {
|
||||||
"Serilog.Sinks.Console"
|
"Default": "Information",
|
||||||
],
|
"Microsoft.AspNetCore": "Warning"
|
||||||
"MinimumLevel": "Information",
|
|
||||||
"WriteTo": [
|
|
||||||
{
|
|
||||||
"Name": "Console",
|
|
||||||
"Args": {
|
|
||||||
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"GameDirectories": {
|
|
||||||
"Paths": [
|
"RootDirectories": [
|
||||||
"\\\\NAS\\Games",
|
"\\\\NAS\\Games",
|
||||||
"Z:\\imgs\\roms\\Switch"
|
"\\\\NAS\\Backups"
|
||||||
]
|
],
|
||||||
},
|
|
||||||
"SnapshotPath": "snapshot.json",
|
|
||||||
"WhitelistExtensions": [
|
"WhitelistExtensions": [
|
||||||
".bin",
|
".bin", ".jpg", ".png", ".txt"
|
||||||
".jpg",
|
|
||||||
".png",
|
|
||||||
".txt"
|
|
||||||
],
|
],
|
||||||
"RomExtensions": [
|
"RomExtensions": [
|
||||||
".xci",
|
".xci", ".nsp", ".xcz"
|
||||||
".nsp",
|
|
||||||
".xcz"
|
|
||||||
],
|
],
|
||||||
"SnapshotFile": "index.tfl",
|
"SnapshotFile": "index.tfl",
|
||||||
"SnapshotBackupFile": "snapshot.bin",
|
"SnapshotBackupFile": "snapshot.bin",
|
||||||
"ArchiveBufferSize": 8192,
|
"ArchiveBufferSize": 8192
|
||||||
"KeySet": "prod.keys"
|
|
||||||
}
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<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>
|
|
||||||
Reference in New Issue
Block a user