cb60d768df
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>
197 lines
8.7 KiB
C#
197 lines
8.7 KiB
C#
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>
|
||
/// Catch‑all 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 cancellation‑aware 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 light‑weight 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 can’t 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;
|
||
}
|
||
} |