From cf3e6ea025a180c8f0dd2c8c731ad4da69880240 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:14:17 +0300 Subject: [PATCH 1/8] ci: run client builds on a Java 25 build agent instead of the controller The toolchain image becomes a Jenkins inbound agent (jenkins/inbound-agent, Java 25) labelled dorknet-builder. Jenkins itself stays on the stock image and runs no builds, which also clears the built-in-node warning. - Dockerfile: inbound-agent base; restic cache on the agent's own volume - docker-compose.yml: the agent as a Coolify Docker Compose application, reaching Jenkins over WebSocket on the predefined Coolify network - Jenkinsfile: agent { label 'dorknet-builder' } - README: Java 25 for both sides (Java 21 support ends Sep 2027, Java 17 is unsupported), restart policy, node setup and Coolify deployment --- Jenkinsfile | 25 +++++----- ci/jenkins/Dockerfile | 25 +++++----- ci/jenkins/README.md | 88 +++++++++++++++++++++++------------ ci/jenkins/docker-compose.yml | 36 +++++++------- 4 files changed, 102 insertions(+), 72 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 9609d05..6182520 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,14 +1,13 @@ // DorkNet client builds — PC (patcher zip + full client) and Quest (.xapk). // -// Runs on the Jenkins image from ci/jenkins (the stock image plus Python with -// LIEF, .NET, the Android NDK/build-tools, apktool, restic and rclone). +// Runs on the "dorknet-builder" agent from ci/jenkins (an inbound agent with +// Python + LIEF, .NET, the Android NDK/build-tools, apktool, restic and rclone). // // Stock builds come out of the restic build archive on the FTP. Where it lives // is server configuration, not part of the repo: the FTP host, login and the // restic password are Jenkins credentials, and the repository folders come -// from the Jenkins container's environment (ARCHIVE_PC_REPO / -// ARCHIVE_QUEST_REPO), each overridable per run. A build is picked by -// snapshot tags. +// from the agent's environment (ARCHIVE_PC_REPO / ARCHIVE_QUEST_REPO), each +// overridable per run. A build is picked by snapshot tags. // // On a push, only the platforms whose code changed are rebuilt. A manual run // picks the platforms with PLATFORMS. See ci/jenkins/README.md. @@ -23,7 +22,7 @@ def buildPc = false def buildQuest = false pipeline { - agent any + agent { label 'dorknet-builder' } options { timestamps() @@ -45,9 +44,9 @@ pipeline { string(name: 'QUEST_SNAPSHOT_TAGS', defaultValue: '', description: "restic tags picking the stock Quest build, comma = AND. Empty: ${QUEST_DEFAULT_TAGS}") string(name: 'PC_REPO', defaultValue: '', - description: 'PC restic repository folder on the FTP. Empty: ARCHIVE_PC_REPO from the Jenkins environment.') + description: 'PC restic repository folder on the FTP. Empty: ARCHIVE_PC_REPO set on the agent.') string(name: 'QUEST_REPO', defaultValue: '', - description: 'Quest restic repository folder on the FTP. Empty: ARCHIVE_QUEST_REPO from the Jenkins environment.') + description: 'Quest restic repository folder on the FTP. Empty: ARCHIVE_QUEST_REPO set on the agent.') booleanParam(name: 'LIST_SNAPSHOTS', defaultValue: false, description: 'Only list the snapshots matching the tags in both repositories (to find a build), then stop.') } @@ -88,8 +87,8 @@ pipeline { "PC_TAGS=${params.PC_SNAPSHOT_TAGS}", "QUEST_TAGS=${params.QUEST_SNAPSHOT_TAGS}"]) { sh ''' export RESTIC_CACHE_DIR="$DORKNET_CACHE/restic" - pc="${PC_REPO_OVERRIDE:-${ARCHIVE_PC_REPO:?set ARCHIVE_PC_REPO on Jenkins or PC_REPO on the run}}" - quest="${QUEST_REPO_OVERRIDE:-${ARCHIVE_QUEST_REPO:?set ARCHIVE_QUEST_REPO on Jenkins or QUEST_REPO on the run}}" + pc="${PC_REPO_OVERRIDE:-${ARCHIVE_PC_REPO:?set ARCHIVE_PC_REPO on the agent or PC_REPO on the run}}" + quest="${QUEST_REPO_OVERRIDE:-${ARCHIVE_QUEST_REPO:?set ARCHIVE_QUEST_REPO on the agent or QUEST_REPO on the run}}" echo "== PC: $pc"; archive-restore list "$pc" "$PC_TAGS" echo "== Quest: $quest"; archive-restore list "$quest" "$QUEST_TAGS" ''' @@ -113,7 +112,7 @@ pipeline { withEnv(["REPO_OVERRIDE=${params.PC_REPO}", "TAGS=${params.PC_SNAPSHOT_TAGS ?: PC_DEFAULT_TAGS}"]) { sh(returnStdout: true, script: ''' export RESTIC_CACHE_DIR="$DORKNET_CACHE/restic" - repo="${REPO_OVERRIDE:-${ARCHIVE_PC_REPO:?set ARCHIVE_PC_REPO on Jenkins or PC_REPO on the run}}" + repo="${REPO_OVERRIDE:-${ARCHIVE_PC_REPO:?set ARCHIVE_PC_REPO on the agent or PC_REPO on the run}}" archive-restore restore "$repo" "$TAGS" "$DORKNET_CACHE/pc" --exclude /.DepotDownloader ''').trim().readLines().last() } @@ -141,7 +140,7 @@ pipeline { withEnv(["REPO_OVERRIDE=${params.QUEST_REPO}", "TAGS=${params.QUEST_SNAPSHOT_TAGS ?: QUEST_DEFAULT_TAGS}"]) { sh(returnStdout: true, script: ''' export RESTIC_CACHE_DIR="$DORKNET_CACHE/restic" - repo="${REPO_OVERRIDE:-${ARCHIVE_QUEST_REPO:?set ARCHIVE_QUEST_REPO on Jenkins or QUEST_REPO on the run}}" + repo="${REPO_OVERRIDE:-${ARCHIVE_QUEST_REPO:?set ARCHIVE_QUEST_REPO on the agent or QUEST_REPO on the run}}" archive-restore restore "$repo" "$TAGS" "$DORKNET_CACHE/quest" ''').trim().readLines().last() } @@ -187,7 +186,7 @@ pipeline { } cleanup { // Runs after archiving. Keeps the restic cache and restored stock - // builds ($DORKNET_CACHE in jenkins_home, outside the workspace); drops + // builds ($DORKNET_CACHE on the agent, outside the workspace); drops // the workspace's build output and scratch. cleanWs(deleteDirs: true, notFailBuild: true) } diff --git a/ci/jenkins/Dockerfile b/ci/jenkins/Dockerfile index 78486f8..85c9b6b 100644 --- a/ci/jenkins/Dockerfile +++ b/ci/jenkins/Dockerfile @@ -1,7 +1,7 @@ -# DorkNet Jenkins: the stock Jenkins image plus everything the PC and Quest -# client builds need, so the job (the Jenkinsfile at the repo root) runs right -# on the controller. Jobs, plugins and settings stay in the jenkins_home -# volume; only the image changes. +# DorkNet build agent: a Jenkins inbound agent with everything the PC and +# Quest client builds need. It runs next to Jenkins on Coolify, connects to +# the controller over WebSocket, and runs the jobs labelled "dorknet-builder" +# (the Jenkinsfile at the repo root). Jenkins itself stays on the stock image. # # PC Python + .NET SDK tools/build_pc.py # Quest Python/LIEF + JDK + Android NDK, build-tools, apktool @@ -9,13 +9,13 @@ # Builds restic + rclone stock builds restored from the restic # archive on the FTP (archive-restore.sh) # +# Java 25: Jenkins supports it until Sep 2029 (Java 21 only until Sep 2027). +# Keep the agent's Java the same major version as the controller's. +# # See ci/jenkins/README.md. -# Match this to the Jenkins image your Dokploy service runs today, so the -# upgrade keeps the same Jenkins version and Java: e.g. -# --build-arg JENKINS_IMAGE=jenkins/jenkins:2.492.3-lts-jdk17 -ARG JENKINS_IMAGE=jenkins/jenkins:lts-jdk17 -FROM ${JENKINS_IMAGE} +ARG AGENT_IMAGE=jenkins/inbound-agent:latest-jdk25 +FROM ${AGENT_IMAGE} USER root @@ -73,9 +73,10 @@ RUN wget -q https://github.com/restic/restic/releases/download/v${RESTIC}/restic COPY archive-restore.sh /usr/local/bin/archive-restore RUN chmod +x /usr/local/bin/archive-restore -# Restored stock builds and the restic cache live in the persistent -# jenkins_home volume, so each multi-GB build is downloaded once. -ENV DORKNET_CACHE=/var/jenkins_home/dorknet-cache +# Restored stock builds and the restic cache. Mount a volume here so each +# multi-GB stock build is downloaded once, not after every restart. +ENV DORKNET_CACHE=/home/jenkins/dorknet-cache +RUN mkdir -p ${DORKNET_CACHE} && chown jenkins:jenkins ${DORKNET_CACHE} ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 \ DOTNET_NOLOGO=1 diff --git a/ci/jenkins/README.md b/ci/jenkins/README.md index a6f2eda..47de6ea 100644 --- a/ci/jenkins/README.md +++ b/ci/jenkins/README.md @@ -3,12 +3,13 @@ The `Jenkinsfile` at the repo root builds the DorkNet clients on your existing Jenkins. -It runs right on the Jenkins container, so that container's image needs the -build toolchain: Python with LIEF, the .NET SDK, the Android NDK and build-tools, -apktool, restic and rclone. `ci/jenkins/Dockerfile` is the stock -Jenkins image with that toolchain added. Your jobs, plugins and settings live -in the `jenkins_home` volume, not in the image, so switching images leaves -them alone. +The builds run on a **build agent**, not on Jenkins itself: +- **The agent:** a container built from `ci/jenkins/Dockerfile`, which is the + Jenkins inbound agent plus Python with LIEF, the .NET SDK, the Android NDK + and build-tools, apktool, restic and rclone. It runs on Coolify next to + Jenkins and connects to it over WebSocket on Docker's internal network. The + job runs on it through the `dorknet-builder` label. +- **Jenkins itself:** stays on the stock image and runs no builds. | Platform | Output (Jenkins artifacts) | How | |---|---|---| @@ -37,8 +38,8 @@ The job defaults to the builds this branch targets: Steam manifest build can be chosen per run. Tick **LIST_SNAPSHOTS** to print the snapshots matching a filter without building anything. -A restored snapshot is cached in `jenkins_home/dorknet-cache`, so each stock -build is downloaded once. +A restored snapshot is cached on the agent's `dorknet_cache` volume, so each +stock build is downloaded once. A Quest build needs a resolved hook table for its versionCode, committed under `DorkNet.Quest/targets/`. Only `20230317-2128210.json` exists today. The build @@ -57,32 +58,61 @@ Resolve a table for another build locally with `build.py build --dump-cs`. ## Setup -### 1. Switch your Jenkins to this image +### 1. Jenkins itself -On your Jenkins service in Dokploy: +Jenkins runs as a Coolify service from a Compose file: -1. **Build from this repo.** - - For an Application service: provider GitHub, branch `march-2023-03-21`, - build type Dockerfile, Dockerfile path `ci/jenkins/Dockerfile`, build - context `ci/jenkins`. - - For a Compose service: change its `jenkins` service to `build:` from - `ci/jenkins` as in `ci/jenkins/docker-compose.yml`. - - Either way, **keep the same volume on `/var/jenkins_home`**. -2. **Match the Jenkins version.** Set the build argument `JENKINS_IMAGE` to the - image the service runs today (e.g. `jenkins/jenkins:2.492.3-lts-jdk17`). - The default is `jenkins/jenkins:lts-jdk17`. -3. **Add the environment:** +- **Use the Java 25 image**, e.g. `jenkins/jenkins:latest-jdk25`, or + `2.587-jdk25` to pin. Jenkins supports Java 25 until September 2029, Java 21 + only until September 2027, and Java 17 not at all. Keep the agent on the same + Java major version; its image defaults to Java 25. +- **Add `restart: unless-stopped`** to the `jenkins` service. A restart from + the Jenkins UI exits the container with code 5 and relies on Docker to + start it again. Without a restart policy it stays stopped. +- **Turn on "Connect to predefined network"** (Advanced) so the agent can + reach Jenkins by its container name, `jenkins-`. +- **Run no builds on it:** **Manage Jenkins → Nodes → Built-In Node → + Configure**, set **Number of executors** to `0`. + +### 2. The agent node + +**Manage Jenkins → Nodes → New Node**, a permanent agent: + +| Setting | Value | +|---|---| +| Name | `dorknet-builder` | +| Labels | `dorknet-builder` | +| Remote root directory | `/home/jenkins/agent` | +| Launch method | Launch agent by connecting it to the controller, with **Use WebSocket** ticked | +| Number of executors | 1 | + +Save, open the node, and copy its **secret**. + +### 3. Deploy the agent on Coolify + +1. **Create a Docker Compose application** from this repository: build pack + Docker Compose, base directory `/`, compose location + `/ci/jenkins/docker-compose.yml`. It needs no domain. Under Advanced, turn + on **Connect to predefined network**. +2. **Set its environment:** | Variable | Value | |---|---| + | `JENKINS_URL` | Jenkins' **internal** address, `http://jenkins-:8080/`. Not the public URL, which is behind Cloudflare Access. | + | `JENKINS_SECRET` | the node secret from step 2 | + | `JENKINS_AGENT_NAME` | optional, default `dorknet-builder` | | `ARCHIVE_PC_REPO` | Steam repository folder on the FTP, e.g. `Rec Room Versions/steam-v2` (plain text, not URL-encoded) | | `ARCHIVE_QUEST_REPO` | Quest repository folder, e.g. `Rec Room Versions/quest-v2` | | `ARCHIVE_FTP_TLS` | `explicit` (default), `implicit` or `none` | -4. **Redeploy.** The first image build downloads the Android NDK (~1.5 GB) and - takes a while. +3. **Deploy.** The first image build downloads the Android NDK (~1.5 GB) and + takes a while. The node shows as connected in Jenkins once the agent is up. -### 2. Plugins +Both Jenkins and the agent join the server's predefined Coolify network, so +the agent reaches Jenkins by container name. Check the name under the Jenkins +service's containers before using it. + +### 4. Plugins The job needs these plugins: @@ -96,7 +126,7 @@ The job needs these plugins: Most are part of the suggested set. -### 3. Credentials +### 5. Credentials Add these under **Manage Jenkins → Credentials**, with exactly these IDs: @@ -115,7 +145,7 @@ Add these under **Manage Jenkins → Credentials**, with exactly these IDs: different key means every player has to uninstall before installing a CI build. -### 4. The job +### 6. The job 1. Create a **New Item → Pipeline** (e.g. `dorknet-client`). 2. Under **Pipeline**, choose **Pipeline script from SCM**: @@ -126,7 +156,7 @@ build. 4. Run it once with **Build** so Jenkins learns the parameters. Later runs offer **Build with Parameters**. -### 5. GitHub webhook +### 7. GitHub webhook In the repository's **Settings → Webhooks**: @@ -136,5 +166,5 @@ In the repository's **Settings → Webhooks**: ### Disk -The restored stock builds are cached in `jenkins_home/dorknet-cache`, several -GB each. Jenkins keeps the artifacts of the last 3 runs. +The agent's `dorknet_cache` volume holds the restored stock builds, several GB +each. Jenkins keeps the artifacts of the last 3 runs, on its own volume. diff --git a/ci/jenkins/docker-compose.yml b/ci/jenkins/docker-compose.yml index cc05993..93b97d0 100644 --- a/ci/jenkins/docker-compose.yml +++ b/ci/jenkins/docker-compose.yml @@ -1,31 +1,31 @@ -# Reference for the Jenkins service on Dokploy, built from this directory's -# Dockerfile (the stock Jenkins image plus the client build toolchain). -# -# Use it on your EXISTING Jenkins service rather than as a second Jenkins. -# The data volume must be the one Jenkins uses today, or Jenkins starts empty. -# Point `volumes:` at that volume (and its name under the top-level -# `volumes:`, marked `external: true`), or keep this service's name and volume -# if they already match. See ci/jenkins/README.md. +# DorkNet build agent on Coolify. Deploy as a Docker Compose application from +# this repository with compose location /ci/jenkins/docker-compose.yml and +# "Connect to predefined network" on. It needs no domain: it dials out to +# Jenkins over WebSocket on Docker's internal network. Set the variables in +# the resource's Environment Variables; see ci/jenkins/README.md. services: - jenkins: + agent: build: context: . dockerfile: Dockerfile - args: - # The image your Jenkins runs today, so the Jenkins version and Java - # stay the same. - JENKINS_IMAGE: ${JENKINS_IMAGE:-jenkins/jenkins:lts-jdk17} restart: unless-stopped - expose: - - "8080" environment: + # Jenkins as seen from inside Docker (e.g. http://:8080), not the public URL, which is behind Cloudflare Access. + JENKINS_URL: ${JENKINS_URL:?set JENKINS_URL to Jenkins' internal address} + JENKINS_AGENT_NAME: ${JENKINS_AGENT_NAME:-dorknet-builder} + JENKINS_SECRET: ${JENKINS_SECRET:?set JENKINS_SECRET to the node secret} + JENKINS_WEB_SOCKET: "true" + JENKINS_AGENT_WORKDIR: /home/jenkins/agent # Where the stock builds live in the archive (restic repository folders - # on the FTP), as plain text. Overridable per run. + # on the FTP, plain text). Overridable per run. ARCHIVE_PC_REPO: ${ARCHIVE_PC_REPO:-} ARCHIVE_QUEST_REPO: ${ARCHIVE_QUEST_REPO:-} ARCHIVE_FTP_TLS: ${ARCHIVE_FTP_TLS:-explicit} volumes: - - jenkins_home:/var/jenkins_home + - dorknet_cache:/home/jenkins/dorknet-cache + - agent_work:/home/jenkins/agent volumes: - jenkins_home: + dorknet_cache: + agent_work: From 3266ce47f3bd5eb0f393ea00ac05bb7634114e7d Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:18:35 +0300 Subject: [PATCH 2/8] ci: keep the agent's Jenkins URL and node secret runtime-only Coolify interpolates the compose file during the image build with only build-time variables. Marking JENKINS_URL and JENKINS_SECRET required (${VAR:?}) made the build fail when they are runtime-only, and making them build-time would pass the node secret as a build argument. --- ci/jenkins/docker-compose.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ci/jenkins/docker-compose.yml b/ci/jenkins/docker-compose.yml index 93b97d0..c5d0707 100644 --- a/ci/jenkins/docker-compose.yml +++ b/ci/jenkins/docker-compose.yml @@ -12,9 +12,11 @@ services: environment: # Jenkins as seen from inside Docker (e.g. http://:8080), not the public URL, which is behind Cloudflare Access. - JENKINS_URL: ${JENKINS_URL:?set JENKINS_URL to Jenkins' internal address} + # Both are needed at runtime only; keep them out of the image build so + # the node secret never reaches a build argument. + JENKINS_URL: ${JENKINS_URL} JENKINS_AGENT_NAME: ${JENKINS_AGENT_NAME:-dorknet-builder} - JENKINS_SECRET: ${JENKINS_SECRET:?set JENKINS_SECRET to the node secret} + JENKINS_SECRET: ${JENKINS_SECRET} JENKINS_WEB_SOCKET: "true" JENKINS_AGENT_WORKDIR: /home/jenkins/agent # Where the stock builds live in the archive (restic repository folders From 19402459a9827b8c4785a7fa70f2cff69dc57e9a Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:21:07 +0300 Subject: [PATCH 3/8] ci: deploy the agent with base directory ci/jenkins The base directory is the compose build context; from the repo root the build used the server's Dockerfile instead of ci/jenkins/Dockerfile. --- ci/jenkins/README.md | 7 ++++--- ci/jenkins/docker-compose.yml | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/ci/jenkins/README.md b/ci/jenkins/README.md index 47de6ea..1970e8d 100644 --- a/ci/jenkins/README.md +++ b/ci/jenkins/README.md @@ -91,9 +91,10 @@ Save, open the node, and copy its **secret**. ### 3. Deploy the agent on Coolify 1. **Create a Docker Compose application** from this repository: build pack - Docker Compose, base directory `/`, compose location - `/ci/jenkins/docker-compose.yml`. It needs no domain. Under Advanced, turn - on **Connect to predefined network**. + Docker Compose, base directory **`/ci/jenkins`**, compose location + `/docker-compose.yml`. The base directory is the build context, so with + `/` the build would pick up the server's `Dockerfile` at the repo root. It + needs no domain. Under Advanced, turn on **Connect to predefined network**. 2. **Set its environment:** | Variable | Value | diff --git a/ci/jenkins/docker-compose.yml b/ci/jenkins/docker-compose.yml index c5d0707..eea0283 100644 --- a/ci/jenkins/docker-compose.yml +++ b/ci/jenkins/docker-compose.yml @@ -1,6 +1,6 @@ # DorkNet build agent on Coolify. Deploy as a Docker Compose application from -# this repository with compose location /ci/jenkins/docker-compose.yml and -# "Connect to predefined network" on. It needs no domain: it dials out to +# this repository with base directory /ci/jenkins, compose location +# /docker-compose.yml and "Connect to predefined network" on. It needs no domain: it dials out to # Jenkins over WebSocket on Docker's internal network. Set the variables in # the resource's Environment Variables; see ci/jenkins/README.md. services: 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 4/8] 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() {