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);