2 Commits

Author SHA1 Message Date
ecenshu 6e741552e7 Sanitize github.ref to be safely used as docker tag
ci / build_linux (push) Successful in 9m59s
ci / build_linux (pull_request) Successful in 5m13s
2025-11-13 19:22:59 +10:30
ecenshu 33a724a796 Consolidate config files for both local dev and docker
ci / build_linux (push) Failing after 1m49s
Consolidate data into separate mapped volume for docker and local
2025-11-13 18:54:57 +10:30
16 changed files with 142 additions and 308 deletions
+3 -27
View File
@@ -25,30 +25,6 @@ jobs:
# echo "MY_LOWER=$lower_value" >> $GITHUB_ENV # echo "MY_LOWER=$lower_value" >> $GITHUB_ENV
# If you want to use it as an output of this step: # If you want to use it as an output of this step:
echo "lowercase=$lower_value" >> $GITHUB_OUTPUT echo "lowercase=$lower_value" >> $GITHUB_OUTPUT
- name: Convert ref to buildx safe value
id: docker_tag_from_ref
shell: bash
run: |
# Grab the raw ref
REF="${{ github.ref }}"
# Strip the "refs/*/" prefix (refs/heads/, refs/tags/…)
TAG=${REF#refs/*/}
# Replace characters that Docker tags disallow
# * "/" → "-"
# * ":" → "-"
# * Any other nonalphanumeric / . / _ / - → "-"
TAG=${TAG//\//-}
TAG=${TAG//:/-}
TAG=${TAG//[^a-zA-Z0-9._-]/-}
# (Optional) force lowercase Docker tags are casesensitive,
# but many people prefer lowercase
TAG=${TAG,,}
# Export to the action's output
echo "docker-tag=${TAG}" >> $GITHUB_OUTPUT
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 1. Checkout repository # 1. Checkout repository
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -94,7 +70,7 @@ jobs:
push: false push: false
tags: | tags: |
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.sha }} ${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.sha }}
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ steps.docker_tag_from_ref.outputs.docker-tag }} ${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.ref_name }}
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:latest ${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:latest
build-args: | build-args: |
# Add any build args here # Add any build args here
@@ -113,7 +89,7 @@ jobs:
push: true push: true
tags: | tags: |
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.sha }} ${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.sha }}
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ steps.docker_tag_from_ref.outputs.docker-tag }} ${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.ref_name }}
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:latest ${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:latest
cache-from: type=local,src=/tmp/.buildx-cache cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache
@@ -132,5 +108,5 @@ jobs:
run: | run: |
echo "Pushed image tags:" 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.sha }}"
echo "- ${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ steps.docker_tag_from_ref.outputs.docker-tag }}" 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" echo "- ${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:latest"
-3
View File
@@ -87,10 +87,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUniqueRef_00601_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fa0_003F631946a0_003FUniqueRef_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUniqueRef_00601_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fa0_003F631946a0_003FUniqueRef_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUnpack_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F4c375bcb6a2d2378855cad1b1c7cfe7ca1448866f1e8af44226775b5f75df86_003FUnpack_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUnpack_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F4c375bcb6a2d2378855cad1b1c7cfe7ca1448866f1e8af44226775b5f75df86_003FUnpack_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUnsafeHelpers_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fd1_003Fc59f91c2_003FUnsafeHelpers_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUnsafeHelpers_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fd1_003Fc59f91c2_003FUnsafeHelpers_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUriExt_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F25edf3e734d0c76e87b29a9954b8b4a7383648a69396554742e5529205e2dd7_003FUriExt_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUriSyntax_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F7ed82aa0f48a6bf284b4aba7c70aff35142349e44fb4f6caec3d71611f9929_003FUriSyntax_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUtility_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F3ea3ed6216d2412ac7c33016ad940618bcfbcafe1633dc26832be514633b4_003FUtility_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUtility_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F3ea3ed6216d2412ac7c33016ad940618bcfbcafe1633dc26832be514633b4_003FUtility_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AWaitHandle_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fcab54c7c1f8d9b3da5f7878e39faba52467f16d0899a2c4b10086cb2ef73f2b_003FWaitHandle_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AXciPartition_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F04_003F4e8815da_003FXciPartition_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AXciPartition_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F04_003F4e8815da_003FXciPartition_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AZipArchive_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F39ececcf144e1f9c884152723ed93931cd232485eaf2824bf5beb526f1f321b_003FZipArchive_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AZipArchive_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F39ececcf144e1f9c884152723ed93931cd232485eaf2824bf5beb526f1f321b_003FZipArchive_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
@@ -61,7 +61,7 @@ public class AuthStore : IDisposable, IAuthStore
private static string DetermineCredentialsPath(string? settingsCredentialsFile, IHostEnvironment env) private static string DetermineCredentialsPath(string? settingsCredentialsFile, IHostEnvironment env)
{ {
if (settingsCredentialsFile == null) return Path.Combine("app","data","credentials.json"); if (settingsCredentialsFile == null) return Path.Combine("app","data","credentials.json");
return Path.IsPathRooted(settingsCredentialsFile) ? settingsCredentialsFile : Path.Combine(env.ContentRootPath,"data",settingsCredentialsFile); return Path.IsPathRooted(settingsCredentialsFile) ? settingsCredentialsFile : Path.Combine(env.ContentRootPath,"app","data",settingsCredentialsFile);
} }
public void Dispose() public void Dispose()
+1 -1
View File
@@ -8,6 +8,6 @@ namespace TinfoilVibeServer.Models;
public sealed record FileEntry( public sealed record FileEntry(
string Path, // nsp or archive path string Path, // nsp or archive path
long Size, // size of nsp or full archive long Size, // size of nsp or full archive
string? Hash, // SHA256 hex of first NCA of first NCP in NSP or archive string Hash, // SHA256 hex of first NCA of first NCP in NSP or archive
List<NcaMetadataWithHash> Titles // Details of all NSP Roms in the Path List<NcaMetadataWithHash> Titles // Details of all NSP Roms in the Path
); );
@@ -1,7 +1,7 @@
using System.Globalization; using System.Globalization;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace TinfoilVibeServer.Utilities; namespace TinfoilVibeServer.Models;
public static class IdHelper public static class IdHelper
@@ -1,26 +0,0 @@
using LibHac.Ncm;
namespace TinfoilVibeServer.Models;
/// <summary>
/// DTO returned by the extractor contains all data the snapshot needs.
/// </summary>
public sealed class NcaMetadataWithHash
{
public string TitleId { get; }
public string ApplicationTitle { get; set; }
public int Version { get; }
public ContentMetaType ContentMetaType { get; set; }
public string? Hash { get; }
public NcaMetadataWithHash(string titleId, string applicationTitle, int version,
ContentMetaType contentMetaType, string? hash = null)
{
TitleId = titleId;
ApplicationTitle = applicationTitle;
Version = version;
ContentMetaType = contentMetaType;
Hash = hash;
}
}
-26
View File
@@ -1,4 +1,3 @@
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using TinfoilVibeServer.Authentication; using TinfoilVibeServer.Authentication;
@@ -13,31 +12,6 @@ builder.Logging.AddDebug();
builder.Services.AddMemoryCache(); builder.Services.AddMemoryCache();
var dataRoot = builder.Configuration["CONFIG_ROOT"] ?? "/app/config/"; var dataRoot = builder.Configuration["CONFIG_ROOT"] ?? "/app/config/";
// 1️⃣ Load the embedded default
var defaultResource = typeof(Program).Assembly
.GetManifestResourceStream("TinfoilVibeServer.appsettings.default.json")!; // adjust namespace
var defaultConfig = JsonDocument.Parse(defaultResource).RootElement;
// 2️⃣ Try to write the file if it doesn't exist
var configPath = Path.Combine(dataRoot, "appsettings.json");
if (!File.Exists(configPath))
{
// write the embedded JSON straight to disk
try
{
File.WriteAllText(configPath, defaultConfig.GetRawText());
}
catch (Exception e)
{
var tempFactory = LoggerFactory.Create(loggingBuilder =>
{
loggingBuilder.AddConsole();
loggingBuilder.AddDebug();
});
var logger = tempFactory.CreateLogger<Program>();
logger.LogError(e, "Failed to write default config file");
}
}
var config = new ConfigurationBuilder() var config = new ConfigurationBuilder()
.AddJsonFile(Path.Combine(dataRoot,"appsettings.json"), optional: false, reloadOnChange: true) .AddJsonFile(Path.Combine(dataRoot,"appsettings.json"), optional: false, reloadOnChange: true)
+1 -1
View File
@@ -19,7 +19,7 @@ public class ConfigManager
public ConfigManager() public ConfigManager()
{ {
_configPath = Path.Combine(AppContext.BaseDirectory, "config", "appsettings.json"); _configPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
Load(); Load();
_watcher = new FileSystemWatcher _watcher = new FileSystemWatcher
@@ -119,11 +119,9 @@ public sealed class IndexBuilderService: IHostedService
var fileName =Uri.EscapeDataString($"{name}[{titleId}][v{versionNumberParsed}][{patchOrApp}].nsp"); var fileName =Uri.EscapeDataString($"{name}[{titleId}][v{versionNumberParsed}][{patchOrApp}].nsp");
var url = $"{baseUri.ToString().TrimEnd('/')}/{fileName}"; var url = $"{baseUri.ToString().TrimEnd('/')}/{fileName}";
var isWellFormed = Uri.TryCreate(url, UriKind.Absolute, out Uri? parsedUri); if (Uri.IsWellFormedUriString(url, UriKind.Absolute))
if (isWellFormed && parsedUri != null)
{ {
fileDtos.Add(new FileDto(parsedUri.AbsoluteUri, e.Size)); fileDtos.Add(new FileDto(url, e.Size));
} }
else else
{ {
+36 -50
View File
@@ -10,7 +10,6 @@ using LibHac.Ncm;
using LibHac.Tools.Fs; using LibHac.Tools.Fs;
using LibHac.Tools.Ncm; using LibHac.Tools.Ncm;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using TinfoilVibeServer.Models;
using Path = System.IO.Path; using Path = System.IO.Path;
namespace TinfoilVibeServer.Services namespace TinfoilVibeServer.Services
@@ -35,49 +34,21 @@ namespace TinfoilVibeServer.Services
/// </summary> /// </summary>
public sealed class NSPExtractor : INSPExtractor public sealed class NSPExtractor : INSPExtractor
{ {
private KeySet? _keySet; private readonly KeySet _keySet;
private readonly ILogger<INSPExtractor> _logger;
public KeySet? KeySet public NSPExtractor(IOptions<NSPExtractorOptions> options, ILogger<INSPExtractor> logger, IHostEnvironment environment)
{ {
get var dataRoot = environment.ContentRootPath ?? "/app/config";
if (Path.IsPathRooted(options.Value.keyFile))
{ {
if (_keySet != null) return _keySet; _keySet = ExternalKeyReader.ReadKeyFile(options.Value.keyFile);
if (_options.CurrentValue.KeyFile == null) return null;
var dataRoot = _environment.ContentRootPath ?? "/app/config";
if (Path.IsPathRooted(_options.CurrentValue.KeyFile))
{
_keySet = ExternalKeyReader.ReadKeyFile(_options.CurrentValue.KeyFile);
} }
else else
{ {
_keySet = ExternalKeyReader.ReadKeyFile(Path.Combine(dataRoot, "config", _options.CurrentValue.KeyFile)); _keySet = ExternalKeyReader.ReadKeyFile(Path.Combine(dataRoot, "config", options.Value.keyFile));
} }
return _keySet;
}
}
private readonly IOptionsMonitor<NSPExtractorOptions> _options;
private readonly ILogger<INSPExtractor> _logger;
private readonly IHostEnvironment _environment;
public NSPExtractor(IOptionsMonitor<NSPExtractorOptions> options, ILogger<INSPExtractor> logger, IHostEnvironment environment)
{
_options = options;
_options.OnChange(o =>
{
if (o.KeyFile == null)
{
_logger?.LogInformation("No KeySet specified, skipping key validation");
}
if (!File.Exists(o.KeyFile))
{
_logger?.LogWarning("KeySet file {KeyFile} does not exist", o.KeyFile);
}
});
_logger = logger; _logger = logger;
_environment = environment;
} }
/// <summary> /// <summary>
@@ -94,8 +65,6 @@ namespace TinfoilVibeServer.Services
/// </summary> /// </summary>
public NcaMetadataWithHash? ExtractFromStream(Stream stream) public NcaMetadataWithHash? ExtractFromStream(Stream stream)
{ {
if (KeySet == null) return null;
if (!stream.CanSeek) return null; if (!stream.CanSeek) return null;
stream.Seek(0, SeekOrigin.Begin); stream.Seek(0, SeekOrigin.Begin);
@@ -108,7 +77,7 @@ namespace TinfoilVibeServer.Services
if (IsXciFileSystem(stream)) if (IsXciFileSystem(stream))
{ {
var xci = new Xci(KeySet, storage); var xci = new Xci(_keySet, storage);
List<DirectoryEntryEx> ncaEntries; List<DirectoryEntryEx> ncaEntries;
if (xci.HasPartition(XciPartitionType.Secure)) if (xci.HasPartition(XciPartitionType.Secure))
{ {
@@ -126,7 +95,7 @@ namespace TinfoilVibeServer.Services
using var ncaFile = fileRef.Release(); using var ncaFile = fileRef.Release();
using var ncaFileStorage = new FileStorage(ncaFile); using var ncaFileStorage = new FileStorage(ncaFile);
var nca = new Nca(KeySet, ncaFileStorage); var nca = new Nca(_keySet, ncaFileStorage);
if (hash == null) if (hash == null)
{ {
// Hash the *first* NCA stream the stream we just opened // Hash the *first* NCA stream the stream we just opened
@@ -153,8 +122,6 @@ namespace TinfoilVibeServer.Services
private NcaMetadataWithHash? ExtractNSPFromStream(StreamStorage storage) private NcaMetadataWithHash? ExtractNSPFromStream(StreamStorage storage)
{ {
if (KeySet == null) return null;
List<DirectoryEntryEx> ncaEntries; List<DirectoryEntryEx> ncaEntries;
_logger.LogInformation("Processing as NSP"); _logger.LogInformation("Processing as NSP");
var partition = new PartitionFileSystem(); var partition = new PartitionFileSystem();
@@ -172,7 +139,7 @@ namespace TinfoilVibeServer.Services
using var ncaFile = fileRef.Release(); using var ncaFile = fileRef.Release();
using var ncaFileStorage = new FileStorage(ncaFile); using var ncaFileStorage = new FileStorage(ncaFile);
var nca = new Nca(KeySet, ncaFileStorage); var nca = new Nca(_keySet, ncaFileStorage);
if (hash == null) if (hash == null)
{ {
// Hash the *first* NCA stream the stream we just opened // Hash the *first* NCA stream the stream we just opened
@@ -241,8 +208,6 @@ namespace TinfoilVibeServer.Services
} }
private bool IsXciFileSystem(Stream stream) private bool IsXciFileSystem(Stream stream)
{ {
if (KeySet == null) return false;
try try
{ {
if (!stream.CanSeek) return false; if (!stream.CanSeek) return false;
@@ -251,7 +216,7 @@ namespace TinfoilVibeServer.Services
var storage = new StreamStorage(stream, true); var storage = new StreamStorage(stream, true);
try try
{ {
var xciBlock = new Xci(KeySet, storage); var xciBlock = new Xci(_keySet, storage);
_logger.LogInformation("XCI found"); _logger.LogInformation("XCI found");
return xciBlock.HasPartition(XciPartitionType.Secure); return xciBlock.HasPartition(XciPartitionType.Secure);
} }
@@ -270,8 +235,6 @@ namespace TinfoilVibeServer.Services
public string ExtractHashFromStream(Stream nspStream) public string ExtractHashFromStream(Stream nspStream)
{ {
if (KeySet == null) return string.Empty;
if (!IsPfs0FileSystem(nspStream)) if (!IsPfs0FileSystem(nspStream))
return string.Empty; return string.Empty;
@@ -296,7 +259,7 @@ namespace TinfoilVibeServer.Services
try try
{ {
var nca = new Nca(KeySet, ncaFileStorage); var nca = new Nca(_keySet, ncaFileStorage);
if (nca.Header.ContentType != NcaContentType.Meta) if (nca.Header.ContentType != NcaContentType.Meta)
continue; // only the meta NCA contains title metadata continue; // only the meta NCA contains title metadata
@@ -320,6 +283,29 @@ namespace TinfoilVibeServer.Services
public class NSPExtractorOptions public class NSPExtractorOptions
{ {
public string? KeyFile { get; set; } public string keyFile { get; set; }
}
/// <summary>
/// DTO returned by the extractor contains all data the snapshot needs.
/// </summary>
public sealed class NcaMetadataWithHash
{
public string TitleId { get; }
public string ApplicationTitle { get; set; }
public int Version { get; }
public ContentMetaType ContentMetaType { get; set; }
public string Hash { get; }
public NcaMetadataWithHash(string titleId, string applicationTitle, int version,
ContentMetaType contentMetaType, string hash)
{
TitleId = titleId;
ApplicationTitle = applicationTitle;
Version = version;
ContentMetaType = contentMetaType;
Hash = hash;
}
} }
} }
+68 -70
View File
@@ -79,7 +79,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_snapshotPath) ?? throw new InvalidOperationException())); FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_snapshotPath) ?? throw new InvalidOperationException()));
// 1️⃣ Register for *property* changes // 1️⃣ Register for *property* changes
options.OnChange((snapshotOptions, arg) => options.OnChange(snapshotOptions =>
{ {
_options.RootDirectories = snapshotOptions.RootDirectories; _options.RootDirectories = snapshotOptions.RootDirectories;
}); });
@@ -256,40 +256,61 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
var index = LoadSnapshotIndex(); var index = LoadSnapshotIndex();
var latestModifiedUtcParallel = FileSystemExtensions.GetLatestModifiedUtcParallel(_options.RootDirectories); var latestModifiedUtcParallel = FileSystemExtensions.GetLatestModifiedUtcParallel(_options.RootDirectories);
var fileInfo = new FileInfo(_snapshotPath); var fileInfo = new FileInfo(_snapshotPath);
bool snapshotVerified = fileInfo.Exists; bool snapshotVerified = true;
if (latestModifiedUtcParallel.HasValue && latestModifiedUtcParallel.Value < fileInfo.LastWriteTimeUtc) if (latestModifiedUtcParallel.HasValue && latestModifiedUtcParallel.Value < fileInfo.LastWriteTimeUtc)
{ {
if (index.Count != 0) if (index.Count != 0)
{ {
// directory may have been added with older roms, verify that the snapshot is still up to date
foreach (var dir in _options.RootDirectories) foreach (var dir in _options.RootDirectories)
{ {
var firstEntry = BuildSnapshot(dir).FirstOrDefault(); // check first entry is in index
if (firstEntry != null && !index.TryGetValue(firstEntry.Path, out _)) var entry = BuildSnapshot(dir).FirstOrDefault();
if (entry != null)
{
if (!index.TryGetValue(entry.Path, out var cached))
{ {
snapshotVerified = false; snapshotVerified = false;
_logger.LogInformation("Snapshot does not contain first entry in directory {Directory}", dir); _logger.LogInformation("Snapshot does not contain first entry in directory {Directory}", dir);
} }
} }
} }
}
if (!snapshotVerified) if (snapshotVerified)
{ {
_logger.LogInformation("Snapshot is up to date");
return Task.CompletedTask;
}
}
else
{
_logger.LogInformation("Snapshot is up to date but index is empty");
}
}
_logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", _options.RootDirectories.Count); _logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", _options.RootDirectories.Count);
var entries = new List<FileEntry>(); var entries = new List<FileEntry>();
var snapshotChanged = false;
foreach (var dir in _options.RootDirectories) foreach (var dir in _options.RootDirectories)
{ {
foreach (var entry in BuildSnapshot(dir)) _ = Task.Run(() =>
{ {
if (entry != null) entries.Add(entry); _logger.LogInformation("Rebuilding directory {Directory}", dir);
} var buildSnapshot = BuildSnapshot(dir);
} var fileEntries = buildSnapshot.ToList();
var currentHash = ComputeSnapshotHash(entries); snapshotChanged = snapshotChanged || fileEntries.Count != 0;
if (entries.Count > 0 || fileInfo.Exists && index.Count == 0) entries.AddRange(fileEntries.Where(entry => entry != null)!);
SnapshotRebuilt?.Invoke(this, EventArgs.Empty); });
} }
PersistSnapshotAsync(); var snapshotEmptied = fileInfo.Exists && index.Count == 0 && _options.RootDirectories.Count == 0;
// Replace the entire snapshot
var currentSnapshotHash = ComputeSnapshotHash(entries);
if (snapshotChanged || snapshotEmptied)
{
_logger.LogInformation("Snapshot rebuilt");
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
}
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -320,15 +341,6 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
var titles = new List<(string, long, NcaMetadataWithHash)>(); var titles = new List<(string, long, NcaMetadataWithHash)>();
if (_options.RomExtensions.Contains(ext)) if (_options.RomExtensions.Contains(ext))
{ {
var fileInfo = new FileInfo(file);
var ncaMetadataWithHash = fileInfo.GetNcaMetadataWithHash();
if (ncaMetadataWithHash != null)
{
//var titleInfo = _titleDatabaseService.GetAsync(ncaMetadataWithHash.TitleId).Result;
var fileEntryFromFileName = new FileEntry(file, fileInfo.Length, ncaMetadataWithHash.Hash, [ncaMetadataWithHash]);
AddToSnapshotAsync(fileEntryFromFileName);
yield return fileEntryFromFileName;
}
using var nspStream = File.OpenRead(file); using var nspStream = File.OpenRead(file);
hash = ComputeFirstStreamHash(nspStream); hash = ComputeFirstStreamHash(nspStream);
@@ -404,47 +416,48 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
await Task.CompletedTask; await Task.CompletedTask;
} }
private string ComputeFirstStreamHash(Stream nspStream) => _nspExtractor.ExtractHashFromStream(nspStream); private string ComputeFirstStreamHash(Stream nspStream)
{
return _nspExtractor.ExtractHashFromStream(nspStream);
}
private void UpdateSnapshot() => BuildSnapshotAsync(); private void UpdateSnapshot() => BuildSnapshotAsync();
private IEnumerable<FileEntry> GetEntries() IEnumerable<FileEntry> GetEntries()
{ {
foreach (var kv in _cache) foreach (var snapshotEntry in _cache)
yield return new FileEntry(kv.Key, kv.Value.Size, kv.Value.Hash, kv.Value.NcaMetadataWithHash); {
_sizeLookup.TryGetValue(snapshotEntry.Value.Hash, out var size);
var fileEntry = new FileEntry(snapshotEntry.Key, snapshotEntry.Value.Size, snapshotEntry.Value.Hash, snapshotEntry.Value.NcaMetadataWithHash);
yield return fileEntry;
}
} }
private Task PersistSnapshotAsync() private Task PersistSnapshotAsync()
{ {
if (_debouncerCache.TryGetValue(_jsonPath, out _)) if (_debouncerCache.TryGetValue(_jsonPath, out var value))
{ {
_logger.LogInformation("Sliding debounce in progress, skipping snapshot persistence"); _logger.LogInformation("Sliding debounce in progress, skipping snapshot persistence");
return Task.CompletedTask; return Task.CompletedTask;
} }
var entries = GetEntries().ToList();
var newHash = ComputeSnapshotHash(entries);
var snapshot = GetSnapshot(); var snapshot = GetSnapshot();
var entries = GetEntries();
var fileEntries = entries.ToList();
var newHash = ComputeSnapshotHash(fileEntries);
if (snapshot.Hash == newHash) return Task.CompletedTask; if (snapshot.Hash == newHash) return Task.CompletedTask;
var cancellationTokenSource = new CancellationTokenSource(); CancellationTokenSource cts = new();
using var cacheEntry = _debouncerCache.CreateEntry(_jsonPath) _logger.LogInformation("Snapshot hash changed persisting new snapshot");
.AddExpirationToken(new CancellationChangeToken(cancellationTokenSource.Token)) using var debouncedPersistence = _debouncerCache.CreateEntry(_jsonPath);
.SetValue(entries) debouncedPersistence.AddExpirationToken(new CancellationChangeToken(cts.Token));
.SetOptions(new MemoryCacheEntryOptions //debouncedPersistence.AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(DebounceMs);
debouncedPersistence.Value = fileEntries;
debouncedPersistence.PostEvictionCallbacks.Add(new PostEvictionCallbackRegistration
{ {
PostEvictionCallbacks = EvictionCallback = (key, entriesCallback, reason, state) =>
{ {
new PostEvictionCallbackRegistration if (entriesCallback is IEnumerable<FileEntry> entriesToPersist && key is string filePath)
{ {
EvictionCallback = (key, value, reason, state) =>
{
if (!(reason == EvictionReason.Expired || reason == EvictionReason.TokenExpired))
return;
var filePath = (string)key;
if (_snapshotFileSemaphore.Wait(SnapshotFileLockTimeout)) if (_snapshotFileSemaphore.Wait(SnapshotFileLockTimeout))
{
try
{ {
if (IsFileLocked(filePath)) if (IsFileLocked(filePath))
{ {
@@ -452,23 +465,25 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
} }
else else
{ {
File.WriteAllText(filePath, JsonSerializer.Serialize(value, _jsonSerializerOptions)); File.WriteAllText(filePath,
JsonSerializer.Serialize(entriesToPersist, _jsonSerializerOptions));
_snapshotFileSemaphore.Release();
_logger.LogInformation("Persisted snapshot");
SnapshotRebuilt?.Invoke(this, EventArgs.Empty); SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
} }
} }
finally else
{ {
_snapshotFileSemaphore.Release(); _logger.LogInformation("Failed to persist file {FilePath} due to timeout", filePath);
}
}
} }
} }
} }
}); });
cancellationTokenSource.CancelAfter(TimeSpan.FromMilliseconds(DebounceMs)); cts.CancelAfter(TimeSpan.FromMilliseconds(DebounceMs));
return Task.CompletedTask; return Task.CompletedTask;
} }
private static string ComputeHash(string filePath) private static string ComputeHash(string filePath)
{ {
using var sha = SHA256.Create(); using var sha = SHA256.Create();
@@ -508,29 +523,12 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
_logger.LogWarning("Duplicate hash found in snapshot: {Hash}, {OldPath}, {newPath}", fileEntry.Hash, value, fileEntry.Path); _logger.LogWarning("Duplicate hash found in snapshot: {Hash}, {OldPath}, {newPath}", fileEntry.Hash, value, fileEntry.Path);
} }
var nspOrArchivePath = fileEntry.Path.Split(ArchivePathSeparator)[0]; if (!File.Exists(fileEntry.Path))
if (!File.Exists(nspOrArchivePath))
{ {
_logger.LogWarning("Nonexistent entry found: {Path}", fileEntry.Path); _logger.LogWarning("Nonexistent entry found: {Path}", fileEntry.Path);
continue; continue;
} }
var fileContainedInRootDirectories = false;
foreach (var optionsRootDirectory in _options.RootDirectories)
{
if (fileEntry.Path.StartsWith(optionsRootDirectory))
{
fileContainedInRootDirectories = true;
break;
}
}
if (!fileContainedInRootDirectories)
{
_logger.LogInformation("Entry {Path} is not contained in any root directory", fileEntry.Path);
continue;
};
if (_options.RomExtensions.Contains(Path.GetExtension(fileEntry.Path))) if (_options.RomExtensions.Contains(Path.GetExtension(fileEntry.Path)))
{ {
if (fileEntry.Path.Contains(ArchivePathSeparator)) if (fileEntry.Path.Contains(ArchivePathSeparator))
@@ -24,7 +24,9 @@ public sealed class TitleDatabaseService : IHostedService
private readonly IOptionsMonitor<TitleDbOptions> _options; private readonly IOptionsMonitor<TitleDbOptions> _options;
private readonly ILogger<TitleDatabaseService> _logger; private readonly ILogger<TitleDatabaseService> _logger;
private readonly IHttpClientFactory _httpFactory; private readonly IHttpClientFactory _httpFactory;
private readonly INSPExtractor _nspExtractor;
private readonly string _cacheFolder; // Where the JSON is cached. private readonly string _cacheFolder; // Where the JSON is cached.
private readonly List<string> _rootDirectories; // directories that contain game files
private readonly IMemoryCache _cache; private readonly IMemoryCache _cache;
private readonly ISnapshotService _snapshotService; private readonly ISnapshotService _snapshotService;
@@ -48,6 +50,7 @@ public sealed class TitleDatabaseService : IHostedService
/// directories that contain the NSP files. /// directories that contain the NSP files.
/// </summary> /// </summary>
public TitleDatabaseService( public TitleDatabaseService(
IConfiguration configuration,
IOptionsMonitor<TitleDbOptions> options, IOptionsMonitor<TitleDbOptions> options,
ILogger<TitleDatabaseService> logger, ILogger<TitleDatabaseService> logger,
ISnapshotService snapshotService, ISnapshotService snapshotService,
@@ -59,10 +62,11 @@ public sealed class TitleDatabaseService : IHostedService
_logger = logger; _logger = logger;
_snapshotService = snapshotService; _snapshotService = snapshotService;
_httpFactory = httpFactory; _httpFactory = httpFactory;
_nspExtractor = nspExtractor;
_cache = cache; _cache = cache;
_cacheFolder = Path.Combine(AppContext.BaseDirectory, "data", "titledb-cache"); _cacheFolder = Path.Combine(AppContext.BaseDirectory, "titledb-cache");
new List<string> _rootDirectories = new List<string>
{ {
// You can extend this list it is the set of directories that // You can extend this list it is the set of directories that
// are scanned when the service starts up. // are scanned when the service starts up.
@@ -58,8 +58,5 @@
<ItemGroup> <ItemGroup>
<EmbeddedResource Remove="obj\**" /> <EmbeddedResource Remove="obj\**" />
<EmbeddedResource Include="appsettings.default.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</EmbeddedResource>
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,30 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using LibHac.Ncm;
using TinfoilVibeServer.Models;
namespace TinfoilVibeServer.Utilities;
public static class NcaMetadataWithHashHelper
{
public static NcaMetadataWithHash? GetNcaMetadataWithHash(this FileInfo fileInfo)
{
var match = Regex.Match(fileInfo.Name, @"^(.+)\[(\w{16})\]\[v(\d{1,7})\]\[(\w+).*\]\.nsp$");
if (!match.Success) return null;
var titleId = match.Groups[2].Value;
var applicationTitle = match.Groups[1].Value.Trim();
var version = int.Parse(match.Groups[3].Value) / 0x10000;
var nspType = match.Groups[4].Value.ToLowerInvariant() switch
{
"base" => ContentMetaType.Application,
"update" => ContentMetaType.Patch,
"dlc" => ContentMetaType.AddOnContent,
_ => ContentMetaType.Patch
};
var bytes = Encoding.UTF8.GetBytes(fileInfo.FullName);
var hash = SHA256.HashData(bytes);
return new NcaMetadataWithHash(titleId, applicationTitle, version, nspType, Convert.ToBase64String(hash));
}
}
@@ -1,39 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"CredentialsFile": "/app/data/credentials.json",
"FingerprintsFile": "/app/data/fingerprints.json",
"BlacklistFile": "/app/data/blacklist.json",
"MaxFailedAttempts": 5,
"Snapshot" : {
"RootDirectories": [ ],
"ArchiveExtensions": [ ".zip", ".rar", ".7z" ],
"RomExtensions": [ ".xci", ".nsp", ".xcz" ],
"CacheTtl": 60,
"SnapshotFile": "/app/data/snapshot.json",
"SnapshotBackupFile": "/app/data/snapshot.bak"
},
"NSPExtractor": {
"KeyFile": "/app/config/prod.keys"
},
"IndexBuilder": {
"ApiBaseUrl": "http://tinfoil.localhost:8080",
"IndexDirectories": [
"https://url1",
"sdmc:/url2",
"http://url3"
],
"Success" : "Welcome to Tinfoil Vibe Server!"
},
"TitleDb": {
"CountryCode": "AU",
"Language": "en",
"TtlSeconds" : 90
}
}
-1
View File
@@ -8,7 +8,6 @@ services:
image: gitea.ecenshu.net/ecenshu/tinfoilvibeserver:latest image: gitea.ecenshu.net/ecenshu/tinfoilvibeserver:latest
container_name: tinfoilvibeserver container_name: tinfoilvibeserver
restart: unless-stopped restart: unless-stopped
user: "1000:1000"
env_file: env_file:
- .env - .env
environment: environment: