Files
TinfoilVibeServer/TinfoilVibeServer/Controllers/IndexController.cs
T
ecenshu cb60d768df
Build & Push Docker image / build-and-push (push) Successful in 7m5s
ci / build_linux (push) Successful in 4m53s
Synchronous Snapshot Build and ordered persistence (#5)
Fix build warnings
Snapshot now persisted in lastmodified date descending, hopefully aligns with snapshot simple check against first entry in a directory not existing in the snapshot during build
Building Snapshot should only be done synchrnonously and atomically
Blacklist watched for changes

Reviewed-on: #5
Co-authored-by: Huy Nguyen <ecenshu@gmail.com>
Co-committed-by: Huy Nguyen <ecenshu@gmail.com>
2025-11-16 01:27:43 +00:00

197 lines
8.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.AspNetCore.Mvc;
using SharpCompress.Readers;
using TinfoilVibeServer.Models;
using TinfoilVibeServer.Services;
using TinfoilVibeServer.Utilities;
namespace TinfoilVibeServer.Controllers;
[ApiController]
[Route("/")]
public sealed class IndexController(ISnapshotService snapshotService, IndexBuilderService indexBuilderService) : ControllerBase
{
// ------------------------------------------------------------
// GET /
// ------------------------------------------------------------
/// <summary>
/// Return the “index snapshot” e.g. a JSON file that lists
/// every available resource. The snapshot could also be a
/// rendered Razor view simply change the return type.
/// </summary>
public IActionResult Index()
{
if (HttpContext.Request.Headers.CacheControl == "no-cache")
{
indexBuilderService.InvalidateIndex(this, EventArgs.Empty);
}
var index = indexBuilderService.Build(HttpContext);
return Ok(index);
}
// ------------------------------------------------------------
// GET /{*path}
// ------------------------------------------------------------
/// <summary>
/// Catchall action. Any URL that is **not** “/” is routed
/// here. The value of *path* is the relative location you
/// want to stream back to the client.
/// </summary>
/// <param name="path">The relative file path requested.</param>
[HttpGet("{*path}")]
public async Task<IActionResult> Download(string path)
{
if (string.IsNullOrWhiteSpace(path))
return BadRequest("Missing url query parameter.");
// ---- 1️⃣ Parse the brackets --------------------------------
// Expected format: [name][TitleId][v][patchOrApp].nsp
var match = System.Text.RegularExpressions.Regex.Match(path,
@"(?<name>.*?)\[(?<id>[0-9a-fA-F]{8}[0-9a-fA-F]{8})\]\[v(?<v>[0-9]+)\]\[(?<app>Base|Update)\]\.nsp",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (!match.Success)
return BadRequest("Url does not match the expected pattern.");
var titleId = match.Groups["id"].Value.ToUpperInvariant();
// ---- 2️⃣ Find the file that contains this TitleId ------------
var entry = snapshotService.GetSnapshot().Files
.FirstOrDefault(e => { return e.Titles.FirstOrDefault(hash => hash.TitleId == titleId)?.TitleId == titleId; });
if (entry == null)
return NotFound("No file with that TitleId found.");
// ---- 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 (System.IO.File.Exists(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 cancellationaware result
return new CancelableFileResult(
contentType: "application/octet-stream",
fileStream: fileStream)
{
FileDownloadName = Path.GetFileName(entry.Path) // optional but nice
};
}
}
// Check if it is inside an archive.
// If the path contains a slash that is not the root separator
// it might be an entry inside an archive; we simply stream it.
// - For normal files, we can use SendFileAsync.
// - For archives, we stream the entry using ArchiveHandler.
if (IsInsideArchive(entry.Path))
{
// Example: file is inside an archive use ArchiveHandler
var innerFileName = entry.Path.Split(snapshotService.GetArchivePathSeparator()).Last();
var stream = StreamFromArchive(entry, titleId, out var streamContainer);
if (stream == null)
return NotFound("Could not stream entry from archive.");
var wrappedStream = new DependentStream(stream, streamContainer);
var contentDisposition = $"inline; filename=\"{innerFileName}\"";
Response.ContentType = "application/octet-stream";
Response.ContentLength = entry.Size;
Response.Headers["Content-Disposition"] = contentDisposition;
await using var entryStream = wrappedStream;
const int bufferSize = 10 * 1024 * 1024;// 81920; // 80 KB default used by CopyToAsync
await entryStream.CopyToAsync(Response.Body, bufferSize, HttpContext.RequestAborted);
// Once the copy completes, the `await using` disposes the archive.
return new EmptyResult(); // We already wrote the stream to Response.Body
}
return NotFound("Requested URL does not reference an NSP file.");
}
/// <summary>
/// Very lightweight helper decides whether the file path
/// represents a file inside an archive.
/// </summary>
private bool IsInsideArchive(string path)
{
// If the path contains a separator that is not a root separator
// (e.g. "Games/MyGame.nsp" is a regular file; "archive.7z/mygame.nsp"
// would be inside an archive). For simplicity we only check
// for common archive extensions.
var filePath = path.Split(snapshotService.GetArchivePathSeparator()).First();
return filePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) ||
filePath.EndsWith(".7z", StringComparison.OrdinalIgnoreCase) ||
filePath.EndsWith(".rar", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// If the NSP is inside an archive, this method opens the archive
/// and returns the entry stream. It is deliberately minimal
/// if the archive cant be opened we return null.
/// </summary>
private Stream? StreamFromArchive(FileEntry fileEntry, string titleId, out IDisposable? streamContainer)
{
// Example: file is inside an archive use ArchiveHandler
var archivePath = fileEntry.Path.Split(snapshotService.GetArchivePathSeparator()).First();
snapshotService.GetArchiveName(titleId);
var innerFileName = Path.GetFileName(fileEntry.Path.Split(snapshotService.GetArchivePathSeparator()).Last());
// Use SharpCompress to open the archive and find the entry.
// Only the 3 archive types we support are handled.
try
{
// Check which archive type
if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
var zip = SharpCompress.Archives.Zip.ZipArchive.Open(archivePath, new ReaderOptions { LeaveStreamOpen = true });
streamContainer = zip;
var entry = zip.Entries
.FirstOrDefault(e => e.Key != null && e.Key.Equals(innerFileName,
StringComparison.OrdinalIgnoreCase));
if (entry != null)
return entry.OpenEntryStream();
}
else if (archivePath.EndsWith(".7z", StringComparison.OrdinalIgnoreCase))
{
var sevenZip = SharpCompress.Archives.SevenZip.SevenZipArchive.Open(archivePath, new ReaderOptions { LeaveStreamOpen = true });
streamContainer = sevenZip;
var entry = sevenZip.Entries
.FirstOrDefault(e => e.Key != null && e.Key.Equals(innerFileName,
StringComparison.OrdinalIgnoreCase));
if (entry != null)
return entry.OpenEntryStream();
}
else if (archivePath.EndsWith(".rar", StringComparison.OrdinalIgnoreCase))
{
var rar = SharpCompress.Archives.Rar.RarArchive.Open(archivePath, new ReaderOptions { LeaveStreamOpen = true });
streamContainer = rar;
var entry = rar.Entries
.FirstOrDefault(e => e.Key != null && e.Key.Equals(innerFileName,
StringComparison.OrdinalIgnoreCase));
if (entry != null)
return entry.OpenEntryStream();
}
}
catch
{
// ignore we will just return null and the controller
// will respond with 404.
}
streamContainer = null;
return null;
}
}