Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bea70381dd | |||
| 91f394f81b | |||
| 17d12fef6a | |||
| 97fc2dc872 | |||
| a1ea34bc01 |
@@ -25,6 +25,30 @@ jobs:
|
||||
# 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: 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 non‑alphanumeric / . / _ / - → "-"
|
||||
TAG=${TAG//\//-}
|
||||
TAG=${TAG//:/-}
|
||||
TAG=${TAG//[^a-zA-Z0-9._-]/-}
|
||||
|
||||
# (Optional) force lower‑case – Docker tags are case‑sensitive,
|
||||
# but many people prefer lower‑case
|
||||
TAG=${TAG,,}
|
||||
|
||||
# Export to the action's output
|
||||
echo "docker-tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Checkout repository
|
||||
# ------------------------------------------------------------------
|
||||
@@ -70,7 +94,7 @@ jobs:
|
||||
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 }}:${{ steps.docker_tag_from_ref.outputs.docker-tag }}
|
||||
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:latest
|
||||
build-args: |
|
||||
# Add any build args here
|
||||
@@ -89,7 +113,7 @@ jobs:
|
||||
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 }}:${{ steps.docker_tag_from_ref.outputs.docker-tag }}
|
||||
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:latest
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||
@@ -108,5 +132,5 @@ jobs:
|
||||
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 }}:${{ steps.docker_tag_from_ref.outputs.docker-tag }}"
|
||||
echo "- ${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:latest"
|
||||
@@ -87,7 +87,10 @@
|
||||
<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_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_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_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)
|
||||
{
|
||||
if (settingsCredentialsFile == null) return Path.Combine("app","data","credentials.json");
|
||||
return Path.IsPathRooted(settingsCredentialsFile) ? settingsCredentialsFile : Path.Combine(env.ContentRootPath,"app","data",settingsCredentialsFile);
|
||||
return Path.IsPathRooted(settingsCredentialsFile) ? settingsCredentialsFile : Path.Combine(env.ContentRootPath,"data",settingsCredentialsFile);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -8,6 +8,6 @@ namespace TinfoilVibeServer.Models;
|
||||
public sealed record FileEntry(
|
||||
string Path, // nsp or archive path
|
||||
long Size, // size of nsp or full archive
|
||||
string Hash, // SHA‑256 hex of first NCA of first NCP in NSP or archive
|
||||
string? Hash, // SHA‑256 hex of first NCA of first NCP in NSP or archive
|
||||
List<NcaMetadataWithHash> Titles // Details of all NSP Roms in the Path
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TinfoilVibeServer.Authentication;
|
||||
@@ -12,6 +13,31 @@ builder.Logging.AddDebug();
|
||||
|
||||
builder.Services.AddMemoryCache();
|
||||
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()
|
||||
.AddJsonFile(Path.Combine(dataRoot,"appsettings.json"), optional: false, reloadOnChange: true)
|
||||
|
||||
@@ -19,7 +19,7 @@ public class ConfigManager
|
||||
|
||||
public ConfigManager()
|
||||
{
|
||||
_configPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
|
||||
_configPath = Path.Combine(AppContext.BaseDirectory, "config", "appsettings.json");
|
||||
Load();
|
||||
|
||||
_watcher = new FileSystemWatcher
|
||||
|
||||
@@ -119,9 +119,11 @@ public sealed class IndexBuilderService: IHostedService
|
||||
|
||||
var fileName =Uri.EscapeDataString($"{name}[{titleId}][v{versionNumberParsed}][{patchOrApp}].nsp");
|
||||
var url = $"{baseUri.ToString().TrimEnd('/')}/{fileName}";
|
||||
if (Uri.IsWellFormedUriString(url, UriKind.Absolute))
|
||||
var isWellFormed = Uri.TryCreate(url, UriKind.Absolute, out Uri? parsedUri);
|
||||
|
||||
if (isWellFormed && parsedUri != null)
|
||||
{
|
||||
fileDtos.Add(new FileDto(url, e.Size));
|
||||
fileDtos.Add(new FileDto(parsedUri.AbsoluteUri, e.Size));
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ using LibHac.Ncm;
|
||||
using LibHac.Tools.Fs;
|
||||
using LibHac.Tools.Ncm;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TinfoilVibeServer.Models;
|
||||
using Path = System.IO.Path;
|
||||
|
||||
namespace TinfoilVibeServer.Services
|
||||
@@ -34,21 +35,49 @@ namespace TinfoilVibeServer.Services
|
||||
/// </summary>
|
||||
public sealed class NSPExtractor : INSPExtractor
|
||||
{
|
||||
private readonly KeySet _keySet;
|
||||
private readonly ILogger<INSPExtractor> _logger;
|
||||
private KeySet? _keySet;
|
||||
|
||||
public NSPExtractor(IOptions<NSPExtractorOptions> options, ILogger<INSPExtractor> logger, IHostEnvironment environment)
|
||||
public KeySet? KeySet
|
||||
{
|
||||
var dataRoot = environment.ContentRootPath ?? "/app/config";
|
||||
if (Path.IsPathRooted(options.Value.keyFile))
|
||||
get
|
||||
{
|
||||
_keySet = ExternalKeyReader.ReadKeyFile(options.Value.keyFile);
|
||||
if (_keySet != null) return _keySet;
|
||||
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
|
||||
{
|
||||
_keySet = ExternalKeyReader.ReadKeyFile(Path.Combine(dataRoot, "config", _options.CurrentValue.KeyFile));
|
||||
}
|
||||
|
||||
return _keySet;
|
||||
}
|
||||
else
|
||||
}
|
||||
|
||||
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 =>
|
||||
{
|
||||
_keySet = ExternalKeyReader.ReadKeyFile(Path.Combine(dataRoot, "config", options.Value.keyFile));
|
||||
}
|
||||
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;
|
||||
_environment = environment;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -65,6 +94,8 @@ namespace TinfoilVibeServer.Services
|
||||
/// </summary>
|
||||
public NcaMetadataWithHash? ExtractFromStream(Stream stream)
|
||||
{
|
||||
if (KeySet == null) return null;
|
||||
|
||||
if (!stream.CanSeek) return null;
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
@@ -77,7 +108,7 @@ namespace TinfoilVibeServer.Services
|
||||
|
||||
if (IsXciFileSystem(stream))
|
||||
{
|
||||
var xci = new Xci(_keySet, storage);
|
||||
var xci = new Xci(KeySet, storage);
|
||||
List<DirectoryEntryEx> ncaEntries;
|
||||
if (xci.HasPartition(XciPartitionType.Secure))
|
||||
{
|
||||
@@ -95,7 +126,7 @@ namespace TinfoilVibeServer.Services
|
||||
using var ncaFile = fileRef.Release();
|
||||
using var ncaFileStorage = new FileStorage(ncaFile);
|
||||
|
||||
var nca = new Nca(_keySet, ncaFileStorage);
|
||||
var nca = new Nca(KeySet, ncaFileStorage);
|
||||
if (hash == null)
|
||||
{
|
||||
// Hash the *first* NCA stream – the stream we just opened
|
||||
@@ -122,6 +153,8 @@ namespace TinfoilVibeServer.Services
|
||||
|
||||
private NcaMetadataWithHash? ExtractNSPFromStream(StreamStorage storage)
|
||||
{
|
||||
if (KeySet == null) return null;
|
||||
|
||||
List<DirectoryEntryEx> ncaEntries;
|
||||
_logger.LogInformation("Processing as NSP");
|
||||
var partition = new PartitionFileSystem();
|
||||
@@ -139,7 +172,7 @@ namespace TinfoilVibeServer.Services
|
||||
using var ncaFile = fileRef.Release();
|
||||
using var ncaFileStorage = new FileStorage(ncaFile);
|
||||
|
||||
var nca = new Nca(_keySet, ncaFileStorage);
|
||||
var nca = new Nca(KeySet, ncaFileStorage);
|
||||
if (hash == null)
|
||||
{
|
||||
// Hash the *first* NCA stream – the stream we just opened
|
||||
@@ -208,6 +241,8 @@ namespace TinfoilVibeServer.Services
|
||||
}
|
||||
private bool IsXciFileSystem(Stream stream)
|
||||
{
|
||||
if (KeySet == null) return false;
|
||||
|
||||
try
|
||||
{
|
||||
if (!stream.CanSeek) return false;
|
||||
@@ -216,7 +251,7 @@ namespace TinfoilVibeServer.Services
|
||||
var storage = new StreamStorage(stream, true);
|
||||
try
|
||||
{
|
||||
var xciBlock = new Xci(_keySet, storage);
|
||||
var xciBlock = new Xci(KeySet, storage);
|
||||
_logger.LogInformation("XCI found");
|
||||
return xciBlock.HasPartition(XciPartitionType.Secure);
|
||||
}
|
||||
@@ -235,6 +270,8 @@ namespace TinfoilVibeServer.Services
|
||||
|
||||
public string ExtractHashFromStream(Stream nspStream)
|
||||
{
|
||||
if (KeySet == null) return string.Empty;
|
||||
|
||||
if (!IsPfs0FileSystem(nspStream))
|
||||
return string.Empty;
|
||||
|
||||
@@ -259,7 +296,7 @@ namespace TinfoilVibeServer.Services
|
||||
|
||||
try
|
||||
{
|
||||
var nca = new Nca(_keySet, ncaFileStorage);
|
||||
var nca = new Nca(KeySet, ncaFileStorage);
|
||||
if (nca.Header.ContentType != NcaContentType.Meta)
|
||||
continue; // only the meta NCA contains title metadata
|
||||
|
||||
@@ -283,29 +320,6 @@ namespace TinfoilVibeServer.Services
|
||||
|
||||
public class NSPExtractorOptions
|
||||
{
|
||||
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;
|
||||
}
|
||||
public string? KeyFile { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
||||
FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_snapshotPath) ?? throw new InvalidOperationException()));
|
||||
|
||||
// 1️⃣ Register for *property* changes
|
||||
options.OnChange(snapshotOptions =>
|
||||
options.OnChange((snapshotOptions, arg) =>
|
||||
{
|
||||
_options.RootDirectories = snapshotOptions.RootDirectories;
|
||||
});
|
||||
@@ -256,61 +256,40 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
||||
var index = LoadSnapshotIndex();
|
||||
var latestModifiedUtcParallel = FileSystemExtensions.GetLatestModifiedUtcParallel(_options.RootDirectories);
|
||||
var fileInfo = new FileInfo(_snapshotPath);
|
||||
bool snapshotVerified = true;
|
||||
bool snapshotVerified = fileInfo.Exists;
|
||||
if (latestModifiedUtcParallel.HasValue && latestModifiedUtcParallel.Value < fileInfo.LastWriteTimeUtc)
|
||||
{
|
||||
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)
|
||||
{
|
||||
// check first entry is in index
|
||||
var entry = BuildSnapshot(dir).FirstOrDefault();
|
||||
if (entry != null)
|
||||
var firstEntry = BuildSnapshot(dir).FirstOrDefault();
|
||||
if (firstEntry != null && !index.TryGetValue(firstEntry.Path, out _))
|
||||
{
|
||||
if (!index.TryGetValue(entry.Path, out var cached))
|
||||
{
|
||||
snapshotVerified = false;
|
||||
_logger.LogInformation("Snapshot does not contain first entry in directory {Directory}", dir);
|
||||
}
|
||||
snapshotVerified = false;
|
||||
_logger.LogInformation("Snapshot does not contain first entry in directory {Directory}", dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (snapshotVerified)
|
||||
if (!snapshotVerified)
|
||||
{
|
||||
_logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", _options.RootDirectories.Count);
|
||||
var entries = new List<FileEntry>();
|
||||
foreach (var dir in _options.RootDirectories)
|
||||
{
|
||||
foreach (var entry in BuildSnapshot(dir))
|
||||
{
|
||||
_logger.LogInformation("Snapshot is up to date");
|
||||
return Task.CompletedTask;
|
||||
if (entry != null) entries.Add(entry);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Snapshot is up to date but index is empty");
|
||||
}
|
||||
}
|
||||
_logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", _options.RootDirectories.Count);
|
||||
var entries = new List<FileEntry>();
|
||||
|
||||
var snapshotChanged = false;
|
||||
foreach (var dir in _options.RootDirectories)
|
||||
{
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
_logger.LogInformation("Rebuilding directory {Directory}", dir);
|
||||
var buildSnapshot = BuildSnapshot(dir);
|
||||
var fileEntries = buildSnapshot.ToList();
|
||||
snapshotChanged = snapshotChanged || fileEntries.Count != 0;
|
||||
entries.AddRange(fileEntries.Where(entry => entry != null)!);
|
||||
});
|
||||
var currentHash = ComputeSnapshotHash(entries);
|
||||
if (entries.Count > 0 || fileInfo.Exists && index.Count == 0)
|
||||
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
PersistSnapshotAsync();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -341,6 +320,15 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
||||
var titles = new List<(string, long, NcaMetadataWithHash)>();
|
||||
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);
|
||||
hash = ComputeFirstStreamHash(nspStream);
|
||||
|
||||
@@ -416,74 +404,71 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private string ComputeFirstStreamHash(Stream nspStream)
|
||||
{
|
||||
return _nspExtractor.ExtractHashFromStream(nspStream);
|
||||
}
|
||||
private string ComputeFirstStreamHash(Stream nspStream) => _nspExtractor.ExtractHashFromStream(nspStream);
|
||||
|
||||
private void UpdateSnapshot() => BuildSnapshotAsync();
|
||||
|
||||
IEnumerable<FileEntry> GetEntries()
|
||||
private IEnumerable<FileEntry> GetEntries()
|
||||
{
|
||||
foreach (var snapshotEntry in _cache)
|
||||
{
|
||||
_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;
|
||||
}
|
||||
foreach (var kv in _cache)
|
||||
yield return new FileEntry(kv.Key, kv.Value.Size, kv.Value.Hash, kv.Value.NcaMetadataWithHash);
|
||||
}
|
||||
|
||||
private Task PersistSnapshotAsync()
|
||||
{
|
||||
if (_debouncerCache.TryGetValue(_jsonPath, out var value))
|
||||
if (_debouncerCache.TryGetValue(_jsonPath, out _))
|
||||
{
|
||||
_logger.LogInformation("Sliding debounce in progress, skipping snapshot persistence");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var entries = GetEntries().ToList();
|
||||
var newHash = ComputeSnapshotHash(entries);
|
||||
var snapshot = GetSnapshot();
|
||||
var entries = GetEntries();
|
||||
var fileEntries = entries.ToList();
|
||||
var newHash = ComputeSnapshotHash(fileEntries);
|
||||
if (snapshot.Hash == newHash) return Task.CompletedTask;
|
||||
|
||||
CancellationTokenSource cts = new();
|
||||
_logger.LogInformation("Snapshot hash changed – persisting new snapshot");
|
||||
using var debouncedPersistence = _debouncerCache.CreateEntry(_jsonPath);
|
||||
debouncedPersistence.AddExpirationToken(new CancellationChangeToken(cts.Token));
|
||||
//debouncedPersistence.AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(DebounceMs);
|
||||
debouncedPersistence.Value = fileEntries;
|
||||
debouncedPersistence.PostEvictionCallbacks.Add(new PostEvictionCallbackRegistration
|
||||
{
|
||||
EvictionCallback = (key, entriesCallback, reason, state) =>
|
||||
var cancellationTokenSource = new CancellationTokenSource();
|
||||
using var cacheEntry = _debouncerCache.CreateEntry(_jsonPath)
|
||||
.AddExpirationToken(new CancellationChangeToken(cancellationTokenSource.Token))
|
||||
.SetValue(entries)
|
||||
.SetOptions(new MemoryCacheEntryOptions
|
||||
{
|
||||
if (entriesCallback is IEnumerable<FileEntry> entriesToPersist && key is string filePath)
|
||||
PostEvictionCallbacks =
|
||||
{
|
||||
if (_snapshotFileSemaphore.Wait(SnapshotFileLockTimeout))
|
||||
new PostEvictionCallbackRegistration
|
||||
{
|
||||
if (IsFileLocked(filePath))
|
||||
EvictionCallback = (key, value, reason, state) =>
|
||||
{
|
||||
_logger.LogInformation("File {FilePath} is locked, skipping snapshot persistence", filePath);
|
||||
if (!(reason == EvictionReason.Expired || reason == EvictionReason.TokenExpired))
|
||||
return;
|
||||
var filePath = (string)key;
|
||||
if (_snapshotFileSemaphore.Wait(SnapshotFileLockTimeout))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (IsFileLocked(filePath))
|
||||
{
|
||||
_logger.LogInformation("File {FilePath} is locked, skipping snapshot persistence", filePath);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.WriteAllText(filePath, JsonSerializer.Serialize(value, _jsonSerializerOptions));
|
||||
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_snapshotFileSemaphore.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
File.WriteAllText(filePath,
|
||||
JsonSerializer.Serialize(entriesToPersist, _jsonSerializerOptions));
|
||||
_snapshotFileSemaphore.Release();
|
||||
_logger.LogInformation("Persisted snapshot");
|
||||
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Failed to persist file {FilePath} due to timeout", filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
cts.CancelAfter(TimeSpan.FromMilliseconds(DebounceMs));
|
||||
});
|
||||
cancellationTokenSource.CancelAfter(TimeSpan.FromMilliseconds(DebounceMs));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
private static string ComputeHash(string filePath)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
@@ -523,12 +508,29 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
||||
_logger.LogWarning("Duplicate hash found in snapshot: {Hash}, {OldPath}, {newPath}", fileEntry.Hash, value, fileEntry.Path);
|
||||
}
|
||||
|
||||
if (!File.Exists(fileEntry.Path))
|
||||
var nspOrArchivePath = fileEntry.Path.Split(ArchivePathSeparator)[0];
|
||||
if (!File.Exists(nspOrArchivePath))
|
||||
{
|
||||
_logger.LogWarning("Nonexistent entry found: {Path}", fileEntry.Path);
|
||||
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 (fileEntry.Path.Contains(ArchivePathSeparator))
|
||||
|
||||
@@ -24,9 +24,7 @@ public sealed class TitleDatabaseService : IHostedService
|
||||
private readonly IOptionsMonitor<TitleDbOptions> _options;
|
||||
private readonly ILogger<TitleDatabaseService> _logger;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly INSPExtractor _nspExtractor;
|
||||
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 ISnapshotService _snapshotService;
|
||||
@@ -50,7 +48,6 @@ public sealed class TitleDatabaseService : IHostedService
|
||||
/// directories that contain the NSP files.
|
||||
/// </summary>
|
||||
public TitleDatabaseService(
|
||||
IConfiguration configuration,
|
||||
IOptionsMonitor<TitleDbOptions> options,
|
||||
ILogger<TitleDatabaseService> logger,
|
||||
ISnapshotService snapshotService,
|
||||
@@ -62,11 +59,10 @@ public sealed class TitleDatabaseService : IHostedService
|
||||
_logger = logger;
|
||||
_snapshotService = snapshotService;
|
||||
_httpFactory = httpFactory;
|
||||
_nspExtractor = nspExtractor;
|
||||
_cache = cache;
|
||||
|
||||
_cacheFolder = Path.Combine(AppContext.BaseDirectory, "titledb-cache");
|
||||
_rootDirectories = new List<string>
|
||||
_cacheFolder = Path.Combine(AppContext.BaseDirectory, "data", "titledb-cache");
|
||||
new List<string>
|
||||
{
|
||||
// You can extend this list – it is the set of directories that
|
||||
// are scanned when the service starts up.
|
||||
|
||||
@@ -58,5 +58,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Remove="obj\**" />
|
||||
<EmbeddedResource Include="appsettings.default.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace TinfoilVibeServer.Models;
|
||||
namespace TinfoilVibeServer.Utilities;
|
||||
|
||||
|
||||
public static class IdHelper
|
||||
@@ -0,0 +1,30 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ services:
|
||||
image: gitea.ecenshu.net/ecenshu/tinfoilvibeserver:latest
|
||||
container_name: tinfoilvibeserver
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
||||
Reference in New Issue
Block a user