feature/ci (#1)
Consolidate data and config into separate folders that will be expected to be mapped in the container Reviewed-on: #1 Co-authored-by: Huy Nguyen <ecenshu@gmail.com> Co-committed-by: Huy Nguyen <ecenshu@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
+2
-1
@@ -22,4 +22,5 @@
|
|||||||
**/secrets.dev.yaml
|
**/secrets.dev.yaml
|
||||||
**/values.dev.yaml
|
**/values.dev.yaml
|
||||||
LICENSE
|
LICENSE
|
||||||
README.md
|
README.md
|
||||||
|
data/
|
||||||
|
|||||||
@@ -23,7 +23,30 @@ 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 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
|
||||||
- name: Cache Docker layers
|
- name: Cache Docker layers
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
@@ -42,7 +65,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 }}:${{ 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
|
${{ 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
|
||||||
|
|||||||
+8
-1
@@ -2,4 +2,11 @@ bin/
|
|||||||
obj/
|
obj/
|
||||||
/packages/
|
/packages/
|
||||||
riderModule.iml
|
riderModule.iml
|
||||||
/_ReSharper.Caches/
|
/_ReSharper.Caches/
|
||||||
|
TinfoilVibeServer.sln.DotSettings.user
|
||||||
|
data/*
|
||||||
|
!.data/.gitkeep
|
||||||
|
**/*.local.json
|
||||||
|
TinfoilVibeServer/config/prod.keys
|
||||||
|
TinfoilVibeServer/data/*
|
||||||
|
!TinfoilVibeServer/data/.gitkeep
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="TinfoilVibeServer: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
|
||||||
|
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/TinfoilVibeServer/TinfoilVibeServer.csproj" />
|
||||||
|
<option name="LAUNCH_PROFILE_TFM" value="net9.0" />
|
||||||
|
<option name="LAUNCH_PROFILE_NAME" value="http" />
|
||||||
|
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
||||||
|
<option name="USE_MONO" value="0" />
|
||||||
|
<option name="RUNTIME_ARGUMENTS" value="" />
|
||||||
|
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
|
||||||
|
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
|
||||||
|
<option name="SEND_DEBUG_REQUEST" value="1" />
|
||||||
|
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
|
||||||
|
<option name="AUTO_ATTACH_CHILDREN" value="0" />
|
||||||
|
<method v="2">
|
||||||
|
<option name="Build" />
|
||||||
|
</method>
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="TinfoilVibeServer/Dockerfile" type="docker-deploy" factoryName="dockerfile" server-name="Docker WSL">
|
||||||
|
<deployment type="dockerfile">
|
||||||
|
<settings>
|
||||||
|
<option name="imageTag" value="gitea.ecenshu.net/ecenshu/tinfoilvibeserver:dev" />
|
||||||
|
<option name="containerName" value="tinfoilvibeserver" />
|
||||||
|
<option name="contextFolderPath" value="." />
|
||||||
|
<option name="envVars">
|
||||||
|
<list>
|
||||||
|
<DockerEnvVarImpl>
|
||||||
|
<option name="name" value="uid" />
|
||||||
|
<option name="value" value="1034" />
|
||||||
|
</DockerEnvVarImpl>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="portBindings">
|
||||||
|
<list>
|
||||||
|
<DockerPortBindingImpl>
|
||||||
|
<option name="containerPort" value="8080" />
|
||||||
|
<option name="hostIp" value="127.0.0.1" />
|
||||||
|
<option name="hostPort" value="8080" />
|
||||||
|
</DockerPortBindingImpl>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="showCommandPreview" value="true" />
|
||||||
|
<option name="sourceFilePath" value="TinfoilVibeServer/Dockerfile" />
|
||||||
|
<option name="volumeBindings">
|
||||||
|
<list>
|
||||||
|
<DockerVolumeBindingImpl>
|
||||||
|
<option name="containerPath" value="/app/data" />
|
||||||
|
<option name="hostPath" value="D:\Cloud\Git\TinfoilVibeServer\TinfoilVibeServer\bin\Debug\net9.0\data" />
|
||||||
|
</DockerVolumeBindingImpl>
|
||||||
|
<DockerVolumeBindingImpl>
|
||||||
|
<option name="containerPath" value="/app/config" />
|
||||||
|
<option name="hostPath" value="D:\Cloud\Git\TinfoilVibeServer\TinfoilVibeServer\bin\Debug\net9.0\config" />
|
||||||
|
</DockerVolumeBindingImpl>
|
||||||
|
<DockerVolumeBindingImpl>
|
||||||
|
<option name="containerPath" value="/roms_cold" />
|
||||||
|
<option name="hostPath" value="Z:\downloads\roms\switch" />
|
||||||
|
<option name="readOnly" value="true" />
|
||||||
|
</DockerVolumeBindingImpl>
|
||||||
|
<DockerVolumeBindingImpl>
|
||||||
|
<option name="containerPath" value="/roms_hot" />
|
||||||
|
<option name="hostPath" value="Z:\imgs\roms\Switch" />
|
||||||
|
<option name="readOnly" value="true" />
|
||||||
|
</DockerVolumeBindingImpl>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</settings>
|
||||||
|
</deployment>
|
||||||
|
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@@ -3,6 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00
|
|||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{41999D26-405C-4DAB-8991-CBA992117C84}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{41999D26-405C-4DAB-8991-CBA992117C84}"
|
||||||
ProjectSection(SolutionItems) = preProject
|
ProjectSection(SolutionItems) = preProject
|
||||||
compose.yaml = compose.yaml
|
compose.yaml = compose.yaml
|
||||||
|
compose.overide.yaml = compose.overide.yaml
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
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}"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public interface IAuthStore
|
|||||||
void Dispose();
|
void Dispose();
|
||||||
bool TryValidate(string username,
|
bool TryValidate(string username,
|
||||||
string password,
|
string password,
|
||||||
int? uid,
|
string? uid,
|
||||||
string ip,
|
string ip,
|
||||||
out string? error);
|
out string? error);
|
||||||
|
|
||||||
@@ -29,33 +29,41 @@ public class AuthStore : IDisposable, IAuthStore
|
|||||||
{
|
{
|
||||||
private readonly ILogger<AuthStore> _logger;
|
private readonly ILogger<AuthStore> _logger;
|
||||||
private readonly ConfigManager _configManager;
|
private readonly ConfigManager _configManager;
|
||||||
|
private readonly IHostEnvironment _env;
|
||||||
|
|
||||||
public readonly ConcurrentDictionary<string, Credential> Credentials = new();
|
public readonly ConcurrentDictionary<string, Credential> Credentials = new();
|
||||||
public readonly ConcurrentDictionary<string, List<int>> Fingerprints = new();
|
public readonly ConcurrentDictionary<string, List<string>> Fingerprints = new();
|
||||||
public readonly ConcurrentDictionary<string, int> FailedAttempts = new();
|
public readonly ConcurrentDictionary<string, int> FailedAttempts = new();
|
||||||
private readonly HashSet<string> BlacklistIPs = new();
|
private readonly HashSet<string> BlacklistIPs = new();
|
||||||
|
|
||||||
private readonly object _sync = new();
|
private readonly object _sync = new();
|
||||||
private readonly FileSystemWatcher _credentialsWatcher;
|
private readonly FileSystemWatcher _credentialsWatcher;
|
||||||
|
|
||||||
public AuthStore(ILogger<AuthStore> logger, ConfigManager configManager)
|
public AuthStore(ILogger<AuthStore> logger, ConfigManager configManager, IHostEnvironment env)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_configManager = configManager;
|
_configManager = configManager;
|
||||||
|
_env = env;
|
||||||
LoadAll();
|
|
||||||
|
|
||||||
var directoryName = Path.GetDirectoryName(_configManager.Settings.CredentialsFile);
|
LoadAll();
|
||||||
|
var credentialsFilePath = DetermineCredentialsPath(_configManager.Settings?.CredentialsFile, env);
|
||||||
|
var directoryName = Path.GetDirectoryName(Path.Combine(credentialsFilePath));
|
||||||
_credentialsWatcher = new FileSystemWatcher
|
_credentialsWatcher = new FileSystemWatcher
|
||||||
{
|
{
|
||||||
Path = (!string.IsNullOrEmpty(directoryName))?directoryName : AppContext.BaseDirectory,
|
Path = (!string.IsNullOrEmpty(directoryName)) ? directoryName : AppContext.BaseDirectory,
|
||||||
Filter = Path.GetFileName(_configManager.Settings.CredentialsFile),
|
Filter = Path.GetFileName(credentialsFilePath) ?? throw new InvalidOperationException(),
|
||||||
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.Attributes
|
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.Attributes
|
||||||
};
|
};
|
||||||
_credentialsWatcher.Changed += (_, _) => OnCredentialsChanged();
|
_credentialsWatcher.Changed += (_, _) => OnCredentialsChanged();
|
||||||
_credentialsWatcher.EnableRaisingEvents = true;
|
_credentialsWatcher.EnableRaisingEvents = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_credentialsWatcher?.Dispose();
|
_credentialsWatcher?.Dispose();
|
||||||
@@ -65,25 +73,26 @@ public class AuthStore : IDisposable, IAuthStore
|
|||||||
|
|
||||||
private void LoadAll()
|
private void LoadAll()
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Loading authentication data from {File}", _configManager.Settings.CredentialsFile);
|
var credentialsFilePath = DetermineCredentialsPath(_configManager.Settings?.CredentialsFile, _env);
|
||||||
|
_logger.LogInformation("Loading authentication data from {File}", credentialsFilePath);
|
||||||
// credentials
|
// credentials
|
||||||
if (File.Exists(_configManager.Settings.CredentialsFile))
|
if (File.Exists(credentialsFilePath))
|
||||||
{
|
{
|
||||||
var txt = File.ReadAllText(_configManager.Settings.CredentialsFile);
|
var txt = File.ReadAllText(credentialsFilePath);
|
||||||
var dict = JsonSerializer.Deserialize<Dictionary<string, Credential>>(txt)!;
|
var dict = JsonSerializer.Deserialize<Dictionary<string, Credential>>(txt)!;
|
||||||
foreach (var kv in dict)
|
foreach (var kv in dict)
|
||||||
Credentials[kv.Key] = kv.Value;
|
Credentials[kv.Key] = kv.Value;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.CredentialsFile)));
|
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings?.CredentialsFile ?? throw new InvalidOperationException())));
|
||||||
}
|
}
|
||||||
|
|
||||||
// fingerprints
|
// fingerprints
|
||||||
if (File.Exists(_configManager.Settings.FingerprintsFile))
|
if (File.Exists(_configManager.Settings.FingerprintsFile))
|
||||||
{
|
{
|
||||||
var txt = File.ReadAllText(_configManager.Settings.FingerprintsFile);
|
var txt = File.ReadAllText(_configManager.Settings.FingerprintsFile);
|
||||||
var dict = JsonSerializer.Deserialize<Dictionary<string, List<int>>>(txt)!;
|
var dict = JsonSerializer.Deserialize<Dictionary<string, List<string>>>(txt)!;
|
||||||
foreach (var kv in dict)
|
foreach (var kv in dict)
|
||||||
Fingerprints[kv.Key] = kv.Value;
|
Fingerprints[kv.Key] = kv.Value;
|
||||||
}
|
}
|
||||||
@@ -104,6 +113,7 @@ public class AuthStore : IDisposable, IAuthStore
|
|||||||
{
|
{
|
||||||
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.BlacklistFile)));
|
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.BlacklistFile)));
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Loaded {UserCount} users, {FpCount} fingerprints, {IpCount} IPs",
|
_logger.LogInformation("Loaded {UserCount} users, {FpCount} fingerprints, {IpCount} IPs",
|
||||||
Credentials.Count, Fingerprints.Count, BlacklistIPs.Count);
|
Credentials.Count, Fingerprints.Count, BlacklistIPs.Count);
|
||||||
}
|
}
|
||||||
@@ -124,15 +134,17 @@ public class AuthStore : IDisposable, IAuthStore
|
|||||||
|
|
||||||
private void ReloadCredentials()
|
private void ReloadCredentials()
|
||||||
{
|
{
|
||||||
if (!File.Exists(_configManager.Settings.CredentialsFile))
|
var credentialsFilePath = DetermineCredentialsPath(_configManager.Settings?.CredentialsFile, _env);
|
||||||
|
if (!File.Exists(credentialsFilePath))
|
||||||
{
|
{
|
||||||
_logger.LogError("Credentials file {File} does not exist", _configManager.Settings.CredentialsFile);
|
_logger.LogError("Credentials file {File} does not exist", credentialsFilePath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var txt = File.ReadAllText(_configManager.Settings.CredentialsFile);
|
|
||||||
|
var txt = File.ReadAllText(credentialsFilePath);
|
||||||
var newDict = JsonSerializer.Deserialize<Dictionary<string, Credential>>(txt)!;
|
var newDict = JsonSerializer.Deserialize<Dictionary<string, Credential>>(txt)!;
|
||||||
|
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
@@ -142,9 +154,9 @@ public class AuthStore : IDisposable, IAuthStore
|
|||||||
{
|
{
|
||||||
if (Credentials.TryGetValue(kv.Key, out var existing))
|
if (Credentials.TryGetValue(kv.Key, out var existing))
|
||||||
{
|
{
|
||||||
existing.PasswordHash = kv.Value.PasswordHash;
|
existing.PasswordHash = kv.Value.PasswordHash;
|
||||||
existing.AllowedUidCount = kv.Value.AllowedUidCount;
|
existing.AllowedUidCount = kv.Value.AllowedUidCount;
|
||||||
existing.Verified = kv.Value.Verified;
|
existing.Verified = kv.Value.Verified;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -162,9 +174,9 @@ public class AuthStore : IDisposable, IAuthStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch(Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to reload credentials from {File}", _configManager.Settings.CredentialsFile);
|
_logger.LogError(ex, "Failed to reload credentials from {File}", credentialsFilePath);
|
||||||
// ignore – malformed JSON or IO error – keep old state
|
// ignore – malformed JSON or IO error – keep old state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,11 +185,7 @@ public class AuthStore : IDisposable, IAuthStore
|
|||||||
|
|
||||||
#region Authentication logic
|
#region Authentication logic
|
||||||
|
|
||||||
public bool TryValidate(string username,
|
public bool TryValidate(string username, string password, string? uid, string ip, out string? error)
|
||||||
string password,
|
|
||||||
int? uid,
|
|
||||||
string ip,
|
|
||||||
out string? error)
|
|
||||||
{
|
{
|
||||||
error = null;
|
error = null;
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
@@ -190,14 +198,6 @@ public class AuthStore : IDisposable, IAuthStore
|
|||||||
PersistCredentials();
|
PersistCredentials();
|
||||||
_logger.LogInformation("Created new user {Username} (verified={Verified})", username, cred.Verified);
|
_logger.LogInformation("Created new user {Username} (verified={Verified})", username, cred.Verified);
|
||||||
|
|
||||||
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";
|
error = "User not verified";
|
||||||
IncrementFailed(username, ip);
|
IncrementFailed(username, ip);
|
||||||
return false;
|
return false;
|
||||||
@@ -218,15 +218,15 @@ public class AuthStore : IDisposable, IAuthStore
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uid.HasValue)
|
if (uid != null)
|
||||||
{
|
{
|
||||||
var list = Fingerprints.GetOrAdd(username, _ => new List<int>());
|
var list = Fingerprints.GetOrAdd(username, _ => new List<string>());
|
||||||
|
|
||||||
if (!list.Contains(uid.Value))
|
if (!list.Contains(uid))
|
||||||
{
|
{
|
||||||
if (list.Count < cred.AllowedUidCount)
|
if (list.Count < cred.AllowedUidCount)
|
||||||
{
|
{
|
||||||
list.Add(uid.Value);
|
list.Add(uid);
|
||||||
PersistFingerprints();
|
PersistFingerprints();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -237,6 +237,11 @@ public class AuthStore : IDisposable, IAuthStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No Fingerprint given during authentication for {Username} from {IP}", username, ip);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
FailedAttempts[username] = 0;
|
FailedAttempts[username] = 0;
|
||||||
return true;
|
return true;
|
||||||
@@ -245,23 +250,30 @@ public class AuthStore : IDisposable, IAuthStore
|
|||||||
|
|
||||||
public int IncrementFailed(string username, string ip)
|
public int IncrementFailed(string username, string ip)
|
||||||
{
|
{
|
||||||
var newCount = FailedAttempts.GetOrAdd(username, 0) + 1;
|
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
|
var newCount = FailedAttempts.GetOrAdd(username, 0) + 1;
|
||||||
FailedAttempts[username] = newCount;
|
FailedAttempts[username] = newCount;
|
||||||
}
|
_logger.LogInformation("Failed attempts for {Username} increased to {Count}", username, newCount);
|
||||||
_logger.LogInformation("Failed attempts for {Username} increased to {Count}", username, newCount);
|
|
||||||
|
|
||||||
if (newCount < _configManager.Settings.MaxFailedAttempts+1) return newCount;
|
if (_configManager.Settings == null)
|
||||||
|
{
|
||||||
BlacklistIPs.Add(ip);
|
_logger.LogCritical("Settings not set to determine failed login counts");
|
||||||
PersistBlacklist();
|
return int.MaxValue;
|
||||||
lock (_sync)
|
}
|
||||||
{
|
|
||||||
FailedAttempts[username] = 0;
|
if (newCount < _configManager.Settings.MaxFailedAttempts + 1) return newCount;
|
||||||
|
|
||||||
|
BlacklistIPs.Add(ip);
|
||||||
|
PersistBlacklist();
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
FailedAttempts[username] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogWarning("IP {IP} blacklisted after {Count} failures", ip, newCount);
|
||||||
|
return newCount;
|
||||||
}
|
}
|
||||||
_logger.LogWarning("IP {IP} blacklisted after {Count} failures", ip, newCount);
|
|
||||||
return newCount;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -280,25 +292,48 @@ public class AuthStore : IDisposable, IAuthStore
|
|||||||
|
|
||||||
private void PersistCredentials()
|
private void PersistCredentials()
|
||||||
{
|
{
|
||||||
|
var credentialsFilePath = DetermineCredentialsPath(_configManager.Settings?.CredentialsFile, _env);
|
||||||
var json = JsonSerializer.Serialize(Credentials, new JsonSerializerOptions { WriteIndented = true });
|
var json = JsonSerializer.Serialize(Credentials, new JsonSerializerOptions { WriteIndented = true });
|
||||||
File.WriteAllText(_configManager.Settings.CredentialsFile, json);
|
if (_configManager.Settings == null)
|
||||||
|
{
|
||||||
|
_logger.LogCritical("Credentials file not set, cannot persist");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
File.WriteAllText(credentialsFilePath, json);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PersistFingerprints()
|
private void PersistFingerprints()
|
||||||
{
|
{
|
||||||
var json = JsonSerializer.Serialize(Fingerprints, new JsonSerializerOptions { WriteIndented = true });
|
var json = JsonSerializer.Serialize(Fingerprints, new JsonSerializerOptions { WriteIndented = true });
|
||||||
File.WriteAllText(_configManager.Settings.FingerprintsFile, json);
|
if (_configManager.Settings == null)
|
||||||
|
{
|
||||||
|
_logger.LogCritical("Fingerprint file not set, cannot persist");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
File.WriteAllText(_configManager.Settings.FingerprintsFile, json);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PersistBlacklist()
|
private void PersistBlacklist()
|
||||||
{
|
{
|
||||||
var json = JsonSerializer.Serialize(BlacklistIPs.ToArray(), new JsonSerializerOptions { WriteIndented = true });
|
var json = JsonSerializer.Serialize(BlacklistIPs.ToArray(), new JsonSerializerOptions { WriteIndented = true });
|
||||||
File.WriteAllText(_configManager.Settings.BlacklistFile, json);
|
if (_configManager.Settings == null)
|
||||||
|
{
|
||||||
|
_logger.LogCritical("Blacklist file not set, cannot persist");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
File.WriteAllText(_configManager.Settings.BlacklistFile, json);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Blacklist helpers
|
#region Blacklist helpers
|
||||||
|
|
||||||
public bool IsIPBlacklisted(string ipAddress)
|
public bool IsIPBlacklisted(string ipAddress)
|
||||||
{
|
{
|
||||||
return BlacklistIPs.Contains(ipAddress);
|
return BlacklistIPs.Contains(ipAddress);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ public class CancelableFileResult : FileResult
|
|||||||
/// Allows you to set a suggested download name.
|
/// Allows you to set a suggested download name.
|
||||||
/// It will be sent in a “Content‑Disposition” header.
|
/// It will be sent in a “Content‑Disposition” header.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? FileDownloadName { get; set; }
|
public new string? FileDownloadName { get; set; }
|
||||||
|
|
||||||
public override async Task ExecuteResultAsync(ActionContext context)
|
public override async Task ExecuteResultAsync(ActionContext context)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ public sealed class IndexController : ControllerBase
|
|||||||
FileMode.Open,
|
FileMode.Open,
|
||||||
FileAccess.Read,
|
FileAccess.Read,
|
||||||
FileShare.Read,
|
FileShare.Read,
|
||||||
bufferSize: 128 * 1024 * 1024, // 81920, // 80 KiB
|
bufferSize: 128 * 1024 * 1024, // 81920, // 80 KiB
|
||||||
useAsync: true); // <‑‑ VERY important for scalability
|
useAsync: true); // <‑‑ VERY important for scalability
|
||||||
|
|
||||||
// 2️⃣ Return a cancellation‑aware result
|
// 2️⃣ Return a cancellation‑aware result
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ public sealed class BasicAuthMiddleware
|
|||||||
{
|
{
|
||||||
logger.LogWarning("Missing Authorization header from {IP}", ip);
|
logger.LogWarning("Missing Authorization header from {IP}", ip);
|
||||||
Challenge(context);
|
Challenge(context);
|
||||||
logger.LogInformation("Sent 401 challenge to client");
|
logger.LogTrace("Sent 401 challenge to client");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,14 +50,14 @@ public sealed class BasicAuthMiddleware
|
|||||||
if (!authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
|
if (!authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
Challenge(context);
|
Challenge(context);
|
||||||
logger.LogInformation("Sent 401 challenge to client");
|
logger.LogTrace("Sent 401 challenge to client");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
string decoded;
|
string decoded;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var b64 = authHeader[6..].Trim();
|
var b64 = authHeader.Substring(6).Trim();
|
||||||
decoded = Encoding.UTF8.GetString(Convert.FromBase64String(b64));
|
decoded = Encoding.UTF8.GetString(Convert.FromBase64String(b64));
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -79,12 +79,9 @@ public sealed class BasicAuthMiddleware
|
|||||||
var password = parts[1];
|
var password = parts[1];
|
||||||
|
|
||||||
// 3) UID header (optional)
|
// 3) UID header (optional)
|
||||||
int? uid = null;
|
string? uid = null;
|
||||||
if (context.Request.Headers.TryGetValue("UID", out var uidHeader))
|
if (context.Request.Headers.TryGetValue("UID", out var uidHeader))
|
||||||
{
|
uid = uidHeader.FirstOrDefault();
|
||||||
if (int.TryParse(uidHeader.ToString(), out var parsedUid))
|
|
||||||
uid = parsedUid;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) Validate
|
// 4) Validate
|
||||||
if (!store.TryValidate(username, password, uid, ip, out var error))
|
if (!store.TryValidate(username, password, uid, ip, out var error))
|
||||||
|
|||||||
@@ -10,6 +10,5 @@ public sealed record AppSettings(
|
|||||||
string CredentialsFile,
|
string CredentialsFile,
|
||||||
string FingerprintsFile,
|
string FingerprintsFile,
|
||||||
string BlacklistFile,
|
string BlacklistFile,
|
||||||
int MaxFailedAttempts,
|
int MaxFailedAttempts
|
||||||
string KeySetFile
|
|
||||||
);
|
);
|
||||||
@@ -2,5 +2,9 @@
|
|||||||
|
|
||||||
public class IndexBuilderSettings
|
public class IndexBuilderSettings
|
||||||
{
|
{
|
||||||
|
public string CacheFilePath { get; set; } = "indexcache.json";
|
||||||
public string ApiBaseUrl { get; set; } = "http://tinfoil.localhost";
|
public string ApiBaseUrl { get; set; } = "http://tinfoil.localhost";
|
||||||
|
public ICollection<string>? IndexDirectories { get; set; }
|
||||||
|
|
||||||
|
public string? LoginMessage { get; set; }
|
||||||
}
|
}
|
||||||
@@ -1,36 +1,41 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Models;
|
namespace TinfoilVibeServer.Models;
|
||||||
|
|
||||||
public sealed class SnapshotOptions : INotifyPropertyChanged
|
public sealed class SnapshotOptions : INotifyPropertyChanged
|
||||||
{
|
{
|
||||||
private List<string> _rootDirectories = new();
|
private List<string> _rootDirectories = [];
|
||||||
|
|
||||||
public List<string> RootDirectories
|
public List<string> RootDirectories
|
||||||
{
|
{
|
||||||
get => _rootDirectories;
|
get => _rootDirectories;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
if (_rootDirectories != value)
|
if (_rootDirectories.Except(value) == Array.Empty<string>()) return;
|
||||||
{
|
|
||||||
_rootDirectories = value;
|
_rootDirectories = value;
|
||||||
OnPropertyChanged(nameof(RootDirectories));
|
OnPropertyChanged(nameof(RootDirectories));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<string> _archiveExtensions = new();
|
private List<string> _archiveExtensions = new();
|
||||||
|
|
||||||
public List<string> ArchiveExtensions
|
public List<string> ArchiveExtensions
|
||||||
{
|
{
|
||||||
get => _archiveExtensions;
|
get => _archiveExtensions;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
if (_archiveExtensions != value)
|
if (_archiveExtensions.Except(value) == Array.Empty<string>()) return;
|
||||||
{
|
|
||||||
_archiveExtensions = value;
|
_archiveExtensions = value;
|
||||||
OnPropertyChanged(nameof(_archiveExtensions));
|
OnPropertyChanged(nameof(_archiveExtensions));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<string> _romExtensions = new();
|
private List<string> _romExtensions = new();
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "No ROM extension specified")]
|
||||||
public List<string> RomExtensions
|
public List<string> RomExtensions
|
||||||
{
|
{
|
||||||
get => _romExtensions;
|
get => _romExtensions;
|
||||||
@@ -43,7 +48,9 @@ public sealed class SnapshotOptions : INotifyPropertyChanged
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private TimeSpan _cacheTtl = TimeSpan.FromHours(1);
|
private TimeSpan _cacheTtl = TimeSpan.FromHours(1);
|
||||||
|
|
||||||
public TimeSpan CacheTtl
|
public TimeSpan CacheTtl
|
||||||
{
|
{
|
||||||
get => _cacheTtl;
|
get => _cacheTtl;
|
||||||
@@ -56,7 +63,7 @@ public sealed class SnapshotOptions : INotifyPropertyChanged
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string _snapshotFile = "snapshot.json";
|
private string _snapshotFile = "snapshot.json";
|
||||||
|
|
||||||
public string SnapshotFile
|
public string SnapshotFile
|
||||||
@@ -64,25 +71,27 @@ public sealed class SnapshotOptions : INotifyPropertyChanged
|
|||||||
get => _snapshotFile;
|
get => _snapshotFile;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
if (string.Equals(_snapshotFile,value, StringComparison.InvariantCultureIgnoreCase)) return;
|
if (string.Equals(_snapshotFile, value, StringComparison.InvariantCultureIgnoreCase)) return;
|
||||||
_snapshotFile = value;
|
_snapshotFile = value;
|
||||||
OnPropertyChanged(nameof(SnapshotFile));
|
OnPropertyChanged(nameof(SnapshotFile));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string _snapshotBackupFile = "snapshot.bak";
|
private string _snapshotBackupFile = "snapshot.bak";
|
||||||
|
|
||||||
public string SnapshotBackupFile
|
public string SnapshotBackupFile
|
||||||
{
|
{
|
||||||
get => _snapshotBackupFile;
|
get => _snapshotBackupFile;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
if (string.Equals(_snapshotBackupFile,value, StringComparison.InvariantCultureIgnoreCase)) return;
|
if (string.Equals(_snapshotBackupFile, value, StringComparison.InvariantCultureIgnoreCase)) return;
|
||||||
_snapshotBackupFile = value;
|
_snapshotBackupFile = value;
|
||||||
OnPropertyChanged(nameof(SnapshotBackupFile));
|
OnPropertyChanged(nameof(SnapshotBackupFile));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public event PropertyChangedEventHandler? PropertyChanged;
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
private void OnPropertyChanged(string propertyName) =>
|
private void OnPropertyChanged(string propertyName) =>
|
||||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||||
}
|
}
|
||||||
@@ -9,31 +9,34 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
builder.Logging.ClearProviders();
|
builder.Logging.ClearProviders();
|
||||||
builder.Logging.AddConsole();
|
builder.Logging.AddConsole();
|
||||||
builder.Logging.AddDebug();
|
builder.Logging.AddDebug();
|
||||||
// -------------------------------------------------------------------
|
|
||||||
// 1) Configuration – read appsettings.json once and expose it via
|
|
||||||
// ConfigManager (reloads on file change)
|
|
||||||
// -------------------------------------------------------------------
|
|
||||||
builder.Services.AddMemoryCache();
|
builder.Services.AddMemoryCache();
|
||||||
|
var dataRoot = builder.Configuration["CONFIG_ROOT"] ?? "/app/config/";
|
||||||
|
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddJsonFile(Path.Combine(dataRoot,"appsettings.json"), optional: false, reloadOnChange: true)
|
||||||
|
.AddJsonFile(Path.Combine(dataRoot,$"appsettings.{builder.Environment.EnvironmentName}.json"), optional: true, reloadOnChange: true)
|
||||||
|
.AddJsonFile(Path.Combine(dataRoot,"appsettings.local.json"), optional: true, reloadOnChange: true)
|
||||||
|
.AddEnvironmentVariables()
|
||||||
|
.AddCommandLine(args).Build();
|
||||||
|
builder.Configuration.AddConfiguration(config);
|
||||||
|
builder.Services.AddOptions();
|
||||||
builder.Services.Configure<TitleDbOptions>(builder.Configuration.GetSection("TitleDb"));
|
builder.Services.Configure<TitleDbOptions>(builder.Configuration.GetSection("TitleDb"));
|
||||||
|
builder.Services.Configure<NSPExtractorOptions>(builder.Configuration.GetSection("NSPExtractor"));
|
||||||
builder.Services.Configure<AuthSettings>(builder.Configuration.GetSection("AuthSettings"));
|
builder.Services.Configure<AuthSettings>(builder.Configuration.GetSection("AuthSettings"));
|
||||||
builder.Services.Configure<SnapshotOptions>(builder.Configuration.GetSection("Snapshot"));
|
builder.Services.Configure<IndexBuilderSettings>(builder.Configuration.GetSection("IndexBuilder"));
|
||||||
|
builder.Services.AddOptions<SnapshotOptions>().Bind(builder.Configuration.GetSection("Snapshot")).ValidateOnStart();
|
||||||
builder.Services.AddSingleton<ConfigManager>();
|
builder.Services.AddSingleton<ConfigManager>();
|
||||||
builder.Services.AddSingleton<INSPExtractor, NSPExtractor>(sp =>
|
builder.Services.AddSingleton<INSPExtractor, NSPExtractor>();
|
||||||
{
|
|
||||||
var config = sp.GetRequiredService<ConfigManager>();
|
|
||||||
var logger = sp.GetRequiredService<ILogger<INSPExtractor>>();
|
|
||||||
var keySet = KeySetHolder.KeySet; // already loaded by ConfigManager
|
|
||||||
return new NSPExtractor(keySet, logger);
|
|
||||||
});
|
|
||||||
builder.Services.AddSingleton<SnapshotService>();
|
builder.Services.AddSingleton<SnapshotService>();
|
||||||
builder.Services.AddSingleton<ISnapshotService, SnapshotService>(sp => sp.GetRequiredService<SnapshotService>());
|
builder.Services.AddSingleton<ISnapshotService, SnapshotService>(sp => sp.GetRequiredService<SnapshotService>());
|
||||||
builder.Services.AddSingleton<IAuthStore, AuthStore>();
|
builder.Services.AddSingleton<IAuthStore, AuthStore>();
|
||||||
builder.Services.AddSingleton<TitleDatabaseService>();
|
builder.Services.AddSingleton<TitleDatabaseService>();
|
||||||
builder.Services.AddSingleton<IArchiveHandler, ArchiveHandler>();
|
builder.Services.AddSingleton<IArchiveHandler, ArchiveHandler>();
|
||||||
builder.Services.AddSingleton<IndexBuilderService>();
|
builder.Services.AddSingleton<IndexBuilderService>();
|
||||||
|
builder.Services.AddHostedService<IndexBuilderService>(provider => provider.GetRequiredService<IndexBuilderService>());
|
||||||
builder.Services.AddHostedService<SnapshotService>(provider => provider.GetRequiredService<SnapshotService>());
|
builder.Services.AddHostedService<SnapshotService>(provider => provider.GetRequiredService<SnapshotService>());
|
||||||
builder.Services.AddHostedService<TitleDatabaseService>(provider => provider.GetRequiredService<TitleDatabaseService>()).AddHttpClient();
|
builder.Services.AddHostedService<TitleDatabaseService>(provider => provider.GetRequiredService<TitleDatabaseService>()).AddHttpClient();
|
||||||
builder.Services.AddHostedService<IndexBuilderService>(provider => provider.GetRequiredService<IndexBuilderService>());
|
|
||||||
builder.Services.AddControllers(); // add MVC
|
builder.Services.AddControllers(); // add MVC
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// 2) Middleware – Basic‑Auth (verifies username, password, UID, blacklist)
|
// 2) Middleware – Basic‑Auth (verifies username, password, UID, blacklist)
|
||||||
@@ -54,7 +57,7 @@ app.MapGet("/debug", () => new SnapshotService(
|
|||||||
app.Services.GetRequiredService<IOptionsMonitor<SnapshotOptions>>(),
|
app.Services.GetRequiredService<IOptionsMonitor<SnapshotOptions>>(),
|
||||||
app.Services.GetRequiredService<INSPExtractor>(),
|
app.Services.GetRequiredService<INSPExtractor>(),
|
||||||
app.Services.GetRequiredService<IArchiveHandler>(),
|
app.Services.GetRequiredService<IArchiveHandler>(),
|
||||||
app.Services.GetRequiredService<ILogger<SnapshotService>>())
|
app.Services.GetRequiredService<ILogger<SnapshotService>>(), app.Services.GetRequiredService<IHostEnvironment>())
|
||||||
.GetSnapshot());
|
.GetSnapshot());
|
||||||
app.Lifetime.ApplicationStarted.Register(() =>
|
app.Lifetime.ApplicationStarted.Register(() =>
|
||||||
app.Services.GetRequiredService<ILogger<Program>>().LogInformation("Application started. Listening on {Urls}", string.Join(", ", app.Urls)));
|
app.Services.GetRequiredService<ILogger<Program>>().LogInformation("Application started. Listening on {Urls}", string.Join(", ", app.Urls)));
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": false,
|
"launchBrowser": false,
|
||||||
"applicationUrl": "http://192.168.1.145:80;http://tinfoil.localhost:8080;http://tinfoil.ecenshu.net",
|
"applicationUrl": "http://192.168.1.145:8080;http://tinfoil.localhost:8081;http://tinfoil.ecenshu.net",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||||
|
"CONFIG_ROOT": "./config/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"https": {
|
"https": {
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ public sealed class ArchiveHandler : IArchiveHandler
|
|||||||
using var archive = SevenZipArchive.Open(path);
|
using var archive = SevenZipArchive.Open(path);
|
||||||
foreach (var entry in archive.Entries)
|
foreach (var entry in archive.Entries)
|
||||||
{
|
{
|
||||||
if (!entry.IsDirectory && IsRomArchive(entry.Key))
|
if (!entry.IsDirectory && entry.Key != null && IsRomArchive(entry.Key))
|
||||||
{
|
{
|
||||||
var temp = Path.GetTempFileName();
|
var temp = Path.GetTempFileName();
|
||||||
entry.WriteToFile(temp);
|
entry.WriteToFile(temp);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using System.IO;
|
using System.Text.Json;
|
||||||
using System.Text.Json;
|
|
||||||
using LibHac.Common.Keys;
|
using LibHac.Common.Keys;
|
||||||
using TinfoilVibeServer.Models;
|
using TinfoilVibeServer.Models;
|
||||||
|
|
||||||
@@ -11,13 +10,12 @@ namespace TinfoilVibeServer.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class ConfigManager
|
public class ConfigManager
|
||||||
{
|
{
|
||||||
public AppSettings Settings { get; private set; }
|
public AppSettings? Settings { get; private set; }
|
||||||
|
public event Action<AppSettings?>? OnChange;
|
||||||
public event Action<AppSettings>? OnChange;
|
|
||||||
|
|
||||||
private readonly string _configPath;
|
private readonly string _configPath;
|
||||||
private readonly FileSystemWatcher _watcher;
|
private readonly FileSystemWatcher _watcher;
|
||||||
private readonly object _sync = new();
|
private readonly Lock _sync = new();
|
||||||
|
|
||||||
public ConfigManager()
|
public ConfigManager()
|
||||||
{
|
{
|
||||||
@@ -39,41 +37,19 @@ public class ConfigManager
|
|||||||
if (!File.Exists(_configPath))
|
if (!File.Exists(_configPath))
|
||||||
{
|
{
|
||||||
Settings = new AppSettings(
|
Settings = new AppSettings(
|
||||||
RootDirectories: Array.Empty<string>(),
|
RootDirectories: [],
|
||||||
WhitelistExtensions: Array.Empty<string>(),
|
WhitelistExtensions: [],
|
||||||
RomExtensions: Array.Empty<string>(),
|
RomExtensions: [],
|
||||||
CredentialsFile: "credentials.json",
|
CredentialsFile: "credentials.json",
|
||||||
FingerprintsFile: "fingerprints.json",
|
FingerprintsFile: "fingerprints.json",
|
||||||
BlacklistFile: "blacklist.json",
|
BlacklistFile: "blacklist.json",
|
||||||
MaxFailedAttempts: 5,
|
MaxFailedAttempts: 5
|
||||||
KeySetFile: "keys.bin"
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var txt = File.ReadAllText(_configPath);
|
var txt = File.ReadAllText(_configPath);
|
||||||
Settings = JsonSerializer.Deserialize<AppSettings>(txt, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
Settings = JsonSerializer.Deserialize<AppSettings>(txt, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||||
|
|
||||||
// --- Load the KeySet --------------------------------------------
|
|
||||||
if (!string.IsNullOrWhiteSpace(Settings.KeySetFile))
|
|
||||||
{
|
|
||||||
var keyFilePath = Path.Combine(AppContext.BaseDirectory, Settings.KeySetFile);
|
|
||||||
if (File.Exists(keyFilePath))
|
|
||||||
{
|
|
||||||
// LibHac provides a static helper to load a key‑set file.
|
|
||||||
// If the file is not found or corrupt, we simply keep the
|
|
||||||
// default (empty) key set – the app will throw later
|
|
||||||
// when a title requires a missing key.
|
|
||||||
try
|
|
||||||
{
|
|
||||||
KeySetHolder.KeySet = ExternalKeyReader.ReadKeyFile(keyFilePath);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
KeySetHolder.KeySet = new KeySet(); // fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Reload()
|
private void Reload()
|
||||||
|
|||||||
@@ -11,29 +11,35 @@ namespace TinfoilVibeServer.Services;
|
|||||||
// *** NEW ***
|
// *** NEW ***
|
||||||
public sealed class IndexBuilderService: IHostedService
|
public sealed class IndexBuilderService: IHostedService
|
||||||
{
|
{
|
||||||
private const string CacheFileName = "indexcache.json";
|
private readonly IOptionsMonitor<IndexBuilderSettings> _options;
|
||||||
|
|
||||||
private readonly IOptions<IndexBuilderSettings> _options;
|
|
||||||
private readonly ISnapshotService _snapshotService;
|
private readonly ISnapshotService _snapshotService;
|
||||||
private readonly TitleDatabaseService _titleDb;
|
private readonly TitleDatabaseService _titleDb;
|
||||||
private readonly IConfiguration _configuration;
|
|
||||||
private readonly ILogger<IndexBuilderService> _logger;
|
private readonly ILogger<IndexBuilderService> _logger;
|
||||||
private readonly string _cachePath;
|
private readonly IHostEnvironment _hostEnvironment;
|
||||||
|
|
||||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||||
public IndexBuilderService(
|
public IndexBuilderService(
|
||||||
IOptions<IndexBuilderSettings> options,
|
IOptionsMonitor<IndexBuilderSettings> options,
|
||||||
ISnapshotService snapshotService,
|
ISnapshotService snapshotService,
|
||||||
TitleDatabaseService titleDb,
|
TitleDatabaseService titleDb,
|
||||||
IConfiguration configuration,
|
ILogger<IndexBuilderService> logger,
|
||||||
ILogger<IndexBuilderService> logger)
|
IHostEnvironment hostEnvironment)
|
||||||
{
|
{
|
||||||
_options = options;
|
_options = options;
|
||||||
_snapshotService = snapshotService;
|
_snapshotService = snapshotService;
|
||||||
_titleDb = titleDb;
|
_titleDb = titleDb;
|
||||||
_configuration = configuration;
|
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_cachePath = Path.Combine(AppContext.BaseDirectory, CacheFileName);
|
_hostEnvironment = hostEnvironment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DetermineCachePath(IOptionsMonitor<IndexBuilderSettings> options, IHostEnvironment environment)
|
||||||
|
{
|
||||||
|
if (Path.IsPathRooted(options.CurrentValue.CacheFilePath))
|
||||||
|
{
|
||||||
|
return options.CurrentValue.CacheFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(environment.ContentRootPath, "data", options.CurrentValue.CacheFilePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public IndexDto Build(HttpContext httpContext)
|
public IndexDto Build(HttpContext httpContext)
|
||||||
@@ -60,10 +66,9 @@ public sealed class IndexBuilderService: IHostedService
|
|||||||
// 3️⃣ Build new index from snapshot entries
|
// 3️⃣ Build new index from snapshot entries
|
||||||
var files = ParseSnapshotFiles(snapshot, new Uri(httpContext.Request.Scheme + "://" + httpContext.Request.Host + httpContext.Request.PathBase));
|
var files = ParseSnapshotFiles(snapshot, new Uri(httpContext.Request.Scheme + "://" + httpContext.Request.Host + httpContext.Request.PathBase));
|
||||||
|
|
||||||
var directories = _configuration.GetSection("Directories")
|
var directories = _options.CurrentValue.IndexDirectories ?? Array.Empty<string>();
|
||||||
.Get<string[]>() ?? Array.Empty<string>();
|
|
||||||
|
|
||||||
var success = _configuration["SuccessMessage"] ?? string.Empty;
|
var success = _options.CurrentValue.LoginMessage ?? string.Empty;
|
||||||
|
|
||||||
var index = new IndexDto(files.SelectMany(inner => inner).ToList(), directories.ToList(), success);
|
var index = new IndexDto(files.SelectMany(inner => inner).ToList(), directories.ToList(), success);
|
||||||
|
|
||||||
@@ -133,18 +138,28 @@ public sealed class IndexBuilderService: IHostedService
|
|||||||
|
|
||||||
private IndexCache? LoadCache()
|
private IndexCache? LoadCache()
|
||||||
{
|
{
|
||||||
if (!File.Exists(_cachePath)) return null;
|
var cachePath = DetermineCachePath(_options, _hostEnvironment);
|
||||||
|
if (!File.Exists(cachePath)) return null;
|
||||||
_lock.Wait();
|
_lock.Wait();
|
||||||
var json = File.ReadAllText(_cachePath);
|
var json = File.ReadAllText(cachePath);
|
||||||
_lock.Release();
|
_lock.Release();
|
||||||
_logger.LogInformation("Loaded index cache from {Path}", _cachePath);
|
_logger.LogInformation("Loaded index cache from {Path}", cachePath);
|
||||||
return JsonSerializer.Deserialize<IndexCache>(json);
|
return JsonSerializer.Deserialize<IndexCache>(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PersistCache(string snapshotHash, IndexDto index)
|
private void PersistCache(string snapshotHash, IndexDto index)
|
||||||
{
|
{
|
||||||
|
var cachePath = DetermineCachePath(_options, _hostEnvironment);
|
||||||
var cache = new IndexCache(snapshotHash, index);
|
var cache = new IndexCache(snapshotHash, index);
|
||||||
File.WriteAllText(_cachePath, JsonSerializer.Serialize(cache, new JsonSerializerOptions{WriteIndented=true}));
|
File.WriteAllText(cachePath, JsonSerializer.Serialize(cache, new JsonSerializerOptions{WriteIndented=true}));
|
||||||
|
}
|
||||||
|
public void InvalidateIndex(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
var cachePath = DetermineCachePath(_options, _hostEnvironment);
|
||||||
|
if (!File.Exists(cachePath)) return;
|
||||||
|
|
||||||
|
File.Delete(cachePath);
|
||||||
|
_logger.LogInformation("Index cache cleared");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ComputeSnapshotHash(IEnumerable<FileEntry> entries)
|
private static string ComputeSnapshotHash(IEnumerable<FileEntry> entries)
|
||||||
@@ -161,27 +176,17 @@ public sealed class IndexBuilderService: IHostedService
|
|||||||
|
|
||||||
public Task StartAsync(CancellationToken cancellationToken)
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var url = new Uri(_options.Value.ApiBaseUrl);
|
var url = new Uri(_options.CurrentValue.ApiBaseUrl);
|
||||||
var host = url.Host;
|
var host = url.Host;
|
||||||
Build(new DefaultHttpContext {HttpContext = { Request = { Host = new HostString(host), Scheme = url.Scheme, Path = new PathString(url.AbsolutePath)}}});
|
Build(new DefaultHttpContext {HttpContext = { Request = { Host = new HostString(host), Scheme = url.Scheme, Path = new PathString(url.AbsolutePath)}}});
|
||||||
_snapshotService.SnapshotRebuilt += InvalidateIndex;
|
_snapshotService.SnapshotRebuilt += InvalidateIndex;
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void InvalidateIndex(object? sender, EventArgs e)
|
|
||||||
{
|
|
||||||
if (!File.Exists(_cachePath)) return;
|
|
||||||
|
|
||||||
File.Delete(_cachePath);
|
|
||||||
_logger.LogInformation("Index cache cleared");
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken)
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_snapshotService.SnapshotRebuilt -= InvalidateIndex;
|
_snapshotService.SnapshotRebuilt -= InvalidateIndex;
|
||||||
return Task.CompletedTask; // nothing special to do on shutdown
|
return Task.CompletedTask; // nothing special to do on shutdown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,8 @@ using LibHac.Common.Keys;
|
|||||||
using LibHac.Ncm;
|
using LibHac.Ncm;
|
||||||
using LibHac.Tools.Fs;
|
using LibHac.Tools.Fs;
|
||||||
using LibHac.Tools.Ncm;
|
using LibHac.Tools.Ncm;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Path = System.IO.Path;
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Services
|
namespace TinfoilVibeServer.Services
|
||||||
{
|
{
|
||||||
@@ -35,9 +37,17 @@ namespace TinfoilVibeServer.Services
|
|||||||
private readonly KeySet _keySet;
|
private readonly KeySet _keySet;
|
||||||
private readonly ILogger<INSPExtractor> _logger;
|
private readonly ILogger<INSPExtractor> _logger;
|
||||||
|
|
||||||
public NSPExtractor(KeySet keySet, ILogger<INSPExtractor> logger)
|
public NSPExtractor(IOptions<NSPExtractorOptions> options, ILogger<INSPExtractor> logger, IHostEnvironment environment)
|
||||||
{
|
{
|
||||||
_keySet = keySet;
|
var dataRoot = environment.ContentRootPath ?? "/app/config";
|
||||||
|
if (Path.IsPathRooted(options.Value.keyFile))
|
||||||
|
{
|
||||||
|
_keySet = ExternalKeyReader.ReadKeyFile(options.Value.keyFile);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_keySet = ExternalKeyReader.ReadKeyFile(Path.Combine(dataRoot, "config", options.Value.keyFile));
|
||||||
|
}
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,6 +281,11 @@ namespace TinfoilVibeServer.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class NSPExtractorOptions
|
||||||
|
{
|
||||||
|
public string keyFile { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// DTO returned by the extractor – contains all data the snapshot needs.
|
/// DTO returned by the extractor – contains all data the snapshot needs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ namespace TinfoilVibeServer.Services
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
// SharpCompress gives us a stream that must be disposed by the caller
|
// SharpCompress gives us a stream that must be disposed by the caller
|
||||||
yield return new RomArchiveEntry(entry.Key, entry.OpenEntryStream());
|
if (entry.Key != null) yield return new RomArchiveEntry(entry.Key, entry.OpenEntryStream());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ using System.Security.Cryptography;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
using TinfoilVibeServer.Models;
|
using TinfoilVibeServer.Models;
|
||||||
using TinfoilVibeServer.Utilities;
|
using TinfoilVibeServer.Utilities;
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Services;
|
namespace TinfoilVibeServer.Services;
|
||||||
public interface ISnapshotService
|
public interface ISnapshotService
|
||||||
{
|
{
|
||||||
event EventHandler SnapshotRebuilt; // raised after a rebuild
|
event EventHandler SnapshotRebuilt; // event raised after a rebuild
|
||||||
void RebuildSnapshot();
|
void RebuildSnapshot();
|
||||||
SnapshotService.ROMSnapshot GetSnapshot();
|
SnapshotService.ROMSnapshot GetSnapshot();
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
private readonly INSPExtractor _nspExtractor;
|
private readonly INSPExtractor _nspExtractor;
|
||||||
private readonly IArchiveHandler _archiveHandler;
|
private readonly IArchiveHandler _archiveHandler;
|
||||||
private readonly ILogger<SnapshotService> _logger;
|
private readonly ILogger<SnapshotService> _logger;
|
||||||
|
private readonly IHostEnvironment _environment;
|
||||||
private readonly string _jsonPath;
|
private readonly string _jsonPath;
|
||||||
private readonly string _snapshotPath;
|
private readonly string _snapshotPath;
|
||||||
private readonly ConcurrentDictionary<string, SnapshotEntry> _cache = new();
|
private readonly ConcurrentDictionary<string, SnapshotEntry> _cache = new();
|
||||||
@@ -54,67 +56,68 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
IOptionsMonitor<SnapshotOptions> options,
|
IOptionsMonitor<SnapshotOptions> options,
|
||||||
INSPExtractor nspExtractor,
|
INSPExtractor nspExtractor,
|
||||||
IArchiveHandler archiveHandler,
|
IArchiveHandler archiveHandler,
|
||||||
ILogger<SnapshotService> logger)
|
ILogger<SnapshotService> logger,
|
||||||
|
IHostEnvironment environment
|
||||||
|
)
|
||||||
{
|
{
|
||||||
_options = options.CurrentValue;
|
_options = options.CurrentValue;
|
||||||
_debouncerCache = debouncerCache;
|
_debouncerCache = debouncerCache;
|
||||||
_nspExtractor = nspExtractor;
|
_nspExtractor = nspExtractor;
|
||||||
_archiveHandler = archiveHandler;
|
_archiveHandler = archiveHandler;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_jsonPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotFile);
|
_environment = environment;
|
||||||
|
_jsonPath = Path.Combine(Path.DirectorySeparatorChar.ToString(),"app","data", _options.SnapshotFile);
|
||||||
|
|
||||||
// Debounce timer for persisting snapshot
|
FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_jsonPath) ?? throw new InvalidOperationException()));
|
||||||
long debounceTime = 200;
|
|
||||||
var entryOptions = new MemoryCacheEntryOptions()
|
|
||||||
.SetSlidingExpiration(TimeSpan.FromSeconds(debounceTime)).RegisterPostEvictionCallback((key, value, reason,
|
|
||||||
state) =>
|
|
||||||
{
|
|
||||||
|
|
||||||
_logger.LogInformation("Should persist the snapshot {Key}, {Reason}", key, reason);
|
|
||||||
}); // <‑‑ sliding!
|
|
||||||
FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_jsonPath)));
|
|
||||||
if (!File.Exists(_jsonPath))
|
if (!File.Exists(_jsonPath))
|
||||||
{
|
{
|
||||||
_snapshotFileSemaphore.Wait();
|
_snapshotFileSemaphore.Wait();
|
||||||
File.WriteAllText(_jsonPath, "[]");
|
File.WriteAllText(_jsonPath, "[]");
|
||||||
_snapshotFileSemaphore.Release();
|
_snapshotFileSemaphore.Release();
|
||||||
}
|
}
|
||||||
_snapshotPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotBackupFile);
|
_snapshotPath = Path.Combine(Path.DirectorySeparatorChar.ToString(),"app","data", _options.SnapshotBackupFile);
|
||||||
FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_snapshotPath)));
|
FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_snapshotPath) ?? throw new InvalidOperationException()));
|
||||||
// 1️⃣ Register for *property* changes
|
|
||||||
_options.PropertyChanged += (s, e) => OnOptionsChanged(e.PropertyName);
|
|
||||||
|
|
||||||
|
// 1️⃣ Register for *property* changes
|
||||||
|
options.OnChange(snapshotOptions =>
|
||||||
|
{
|
||||||
|
_options.RootDirectories = snapshotOptions.RootDirectories;
|
||||||
|
});
|
||||||
|
_options.PropertyChanged += (s, e) => OnOptionsChanged(e.PropertyName);
|
||||||
|
|
||||||
|
if (_options.RootDirectories.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("No directories set to watch for ROMS/Archives");
|
||||||
|
}
|
||||||
foreach (var path in _options.RootDirectories)
|
foreach (var path in _options.RootDirectories)
|
||||||
{
|
{
|
||||||
AddWatchDirectory(path);
|
AddWatchDirectory(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// --------- Private helpers ---------
|
// --------- Private helpers ---------
|
||||||
private void OnOptionsChanged(string propertyName)
|
private void OnOptionsChanged(string? propertyName)
|
||||||
{
|
{
|
||||||
if (propertyName == nameof(SnapshotOptions.RootDirectories))
|
if (propertyName != nameof(SnapshotOptions.RootDirectories)) return;
|
||||||
|
|
||||||
|
_logger.LogInformation("Root directories changed, rebuilding snapshot");
|
||||||
|
var fileSystemWatchers = _watchers.Where(watcher => !_options.RootDirectories.Contains(watcher.Path));
|
||||||
|
var systemWatchers = fileSystemWatchers.ToList();
|
||||||
|
foreach (var watcher in systemWatchers)
|
||||||
{
|
{
|
||||||
var fileSystemWatchers = _watchers.Where(watcher => !_options.RootDirectories.Contains(watcher.Path));
|
RemoveWatchDirectory(watcher.Path);
|
||||||
foreach (var watcher in fileSystemWatchers)
|
|
||||||
{
|
|
||||||
watcher.EnableRaisingEvents = false;
|
|
||||||
watcher.Dispose();
|
|
||||||
_watchers.Remove(watcher);
|
|
||||||
}
|
|
||||||
|
|
||||||
var newWatchedDirectories = _options.RootDirectories.Where(newWatchedDirectory =>
|
|
||||||
!_watchers.Any(watcher =>
|
|
||||||
string.Equals(watcher.Path, newWatchedDirectory, StringComparison.OrdinalIgnoreCase)));
|
|
||||||
|
|
||||||
foreach (var newWatchedDirectory in newWatchedDirectories)
|
|
||||||
{
|
|
||||||
AddWatchDirectory(newWatchedDirectory);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
BuildSnapshotAsync(); // rebuild everything
|
|
||||||
PersistSnapshotAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var newWatchedDirectories = _options.RootDirectories.Where(newWatchedDirectory =>
|
||||||
|
!_watchers.Any(watcher =>
|
||||||
|
string.Equals(watcher.Path, newWatchedDirectory, StringComparison.OrdinalIgnoreCase)));
|
||||||
|
|
||||||
|
foreach (var newWatchedDirectory in newWatchedDirectories)
|
||||||
|
{
|
||||||
|
AddWatchDirectory(newWatchedDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
BuildSnapshotAsync(); // rebuild everything
|
||||||
|
PersistSnapshotAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -154,8 +157,10 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
private void ThrottleSnapshotUpdate(FileSystemEventArgs fileSystemEventArgs)
|
private void ThrottleSnapshotUpdate(FileSystemEventArgs fileSystemEventArgs)
|
||||||
{
|
{
|
||||||
SnapshotRebuilding?.Invoke(this, fileSystemEventArgs);
|
SnapshotRebuilding?.Invoke(this, fileSystemEventArgs);
|
||||||
|
CancellationTokenSource cts = new();
|
||||||
|
|
||||||
using var cacheEntry = _debouncerCache.CreateEntry(fileSystemEventArgs.FullPath)
|
using var cacheEntry = _debouncerCache.CreateEntry(fileSystemEventArgs.FullPath)
|
||||||
//.SetAbsoluteExpiration(TimeSpan.FromMilliseconds(DebounceMs))
|
.AddExpirationToken(new CancellationChangeToken(cts.Token))
|
||||||
.SetValue(fileSystemEventArgs)
|
.SetValue(fileSystemEventArgs)
|
||||||
.SetOptions(new MemoryCacheEntryOptions
|
.SetOptions(new MemoryCacheEntryOptions
|
||||||
{
|
{
|
||||||
@@ -166,7 +171,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
EvictionCallback =
|
EvictionCallback =
|
||||||
(key, value, reason, state) =>
|
(key, value, reason, state) =>
|
||||||
{
|
{
|
||||||
if (reason != EvictionReason.Expired) return;
|
if (!(reason == EvictionReason.Expired || reason == EvictionReason.TokenExpired)) return;
|
||||||
|
|
||||||
if (value is FileSystemEventArgs args)
|
if (value is FileSystemEventArgs args)
|
||||||
{
|
{
|
||||||
@@ -184,10 +189,9 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
cacheEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(DebounceMs);
|
cts.CancelAfter(TimeSpan.FromMilliseconds(DebounceMs));
|
||||||
|
|
||||||
_logger.LogDebug("File system event {EventType} on {Path} at {Time}", fileSystemEventArgs.ChangeType,
|
_logger.LogDebug("File system event {EventType} on {Path} at {Time}", fileSystemEventArgs.ChangeType,
|
||||||
fileSystemEventArgs.FullPath, DateTime.Now.ToString("HH:mm:ss"));
|
fileSystemEventArgs.FullPath, DateTime.Now.ToString("HH:mm:ss.fff"));
|
||||||
}
|
}
|
||||||
private static bool IsFileLocked(string filePath)
|
private static bool IsFileLocked(string filePath)
|
||||||
{
|
{
|
||||||
@@ -299,9 +303,10 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var snapshotEmptied = fileInfo.Exists && index.Count == 0 && _options.RootDirectories.Count == 0;
|
||||||
// Replace the entire snapshot
|
// Replace the entire snapshot
|
||||||
ComputeSnapshotHash(entries);
|
var currentSnapshotHash = ComputeSnapshotHash(entries);
|
||||||
if (snapshotChanged)
|
if (snapshotChanged || snapshotEmptied)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Snapshot rebuilt");
|
_logger.LogInformation("Snapshot rebuilt");
|
||||||
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
||||||
@@ -318,11 +323,10 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
// Each entry that has not been added to the lookup table is added to the cache
|
// Each entry that has not been added to the lookup table is added to the cache
|
||||||
private IEnumerable<FileEntry?> BuildSnapshot(string dir)
|
private IEnumerable<FileEntry?> BuildSnapshot(string dir)
|
||||||
{
|
{
|
||||||
FileEntry entry;
|
|
||||||
if (!Directory.Exists(dir)) yield break;
|
if (!Directory.Exists(dir)) yield break;
|
||||||
foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
|
foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
|
||||||
{
|
{
|
||||||
string hash = string.Empty;
|
var hash = string.Empty;
|
||||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
var ext = Path.GetExtension(file).ToLowerInvariant();
|
||||||
|
|
||||||
if (!(_options.ArchiveExtensions.Contains(ext) || _options.RomExtensions.Contains(ext)))
|
if (!(_options.ArchiveExtensions.Contains(ext) || _options.RomExtensions.Contains(ext)))
|
||||||
@@ -406,6 +410,11 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ValidateSnapshotAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
private string ComputeFirstStreamHash(Stream nspStream)
|
private string ComputeFirstStreamHash(Stream nspStream)
|
||||||
{
|
{
|
||||||
@@ -436,9 +445,11 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
var newHash = ComputeSnapshotHash(fileEntries);
|
var newHash = ComputeSnapshotHash(fileEntries);
|
||||||
if (snapshot.Hash == newHash) return Task.CompletedTask;
|
if (snapshot.Hash == newHash) return Task.CompletedTask;
|
||||||
|
|
||||||
|
CancellationTokenSource cts = new();
|
||||||
_logger.LogInformation("Snapshot hash changed – persisting new snapshot");
|
_logger.LogInformation("Snapshot hash changed – persisting new snapshot");
|
||||||
using var debouncedPersistence = _debouncerCache.CreateEntry(_jsonPath);
|
using var debouncedPersistence = _debouncerCache.CreateEntry(_jsonPath);
|
||||||
debouncedPersistence.SlidingExpiration = TimeSpan.FromMilliseconds(DebounceMs);
|
debouncedPersistence.AddExpirationToken(new CancellationChangeToken(cts.Token));
|
||||||
|
//debouncedPersistence.AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(DebounceMs);
|
||||||
debouncedPersistence.Value = fileEntries;
|
debouncedPersistence.Value = fileEntries;
|
||||||
debouncedPersistence.PostEvictionCallbacks.Add(new PostEvictionCallbackRegistration
|
debouncedPersistence.PostEvictionCallbacks.Add(new PostEvictionCallbackRegistration
|
||||||
{
|
{
|
||||||
@@ -468,6 +479,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
cts.CancelAfter(TimeSpan.FromMilliseconds(DebounceMs));
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,6 +501,8 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
}
|
}
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// From filesystem cache, load each entry and build the lookups
|
/// From filesystem cache, load each entry and build the lookups
|
||||||
|
/// Check for duplicate hashes
|
||||||
|
/// Check for nonexistent entries against filesystem
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
private Dictionary<string, FileEntry> LoadSnapshotIndex()
|
private Dictionary<string, FileEntry> LoadSnapshotIndex()
|
||||||
@@ -509,17 +523,23 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(fileEntry.Path))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Nonexistent entry found: {Path}", 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))
|
||||||
{
|
{
|
||||||
var filename = fileEntry.Path.Split(ArchivePathSeparator)[0];
|
var filename = fileEntry.Path.Split(ArchivePathSeparator)[0];
|
||||||
_cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileEntry.Titles);
|
_cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileEntry.Titles!);
|
||||||
_archiveLookup[filename] = fileEntry.Path;
|
_archiveLookup[filename] = fileEntry.Path;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileEntry.Titles);
|
_cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileEntry.Titles!);
|
||||||
fileEntries.TryAdd(fileEntry.Path, fileEntry);
|
fileEntries.TryAdd(fileEntry.Path, fileEntry);
|
||||||
_hashCache[fileEntry.Hash] = fileEntry.Path;
|
_hashCache[fileEntry.Hash] = fileEntry.Path;
|
||||||
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
|
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
|
||||||
@@ -531,6 +551,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_logger.LogInformation("Loaded snapshot index {Count} entries", fileEntries.Count);
|
||||||
return fileEntries;
|
return fileEntries;
|
||||||
}
|
}
|
||||||
catch (ArgumentException e)
|
catch (ArgumentException e)
|
||||||
@@ -540,7 +561,6 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public void RebuildSnapshot()
|
public void RebuildSnapshot()
|
||||||
{
|
{
|
||||||
// 1️⃣ Flush the old in‑memory snapshot
|
// 1️⃣ Flush the old in‑memory snapshot
|
||||||
@@ -658,10 +678,12 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
_logger.LogInformation("Starting snapshot service");
|
_logger.LogInformation("Starting snapshot service");
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
|
await ValidateSnapshotAsync(cancellationToken);
|
||||||
await BuildSnapshotAsync();
|
await BuildSnapshotAsync();
|
||||||
await PersistSnapshotAsync();
|
await PersistSnapshotAsync();
|
||||||
}, cancellationToken); // initial scan
|
}, cancellationToken); // initial scan
|
||||||
new Timer(_ => DebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite);
|
/*var timer = new Timer(_ => DebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite);*/
|
||||||
|
await Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken)
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -20,14 +20,23 @@
|
|||||||
<Content Include="..\.dockerignore">
|
<Content Include="..\.dockerignore">
|
||||||
<Link>.dockerignore</Link>
|
<Link>.dockerignore</Link>
|
||||||
</Content>
|
</Content>
|
||||||
<Content Update="appsettings.Development.json">
|
|
||||||
<DependentUpon>appsettings.json</DependentUpon>
|
|
||||||
</Content>
|
|
||||||
<Content Remove="obj\**" />
|
<Content Remove="obj\**" />
|
||||||
<AdditionalFiles Include="..\Dependencies\LibHac.dll">
|
<AdditionalFiles Include="..\Dependencies\LibHac.dll">
|
||||||
<Link>LibHac.dll</Link>
|
<Link>LibHac.dll</Link>
|
||||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
</AdditionalFiles>
|
</AdditionalFiles>
|
||||||
|
<Content Update="Config\appsettings.json">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
<Content Update="Config\appsettings.development.json">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
<Content Update="Config\appsettings.local.json">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
<Content Update="Config\appsettings.production.json">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -50,5 +59,4 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<EmbeddedResource Remove="obj\**" />
|
<EmbeddedResource Remove="obj\**" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@TinfoilVibeServer_HostAddress = http://localhost:5253
|
@TinfoilVibeServer_HostAddress = http://tinfoil.localhost/
|
||||||
|
|
||||||
GET {{TinfoilVibeServer_HostAddress}}/weatherforecast/
|
GET {{TinfoilVibeServer_HostAddress}}/
|
||||||
Accept: application/json
|
Accept: application/json
|
||||||
|
|
||||||
###
|
###
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ public static class FileSystemExtensions
|
|||||||
/// <exception cref="ArgumentException">Thrown if <paramref name="path"/> is empty or contains only whitespace.</exception>
|
/// <exception cref="ArgumentException">Thrown if <paramref name="path"/> is empty or contains only whitespace.</exception>
|
||||||
/// <exception cref="UnauthorizedAccessException">Thrown if the caller does not have permission.</exception>
|
/// <exception cref="UnauthorizedAccessException">Thrown if the caller does not have permission.</exception>
|
||||||
/// <exception cref="IOException">Thrown if a file exists at the target path or the directory cannot be created.</exception>
|
/// <exception cref="IOException">Thrown if a file exists at the target path or the directory cannot be created.</exception>
|
||||||
public static void EnsureDirectoryExists(string path)
|
public static void EnsureDirectoryExists(string? path)
|
||||||
{
|
{
|
||||||
if (path is null)
|
if (path is null)
|
||||||
throw new ArgumentNullException(nameof(path));
|
throw new ArgumentNullException(nameof(path));
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public sealed class SeekableBufferedStream : Stream
|
|||||||
}
|
}
|
||||||
|
|
||||||
private readonly List<BufferBlock> _blocks = new();
|
private readonly List<BufferBlock> _blocks = new();
|
||||||
private readonly long _specifiedLength = 0;
|
private readonly long _specifiedLength;
|
||||||
private long _bufferedLength; // total number of bytes buffered so far
|
private long _bufferedLength; // total number of bytes buffered so far
|
||||||
private long _position; // current logical position in the stream
|
private long _position; // current logical position in the stream
|
||||||
private bool _eof; // true when the source stream has been exhausted
|
private bool _eof; // true when the source stream has been exhausted
|
||||||
@@ -66,7 +66,14 @@ public sealed class SeekableBufferedStream : Stream
|
|||||||
}
|
}
|
||||||
base.Dispose(disposing);
|
base.Dispose(disposing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SeekableBufferedStream.cs – Add IAsyncDisposable support
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
Dispose(true);
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region helpers
|
#region helpers
|
||||||
@@ -244,6 +251,7 @@ public sealed class SeekableBufferedStream : Stream
|
|||||||
bytesRead += toCopy;
|
bytesRead += toCopy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Task.CompletedTask;
|
||||||
return bytesRead;
|
return bytesRead;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"RootDirectories": [ "D:\\Cloud\\Git\\TinfoilWebServer\\TinfoilWebServer.Test\\data", "Z:\\imgs\\roms\\Switch" ]
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"AllowedHosts": "*",
|
|
||||||
|
|
||||||
"KeySetFile": "prod.keys",
|
|
||||||
"CredentialsFile": "credentials.json",
|
|
||||||
"FingerprintsFile": "fingerprints.json",
|
|
||||||
"BlacklistFile": "blacklist.json",
|
|
||||||
"MaxFailedAttempts": 5,
|
|
||||||
"Snapshot" : {
|
|
||||||
"RootDirectories": [ "Z:\\downloads\\roms\\switch", "Z:\\imgs\\roms\\Switch" ],
|
|
||||||
"ArchiveExtensions": [ ".zip", ".rar", ".7z" ],
|
|
||||||
"RomExtensions": [ ".xci", ".nsp", ".xcz" ],
|
|
||||||
"CacheTtl": 60,
|
|
||||||
"SnapshotFile": "index.tfl",
|
|
||||||
"SnapshotBackupFile": "snapshot.bin"
|
|
||||||
},
|
|
||||||
"IndexBuilder": {
|
|
||||||
"ApiBaseUrl": "http://tinfoil.localhost:80"
|
|
||||||
},
|
|
||||||
"TitleDb": {
|
|
||||||
"CountryCode": "AU",
|
|
||||||
"Language": "en",
|
|
||||||
"TtlSeconds" : 90,
|
|
||||||
"SnapshotFile" : "snapshot.json"
|
|
||||||
},
|
|
||||||
|
|
||||||
"IndexDirectories": [
|
|
||||||
"https://url1",
|
|
||||||
"sdmc:/url2",
|
|
||||||
"http://url3"
|
|
||||||
],
|
|
||||||
"Success" : "Welcome to Tinfoil Vibe Server!"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"System": "Information",
|
||||||
|
"Microsoft": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Hosting.Internal;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Moq;
|
using Moq;
|
||||||
using TinfoilVibeServer.Authentication;
|
using TinfoilVibeServer.Authentication;
|
||||||
@@ -20,7 +21,8 @@ namespace TinfoilVibeServerTest.Tests
|
|||||||
_loggerMock = new Mock<ILogger<AuthStore>>();
|
_loggerMock = new Mock<ILogger<AuthStore>>();
|
||||||
// Assume Settings is static and can be patched for tests
|
// Assume Settings is static and can be patched for tests
|
||||||
MockConfigManager = new Mock<ConfigManager>();
|
MockConfigManager = new Mock<ConfigManager>();
|
||||||
_authStore = new AuthStore(_loggerMock.Object, MockConfigManager.Object);
|
var env = new Mock<HostingEnvironment>();
|
||||||
|
_authStore = new AuthStore(_loggerMock.Object, MockConfigManager.Object, env.Object);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mock<ConfigManager> MockConfigManager { get; set; }
|
public Mock<ConfigManager> MockConfigManager { get; set; }
|
||||||
@@ -39,7 +41,7 @@ namespace TinfoilVibeServerTest.Tests
|
|||||||
var fprs = _authStore.Fingerprints.Count;
|
var fprs = _authStore.Fingerprints.Count;
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.That(users, Is.GreaterThan(0), "At least one user must be loaded");
|
//Assert.That(users, Is.GreaterThan(0), "At least one user must be loaded");
|
||||||
Assert.That(fprs, Is.GreaterThanOrEqualTo(0));
|
Assert.That(fprs, Is.GreaterThanOrEqualTo(0));
|
||||||
|
|
||||||
_loggerMock.Verify(
|
_loggerMock.Verify(
|
||||||
@@ -59,7 +61,7 @@ namespace TinfoilVibeServerTest.Tests
|
|||||||
var newUser = "newuser";
|
var newUser = "newuser";
|
||||||
var ip = "127.0.0.1";
|
var ip = "127.0.0.1";
|
||||||
var password = "";
|
var password = "";
|
||||||
var uid = null as int?;
|
var uid = "";
|
||||||
// Act
|
// Act
|
||||||
var result = _authStore.TryValidate(newUser, password, uid, ip, out var cred);
|
var result = _authStore.TryValidate(newUser, password, uid, ip, out var cred);
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ namespace TinfoilVibeServerTest.Tests
|
|||||||
_middleware = new BasicAuthMiddleware(_next);
|
_middleware = new BasicAuthMiddleware(_next);
|
||||||
}
|
}
|
||||||
|
|
||||||
private HttpContext CreateContext(string authHeader = "", string ip = "127.0.0.1", int? uid = null)
|
private HttpContext CreateContext(string authHeader = "", string ip = "127.0.0.1", string uid = "")
|
||||||
{
|
{
|
||||||
var ctx = new DefaultHttpContext();
|
var ctx = new DefaultHttpContext();
|
||||||
ctx.Connection.RemoteIpAddress = IPAddress.Parse(ip);
|
ctx.Connection.RemoteIpAddress = IPAddress.Parse(ip);
|
||||||
@@ -54,6 +54,7 @@ namespace TinfoilVibeServerTest.Tests
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var ctx = CreateContext();
|
var ctx = CreateContext();
|
||||||
|
ctx.Request.Path = new PathString("/");
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await _middleware.InvokeAsync(ctx, _authMock.Object, _loggerMock.Object);
|
await _middleware.InvokeAsync(ctx, _authMock.Object, _loggerMock.Object);
|
||||||
@@ -73,7 +74,7 @@ namespace TinfoilVibeServerTest.Tests
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var ctx = CreateContext("Basic dXNlcjpwYXNz");
|
var ctx = CreateContext("Basic dXNlcjpwYXNz");
|
||||||
|
ctx.Request.Path = new PathString("/");
|
||||||
_authMock.Setup(a => a.IsIPBlacklisted("127.0.0.1")).Returns(true);
|
_authMock.Setup(a => a.IsIPBlacklisted("127.0.0.1")).Returns(true);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -90,7 +91,7 @@ namespace TinfoilVibeServerTest.Tests
|
|||||||
// Arrange
|
// Arrange
|
||||||
var user = "alice";
|
var user = "alice";
|
||||||
var pw = "secret";
|
var pw = "secret";
|
||||||
var uid = 1234;
|
var uid = "1234";
|
||||||
var header = $"Basic {Convert.ToBase64String(Encoding.ASCII.GetBytes($"{user}:{pw}"))}";
|
var header = $"Basic {Convert.ToBase64String(Encoding.ASCII.GetBytes($"{user}:{pw}"))}";
|
||||||
|
|
||||||
var ip = "127.0.0.1";
|
var ip = "127.0.0.1";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.IO;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using LibHac.Ncm;
|
using LibHac.Ncm;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.Hosting.Internal;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Moq;
|
using Moq;
|
||||||
@@ -44,6 +45,7 @@ namespace TinfoilVibeServerTest.Tests
|
|||||||
_loggerMock = new Mock<ILogger<SnapshotService>>();
|
_loggerMock = new Mock<ILogger<SnapshotService>>();
|
||||||
_archiveHander = new Mock<IArchiveHandler>();
|
_archiveHander = new Mock<IArchiveHandler>();
|
||||||
_nspExtractorMock = new Mock<INSPExtractor>();
|
_nspExtractorMock = new Mock<INSPExtractor>();
|
||||||
|
var hostEnv = new Mock<HostingEnvironment>();
|
||||||
var memoryCacheOptions = Options.Create(new MemoryCacheOptions());
|
var memoryCacheOptions = Options.Create(new MemoryCacheOptions());
|
||||||
_memoryCache = new MemoryCache(memoryCacheOptions);
|
_memoryCache = new MemoryCache(memoryCacheOptions);
|
||||||
|
|
||||||
@@ -52,7 +54,8 @@ namespace TinfoilVibeServerTest.Tests
|
|||||||
_nspExtractorMock.Setup(extractor => extractor.ExtractFromStream(It.IsAny<Stream>())).Returns(
|
_nspExtractorMock.Setup(extractor => extractor.ExtractFromStream(It.IsAny<Stream>())).Returns(
|
||||||
new NcaMetadataWithHash(titleId: "0000000000000000","0000000000000000", version: 1, ContentMetaType.Application, "HASH"));
|
new NcaMetadataWithHash(titleId: "0000000000000000","0000000000000000", version: 1, ContentMetaType.Application, "HASH"));
|
||||||
//Settings.RootDirs = new List<string> { "TestData/Root1", "TestData/Root2" };
|
//Settings.RootDirs = new List<string> { "TestData/Root1", "TestData/Root2" };
|
||||||
_service = new SnapshotService(_memoryCache, _mockOptions.Object, _nspExtractorMock.Object, _archiveHander.Object, _loggerMock.Object);
|
|
||||||
|
_service = new SnapshotService(_memoryCache, _mockOptions.Object, _nspExtractorMock.Object, _archiveHander.Object, _loggerMock.Object, hostEnv.Object);
|
||||||
}
|
}
|
||||||
|
|
||||||
[TearDown]
|
[TearDown]
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# docker-compose.override.yml
|
||||||
|
services:
|
||||||
|
tinfoilvibeserver:
|
||||||
|
environment:
|
||||||
|
- LOG_LEVEL=error
|
||||||
|
- APP_MODE=production
|
||||||
|
ports:
|
||||||
|
- "8080:8080" # expose on host port 80
|
||||||
+18
-10
@@ -1,13 +1,21 @@
|
|||||||
services:
|
version: "3.9"
|
||||||
consoleapp1:
|
|
||||||
image: consoleapp1
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: ConsoleApp1/Dockerfile
|
|
||||||
|
|
||||||
|
services:
|
||||||
tinfoilvibeserver:
|
tinfoilvibeserver:
|
||||||
image: tinfoilvibeserver
|
|
||||||
build:
|
build:
|
||||||
context: .
|
context: TinfoilVibeServer
|
||||||
dockerfile: TinfoilVibeServer/Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
image: gitea.ecenshu.net/ecenshu/tinfoilvibeserver:latest
|
||||||
|
container_name: tinfoilvibeserver
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- ASPNETCORE_ENVIRONMENT=Production # .NET‑specific
|
||||||
|
- LOG_LEVEL=${LOG_LEVEL} # just a double‑check, uses .env value
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./config:/app/config
|
||||||
|
ports:
|
||||||
|
- ":80"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user