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])} />