diff --git a/DorkNet.Server.Tests/DownloadsControllerTests.cs b/DorkNet.Server.Tests/DownloadsControllerTests.cs new file mode 100644 index 0000000..150f5a4 --- /dev/null +++ b/DorkNet.Server.Tests/DownloadsControllerTests.cs @@ -0,0 +1,131 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json.Nodes; +using DorkNet.Server.Controllers.Site; +using DorkNet.Server.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; + +namespace DorkNet.Server.Tests; + +/// The site's downloads proxy: off without config, only on the site's +/// own host, serves only files the manifest lists, and passes Range through so +/// the browser Quest installer can read an .xapk in place. +public sealed class DownloadsControllerTests +{ + private const string Apex = "dorknet.example.com"; + + private const string Manifest = """ + {"releases":[{"id":"2023.03","title":"March 2023","files":[ + {"id":"quest","platform":"quest","name":"DorkNet-2023.03-Quest.xapk","path":"2023.03/DorkNet-2023.03-Quest.xapk","size":10,"sha256":"x"}]}]} + """; + + [Fact] + public async Task Without_a_base_url_the_manifest_is_404() + { + var (controller, _) = Create(baseUrl: null, _ => throw new InvalidOperationException("no request expected")); + + Assert.IsType(await controller.GetManifest(CancellationToken.None)); + } + + [Theory] + [InlineData("api.dorknet.example.com")] + [InlineData("cdn.dorknet.example.com")] + public async Task Other_hosts_get_404_without_touching_the_bucket(string host) + { + var (controller, ctx) = Create("http://bucket.internal:3902", _ => throw new InvalidOperationException("no request expected")); + ctx.Request.Host = new HostString(host); + + Assert.IsType(await controller.GetManifest(CancellationToken.None)); + await controller.GetFile("2023.03/DorkNet-2023.03-Quest.xapk", CancellationToken.None); + Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode); + } + + [Fact] + public void Only_listed_paths_are_found() + { + var manifest = (JsonObject)JsonNode.Parse(Manifest)!; + + Assert.NotNull(DownloadsService.FindFile(manifest, "2023.03/DorkNet-2023.03-Quest.xapk")); + Assert.Null(DownloadsService.FindFile(manifest, "manifest.json")); + Assert.Null(DownloadsService.FindFile(manifest, "2023.03/../manifest.json")); + } + + [Fact] + public async Task Unlisted_file_is_404_and_never_fetched() + { + var requested = new List(); + var (controller, ctx) = Create("http://bucket.internal:3902", req => + { + requested.Add(req.RequestUri!.AbsolutePath); + return Json(Manifest); + }); + + await controller.GetFile("index.html", CancellationToken.None); + + Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode); + Assert.Equal(["/manifest.json"], requested); + } + + [Fact] + public async Task Range_is_forwarded_and_the_partial_response_streamed() + { + HttpRequestMessage? fileRequest = null; + var (controller, ctx) = Create("http://bucket.internal:3902/", req => + { + if (req.RequestUri!.AbsolutePath == "/manifest.json") return Json(Manifest); + fileRequest = req; + var res = new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = new ByteArrayContent("abcd"u8.ToArray()) }; + res.Content.Headers.ContentRange = new ContentRangeHeaderValue(2, 5, 10); + return res; + }, host: "downloads.example.com"); + ctx.Request.Headers.Range = "bytes=2-5"; + + await controller.GetFile("2023.03/DorkNet-2023.03-Quest.xapk", CancellationToken.None); + + Assert.NotNull(fileRequest); + Assert.Equal("/2023.03/DorkNet-2023.03-Quest.xapk", fileRequest!.RequestUri!.AbsolutePath); + Assert.Equal("bytes=2-5", fileRequest.Headers.Range!.ToString()); + Assert.Equal("downloads.example.com", fileRequest.Headers.Host); + Assert.Equal(StatusCodes.Status206PartialContent, ctx.Response.StatusCode); + Assert.Equal("bytes 2-5/10", ctx.Response.Headers.ContentRange.ToString()); + Assert.Equal("bytes", ctx.Response.Headers.AcceptRanges.ToString()); + Assert.Equal("abcd", Encoding.ASCII.GetString(((MemoryStream)ctx.Response.Body).ToArray())); + } + + private static (DownloadsController, HttpContext) Create( + string? baseUrl, Func handler, string? host = null) + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["DorkNet:Downloads:BaseUrl"] = baseUrl, + ["DorkNet:Downloads:Host"] = host, + }).Build(); + var service = new DownloadsService(config, new StubFactory(handler), NullLogger.Instance); + var ctx = new DefaultHttpContext(); + ctx.Request.Host = new HostString(Apex); + ctx.Response.Body = new MemoryStream(); + var controller = new DownloadsController(service, new DomainConfig(Apex)) + { + ControllerContext = new ControllerContext { HttpContext = ctx }, + }; + return (controller, ctx); + } + + private static HttpResponseMessage Json(string body) => + new(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + + private sealed class StubFactory(Func handler) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(new StubHandler(handler)); + } + + private sealed class StubHandler(Func handler) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + => Task.FromResult(handler(request)); + } +} diff --git a/DorkNet.Server/Controllers/Site/DownloadsController.cs b/DorkNet.Server/Controllers/Site/DownloadsController.cs new file mode 100644 index 0000000..2de2c94 --- /dev/null +++ b/DorkNet.Server/Controllers/Site/DownloadsController.cs @@ -0,0 +1,67 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.Site; + +/// +/// The client builds CI publishes, for the site's Downloads page and the +/// Quest page's "latest build" install. Files are streamed from the downloads +/// bucket () with Range support, so the browser +/// installer can read an .xapk's zip directory and entries without fetching +/// the whole file. Only files the manifest lists are served. +/// +/// The builds contain Rec Room's own game files, so the routes answer only on +/// the site's own host (the apex) and 404 on every other host the server +/// accepts: the game's API/CDN subdomains are typically left open for the +/// client, while the site sits behind an access proxy (e.g. Cloudflare Access). +/// +[ApiController] +[AllowAnonymous] +[Route("api/site/v1/downloads")] +public class DownloadsController(DownloadsService downloads, DomainConfig domain) : ControllerBase +{ + private bool Available => downloads.IsConfigured + && string.Equals(Request.Host.Host, domain.Apex, StringComparison.OrdinalIgnoreCase); + + /// GET /api/site/v1/downloads — the manifest as published + /// (snake_case keys), or 404 when downloads aren't set up or the request + /// isn't for the site's host. + [HttpGet] + public async Task GetManifest(CancellationToken ct) + { + if (!Available) return NotFound(new { error = "Downloads aren't set up on this server." }); + var manifest = await downloads.GetManifestAsync(ct); + Response.Headers.CacheControl = "no-cache"; + return Content((manifest ?? new System.Text.Json.Nodes.JsonObject { ["releases"] = new System.Text.Json.Nodes.JsonArray() }) + .ToJsonString(), "application/json"); + } + + /// GET /api/site/v1/downloads/files/{path} — one listed file, + /// streamed; honours Range (206). + [HttpGet("files/{**path}")] + public async Task GetFile(string path, CancellationToken ct) + { + if (!Available) { Response.StatusCode = StatusCodes.Status404NotFound; return; } + var manifest = await downloads.GetManifestAsync(ct); + var entry = manifest is null ? null : DownloadsService.FindFile(manifest, path); + if (entry is null) { Response.StatusCode = StatusCodes.Status404NotFound; return; } + + using var upstream = await downloads.GetFileAsync(path, Request.Headers.Range.ToString(), ct); + Response.StatusCode = (int)upstream.StatusCode; + if (!upstream.IsSuccessStatusCode) return; + + var content = upstream.Content.Headers; + if (content.ContentLength is long length) Response.ContentLength = length; + if (content.ContentRange is not null) Response.Headers.ContentRange = content.ContentRange.ToString(); + Response.Headers.AcceptRanges = "bytes"; + if (upstream.Headers.ETag is not null) Response.Headers.ETag = upstream.Headers.ETag.ToString(); + Response.Headers.CacheControl = "private, no-cache"; + Response.ContentType = "application/octet-stream"; + var name = (string?)entry["name"] ?? Path.GetFileName(path); + Response.Headers.ContentDisposition = new System.Net.Mime.ContentDisposition { FileName = name }.ToString(); + + await using var body = await upstream.Content.ReadAsStreamAsync(ct); + await body.CopyToAsync(Response.Body, 1 << 16, ct); + } +} diff --git a/DorkNet.Server/Services/DownloadsService.cs b/DorkNet.Server/Services/DownloadsService.cs new file mode 100644 index 0000000..1d2ee57 --- /dev/null +++ b/DorkNet.Server/Services/DownloadsService.cs @@ -0,0 +1,97 @@ +using System.Net.Http.Headers; +using System.Text.Json.Nodes; + +namespace DorkNet.Server.Services; + +/// +/// Reads the client builds CI publishes (ci/downloads/publish.py): a +/// manifest.json plus the files it lists, in a bucket served over plain +/// HTTP (e.g. Garage's web endpoint on the internal network). The site's +/// Downloads and Quest pages fetch them through the server, so the bucket +/// itself never has to be reachable from outside. +/// +/// Configured with DorkNet:Downloads:BaseUrl (the folder holding +/// manifest.json) and optionally DorkNet:Downloads:Host (the +/// Host header to send, for web endpoints that pick the bucket by host name). +/// Without a base URL is false and the site hides +/// both features. +/// +public sealed class DownloadsService +{ + public const string HttpClientName = "downloads"; + + // How long the bucket may take to start answering. The body then streams + // with no limit (the client has none; the caller's abort token ends it). + private static readonly TimeSpan HeaderTimeout = TimeSpan.FromSeconds(30); + + private readonly IHttpClientFactory _httpClientFactory; + private readonly Uri? _baseUri; + private readonly string? _host; + + public DownloadsService(IConfiguration config, IHttpClientFactory httpClientFactory, ILogger log) + { + _httpClientFactory = httpClientFactory; + var baseUrl = config["DorkNet:Downloads:BaseUrl"]?.Trim(); + if (!string.IsNullOrEmpty(baseUrl) && Uri.TryCreate(baseUrl.TrimEnd('/') + "/", UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) + { + _baseUri = uri; + } + var host = config["DorkNet:Downloads:Host"]?.Trim(); + _host = string.IsNullOrEmpty(host) ? null : host; + + if (_baseUri is null) + log.LogInformation("[downloads] no DorkNet:Downloads:BaseUrl; the Downloads page and Quest latest-build install are off"); + else + log.LogInformation("[downloads] reading builds from {BaseUrl}{Host}", _baseUri, _host is null ? "" : $" (Host: {_host})"); + } + + public bool IsConfigured => _baseUri is not null; + + /// The published manifest, or null when none has been published yet. + public async Task GetManifestAsync(CancellationToken ct) + { + using var res = await SendAsync("manifest.json", range: null, ct); + if (res.StatusCode == System.Net.HttpStatusCode.NotFound) return null; + res.EnsureSuccessStatusCode(); + return JsonNode.Parse(await res.Content.ReadAsStringAsync(ct)) as JsonObject; + } + + /// A manifest entry by its bucket path, or null when the manifest + /// doesn't list it. Only listed files are ever served. + public static JsonObject? FindFile(JsonObject manifest, string path) + { + if (manifest["releases"] is not JsonArray releases) return null; + return releases.OfType() + .SelectMany(r => r["files"] is JsonArray files ? files.OfType() : []) + .FirstOrDefault(f => string.Equals((string?)f["path"], path, StringComparison.Ordinal)); + } + + /// GET a file, forwarding the caller's Range header. The caller + /// owns the response and streams its content. + public Task GetFileAsync(string path, string? range, CancellationToken ct) + => SendAsync(path, range, ct); + + private async Task SendAsync(string path, string? range, CancellationToken ct) + { + if (_baseUri is null) throw new InvalidOperationException("Downloads are not configured."); + var url = new Uri(_baseUri, string.Join('/', path.Split('/').Select(Uri.EscapeDataString))); + using var req = new HttpRequestMessage(HttpMethod.Get, url); + if (_host is not null) req.Headers.Host = _host; + if (!string.IsNullOrEmpty(range) && RangeHeaderValue.TryParse(range, out var r)) req.Headers.Range = r; + var client = _httpClientFactory.CreateClient(HttpClientName); + // The timeout covers only getting the headers: with ResponseHeadersRead + // the token is released once they arrive, and disposing the source + // here stops its timer before the body is streamed. + using var headers = CancellationTokenSource.CreateLinkedTokenSource(ct); + headers.CancelAfter(HeaderTimeout); + try + { + return await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, headers.Token); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + throw new TimeoutException($"The downloads bucket didn't answer within {HeaderTimeout.TotalSeconds:0} s."); + } + } +} diff --git a/DorkNet.Server/Startup/RequestTracingExtensions.cs b/DorkNet.Server/Startup/RequestTracingExtensions.cs index 0ceadb9..3cef9b6 100644 --- a/DorkNet.Server/Startup/RequestTracingExtensions.cs +++ b/DorkNet.Server/Startup/RequestTracingExtensions.cs @@ -238,6 +238,9 @@ public static class RequestTracingExtensions return false; var path = request.Path.Value ?? ""; + // Client builds are streamed through (several GB): never buffer them. + if (path.StartsWith("/api/site/v1/downloads/files/", StringComparison.OrdinalIgnoreCase)) + return false; var ext = Path.GetExtension(path); if (string.IsNullOrEmpty(ext)) return true; diff --git a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs index a255b0c..e023f4b 100644 --- a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs +++ b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs @@ -217,6 +217,13 @@ public static class ServiceCollectionExtensions // so a deployment with no GitHub credentials behaves normally. services.AddSingleton(); services.AddHostedService(); + // CI-published client builds for the site's Downloads / Quest pages. + // Off (IsConfigured=false) without DorkNet:Downloads:BaseUrl. No client + // timeout: a multi-GB file streams for longer than the default 100 s. + // DownloadsService limits the wait for headers itself, and the + // request's own abort token ends the stream. + services.AddHttpClient(DownloadsService.HttpClientName, c => c.Timeout = Timeout.InfiniteTimeSpan); + services.AddSingleton(); // Scoped (per-request) — these services hold DbContext references and // must share the request's scope to participate in the request's // transaction lifecycle. Pre-PR-3 they were Singleton with in-process diff --git a/DorkNet.Server/site/src/App.tsx b/DorkNet.Server/site/src/App.tsx index 7978f7b..0e17931 100644 --- a/DorkNet.Server/site/src/App.tsx +++ b/DorkNet.Server/site/src/App.tsx @@ -8,6 +8,7 @@ import { Players } from './pages/Players'; import { PlayerProfile } from './pages/PlayerProfile'; import { Rooms } from './pages/Rooms'; import { Join } from './pages/Join'; +import { Downloads } from './pages/Downloads'; import { NotFound } from './pages/NotFound'; // The Quest installer pulls in the WebUSB/ADB stack; load it only on /quest. @@ -27,6 +28,7 @@ export function App() { } /> } /> } /> + } /> {/* Legacy paths from the vanilla-JS feed.localhost site */} } /> } /> diff --git a/DorkNet.Server/site/src/components/Header.tsx b/DorkNet.Server/site/src/components/Header.tsx index 57735ff..ecfe66d 100644 --- a/DorkNet.Server/site/src/components/Header.tsx +++ b/DorkNet.Server/site/src/components/Header.tsx @@ -1,6 +1,7 @@ import { NavLink, Link, useLocation, useNavigate } from 'react-router'; import { useEffect, useRef, useState } from 'react'; import { BrandMark } from './BrandMark'; +import { getDownloads } from '../lib/downloads'; const NAV = [ { to: '/', label: 'Home', exact: true }, @@ -10,6 +11,8 @@ const NAV = [ { to: '/join', label: 'Join' }, { to: '/quest', label: 'Quest' }, ]; +// Shown only when the server offers downloads (DorkNet:Downloads:BaseUrl). +const DOWNLOADS = { to: '/downloads', label: 'Downloads', exact: false }; export function Header() { const [open, setOpen] = useState(false); @@ -17,6 +20,12 @@ export function Header() { const nav = useNavigate(); const loc = useLocation(); const inputRef = useRef(null); + const [hasDownloads, setHasDownloads] = useState(false); + const navItems = hasDownloads ? [...NAV, DOWNLOADS] : NAV; + + useEffect(() => { + getDownloads().then((m) => setHasDownloads(m !== null)).catch(() => {}); + }, []); // Close the mobile drawer on route change so tapping a nav item // doesn't leave a half-open overlay covering the page. @@ -37,7 +46,7 @@ export function Header() {