mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 23:48:45 -07:00
fix: serve downloads only on the site host and select the clicked Quest build
Review follow-ups: - The downloads routes now answer only on the apex (the site's host) and 404 on every other accepted host, so the game's open API/CDN subdomains can't serve the builds. - Install on Quest passes its own file to the Quest page instead of always picking the newest release. - Waiting for the bucket's headers is limited to 30 s; the body still streams without a limit. - Dispose the upstream request message; use LINQ in FindFile.
This commit is contained in:
@@ -11,11 +11,13 @@ using Microsoft.Extensions.Logging.Abstractions;
|
|||||||
|
|
||||||
namespace DorkNet.Server.Tests;
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
/// <summary>The site's downloads proxy: off without config, serves only files
|
/// <summary>The site's downloads proxy: off without config, only on the site's
|
||||||
/// the manifest lists, and passes Range through so the browser Quest installer
|
/// own host, serves only files the manifest lists, and passes Range through so
|
||||||
/// can read an .xapk in place.</summary>
|
/// the browser Quest installer can read an .xapk in place.</summary>
|
||||||
public sealed class DownloadsControllerTests
|
public sealed class DownloadsControllerTests
|
||||||
{
|
{
|
||||||
|
private const string Apex = "dorknet.example.com";
|
||||||
|
|
||||||
private const string Manifest = """
|
private const string Manifest = """
|
||||||
{"releases":[{"id":"2023.03","title":"March 2023","files":[
|
{"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"}]}]}
|
{"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<NotFoundObjectResult>(await controller.GetManifest(CancellationToken.None));
|
Assert.IsType<NotFoundObjectResult>(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<NotFoundObjectResult>(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]
|
[Fact]
|
||||||
public void Only_listed_paths_are_found()
|
public void Only_listed_paths_are_found()
|
||||||
{
|
{
|
||||||
@@ -91,8 +106,12 @@ public sealed class DownloadsControllerTests
|
|||||||
}).Build();
|
}).Build();
|
||||||
var service = new DownloadsService(config, new StubFactory(handler), NullLogger<DownloadsService>.Instance);
|
var service = new DownloadsService(config, new StubFactory(handler), NullLogger<DownloadsService>.Instance);
|
||||||
var ctx = new DefaultHttpContext();
|
var ctx = new DefaultHttpContext();
|
||||||
|
ctx.Request.Host = new HostString(Apex);
|
||||||
ctx.Response.Body = new MemoryStream();
|
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);
|
return (controller, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,20 +11,26 @@ namespace DorkNet.Server.Controllers.Site;
|
|||||||
/// installer can read an .xapk's zip directory and entries without fetching
|
/// installer can read an .xapk's zip directory and entries without fetching
|
||||||
/// the whole file. Only files the manifest lists are served.
|
/// the whole file. Only files the manifest lists are served.
|
||||||
///
|
///
|
||||||
/// The builds contain Rec Room's own game files: put the site behind an
|
/// The builds contain Rec Room's own game files, so the routes answer only on
|
||||||
/// access proxy (e.g. Cloudflare Access) before turning this 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).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[Route("api/site/v1/downloads")]
|
[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);
|
||||||
|
|
||||||
/// <summary>GET /api/site/v1/downloads — the manifest as published
|
/// <summary>GET /api/site/v1/downloads — the manifest as published
|
||||||
/// (snake_case keys), or 404 when downloads aren't set up.</summary>
|
/// (snake_case keys), or 404 when downloads aren't set up or the request
|
||||||
|
/// isn't for the site's host.</summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetManifest(CancellationToken ct)
|
public async Task<IActionResult> 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);
|
var manifest = await downloads.GetManifestAsync(ct);
|
||||||
Response.Headers.CacheControl = "no-cache";
|
Response.Headers.CacheControl = "no-cache";
|
||||||
return Content((manifest ?? new System.Text.Json.Nodes.JsonObject { ["releases"] = new System.Text.Json.Nodes.JsonArray() })
|
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}")]
|
[HttpGet("files/{**path}")]
|
||||||
public async Task GetFile(string path, CancellationToken ct)
|
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 manifest = await downloads.GetManifestAsync(ct);
|
||||||
var entry = manifest is null ? null : DownloadsService.FindFile(manifest, path);
|
var entry = manifest is null ? null : DownloadsService.FindFile(manifest, path);
|
||||||
if (entry is null) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
|
if (entry is null) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ public sealed class DownloadsService
|
|||||||
{
|
{
|
||||||
public const string HttpClientName = "downloads";
|
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 IHttpClientFactory _httpClientFactory;
|
||||||
private readonly Uri? _baseUri;
|
private readonly Uri? _baseUri;
|
||||||
private readonly string? _host;
|
private readonly string? _host;
|
||||||
@@ -58,15 +62,9 @@ public sealed class DownloadsService
|
|||||||
public static JsonObject? FindFile(JsonObject manifest, string path)
|
public static JsonObject? FindFile(JsonObject manifest, string path)
|
||||||
{
|
{
|
||||||
if (manifest["releases"] is not JsonArray releases) return null;
|
if (manifest["releases"] is not JsonArray releases) return null;
|
||||||
foreach (var release in releases.OfType<JsonObject>())
|
return releases.OfType<JsonObject>()
|
||||||
{
|
.SelectMany(r => r["files"] is JsonArray files ? files.OfType<JsonObject>() : [])
|
||||||
if (release["files"] is not JsonArray files) continue;
|
.FirstOrDefault(f => string.Equals((string?)f["path"], path, StringComparison.Ordinal));
|
||||||
foreach (var file in files.OfType<JsonObject>())
|
|
||||||
{
|
|
||||||
if (string.Equals((string?)file["path"], path, StringComparison.Ordinal)) return file;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>GET a file, forwarding the caller's Range header. The caller
|
/// <summary>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.");
|
if (_baseUri is null) throw new InvalidOperationException("Downloads are not configured.");
|
||||||
var url = new Uri(_baseUri, string.Join('/', path.Split('/').Select(Uri.EscapeDataString)));
|
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 (_host is not null) req.Headers.Host = _host;
|
||||||
if (!string.IsNullOrEmpty(range) && RangeHeaderValue.TryParse(range, out var r)) req.Headers.Range = r;
|
if (!string.IsNullOrEmpty(range) && RangeHeaderValue.TryParse(range, out var r)) req.Headers.Range = r;
|
||||||
var client = _httpClientFactory.CreateClient(HttpClientName);
|
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.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,8 +219,9 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddHostedService<TestCaseIssueReconciler>();
|
services.AddHostedService<TestCaseIssueReconciler>();
|
||||||
// CI-published client builds for the site's Downloads / Quest pages.
|
// CI-published client builds for the site's Downloads / Quest pages.
|
||||||
// Off (IsConfigured=false) without DorkNet:Downloads:BaseUrl. No client
|
// Off (IsConfigured=false) without DorkNet:Downloads:BaseUrl. No client
|
||||||
// timeout: a multi-GB file streams for longer than the default 100 s;
|
// timeout: a multi-GB file streams for longer than the default 100 s.
|
||||||
// the request's own abort token still ends it.
|
// 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.AddHttpClient(DownloadsService.HttpClientName, c => c.Timeout = Timeout.InfiniteTimeSpan);
|
||||||
services.AddSingleton<DownloadsService>();
|
services.AddSingleton<DownloadsService>();
|
||||||
// Scoped (per-request) — these services hold DbContext references and
|
// Scoped (per-request) — these services hold DbContext references and
|
||||||
|
|||||||
@@ -46,10 +46,13 @@ export function getDownloads(fresh = false): Promise<DownloadsManifest | null> {
|
|||||||
export const fileUrl = (f: DownloadFile) =>
|
export const fileUrl = (f: DownloadFile) =>
|
||||||
`/api/site/v1/downloads/files/${f.path.split('/').map(encodeURIComponent).join('/')}`;
|
`/api/site/v1/downloads/files/${f.path.split('/').map(encodeURIComponent).join('/')}`;
|
||||||
|
|
||||||
/** The newest release's Quest package, if one is published. */
|
export interface QuestBuild { release: DownloadRelease; file: DownloadFile }
|
||||||
export function latestQuest(m: DownloadsManifest | null): { release: DownloadRelease; file: DownloadFile } | null {
|
|
||||||
|
/** 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 ?? []) {
|
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 };
|
if (file) return { release, file };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ function FileCard({ f, rel }: { f: DownloadFile; rel: DownloadRelease }) {
|
|||||||
<div className="mt-auto flex flex-wrap items-center gap-2 pt-1">
|
<div className="mt-auto flex flex-wrap items-center gap-2 pt-1">
|
||||||
{f.id === 'quest' ? (
|
{f.id === 'quest' ? (
|
||||||
<>
|
<>
|
||||||
<Link className="btn-primary" to="/quest?build=latest">Install on Quest</Link>
|
<Link className="btn-primary" to={`/quest?file=${encodeURIComponent(f.path)}`}>Install on Quest</Link>
|
||||||
<a className="btn-secondary" href={fileUrl(f)} download={f.name}>Download</a>
|
<a className="btn-secondary" href={fileUrl(f)} download={f.name}>Download</a>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
import { fileSource, openXapk, urlSource, type Xapk } from '../lib/xapk';
|
import { fileSource, openXapk, urlSource, type Xapk } from '../lib/xapk';
|
||||||
import { connectHeadset, installXapk, webUsbSupported, type Headset, type Progress } from '../lib/questInstall';
|
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) =>
|
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`;
|
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 [done, setDone] = useState(false);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
// The server's latest published Quest build, if it offers downloads.
|
// The server's latest published Quest build, if it offers downloads.
|
||||||
const [latest, setLatest] = useState<ReturnType<typeof latestQuest>>(null);
|
const [latest, setLatest] = useState<QuestBuild | null>(null);
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
|
|
||||||
const open = async (load: () => Promise<Xapk>) => {
|
const open = async (load: () => Promise<Xapk>) => {
|
||||||
@@ -60,16 +60,17 @@ export function Quest() {
|
|||||||
if (f) await open(() => openXapk(fileSource(f)));
|
if (f) await open(() => openXapk(fileSource(f)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const pickLatest = async (l = latest) => {
|
const pickBuild = async (b: QuestBuild | null) => {
|
||||||
if (l) await open(() => openXapk(urlSource(fileUrl(l.file), l.file.name, l.file.size)));
|
if (b) await open(() => openXapk(urlSource(fileUrl(b.file), b.file.name, b.file.size)));
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getDownloads().then((m) => {
|
getDownloads().then((m) => {
|
||||||
const l = latestQuest(m);
|
setLatest(questBuild(m));
|
||||||
setLatest(l);
|
// Arriving from a Quest card's "Install on Quest" on the Downloads page:
|
||||||
// Arriving from the Downloads page's "Install on Quest".
|
// that card's own file, which may not be the newest release's.
|
||||||
if (l && params.get('build') === 'latest') void pickLatest(l);
|
const wanted = params.get('file');
|
||||||
|
if (wanted) void pickBuild(questBuild(m, wanted));
|
||||||
}).catch(() => { /* no downloads: only the file picker */ });
|
}).catch(() => { /* no downloads: only the file picker */ });
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
@@ -142,7 +143,7 @@ export function Quest() {
|
|||||||
<input ref={fileRef} type="file" accept=".xapk,.zip" className="hidden" onChange={(e) => pickFile(e.target.files?.[0])} />
|
<input ref={fileRef} type="file" accept=".xapk,.zip" className="hidden" onChange={(e) => pickFile(e.target.files?.[0])} />
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{latest && (
|
{latest && (
|
||||||
<button className="btn-primary" disabled={busy !== null} onClick={() => pickLatest()}>
|
<button className="btn-primary" disabled={busy !== null} onClick={() => pickBuild(latest)}>
|
||||||
{busy === 'file' ? 'Reading…' : `Latest build: ${latest.release.title}${latest.file.build ? ` (build ${latest.file.build})` : ''}`}
|
{busy === 'file' ? 'Reading…' : `Latest build: ${latest.release.title}${latest.file.build ? ` (build ${latest.file.build})` : ''}`}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+4
-1
@@ -438,7 +438,10 @@ DorkNet__Downloads__Host=<bucket name>
|
|||||||
|
|
||||||
Unset, the site hides both features. The builds contain Rec Room's own game
|
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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user