diff --git a/DorkNet.Server.Tests/DownloadsControllerTests.cs b/DorkNet.Server.Tests/DownloadsControllerTests.cs index fa5db44..150f5a4 100644 --- a/DorkNet.Server.Tests/DownloadsControllerTests.cs +++ b/DorkNet.Server.Tests/DownloadsControllerTests.cs @@ -11,11 +11,13 @@ 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. +/// 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"}]}]} @@ -29,6 +31,19 @@ public sealed class DownloadsControllerTests 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() { @@ -91,8 +106,12 @@ public sealed class DownloadsControllerTests }).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) { ControllerContext = new ControllerContext { HttpContext = ctx } }; + var controller = new DownloadsController(service, new DomainConfig(Apex)) + { + ControllerContext = new ControllerContext { HttpContext = ctx }, + }; return (controller, ctx); } diff --git a/DorkNet.Server/Controllers/Site/DownloadsController.cs b/DorkNet.Server/Controllers/Site/DownloadsController.cs index ac636a0..2de2c94 100644 --- a/DorkNet.Server/Controllers/Site/DownloadsController.cs +++ b/DorkNet.Server/Controllers/Site/DownloadsController.cs @@ -11,20 +11,26 @@ namespace DorkNet.Server.Controllers.Site; /// 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. +/// 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) : ControllerBase +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. + /// (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 (!downloads.IsConfigured) return NotFound(new { error = "Downloads aren't set up on this server." }); + 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() }) @@ -36,7 +42,7 @@ public class DownloadsController(DownloadsService downloads) : ControllerBase [HttpGet("files/{**path}")] public async Task GetFile(string path, CancellationToken ct) { - if (!downloads.IsConfigured) { Response.StatusCode = StatusCodes.Status404NotFound; return; } + 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; } diff --git a/DorkNet.Server/Services/DownloadsService.cs b/DorkNet.Server/Services/DownloadsService.cs index 9b6377b..1d2ee57 100644 --- a/DorkNet.Server/Services/DownloadsService.cs +++ b/DorkNet.Server/Services/DownloadsService.cs @@ -20,6 +20,10 @@ 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; @@ -58,15 +62,9 @@ public sealed class DownloadsService 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; + 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 @@ -78,10 +76,22 @@ public sealed class DownloadsService { 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); + 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); - return await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); + // 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/ServiceCollectionExtensions.cs b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs index d442a7b..e023f4b 100644 --- a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs +++ b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs @@ -219,8 +219,9 @@ public static class ServiceCollectionExtensions 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. + // 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 diff --git a/DorkNet.Server/site/src/lib/downloads.ts b/DorkNet.Server/site/src/lib/downloads.ts index 8dc0cec..f7fa09b 100644 --- a/DorkNet.Server/site/src/lib/downloads.ts +++ b/DorkNet.Server/site/src/lib/downloads.ts @@ -46,10 +46,13 @@ export function getDownloads(fresh = false): Promise { export const fileUrl = (f: DownloadFile) => `/api/site/v1/downloads/files/${f.path.split('/').map(encodeURIComponent).join('/')}`; -/** The newest release's Quest package, if one is published. */ -export function latestQuest(m: DownloadsManifest | null): { release: DownloadRelease; file: DownloadFile } | null { +export interface QuestBuild { release: DownloadRelease; file: DownloadFile } + +/** A published Quest package: the one at `path` when given, otherwise the + * newest release's. Null when there's no match. */ +export function questBuild(m: DownloadsManifest | null, path?: string | null): QuestBuild | null { for (const release of m?.releases ?? []) { - const file = release.files.find((f) => f.id === 'quest'); + const file = release.files.find((f) => f.id === 'quest' && (!path || f.path === path)); if (file) return { release, file }; } return null; diff --git a/DorkNet.Server/site/src/pages/Downloads.tsx b/DorkNet.Server/site/src/pages/Downloads.tsx index a337b1a..27111fd 100644 --- a/DorkNet.Server/site/src/pages/Downloads.tsx +++ b/DorkNet.Server/site/src/pages/Downloads.tsx @@ -73,7 +73,7 @@ function FileCard({ f, rel }: { f: DownloadFile; rel: DownloadRelease }) {
{f.id === 'quest' ? ( <> - Install on Quest + Install on Quest Download ) : ( diff --git a/DorkNet.Server/site/src/pages/Quest.tsx b/DorkNet.Server/site/src/pages/Quest.tsx index 92e0f3b..98f8a8d 100644 --- a/DorkNet.Server/site/src/pages/Quest.tsx +++ b/DorkNet.Server/site/src/pages/Quest.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router'; import { fileSource, openXapk, urlSource, type Xapk } from '../lib/xapk'; import { connectHeadset, installXapk, webUsbSupported, type Headset, type Progress } from '../lib/questInstall'; -import { fileUrl, getDownloads, latestQuest } from '../lib/downloads'; +import { fileUrl, getDownloads, questBuild, type QuestBuild } from '../lib/downloads'; const fmtSize = (n: number) => n >= 1e9 ? `${(n / 1e9).toFixed(2)} GB` : n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1e3))} KB`; @@ -46,7 +46,7 @@ export function Quest() { const [done, setDone] = useState(false); const fileRef = useRef(null); // The server's latest published Quest build, if it offers downloads. - const [latest, setLatest] = useState>(null); + const [latest, setLatest] = useState(null); const [params] = useSearchParams(); const open = async (load: () => Promise) => { @@ -60,16 +60,17 @@ export function Quest() { if (f) await open(() => openXapk(fileSource(f))); }; - const pickLatest = async (l = latest) => { - if (l) await open(() => openXapk(urlSource(fileUrl(l.file), l.file.name, l.file.size))); + const pickBuild = async (b: QuestBuild | null) => { + if (b) await open(() => openXapk(urlSource(fileUrl(b.file), b.file.name, b.file.size))); }; useEffect(() => { getDownloads().then((m) => { - const l = latestQuest(m); - setLatest(l); - // Arriving from the Downloads page's "Install on Quest". - if (l && params.get('build') === 'latest') void pickLatest(l); + setLatest(questBuild(m)); + // Arriving from a Quest card's "Install on Quest" on the Downloads page: + // that card's own file, which may not be the newest release's. + const wanted = params.get('file'); + if (wanted) void pickBuild(questBuild(m, wanted)); }).catch(() => { /* no downloads: only the file picker */ }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -142,7 +143,7 @@ export function Quest() { pickFile(e.target.files?.[0])} />
{latest && ( - )} diff --git a/docs/deploy.md b/docs/deploy.md index 7581d2b..85ad524 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -438,7 +438,10 @@ DorkNet__Downloads__Host= Unset, the site hides both features. The builds contain Rec Room's own game files: only turn this on when the site is behind an access proxy (e.g. -Cloudflare Access) that admits just your community. +Cloudflare Access) that admits just your community. The downloads routes +answer only on the apex host, where the site lives, and return 404 on every +other host the server accepts, so leaving the game's own subdomains open for +the client doesn't expose the files. Put the apex behind the proxy. ---