diff --git a/Dependencies/LibHac.dll b/Dependencies/LibHac.dll
new file mode 100644
index 0000000..3dddd7b
Binary files /dev/null and b/Dependencies/LibHac.dll differ
diff --git a/TinfoilVibeServer.sln.DotSettings.user b/TinfoilVibeServer.sln.DotSettings.user
index 5a582b6..e06bf7e 100644
--- a/TinfoilVibeServer.sln.DotSettings.user
+++ b/TinfoilVibeServer.sln.DotSettings.user
@@ -13,6 +13,7 @@
ForceIncluded
ForceIncluded
ForceIncluded
+ ForceIncluded
ForceIncluded
ForceIncluded
ForceIncluded
diff --git a/TinfoilVibeServer/Controllers/CancelableFileResult.cs b/TinfoilVibeServer/Controllers/CancelableFileResult.cs
new file mode 100644
index 0000000..ed73d01
--- /dev/null
+++ b/TinfoilVibeServer/Controllers/CancelableFileResult.cs
@@ -0,0 +1,53 @@
+using Microsoft.AspNetCore.Mvc;
+
+namespace TinfoilVibeServer.Controllers;
+
+public class CancelableFileResult : FileResult
+{
+ private readonly Stream _fileStream;
+
+ public CancelableFileResult(string contentType, Stream fileStream)
+ : base(contentType)
+ {
+ _fileStream = fileStream ?? throw new ArgumentNullException(nameof(fileStream));
+ }
+
+ ///
+ /// Allows you to set a suggested download name.
+ /// It will be sent in a “Content‑Disposition” header.
+ ///
+ public string? FileDownloadName { get; set; }
+
+ public override async Task ExecuteResultAsync(ActionContext context)
+ {
+ var response = context.HttpContext.Response;
+
+ if (!string.IsNullOrEmpty(FileDownloadName))
+ {
+ // Typical “attachment” disposition – most browsers honour it.
+ response.Headers.Append("Content-Disposition",
+ $"attachment; filename=\"{FileDownloadName}\"");
+ }
+
+ // The request‑aborted token tells the stream copy to stop ASAP
+ var cancellationToken = context.HttpContext.RequestAborted;
+
+ try
+ {
+ // Copy the file to the response body in 8 KiB chunks
+ await _fileStream.CopyToAsync(
+ response.Body,
+ bufferSize: 81920, // 80 KiB – default for Stream.CopyToAsync
+ cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ // The client disconnected – nothing to do.
+ // Swallowing keeps the API from returning a 500 error.
+ }
+ finally
+ {
+ await _fileStream.DisposeAsync();
+ }
+ }
+}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Controllers/IndexController.cs b/TinfoilVibeServer/Controllers/IndexController.cs
index 5043574..c85edd1 100644
--- a/TinfoilVibeServer/Controllers/IndexController.cs
+++ b/TinfoilVibeServer/Controllers/IndexController.cs
@@ -43,7 +43,7 @@ public sealed class IndexController : ControllerBase
_indexBuilderService.InvalidateIndex(this, EventArgs.Empty);
}
- var index = _indexBuilderService.Build();
+ var index = _indexBuilderService.Build(HttpContext);
return Ok(index);
}
@@ -83,13 +83,27 @@ public sealed class IndexController : ControllerBase
// ---- 3️⃣ If the file is a normal NSP → send it ----------------
- if (Path.GetExtension(entry.Path).Equals(".nsp", StringComparison.OrdinalIgnoreCase) && !entry.Path.Contains(_snapshotService.GetArchivePathSeparator()))
+ if (Path.GetExtension(entry.Path).Equals(".nsp", StringComparison.OrdinalIgnoreCase)
+ && !entry.Path.Contains(_snapshotService.GetArchivePathSeparator()))
{
if (System.IO.File.Exists(entry.Path))
{
- // Regular file – just serve it.
- return PhysicalFile(entry.Path, "application/octet-stream",
- Path.GetFileName(entry.Path));
+ // 1️⃣ Open the file for async read
+ var fileStream = new FileStream(
+ entry.Path,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.Read,
+ bufferSize: 128 * 1024 * 1024, // 81920, // 80 KiB
+ useAsync: true); // <‑‑ VERY important for scalability
+
+ // 2️⃣ Return a cancellation‑aware result
+ return new CancelableFileResult(
+ contentType: "application/octet-stream",
+ fileStream: fileStream)
+ {
+ FileDownloadName = Path.GetFileName(entry.Path) // optional but nice
+ };
}
}
@@ -220,6 +234,12 @@ public sealed class IndexController : ControllerBase
public override void Write(byte[] buffer, int offset, int count) => _innerStream.Write(buffer, offset, count);
+
+ public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken)
+ {
+ return _innerStream.CopyToAsync(destination, bufferSize, cancellationToken);
+ }
+
public override bool CanRead => _innerStream.CanRead;
public override bool CanSeek => _innerStream.CanSeek;
public override bool CanWrite => _innerStream.CanWrite;
diff --git a/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs b/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs
index 11ab6f2..1366725 100644
--- a/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs
+++ b/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs
@@ -91,7 +91,7 @@ public sealed class BasicAuthMiddleware
{
logger.LogWarning("Auth failed for user {User} from {IP}: {Error}", username, ip, error);
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
- context.Response.Headers.Add("WWW-Authenticate", "Basic realm=\"FileSnapshot\"");
+ context.Response.Headers.Append("WWW-Authenticate", "Basic realm=\"FileSnapshot\"");
await context.Response.WriteAsync(error ?? "Unauthorized");
return;
}
@@ -105,6 +105,6 @@ public sealed class BasicAuthMiddleware
private static void Challenge(HttpContext ctx)
{
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
- ctx.Response.Headers.Add("WWW-Authenticate", "Basic realm=\"FileSnapshot\"");
+ ctx.Response.Headers.Append("WWW-Authenticate", "Basic realm=\"FileSnapshot\"");
}
}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Models/IndexBuilderSettings.cs b/TinfoilVibeServer/Models/IndexBuilderSettings.cs
new file mode 100644
index 0000000..a2ade75
--- /dev/null
+++ b/TinfoilVibeServer/Models/IndexBuilderSettings.cs
@@ -0,0 +1,6 @@
+namespace TinfoilVibeServer.Models;
+
+public class IndexBuilderSettings
+{
+ public string ApiBaseUrl { get; set; } = "http://tinfoil.localhost";
+}
\ No newline at end of file
diff --git a/TinfoilVibeServer/Properties/launchSettings.json b/TinfoilVibeServer/Properties/launchSettings.json
index 7158c2e..ed97731 100644
--- a/TinfoilVibeServer/Properties/launchSettings.json
+++ b/TinfoilVibeServer/Properties/launchSettings.json
@@ -5,7 +5,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
- "applicationUrl": "http://192.168.1.145:80",
+ "applicationUrl": "http://192.168.1.145:80;http://tinfoil.localhost:8080;http://tinfoil.ecenshu.net",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
diff --git a/TinfoilVibeServer/Services/IndexBuilderService.cs b/TinfoilVibeServer/Services/IndexBuilderService.cs
index 8921f20..de5d951 100644
--- a/TinfoilVibeServer/Services/IndexBuilderService.cs
+++ b/TinfoilVibeServer/Services/IndexBuilderService.cs
@@ -2,6 +2,7 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using LibHac.Ncm;
+using Microsoft.Extensions.Options;
using TinfoilVibeServer.Models;
namespace TinfoilVibeServer.Services;
@@ -12,6 +13,7 @@ public sealed class IndexBuilderService: IHostedService
{
private const string CacheFileName = "indexcache.json";
+ private readonly IOptions _options;
private readonly ISnapshotService _snapshotService;
private readonly TitleDatabaseService _titleDb;
private readonly IConfiguration _configuration;
@@ -20,11 +22,13 @@ public sealed class IndexBuilderService: IHostedService
private readonly SemaphoreSlim _lock = new(1, 1);
public IndexBuilderService(
+ IOptions options,
ISnapshotService snapshotService,
TitleDatabaseService titleDb,
IConfiguration configuration,
ILogger logger)
{
+ _options = options;
_snapshotService = snapshotService;
_titleDb = titleDb;
_configuration = configuration;
@@ -32,7 +36,7 @@ public sealed class IndexBuilderService: IHostedService
_cachePath = Path.Combine(AppContext.BaseDirectory, CacheFileName);
}
- public IndexDto Build()
+ public IndexDto Build(HttpContext httpContext)
{
// 1️⃣ Load cache if it exists
var cached = LoadCache();
@@ -54,7 +58,7 @@ public sealed class IndexBuilderService: IHostedService
_logger.LogInformation("Building index (snapshot size={Count})", snapshot.Files.Count);
// 3️⃣ Build new index from snapshot entries
- var files = ParseSnapshotFiles(snapshot);
+ var files = ParseSnapshotFiles(snapshot, new Uri(httpContext.Request.Scheme + "://" + httpContext.Request.Host + httpContext.Request.PathBase));
var directories = _configuration.GetSection("Directories")
.Get() ?? Array.Empty();
@@ -69,7 +73,7 @@ public sealed class IndexBuilderService: IHostedService
return index;
}
- private List> ParseSnapshotFiles(SnapshotService.ROMSnapshot snapshot)
+ private List> ParseSnapshotFiles(SnapshotService.ROMSnapshot snapshot, Uri baseUri)
{
var files = snapshot.Files
.Where(e => e.Titles.Count > 0)
@@ -108,9 +112,16 @@ public sealed class IndexBuilderService: IHostedService
}
}
- var url = $"http://192.168.1.145/{name}[{titleId}][v{versionNumberParsed}][{patchOrApp}].nsp";
-
- fileDtos.Add(new FileDto(url, e.Size));
+ var fileName =Uri.EscapeDataString($"{name}[{titleId}][v{versionNumberParsed}][{patchOrApp}].nsp");
+ var url = $"{baseUri.ToString().TrimEnd('/')}/{fileName}";
+ if (Uri.IsWellFormedUriString(url, UriKind.Absolute))
+ {
+ fileDtos.Add(new FileDto(url, e.Size));
+ }
+ else
+ {
+ _logger.LogWarning("Invalid URL for {TitleId}: {Url}", titleId, url);
+ }
}
return fileDtos;
@@ -150,8 +161,10 @@ public sealed class IndexBuilderService: IHostedService
public Task StartAsync(CancellationToken cancellationToken)
{
- Build();
- this._snapshotService.SnapshotRebuilt += InvalidateIndex;
+ var url = new Uri(_options.Value.ApiBaseUrl);
+ var host = url.Host;
+ Build(new DefaultHttpContext {HttpContext = { Request = { Host = new HostString(host), Scheme = url.Scheme, Path = new PathString(url.AbsolutePath)}}});
+ _snapshotService.SnapshotRebuilt += InvalidateIndex;
return Task.CompletedTask;
}
diff --git a/TinfoilVibeServer/Services/SnapshotService.cs b/TinfoilVibeServer/Services/SnapshotService.cs
index 3e0c0e4..5d50f91 100644
--- a/TinfoilVibeServer/Services/SnapshotService.cs
+++ b/TinfoilVibeServer/Services/SnapshotService.cs
@@ -649,7 +649,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
public class ROMSnapshot
{
- public string Hash { get; set; }
+ public string? Hash { get; set; }
public IReadOnlyList Files { get; set; } = new List();
}
@@ -664,8 +664,9 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
new Timer(_ => DebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite);
}
- public async Task StopAsync(CancellationToken cancellationToken)
+ public Task StopAsync(CancellationToken cancellationToken)
{
Dispose();
+ return Task.CompletedTask;
}
}
\ No newline at end of file
diff --git a/TinfoilVibeServer/TinfoilVibeServer.csproj b/TinfoilVibeServer/TinfoilVibeServer.csproj
index cb50206..89f3096 100644
--- a/TinfoilVibeServer/TinfoilVibeServer.csproj
+++ b/TinfoilVibeServer/TinfoilVibeServer.csproj
@@ -24,18 +24,22 @@
appsettings.json
+
+ LibHac.dll
+ Always
+
-
+
- ..\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll
+ ..\Dependencies\LibHac.dll
diff --git a/TinfoilVibeServer/appsettings.json b/TinfoilVibeServer/appsettings.json
index 7e1e8ea..dbf7aac 100644
--- a/TinfoilVibeServer/appsettings.json
+++ b/TinfoilVibeServer/appsettings.json
@@ -20,6 +20,9 @@
"SnapshotFile": "index.tfl",
"SnapshotBackupFile": "snapshot.bin"
},
+ "IndexBuilder": {
+ "ApiBaseUrl": "http://tinfoil.localhost:80"
+ },
"TitleDb": {
"CountryCode": "AU",
"Language": "en",
diff --git a/TinfoilVibeServerTest/Tests/BasicAuthMiddlewareTests.cs b/TinfoilVibeServerTest/Tests/BasicAuthMiddlewareTests.cs
index c661eb6..362a1dd 100644
--- a/TinfoilVibeServerTest/Tests/BasicAuthMiddlewareTests.cs
+++ b/TinfoilVibeServerTest/Tests/BasicAuthMiddlewareTests.cs
@@ -31,7 +31,7 @@ namespace TinfoilVibeServerTest.Tests
_middleware = new BasicAuthMiddleware(_next);
}
- private HttpContext CreateContext(string authHeader = null, string ip = "127.0.0.1", int? uid = null)
+ private HttpContext CreateContext(string authHeader = "", string ip = "127.0.0.1", int? uid = null)
{
var ctx = new DefaultHttpContext();
ctx.Connection.RemoteIpAddress = IPAddress.Parse(ip);