Allow for cancelling downloads from filesystem

Rebuild request orignally will use setting for constructing the url
Rebuild request from client via no-cache will use httppcontext to get runtime pathing to generate url
Escape the url generated
This commit is contained in:
2025-11-07 16:13:48 +10:30
parent c2ed73e03f
commit 995e4aa518
12 changed files with 122 additions and 21 deletions
BIN
View File
Binary file not shown.
+1
View File
@@ -13,6 +13,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConcurrentDictionary_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8d7a996932951dc929c6c3855f98f0c58cae8ac5b0d0bbf223f97c6388b3b61f_003FConcurrentDictionary_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConcurrentDictionary_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8d7a996932951dc929c6c3855f98f0c58cae8ac5b0d0bbf223f97c6388b3b61f_003FConcurrentDictionary_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AControllerBase_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6fa05d7dbf2e454ce78b261b422234da1f1ccedee678fd997e5ef4afe6df6e_003FControllerBase_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AControllerBase_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6fa05d7dbf2e454ce78b261b422234da1f1ccedee678fd997e5ef4afe6df6e_003FControllerBase_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACryptoUtil_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fd4_003F032ece9d_003FCryptoUtil_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACryptoUtil_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fd4_003F032ece9d_003FCryptoUtil_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADefaultHttpContext_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6af28599e71724c5fc6a617444e2f60b5d57dfdc5be0df4ba43ccfc36977_003FDefaultHttpContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADictionary_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fe5d623ea960f2c3c9fda144954d339f8d4cd3dad6dd8bd4ab96093a010ab_003FDictionary_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADictionary_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fe5d623ea960f2c3c9fda144954d339f8d4cd3dad6dd8bd4ab96093a010ab_003FDictionary_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExceptionDispatchInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbd1d5c50194fea68ff3559c160230b0ab50f5acf4ce3061bffd6d62958e2182_003FExceptionDispatchInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExceptionDispatchInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbd1d5c50194fea68ff3559c160230b0ab50f5acf4ce3061bffd6d62958e2182_003FExceptionDispatchInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExecutionContext_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F53b0d531c06faf86bf2ae111a9a2dbce4c52a9153feb9966ade60289c71bf52_003FExecutionContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExecutionContext_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F53b0d531c06faf86bf2ae111a9a2dbce4c52a9153feb9966ade60289c71bf52_003FExecutionContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
@@ -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));
}
/// <summary>
/// Allows you to set a suggested download name.
/// It will be sent in a “ContentDisposition” header.
/// </summary>
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 requestaborted token tells the stream copy to stop ASAP
var cancellationToken = context.HttpContext.RequestAborted;
try
{
// Copy the file to the response body in 8KiB chunks
await _fileStream.CopyToAsync(
response.Body,
bufferSize: 81920, // 80KiB 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();
}
}
}
@@ -43,7 +43,7 @@ public sealed class IndexController : ControllerBase
_indexBuilderService.InvalidateIndex(this, EventArgs.Empty); _indexBuilderService.InvalidateIndex(this, EventArgs.Empty);
} }
var index = _indexBuilderService.Build(); var index = _indexBuilderService.Build(HttpContext);
return Ok(index); return Ok(index);
} }
@@ -83,13 +83,27 @@ public sealed class IndexController : ControllerBase
// ---- 3️⃣ If the file is a normal NSP → send it ---------------- // ---- 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)) if (System.IO.File.Exists(entry.Path))
{ {
// Regular file just serve it. // 1️⃣ Open the file for async read
return PhysicalFile(entry.Path, "application/octet-stream", var fileStream = new FileStream(
Path.GetFileName(entry.Path)); entry.Path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 128 * 1024 * 1024, // 81920, // 80KiB
useAsync: true); // <‑‑ VERY important for scalability
// 2️⃣ Return a cancellationaware 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 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 CanRead => _innerStream.CanRead;
public override bool CanSeek => _innerStream.CanSeek; public override bool CanSeek => _innerStream.CanSeek;
public override bool CanWrite => _innerStream.CanWrite; public override bool CanWrite => _innerStream.CanWrite;
@@ -91,7 +91,7 @@ public sealed class BasicAuthMiddleware
{ {
logger.LogWarning("Auth failed for user {User} from {IP}: {Error}", username, ip, error); logger.LogWarning("Auth failed for user {User} from {IP}: {Error}", username, ip, error);
context.Response.StatusCode = StatusCodes.Status401Unauthorized; 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"); await context.Response.WriteAsync(error ?? "Unauthorized");
return; return;
} }
@@ -105,6 +105,6 @@ public sealed class BasicAuthMiddleware
private static void Challenge(HttpContext ctx) private static void Challenge(HttpContext ctx)
{ {
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
ctx.Response.Headers.Add("WWW-Authenticate", "Basic realm=\"FileSnapshot\""); ctx.Response.Headers.Append("WWW-Authenticate", "Basic realm=\"FileSnapshot\"");
} }
} }
@@ -0,0 +1,6 @@
namespace TinfoilVibeServer.Models;
public class IndexBuilderSettings
{
public string ApiBaseUrl { get; set; } = "http://tinfoil.localhost";
}
@@ -5,7 +5,7 @@
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": false, "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": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }
@@ -2,6 +2,7 @@
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using LibHac.Ncm; using LibHac.Ncm;
using Microsoft.Extensions.Options;
using TinfoilVibeServer.Models; using TinfoilVibeServer.Models;
namespace TinfoilVibeServer.Services; namespace TinfoilVibeServer.Services;
@@ -12,6 +13,7 @@ public sealed class IndexBuilderService: IHostedService
{ {
private const string CacheFileName = "indexcache.json"; private const string CacheFileName = "indexcache.json";
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 IConfiguration _configuration;
@@ -20,11 +22,13 @@ public sealed class IndexBuilderService: IHostedService
private readonly SemaphoreSlim _lock = new(1, 1); private readonly SemaphoreSlim _lock = new(1, 1);
public IndexBuilderService( public IndexBuilderService(
IOptions<IndexBuilderSettings> options,
ISnapshotService snapshotService, ISnapshotService snapshotService,
TitleDatabaseService titleDb, TitleDatabaseService titleDb,
IConfiguration configuration, IConfiguration configuration,
ILogger<IndexBuilderService> logger) ILogger<IndexBuilderService> logger)
{ {
_options = options;
_snapshotService = snapshotService; _snapshotService = snapshotService;
_titleDb = titleDb; _titleDb = titleDb;
_configuration = configuration; _configuration = configuration;
@@ -32,7 +36,7 @@ public sealed class IndexBuilderService: IHostedService
_cachePath = Path.Combine(AppContext.BaseDirectory, CacheFileName); _cachePath = Path.Combine(AppContext.BaseDirectory, CacheFileName);
} }
public IndexDto Build() public IndexDto Build(HttpContext httpContext)
{ {
// 1️⃣ Load cache if it exists // 1️⃣ Load cache if it exists
var cached = LoadCache(); var cached = LoadCache();
@@ -54,7 +58,7 @@ public sealed class IndexBuilderService: IHostedService
_logger.LogInformation("Building index (snapshot size={Count})", snapshot.Files.Count); _logger.LogInformation("Building index (snapshot size={Count})", snapshot.Files.Count);
// 3️⃣ Build new index from snapshot entries // 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") var directories = _configuration.GetSection("Directories")
.Get<string[]>() ?? Array.Empty<string>(); .Get<string[]>() ?? Array.Empty<string>();
@@ -69,7 +73,7 @@ public sealed class IndexBuilderService: IHostedService
return index; return index;
} }
private List<List<FileDto>> ParseSnapshotFiles(SnapshotService.ROMSnapshot snapshot) private List<List<FileDto>> ParseSnapshotFiles(SnapshotService.ROMSnapshot snapshot, Uri baseUri)
{ {
var files = snapshot.Files var files = snapshot.Files
.Where(e => e.Titles.Count > 0) .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"; var fileName =Uri.EscapeDataString($"{name}[{titleId}][v{versionNumberParsed}][{patchOrApp}].nsp");
var url = $"{baseUri.ToString().TrimEnd('/')}/{fileName}";
fileDtos.Add(new FileDto(url, e.Size)); 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; return fileDtos;
@@ -150,8 +161,10 @@ public sealed class IndexBuilderService: IHostedService
public Task StartAsync(CancellationToken cancellationToken) public Task StartAsync(CancellationToken cancellationToken)
{ {
Build(); var url = new Uri(_options.Value.ApiBaseUrl);
this._snapshotService.SnapshotRebuilt += InvalidateIndex; 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; return Task.CompletedTask;
} }
@@ -649,7 +649,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
public class ROMSnapshot public class ROMSnapshot
{ {
public string Hash { get; set; } public string? Hash { get; set; }
public IReadOnlyList<FileEntry> Files { get; set; } = new List<FileEntry>(); public IReadOnlyList<FileEntry> Files { get; set; } = new List<FileEntry>();
} }
@@ -664,8 +664,9 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
new Timer(_ => DebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite); new Timer(_ => DebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite);
} }
public async Task StopAsync(CancellationToken cancellationToken) public Task StopAsync(CancellationToken cancellationToken)
{ {
Dispose(); Dispose();
return Task.CompletedTask;
} }
} }
+6 -2
View File
@@ -24,18 +24,22 @@
<DependentUpon>appsettings.json</DependentUpon> <DependentUpon>appsettings.json</DependentUpon>
</Content> </Content>
<Content Remove="obj\**" /> <Content Remove="obj\**" />
<AdditionalFiles Include="..\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll">
<Link>LibHac.dll</Link>
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</AdditionalFiles>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<!-- libhac ships a native helper called Ryujinx.HLE.HOS.Native.dll. <!-- libhac ships a native helper called Ryujinx.HLE.HOS.Native.dll.
The build script copies it to the output folder automatically. --> The build script copies it to the output folder automatically. -->
<None Update="..\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll" CopyToOutputDirectory="PreserveNewest" /> <None Update="..\Dependencies\LibHac.dll" CopyToOutputDirectory="PreserveNewest" />
<None Remove="obj\**" /> <None Remove="obj\**" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Reference Include="LibHac"> <Reference Include="LibHac">
<HintPath>..\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll</HintPath> <HintPath>..\Dependencies\LibHac.dll</HintPath>
</Reference> </Reference>
</ItemGroup> </ItemGroup>
+3
View File
@@ -20,6 +20,9 @@
"SnapshotFile": "index.tfl", "SnapshotFile": "index.tfl",
"SnapshotBackupFile": "snapshot.bin" "SnapshotBackupFile": "snapshot.bin"
}, },
"IndexBuilder": {
"ApiBaseUrl": "http://tinfoil.localhost:80"
},
"TitleDb": { "TitleDb": {
"CountryCode": "AU", "CountryCode": "AU",
"Language": "en", "Language": "en",
@@ -31,7 +31,7 @@ namespace TinfoilVibeServerTest.Tests
_middleware = new BasicAuthMiddleware(_next); _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(); var ctx = new DefaultHttpContext();
ctx.Connection.RemoteIpAddress = IPAddress.Parse(ip); ctx.Connection.RemoteIpAddress = IPAddress.Parse(ip);