From cc7f77b7de1cdee7c091f385d865c308ef27d729 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:55:43 +0200 Subject: [PATCH 1/2] feat(site): Downloads page and Quest install from the latest build When the server is pointed at the bucket CI publishes builds to (DorkNet:Downloads:BaseUrl), the site lists them on a new Downloads page and the Quest page offers "Latest build" next to "Choose file". The server reads manifest.json and the files over HTTP on the internal network and streams them through with Range support (DownloadsController); only files the manifest lists are served. The xapk reader now reads from a byte source, either the picked File or the server URL via Range requests, so the browser installer streams the APK and OBB to the headset without downloading the package first. Unset, both features stay hidden. --- .../DownloadsControllerTests.cs | 112 +++++++++++++ .../Controllers/Site/DownloadsController.cs | 61 +++++++ DorkNet.Server/Services/DownloadsService.cs | 87 ++++++++++ .../Startup/RequestTracingExtensions.cs | 3 + .../Startup/ServiceCollectionExtensions.cs | 6 + DorkNet.Server/site/src/App.tsx | 2 + DorkNet.Server/site/src/components/Header.tsx | 13 +- DorkNet.Server/site/src/lib/downloads.ts | 56 +++++++ DorkNet.Server/site/src/lib/questInstall.ts | 4 +- DorkNet.Server/site/src/lib/xapk.ts | 84 ++++++++-- DorkNet.Server/site/src/pages/Downloads.tsx | 152 ++++++++++++++++++ DorkNet.Server/site/src/pages/Quest.tsx | 63 ++++++-- docs/deploy.md | 30 ++++ docs/quest.md | 9 +- 14 files changed, 646 insertions(+), 36 deletions(-) create mode 100644 DorkNet.Server.Tests/DownloadsControllerTests.cs create mode 100644 DorkNet.Server/Controllers/Site/DownloadsController.cs create mode 100644 DorkNet.Server/Services/DownloadsService.cs create mode 100644 DorkNet.Server/site/src/lib/downloads.ts create mode 100644 DorkNet.Server/site/src/pages/Downloads.tsx diff --git a/DorkNet.Server.Tests/DownloadsControllerTests.cs b/DorkNet.Server.Tests/DownloadsControllerTests.cs new file mode 100644 index 0000000..fa5db44 --- /dev/null +++ b/DorkNet.Server.Tests/DownloadsControllerTests.cs @@ -0,0 +1,112 @@ +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, 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 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)); + } + + [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.Response.Body = new MemoryStream(); + var controller = new DownloadsController(service) { 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..ac636a0 --- /dev/null +++ b/DorkNet.Server/Controllers/Site/DownloadsController.cs @@ -0,0 +1,61 @@ +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: put the site behind an +/// access proxy (e.g. Cloudflare Access) before turning this on. +/// +[ApiController] +[AllowAnonymous] +[Route("api/site/v1/downloads")] +public class DownloadsController(DownloadsService downloads) : ControllerBase +{ + /// GET /api/site/v1/downloads — the manifest as published + /// (snake_case keys), or 404 when downloads aren't set up. + [HttpGet] + public async Task GetManifest(CancellationToken ct) + { + if (!downloads.IsConfigured) 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 (!downloads.IsConfigured) { 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..9b6377b --- /dev/null +++ b/DorkNet.Server/Services/DownloadsService.cs @@ -0,0 +1,87 @@ +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"; + + 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; + foreach (var release in releases.OfType()) + { + if (release["files"] is not JsonArray files) continue; + foreach (var file in files.OfType()) + { + if (string.Equals((string?)file["path"], path, StringComparison.Ordinal)) return file; + } + } + return null; + } + + /// 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))); + 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); + return await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); + } +} 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..d442a7b 100644 --- a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs +++ b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs @@ -217,6 +217,12 @@ 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; + // the request's own abort token still ends it. + 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() {