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:
Vendored
BIN
Binary file not shown.
@@ -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_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_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_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>
|
||||
|
||||
@@ -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 “Content‑Disposition” 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 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace TinfoilVibeServer.Models;
|
||||
|
||||
public class IndexBuilderSettings
|
||||
{
|
||||
public string ApiBaseUrl { get; set; } = "http://tinfoil.localhost";
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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<IndexBuilderSettings> _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<IndexBuilderSettings> options,
|
||||
ISnapshotService snapshotService,
|
||||
TitleDatabaseService titleDb,
|
||||
IConfiguration configuration,
|
||||
ILogger<IndexBuilderService> 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<string[]>() ?? Array.Empty<string>();
|
||||
@@ -69,7 +73,7 @@ public sealed class IndexBuilderService: IHostedService
|
||||
return index;
|
||||
}
|
||||
|
||||
private List<List<FileDto>> ParseSnapshotFiles(SnapshotService.ROMSnapshot snapshot)
|
||||
private List<List<FileDto>> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -24,18 +24,22 @@
|
||||
<DependentUpon>appsettings.json</DependentUpon>
|
||||
</Content>
|
||||
<Content Remove="obj\**" />
|
||||
<AdditionalFiles Include="..\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll">
|
||||
<Link>LibHac.dll</Link>
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</AdditionalFiles>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- libhac ships a native helper called Ryujinx.HLE.HOS.Native.dll.
|
||||
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\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="LibHac">
|
||||
<HintPath>..\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll</HintPath>
|
||||
<HintPath>..\Dependencies\LibHac.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
"SnapshotFile": "index.tfl",
|
||||
"SnapshotBackupFile": "snapshot.bin"
|
||||
},
|
||||
"IndexBuilder": {
|
||||
"ApiBaseUrl": "http://tinfoil.localhost:80"
|
||||
},
|
||||
"TitleDb": {
|
||||
"CountryCode": "AU",
|
||||
"Language": "en",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user