Bound request bodies and artifact uploads (Refs #94)
CI / checks (push) Successful in 4m34s

This commit is contained in:
2026-10-09 16:52:37 -04:00
parent 8fcb8312a3
commit 9eb3aa84c3
44 changed files with 603 additions and 79 deletions
+1
View File
@@ -32,6 +32,7 @@ RUN chmod +x /entrypoint.sh
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
# Legacy host imports stream whole game files; buffering routes enforce their own caps.
ENV BODY_SIZE_LIMIT=Infinity ENV BODY_SIZE_LIMIT=Infinity
ENV SQLITE_TMPDIR=/data/.sqlite-tmp ENV SQLITE_TMPDIR=/data/.sqlite-tmp
EXPOSE 3000 EXPOSE 3000
+1 -1
View File
@@ -16,7 +16,7 @@ services:
EC_SETUP_TOKEN: ${EC_SETUP_TOKEN:-} EC_SETUP_TOKEN: ${EC_SETUP_TOKEN:-}
EC_TELEMETRY_TOKEN: ${EC_TELEMETRY_TOKEN:-} EC_TELEMETRY_TOKEN: ${EC_TELEMETRY_TOKEN:-}
EC_AUTH_ALLOW_HTTP: ${EC_AUTH_ALLOW_HTTP:-false} EC_AUTH_ALLOW_HTTP: ${EC_AUTH_ALLOW_HTTP:-false}
# Large game files are streamed with manifest size and checksum limits. # Legacy host imports stream whole game files; buffering routes enforce their own caps.
BODY_SIZE_LIMIT: 'Infinity' BODY_SIZE_LIMIT: 'Infinity'
DB_PATH: /data/cherry.db DB_PATH: /data/cherry.db
GAME_FILES_ROOT: /data/game-files GAME_FILES_ROOT: /data/game-files
+22 -4
View File
@@ -657,10 +657,28 @@ an open Game Files page.
### Proxy limits and older uploads ### Proxy limits and older uploads
The controller and reverse proxy must accept 8 MiB upload requests and folder The container and Compose retain `BODY_SIZE_LIMIT=Infinity` for legacy source-host
manifests up to 32 MiB. Compose sets `BODY_SIZE_LIMIT=Infinity`; application routes imports, which stream whole game files directly to disk and verify manifest sizes
still enforce these limits, declared file sizes and checksums. Set a proxy body and checksums. Routes that buffer request bodies enforce limits before parsing:
limit of at least 32 MiB (for example `client_max_body_size 32m` in nginx). ordinary JSON requests (including telemetry) are limited to 1 MiB, authentication
and user-management JSON to 8 KiB, game-file upload chunks to 8 MiB, and folder
manifests to 32 MiB. Existing agent endpoints retain their individual limits, up
to 128 MiB for operation results. Game-file uploads also enforce declared sizes
and checksums.
Script, plugin and config artifacts are limited to 32 MiB per file, matching the
host agent. Multipart uploads allow 33 MiB total, including fields and framing;
beta revisions allow 65 MiB total for multiple replacement files. Both the declared
request length and actual streamed bytes are checked before multipart parsing.
Oversized ordinary JSON and artifact uploads receive HTTP 413. GitHub plugin imports use
the same 32 MiB artifact limit.
Set a proxy body limit of at least 128 MiB (for example
`client_max_body_size 128m` in nginx) for buffered endpoints. If legacy source-host
imports are still in use, allow the largest game file on
`/api/agent/imports/*/files/*`; these transfers do not use the browser's 8 MiB
chunks. Route limits apply even when connecting directly to the controller.
Existing uploads, host caches and server references remain usable. Previously Existing uploads, host caches and server references remain usable. Previously
named libraries are grouped under T5 or T6, and the most recently published named libraries are grouped under T5 or T6, and the most recently published
upload for each game becomes its latest files. Their immutable IDs and stored upload for each game becomes its latest files. Their immutable IDs and stored
+1 -1
View File
@@ -40,7 +40,7 @@ GitHub release is imported from its detail page.
## Limits ## Limits
- Public repositories on github.com only; no GitHub credentials are needed. - Public repositories on github.com only; no GitHub credentials are needed.
- Direct `.dll` release assets up to 64 MB; archives and source-code downloads - Direct `.dll` release assets up to 32 MiB; archives and source-code downloads
are not extracted. Other formats can still use the manual upload workflow. are not extracted. Other formats can still use the manual upload workflow.
- Release tags have the same 80-character limit as manually entered versions. - Release tags have the same 80-character limit as manually entered versions.
- GitHub rate limits, missing releases, download failures and checksum failures - GitHub rate limits, missing releases, download failures and checksum failures
+2
View File
@@ -0,0 +1,2 @@
// Keep managed artifacts within the host agent's MAX_FILE limit.
export const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
+6
View File
@@ -2,6 +2,7 @@ import { putFileBlob, blobContent } from '../db/file-blobs.js';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { configFileForGame, type ArtifactKind } from '../config.js'; import { configFileForGame, type ArtifactKind } from '../config.js';
import { MAX_ARTIFACT_BYTES } from '../../artifact-limits.js';
export type { ArtifactKind }; export type { ArtifactKind };
@@ -72,6 +73,7 @@ export class ScriptError extends Error {
| 'baseline-exists' | 'baseline-exists'
| 'empty-overlay' | 'empty-overlay'
| 'invalid-release-version' | 'invalid-release-version'
| 'too-large'
) { ) {
super(message); super(message);
} }
@@ -218,6 +220,8 @@ export function createScript(input: CreateScriptInput): {
script: ScriptRow; script: ScriptRow;
version: ScriptVersionRow; version: ScriptVersionRow;
} { } {
if (input.content.byteLength > MAX_ARTIFACT_BYTES)
throw new ScriptError('Artifacts must not exceed 32 MiB.', 'too-large');
if (!input.name.trim()) throw new ScriptError('script name is required', 'name-empty'); if (!input.name.trim()) throw new ScriptError('script name is required', 'name-empty');
const kind: ArtifactKind = input.kind ?? 'script'; const kind: ArtifactKind = input.kind ?? 'script';
if (!isArtifactKind(kind)) throw new ScriptError(`invalid kind: ${input.kind}`, 'invalid-kind'); if (!isArtifactKind(kind)) throw new ScriptError(`invalid kind: ${input.kind}`, 'invalid-kind');
@@ -316,6 +320,8 @@ export function uploadNewVersion(
notes?: string, notes?: string,
releaseVersion?: string releaseVersion?: string
): ScriptVersionRow { ): ScriptVersionRow {
if (content.byteLength > MAX_ARTIFACT_BYTES)
throw new ScriptError('Artifacts must not exceed 32 MiB.', 'too-large');
const script = getScript(scriptId); const script = getScript(scriptId);
if (!script) throw new ScriptError(`no such script (id=${scriptId})`, 'no-such-script'); if (!script) throw new ScriptError(`no such script (id=${scriptId})`, 'no-such-script');
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import type { GithubPluginAsset, GithubPluginRelease } from '$lib/plugin-github.js'; import type { GithubPluginAsset, GithubPluginRelease } from '$lib/plugin-github.js';
import { MAX_ARTIFACT_BYTES } from '../../artifact-limits.js';
const MAX_DLL_BYTES = 64 * 1024 * 1024;
const CACHE_MS = 5 * 60 * 1000; const CACHE_MS = 5 * 60 * 1000;
const cache = new Map<string, { expires: number; release: Promise<GithubPluginRelease> }>(); const cache = new Map<string, { expires: number; release: Promise<GithubPluginRelease> }>();
@@ -157,7 +157,7 @@ async function fetchRelease(repository: string, tag: string | null): Promise<Git
a.state === 'uploaded' && a.state === 'uploaded' &&
Number.isSafeInteger(a.size) && Number.isSafeInteger(a.size) &&
a.size > 0 && a.size > 0 &&
a.size <= MAX_DLL_BYTES a.size <= MAX_ARTIFACT_BYTES
) )
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- The preceding filter validates the decoded asset fields. // eslint-disable-next-line @typescript-eslint/no-explicit-any -- The preceding filter validates the decoded asset fields.
.map((a: any) => ({ .map((a: any) => ({
@@ -238,7 +238,7 @@ export async function downloadGithubAsset(
502 502
); );
} }
const content = await limitedBody(response, MAX_DLL_BYTES); const content = await limitedBody(response, MAX_ARTIFACT_BYTES);
if (content.length !== asset.size) if (content.length !== asset.size)
throw new GithubReleaseError( throw new GithubReleaseError(
'Downloaded size differs from the release asset. Preview the release again.', 'Downloaded size differs from the release asset. Preview the release again.',
+78
View File
@@ -0,0 +1,78 @@
import { error } from '@sveltejs/kit';
import { MAX_ARTIFACT_BYTES } from '../artifact-limits.js';
export const JSON_BODY_LIMIT = 1024 * 1024;
// Leave room for form fields and multipart framing around a maximum-size file.
export const ARTIFACT_FORM_LIMIT = MAX_ARTIFACT_BYTES + 1024 * 1024;
export const BETA_REVISION_FORM_LIMIT = 2 * MAX_ARTIFACT_BYTES + 1024 * 1024;
/** Enforce the limit before parsing, including absent or understated lengths. */
export async function boundedBody(request: Request, maxBytes: number) {
if (Number(request.headers.get('content-length')) > maxBytes) {
void request.body?.cancel().catch(() => {});
error(413, 'Request is too large.');
}
const reader = request.body?.getReader();
if (!reader) return Buffer.alloc(0);
const chunks: Buffer[] = [];
let block = Buffer.alloc(0);
let used = 0;
let received = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
received += value.byteLength;
if (received > maxBytes) {
// Cancellation may wait for another branch of a cloned request.
void reader.cancel().catch(() => {});
error(413, 'Request is too large.');
}
// Coalesce tiny chunks so bookkeeping cannot outgrow the byte limit.
let offset = 0;
while (offset < value.byteLength) {
if (used === block.length) {
block = Buffer.allocUnsafe(Math.min(maxBytes, 64 * 1024));
chunks.push(block);
used = 0;
}
const length = Math.min(value.byteLength - offset, block.length - used);
block.set(value.subarray(offset, offset + length), used);
used += length;
offset += length;
}
}
} finally {
reader.releaseLock();
}
return Buffer.concat(chunks, received);
}
// Match Request.json()'s result type; each route retains its input validation.
export async function jsonBody(
request: Request,
maxBytes = JSON_BODY_LIMIT
): ReturnType<Request['json']> {
return JSON.parse((await boundedBody(request, maxBytes)).toString('utf8'));
}
export async function artifactFormBody(
request: Request,
maxBytes = ARTIFACT_FORM_LIMIT
): Promise<FormData> {
const body = await boundedBody(request, maxBytes);
let form: FormData;
try {
form = await new Response(body, {
headers: { 'Content-Type': request.headers.get('content-type') ?? '' }
}).formData();
} catch {
error(400, 'Request body must be valid form data.');
}
// Validate every part before any handler copies file bytes or writes versions.
for (const value of form.values()) {
const size = typeof value === 'string' ? Buffer.byteLength(value, 'utf8') : value.size;
if (size > MAX_ARTIFACT_BYTES) error(413, 'Artifacts must not exceed 32 MiB.');
}
return form;
}
+4 -2
View File
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { import {
saveScaling, saveScaling,
deleteScaling, deleteScaling,
@@ -12,7 +13,7 @@ export function GET() {
} }
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
try { try {
const body = await request.json(); const body = await jsonBody(request);
if (!body || typeof body !== 'object' || Array.isArray(body)) if (!body || typeof body !== 'object' || Array.isArray(body))
throw new ScalingError('Provide a policy action.'); throw new ScalingError('Provide a policy action.');
if (body.id !== undefined && typeof body.id !== 'string') if (body.id !== undefined && typeof body.id !== 'string')
@@ -36,6 +37,7 @@ export async function POST({ request }: { request: Request }) {
} }
throw new ScalingError('Choose a supported policy action.'); throw new ScalingError('Choose a supported policy action.');
} catch (error) { } catch (error) {
if (isHttpError(error, 413)) throw error;
return json( return json(
{ error: (error as Error).message }, { error: (error as Error).message },
{ status: error instanceof ScalingError ? error.status : 400 } { status: error instanceof ScalingError ? error.status : 400 }
+6 -2
View File
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { openBeta, BetaError } from '$lib/server/fleet/beta.js'; import { openBeta, BetaError } from '$lib/server/fleet/beta.js';
import { betaScriptMetadata } from '$lib/server/fleet/library-metadata.js'; import { betaScriptMetadata } from '$lib/server/fleet/library-metadata.js';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
@@ -56,7 +57,10 @@ export async function GET({ url }: { url: URL }) {
// or the legacy form: // or the legacy form:
// { scripts: [{ scriptId, scriptVersionId }], ... } // { scripts: [{ scriptId, scriptVersionId }], ... }
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
const body = (await request.json().catch(() => ({}))) as { const body = (await jsonBody(request).catch((cause) => {
if (isHttpError(cause, 413)) throw cause;
return {};
})) as {
artifacts?: Array<{ id?: number; versionId?: number }>; artifacts?: Array<{ id?: number; versionId?: number }>;
scripts?: Array<{ scriptId?: number; scriptVersionId?: number }>; scripts?: Array<{ scriptId?: number; scriptVersionId?: number }>;
servers?: string[]; servers?: string[];
+6 -2
View File
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { promoteBeta, previewBetaPromotion, BetaError } from '$lib/server/fleet/beta.js'; import { promoteBeta, previewBetaPromotion, BetaError } from '$lib/server/fleet/beta.js';
function parseId(raw: string): number | null { function parseId(raw: string): number | null {
@@ -23,7 +24,10 @@ export async function GET({ params }: { params: { id: string } }) {
export async function POST({ params, request }: { params: { id: string }; request: Request }) { export async function POST({ params, request }: { params: { id: string }; request: Request }) {
const id = parseId(params.id); const id = parseId(params.id);
if (id === null) return json({ ok: false, error: 'bad id' }, { status: 400 }); if (id === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
const body = await request.json().catch(() => null); const body = await jsonBody(request).catch((cause) => {
if (isHttpError(cause, 413)) throw cause;
return null;
});
if (typeof body?.previewToken !== 'string') if (typeof body?.previewToken !== 'string')
return json( return json(
{ ok: false, error: 'Review promotion targets before confirming.' }, { ok: false, error: 'Review promotion targets before confirming.' },
+6 -2
View File
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { emit, type BetaStage } from '$lib/server/pipeline/bus.js'; import { emit, type BetaStage } from '$lib/server/pipeline/bus.js';
import { storedPlan } from '$lib/server/fleet/beta-tests.js'; import { storedPlan } from '$lib/server/fleet/beta-tests.js';
@@ -24,7 +25,10 @@ export async function POST({ params, request }: { params: { id: string }; reques
{ ok: false, error: 'Use the test plan retry action to collect fresh evidence' }, { ok: false, error: 'Use the test plan retry action to collect fresh evidence' },
{ status: 409 } { status: 409 }
); );
const body = (await request.json().catch(() => ({}))) as { server?: string }; const body = (await jsonBody(request).catch((cause) => {
if (isHttpError(cause, 413)) throw cause;
return {};
})) as { server?: string };
if (!body.server) return json({ ok: false, error: 'server is required' }, { status: 400 }); if (!body.server) return json({ ok: false, error: 'server is required' }, { status: 400 });
const row = db() const row = db()
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { artifactFormBody, BETA_REVISION_FORM_LIMIT } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { BetaError, reviseBeta, type BetaRevisionUpload } from '$lib/server/fleet/beta.js'; import { BetaError, reviseBeta, type BetaRevisionUpload } from '$lib/server/fleet/beta.js';
import { ScriptError } from '$lib/server/fleet/scripts.js'; import { ScriptError } from '$lib/server/fleet/scripts.js';
import { kick } from '$lib/server/pipeline/worker.js'; import { kick } from '$lib/server/pipeline/worker.js';
@@ -8,7 +9,7 @@ export async function POST({ params, request }: { params: { id: string }; reques
if (!Number.isSafeInteger(id) || id < 1) if (!Number.isSafeInteger(id) || id < 1)
return json({ ok: false, error: 'Invalid beta ID' }, { status: 400 }); return json({ ok: false, error: 'Invalid beta ID' }, { status: 400 });
try { try {
const form = await request.formData(); const form = await artifactFormBody(request, BETA_REVISION_FORM_LIMIT);
const expectedRevision = Number(form.get('expectedRevision')); const expectedRevision = Number(form.get('expectedRevision'));
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1)
throw new Error('A reviewed revision is required.'); throw new Error('A reviewed revision is required.');
@@ -35,6 +36,7 @@ export async function POST({ params, request }: { params: { id: string }; reques
void kick(); void kick();
return json({ ok: true, result }); return json({ ok: true, result });
} catch (error) { } catch (error) {
if (isHttpError(error)) throw error;
return json( return json(
{ ok: false, error: (error as Error).message }, { ok: false, error: (error as Error).message },
{ status: error instanceof BetaError || error instanceof ScriptError ? 409 : 400 } { status: error instanceof BetaError || error instanceof ScriptError ? 409 : 400 }
+4 -2
View File
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { releaseRestartRequest } from '$lib/server/provisioning/index.js'; import { releaseRestartRequest } from '$lib/server/provisioning/index.js';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { deployBetaServers, getBeta } from '$lib/server/fleet/beta.js'; import { deployBetaServers, getBeta } from '$lib/server/fleet/beta.js';
@@ -27,7 +28,7 @@ export async function POST({ params, request }: { params: { id: string }; reques
if (!['running', 'ready-promote'].includes(beta.state)) if (!['running', 'ready-promote'].includes(beta.state))
return json({ error: 'Beta is closed' }, { status: 409 }); return json({ error: 'Beta is closed' }, { status: 409 });
try { try {
const body = await request.json(); const body = await jsonBody(request);
if ( if (
db() db()
.prepare( .prepare(
@@ -148,6 +149,7 @@ export async function POST({ params, request }: { params: { id: string }; reques
void kick(); void kick();
return json({ ok: true }); return json({ ok: true });
} catch (error) { } catch (error) {
if (isHttpError(error, 413)) throw error;
return json({ ok: false, error: (error as Error).message }, { status: 400 }); return json({ ok: false, error: (error as Error).message }, { status: 400 });
} }
} }
+4 -2
View File
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { builtInPlans, parseBetaPlan } from '$lib/beta-plan.js'; import { builtInPlans, parseBetaPlan } from '$lib/beta-plan.js';
export function GET() { export function GET() {
@@ -14,7 +15,7 @@ export function GET() {
} }
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
try { try {
const body = await request.json(); const body = await jsonBody(request);
if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 80) if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 80)
throw new Error('Preset name must be 1–80 characters'); throw new Error('Preset name must be 1–80 characters');
if (builtInPlans.some((p) => p.name === body.name.trim())) if (builtInPlans.some((p) => p.name === body.name.trim()))
@@ -27,6 +28,7 @@ export async function POST({ request }: { request: Request }) {
.run(body.name.trim(), JSON.stringify(plan)); .run(body.name.trim(), JSON.stringify(plan));
return json({ ok: true }); return json({ ok: true });
} catch (error) { } catch (error) {
if (isHttpError(error, 413)) throw error;
return json({ ok: false, error: (error as Error).message }, { status: 400 }); return json({ ok: false, error: (error as Error).message }, { status: 400 });
} }
} }
+4 -2
View File
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { listBlueprints, saveBlueprint } from '$lib/server/fleet/launch-recipes.js'; import { listBlueprints, saveBlueprint } from '$lib/server/fleet/launch-recipes.js';
export function GET() { export function GET() {
@@ -7,11 +8,12 @@ export function GET() {
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
try { try {
const body = await request.json(); const body = await jsonBody(request);
if (!body || typeof body !== 'object' || Array.isArray(body)) if (!body || typeof body !== 'object' || Array.isArray(body))
throw new Error('Provide a collection name and a launch recipe.'); throw new Error('Provide a collection name and a launch recipe.');
return json({ blueprint: saveBlueprint(body) }, { status: 201 }); return json({ blueprint: saveBlueprint(body) }, { status: 201 });
} catch (err) { } catch (err) {
if (isHttpError(err, 413)) throw err;
return json({ error: (err as Error).message }, { status: 400 }); return json({ error: (err as Error).message }, { status: 400 });
} }
} }
+4 -2
View File
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { deleteBlueprint, updateBlueprint } from '$lib/server/fleet/launch-recipes.js'; import { deleteBlueprint, updateBlueprint } from '$lib/server/fleet/launch-recipes.js';
export function DELETE({ params }: { params: { id: string } }) { export function DELETE({ params }: { params: { id: string } }) {
@@ -9,11 +10,12 @@ export function DELETE({ params }: { params: { id: string } }) {
export async function PUT({ params, request }: { params: { id: string }; request: Request }) { export async function PUT({ params, request }: { params: { id: string }; request: Request }) {
try { try {
const body = await request.json(); const body = await jsonBody(request);
if (!body || typeof body !== 'object' || Array.isArray(body)) if (!body || typeof body !== 'object' || Array.isArray(body))
throw new Error('Provide a collection name and map selections.'); throw new Error('Provide a collection name and map selections.');
return json({ blueprint: updateBlueprint(params.id, body) }); return json({ blueprint: updateBlueprint(params.id, body) });
} catch (err) { } catch (err) {
if (isHttpError(err, 413)) throw err;
return json({ error: (err as Error).message }, { status: 400 }); return json({ error: (err as Error).message }, { status: 400 });
} }
} }
+5 -3
View File
@@ -1,5 +1,6 @@
import { jsonBody } from '$lib/server/request-body.js';
import { getVersionContent } from '$lib/server/fleet/scripts.js'; import { getVersionContent } from '$lib/server/fleet/scripts.js';
import { json } from '@sveltejs/kit'; import { isHttpError, json } from '@sveltejs/kit';
import { getServer, listServers } from '$lib/server/fleet/servers.js'; import { getServer, listServers } from '$lib/server/fleet/servers.js';
import { import {
getBaselineForScope, getBaselineForScope,
@@ -90,8 +91,9 @@ export async function GET({ url }: { url: URL }) {
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
let body; let body;
try { try {
body = await request.json(); body = await jsonBody(request);
} catch { } catch (cause) {
if (isHttpError(cause, 413)) throw cause;
return json({ error: 'Invalid preview request.' }, { status: 400 }); return json({ error: 'Invalid preview request.' }, { status: 400 });
} }
if ( if (
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { listCollections, queueCollection } from '$lib/server/fleet/collections.js'; import { listCollections, queueCollection } from '$lib/server/fleet/collections.js';
import { kickProvisioning } from '$lib/server/pipeline/worker.js'; import { kickProvisioning } from '$lib/server/pipeline/worker.js';
@@ -8,7 +9,7 @@ export function GET() {
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
try { try {
const body = await request.json(); const body = await jsonBody(request);
if (!body || typeof body !== 'object' || Array.isArray(body)) if (!body || typeof body !== 'object' || Array.isArray(body))
throw new Error('Provide a collection preview and its server keys.'); throw new Error('Provide a collection preview and its server keys.');
const result = queueCollection(body); const result = queueCollection(body);
@@ -22,6 +23,7 @@ export async function POST({ request }: { request: Request }) {
}); });
return json(result, { status: result.collectionId ? 202 : 200 }); return json(result, { status: result.collectionId ? 202 : 200 });
} catch (err) { } catch (err) {
if (isHttpError(err, 413)) throw err;
return json({ error: (err as Error).message }, { status: 400 }); return json({ error: (err as Error).message }, { status: 400 });
} }
} }
@@ -1,11 +1,13 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { previewCollection } from '$lib/server/fleet/collections.js'; import { previewCollection } from '$lib/server/fleet/collections.js';
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
try { try {
const body = await request.json(); const body = await jsonBody(request);
return json(previewCollection(body?.blueprintId, body?.mode, body)); return json(previewCollection(body?.blueprintId, body?.mode, body));
} catch (err) { } catch (err) {
if (isHttpError(err, 413)) throw err;
return json({ error: (err as Error).message }, { status: 400 }); return json({ error: (err as Error).message }, { status: 400 });
} }
} }
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { housePartyTargets, queueHouseParty } from '$lib/server/fleet/house-party.js'; import { housePartyTargets, queueHouseParty } from '$lib/server/fleet/house-party.js';
import { kick } from '$lib/server/pipeline/worker.js'; import { kick } from '$lib/server/pipeline/worker.js';
@@ -7,7 +8,10 @@ export function GET() {
} }
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
const body = await request.json().catch(() => null); const body = await jsonBody(request).catch((cause) => {
if (isHttpError(cause, 413)) throw cause;
return null;
});
if ( if (
!Array.isArray(body?.servers) || !Array.isArray(body?.servers) ||
!body.servers.every((name: unknown) => typeof name === 'string') !body.servers.every((name: unknown) => typeof name === 'string')
@@ -1,9 +1,10 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { previewCreation } from '$lib/server/provisioning/index.js'; import { previewCreation } from '$lib/server/provisioning/index.js';
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
try { try {
const body = await request.json(); const body = await jsonBody(request);
if (!body || typeof body !== 'object' || Array.isArray(body)) if (!body || typeof body !== 'object' || Array.isArray(body))
throw new Error('Provide a launch selection.'); throw new Error('Provide a launch selection.');
if (body.blueprintId !== undefined) if (body.blueprintId !== undefined)
@@ -12,6 +13,7 @@ export async function POST({ request }: { request: Request }) {
); );
return json(previewCreation(body)); return json(previewCreation(body));
} catch (err) { } catch (err) {
if (isHttpError(err, 413)) throw err;
return json({ error: (err as Error).message }, { status: 400 }); return json({ error: (err as Error).message }, { status: 400 });
} }
} }
+6 -2
View File
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { previewDrift } from '$lib/server/fleet/drift.js'; import { previewDrift } from '$lib/server/fleet/drift.js';
// GET returns the current drift plan for the whole fleet without touching disk. // GET returns the current drift plan for the whole fleet without touching disk.
@@ -33,7 +34,10 @@ export async function GET() {
// Legacy previews remain readable. Watchdog owns all new reconciliation requests. // Legacy previews remain readable. Watchdog owns all new reconciliation requests.
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
const body = await request.json().catch(() => null); const body = await jsonBody(request).catch((cause) => {
if (isHttpError(cause, 413)) throw cause;
return null;
});
if ( if (
!body || !body ||
(body.servers !== undefined && (body.servers !== undefined &&
+6 -2
View File
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { maintenanceOverview } from '$lib/server/maintenance/index.js'; import { maintenanceOverview } from '$lib/server/maintenance/index.js';
import { pauseMaintenance, setMaintenancePolicy } from '$lib/server/maintenance/policies.js'; import { pauseMaintenance, setMaintenancePolicy } from '$lib/server/maintenance/policies.js';
import { watchdogTick } from '$lib/server/maintenance/recovery.js'; import { watchdogTick } from '$lib/server/maintenance/recovery.js';
@@ -23,7 +24,10 @@ export function GET(event?: { url: URL }) {
return json(maintenanceOverview(job ? Number(job) : null)); return json(maintenanceOverview(job ? Number(job) : null));
} }
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
const body = await request.json().catch(() => null); const body = await jsonBody(request).catch((cause) => {
if (isHttpError(cause, 413)) throw cause;
return null;
});
if (!body || typeof body !== 'object' || Array.isArray(body)) if (!body || typeof body !== 'object' || Array.isArray(body))
return json({ error: 'Invalid request.' }, { status: 400 }); return json({ error: 'Invalid request.' }, { status: 400 });
let notice: string; let notice: string;
+6 -2
View File
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { watchdogDetail, updateWatchdogIncident } from '$lib/server/maintenance/recovery.js'; import { watchdogDetail, updateWatchdogIncident } from '$lib/server/maintenance/recovery.js';
import { kick } from '$lib/server/pipeline/worker.js'; import { kick } from '$lib/server/pipeline/worker.js';
@@ -9,7 +10,10 @@ export function GET({ params }: { params: { id: string } }) {
: json({ error: 'Maintenance finding not found.' }, { status: 404 }); : json({ error: 'Maintenance finding not found.' }, { status: 404 });
} }
export async function POST({ params, request }: { params: { id: string }; request: Request }) { export async function POST({ params, request }: { params: { id: string }; request: Request }) {
const body = await request.json().catch(() => null); const body = await jsonBody(request).catch((cause) => {
if (isHttpError(cause, 413)) throw cause;
return null;
});
if (!body || !['retry', 'dismiss'].includes(body.action)) if (!body || !['retry', 'dismiss'].includes(body.action))
return json({ error: 'Choose retry or dismiss.' }, { status: 400 }); return json({ error: 'Choose retry or dismiss.' }, { status: 400 });
const result = updateWatchdogIncident(params.id, body.action); const result = updateWatchdogIncident(params.id, body.action);
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { importPluginRelease } from '$lib/server/fleet/plugin-releases.js'; import { importPluginRelease } from '$lib/server/fleet/plugin-releases.js';
import { GithubReleaseError } from '$lib/server/integrations/github-releases.js'; import { GithubReleaseError } from '$lib/server/integrations/github-releases.js';
import { ScriptError } from '$lib/server/fleet/scripts.js'; import { ScriptError } from '$lib/server/fleet/scripts.js';
@@ -6,8 +7,9 @@ import { ScriptError } from '$lib/server/fleet/scripts.js';
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
let input; let input;
try { try {
input = await request.json(); input = await jsonBody(request);
} catch { } catch (cause) {
if (isHttpError(cause, 413)) throw cause;
return json({ error: 'Invalid JSON body.' }, { status: 400 }); return json({ error: 'Invalid JSON body.' }, { status: 400 });
} }
if (!input || typeof input !== 'object' || Array.isArray(input)) if (!input || typeof input !== 'object' || Array.isArray(input))
+5 -3
View File
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { validTimeZone } from '$lib/time-zone.js'; import { validTimeZone } from '$lib/time-zone.js';
import { import {
cancelMaintenance, cancelMaintenance,
@@ -15,8 +16,9 @@ export function GET() {
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
let body; let body;
try { try {
body = await request.json(); body = await jsonBody(request);
} catch { } catch (cause) {
if (isHttpError(cause, 413)) throw cause;
return json({ error: 'Invalid JSON.' }, { status: 400 }); return json({ error: 'Invalid JSON.' }, { status: 400 });
} }
if (!body || typeof body !== 'object') if (!body || typeof body !== 'object')
+6 -2
View File
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { deploymentListPage } from '$lib/server/provisioning/history.js'; import { deploymentListPage } from '$lib/server/provisioning/history.js';
import { requestLifecycle } from '$lib/server/provisioning/index.js'; import { requestLifecycle } from '$lib/server/provisioning/index.js';
import { import {
@@ -32,7 +33,10 @@ export function GET({ url }: { url: URL }) {
} }
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
const body = await request.json().catch(() => null); const body = await jsonBody(request).catch((cause) => {
if (isHttpError(cause, 413)) throw cause;
return null;
});
if ( if (
!body || !body ||
typeof body.server !== 'string' || typeof body.server !== 'string' ||
+4 -2
View File
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { import {
RebalanceError, RebalanceError,
previewRebalance, previewRebalance,
@@ -12,7 +13,7 @@ export function GET() {
} }
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
try { try {
const body = await request.json(); const body = await jsonBody(request);
if (!body || typeof body !== 'object' || Array.isArray(body)) if (!body || typeof body !== 'object' || Array.isArray(body))
throw new RebalanceError('Provide a rebalance action.'); throw new RebalanceError('Provide a rebalance action.');
if (body.action === 'preview') return json({ preview: previewRebalance(body.settings) }); if (body.action === 'preview') return json({ preview: previewRebalance(body.settings) });
@@ -26,6 +27,7 @@ export async function POST({ request }: { request: Request }) {
return json({ run: stopRebalance(body.id) }); return json({ run: stopRebalance(body.id) });
throw new RebalanceError('Choose a supported rebalance action.'); throw new RebalanceError('Choose a supported rebalance action.');
} catch (error) { } catch (error) {
if (isHttpError(error, 413)) throw error;
return json( return json(
{ error: (error as Error).message }, { error: (error as Error).message },
{ status: error instanceof RebalanceError ? error.status : 400 } { status: error instanceof RebalanceError ? error.status : 400 }
+2 -1
View File
@@ -1,3 +1,4 @@
import { artifactFormBody } from '$lib/server/request-body.js';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { import {
createScript, createScript,
@@ -38,7 +39,7 @@ export async function GET({ url }: { url: URL }) {
// name, scopeType, scopeValue, notes, releaseVersion, kind (defaults to 'script'), // name, scopeType, scopeValue, notes, releaseVersion, kind (defaults to 'script'),
// content (file). Configs may alternatively send `text` as a form field. // content (file). Configs may alternatively send `text` as a form field.
export async function POST({ request, url }: { request: Request; url: URL }) { export async function POST({ request, url }: { request: Request; url: URL }) {
const form = await request.formData(); const form = await artifactFormBody(request);
const name = String(form.get('name') ?? '').trim(); const name = String(form.get('name') ?? '').trim();
const notes = form.get('notes') ? String(form.get('notes')) : undefined; const notes = form.get('notes') ? String(form.get('notes')) : undefined;
let scopeType: ScopeType | null; let scopeType: ScopeType | null;
+6 -2
View File
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { import {
getScript, getScript,
setScope, setScope,
@@ -65,7 +66,10 @@ export async function POST({ params, request }: { params: { id: string }; reques
const script = getScript(scriptId); const script = getScript(scriptId);
if (!script) return json({ ok: false, error: 'not found' }, { status: 404 }); if (!script) return json({ ok: false, error: 'not found' }, { status: 404 });
const body = (await request.json().catch(() => ({}))) as { const body = (await jsonBody(request).catch((cause) => {
if (isHttpError(cause, 413)) throw cause;
return {};
})) as {
scopeType?: ScopeType | null; scopeType?: ScopeType | null;
scopeValue?: string | null; scopeValue?: string | null;
}; };
@@ -1,3 +1,4 @@
import { artifactFormBody } from '$lib/server/request-body.js';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { uploadNewVersion, getScript, ScriptError } from '$lib/server/fleet/scripts.js'; import { uploadNewVersion, getScript, ScriptError } from '$lib/server/fleet/scripts.js';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
@@ -28,7 +29,7 @@ export async function POST({ params, request }: { params: { id: string }; reques
); );
} }
const form = await request.formData(); const form = await artifactFormBody(request);
const notes = form.get('notes') ? String(form.get('notes')) : undefined; const notes = form.get('notes') ? String(form.get('notes')) : undefined;
const file = form.get('content'); const file = form.get('content');
if (!(file instanceof File)) if (!(file instanceof File))
+2 -1
View File
@@ -1,10 +1,11 @@
import { artifactFormBody } from '$lib/server/request-body.js';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
// Read-only inspection works on HTTP fleet consoles as well as secure origins. // Read-only inspection works on HTTP fleet consoles as well as secure origins.
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
const form = await request.formData(); const form = await artifactFormBody(request);
const file = form.get('content'); const file = form.get('content');
if (!(file instanceof File) || file.size === 0) { if (!(file instanceof File) || file.size === 0) {
return json({ error: 'Choose a non-empty script file.' }, { status: 400 }); return json({ error: 'Choose a non-empty script file.' }, { status: 400 });
+10 -3
View File
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { listServerKeys, saveServerKey, removeServerKey } from '$lib/server/fleet/server-keys.js'; import { listServerKeys, saveServerKey, removeServerKey } from '$lib/server/fleet/server-keys.js';
export function GET() { export function GET() {
@@ -6,7 +7,10 @@ export function GET() {
} }
export async function PUT({ request }: { request: Request }) { export async function PUT({ request }: { request: Request }) {
const body = await request.json().catch(() => null); const body = await jsonBody(request).catch((cause) => {
if (isHttpError(cause, 413)) throw cause;
return null;
});
try { try {
const id = saveServerKey(body?.game, body?.plutoKey, body?.label); const id = saveServerKey(body?.game, body?.plutoKey, body?.label);
return json({ ok: true, id }); return json({ ok: true, id });
@@ -16,7 +20,10 @@ export async function PUT({ request }: { request: Request }) {
} }
export async function DELETE({ request }: { request: Request }) { export async function DELETE({ request }: { request: Request }) {
const body = await request.json().catch(() => null); const body = await jsonBody(request).catch((cause) => {
if (isHttpError(cause, 413)) throw cause;
return null;
});
if (typeof body?.id !== 'string' || !body.id.trim() || body.id.length > 128) if (typeof body?.id !== 'string' || !body.id.trim() || body.id.length > 128)
return json({ error: 'Invalid key ID' }, { status: 400 }); return json({ error: 'Invalid key ID' }, { status: 400 });
try { try {
+6 -2
View File
@@ -1,6 +1,7 @@
import { jsonBody } from '$lib/server/request-body.js';
import { fleetSnapshot } from '$lib/server/fleet/snapshot.js'; import { fleetSnapshot } from '$lib/server/fleet/snapshot.js';
import { kickProvisioning } from '$lib/server/pipeline/worker.js'; import { kickProvisioning } from '$lib/server/pipeline/worker.js';
import { json } from '@sveltejs/kit'; import { isHttpError, json } from '@sveltejs/kit';
import { queueCreateServer } from '$lib/server/provisioning/index.js'; import { queueCreateServer } from '$lib/server/provisioning/index.js';
import type { GameMode, ZombieGame } from '$lib/server/fleet/map-aliases.js'; import type { GameMode, ZombieGame } from '$lib/server/fleet/map-aliases.js';
@@ -9,7 +10,10 @@ export function GET() {
} }
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
const body = (await request.json().catch(() => ({}))) as { const body = (await jsonBody(request).catch((cause) => {
if (isHttpError(cause, 413)) throw cause;
return {};
})) as {
hostId?: string; hostId?: string;
libraryId?: string; libraryId?: string;
image?: string; image?: string;
+6 -3
View File
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { validateFleetSettings, concurrencyKeys, retentionKeys } from '$lib/fleet-settings.js'; import { validateFleetSettings, concurrencyKeys, retentionKeys } from '$lib/fleet-settings.js';
import { fleetSettings, saveFleetSettings } from '$lib/server/settings.js'; import { fleetSettings, saveFleetSettings } from '$lib/server/settings.js';
@@ -9,8 +10,9 @@ export function GET() {
export async function PUT({ request }: { request: Request }) { export async function PUT({ request }: { request: Request }) {
let settings; let settings;
try { try {
settings = validateFleetSettings(await request.json()); settings = validateFleetSettings(await jsonBody(request));
} catch (error) { } catch (error) {
if (isHttpError(error, 413)) throw error;
return json({ error: (error as Error).message }, { status: 400 }); return json({ error: (error as Error).message }, { status: 400 });
} }
return json(saveFleetSettings(settings)); return json(saveFleetSettings(settings));
@@ -18,7 +20,7 @@ export async function PUT({ request }: { request: Request }) {
export async function PATCH({ request }: { request: Request }) { export async function PATCH({ request }: { request: Request }) {
try { try {
const { section, values } = await request.json(); const { section, values } = await jsonBody(request);
const keys = const keys =
section === 'Concurrency' section === 'Concurrency'
? concurrencyKeys ? concurrencyKeys
@@ -37,6 +39,7 @@ export async function PATCH({ request }: { request: Request }) {
throw new Error('Provide all settings for one section.'); throw new Error('Provide all settings for one section.');
return json(saveFleetSettings({ ...fleetSettings(), ...values })); return json(saveFleetSettings({ ...fleetSettings(), ...values }));
} catch (error) { } catch (error) {
if (isHttpError(error, 413)) throw error;
return json({ error: (error as Error).message }, { status: 400 }); return json({ error: (error as Error).message }, { status: 400 });
} }
} }
+4 -2
View File
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { import {
retentionPreview, retentionPreview,
retentionControls, retentionControls,
@@ -22,9 +23,10 @@ export function GET() {
} }
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
try { try {
setRetentionControl(await request.json()); setRetentionControl(await jsonBody(request));
return json({ ok: true }); return json({ ok: true });
} catch (error) { } catch (error) {
if (isHttpError(error, 413)) throw error;
return json({ error: (error as Error).message }, { status: 400 }); return json({ error: (error as Error).message }, { status: 400 });
} }
} }
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { validateFleetSettings } from '$lib/fleet-settings.js'; import { validateFleetSettings } from '$lib/fleet-settings.js';
import { import {
retentionPreview, retentionPreview,
@@ -8,7 +9,7 @@ import {
import { gameFileRetentionPreview } from '$lib/server/game-files/index.js'; import { gameFileRetentionPreview } from '$lib/server/game-files/index.js';
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
try { try {
const settings = validateFleetSettings(await request.json()); const settings = validateFleetSettings(await jsonBody(request));
return json( return json(
{ {
policies: [ policies: [
@@ -21,6 +22,7 @@ export async function POST({ request }: { request: Request }) {
{ headers: { 'Cache-Control': 'no-store' } } { headers: { 'Cache-Control': 'no-store' } }
); );
} catch (error) { } catch (error) {
if (isHttpError(error, 413)) throw error;
return json({ error: (error as Error).message }, { status: 400 }); return json({ error: (error as Error).message }, { status: 400 });
} }
} }
+5 -3
View File
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { parseGameEvent, type GameTelemetry } from '$lib/telemetry.js'; import { parseGameEvent, type GameTelemetry } from '$lib/telemetry.js';
import { getServer } from '$lib/server/fleet/servers.js'; import { getServer } from '$lib/server/fleet/servers.js';
import { saveTelemetry } from '$lib/server/fleet/telemetry.js'; import { saveTelemetry } from '$lib/server/fleet/telemetry.js';
@@ -26,8 +27,9 @@ export async function POST({ request }: { request: Request }) {
let value: unknown; let value: unknown;
let malformed = false; let malformed = false;
try { try {
value = await request.json(); value = await jsonBody(request);
} catch { } catch (cause) {
if (isHttpError(cause, 413)) throw cause;
malformed = true; malformed = true;
} }
+6 -2
View File
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { watchdogOverview, watchdogTick, setWatchdogEnabled } from '$lib/server/watchdog/index.js'; import { watchdogOverview, watchdogTick, setWatchdogEnabled } from '$lib/server/watchdog/index.js';
import { kick } from '$lib/server/pipeline/worker.js'; import { kick } from '$lib/server/pipeline/worker.js';
@@ -6,7 +7,10 @@ export function GET() {
return json(watchdogOverview()); return json(watchdogOverview());
} }
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
const body = await request.json().catch(() => null); const body = await jsonBody(request).catch((cause) => {
if (isHttpError(cause, 413)) throw cause;
return null;
});
if ( if (
!body || !body ||
typeof body !== 'object' || typeof body !== 'object' ||
+6 -2
View File
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { watchdogDetail, updateWatchdogIncident } from '$lib/server/watchdog/index.js'; import { watchdogDetail, updateWatchdogIncident } from '$lib/server/watchdog/index.js';
import { kick } from '$lib/server/pipeline/worker.js'; import { kick } from '$lib/server/pipeline/worker.js';
@@ -7,7 +8,10 @@ export function GET({ params }: { params: { id: string } }) {
return detail ? json(detail) : json({ error: 'Watchdog incident not found.' }, { status: 404 }); return detail ? json(detail) : json({ error: 'Watchdog incident not found.' }, { status: 404 });
} }
export async function POST({ params, request }: { params: { id: string }; request: Request }) { export async function POST({ params, request }: { params: { id: string }; request: Request }) {
const body = await request.json().catch(() => null); const body = await jsonBody(request).catch((cause) => {
if (isHttpError(cause, 413)) throw cause;
return null;
});
if (!body || !['retry', 'dismiss'].includes(body.action)) if (!body || !['retry', 'dismiss'].includes(body.action))
return json({ error: 'Choose retry or dismiss.' }, { status: 400 }); return json({ error: 'Choose retry or dismiss.' }, { status: 400 });
const result = updateWatchdogIncident(params.id, body.action); const result = updateWatchdogIncident(params.id, body.action);
+1 -1
View File
@@ -97,7 +97,7 @@ describe('GitHub release lookup', () => {
normal, normal,
{ ...normal, id: 201, name: 'source.zip' }, { ...normal, id: 201, name: 'source.zip' },
{ ...normal, id: 202, name: '../evil.dll' }, { ...normal, id: 202, name: '../evil.dll' },
{ ...normal, id: 203, size: 65 * 1024 * 1024 }, { ...normal, id: 203, size: 32 * 1024 * 1024 + 1 },
{ ...normal, id: 204, state: 'new' } { ...normal, id: 204, state: 'new' }
] ]
}) })
+181
View File
@@ -0,0 +1,181 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import {
JSON_BODY_LIMIT,
ARTIFACT_FORM_LIMIT,
BETA_REVISION_FORM_LIMIT
} from '../../src/lib/server/request-body.js';
import { MAX_ARTIFACT_BYTES } from '../../src/lib/artifact-limits.js';
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({
kick: vi.fn(),
kickProvisioning: vi.fn()
}));
vi.mock('../../src/lib/server/pipeline/beta-plan-runner.js', () => ({ plannedBetaTick: vi.fn() }));
type Event = { request: Request; url: URL; params: { id: string } };
const routes = import.meta.glob<Record<string, (event: Event) => Promise<Response>>>(
'../../src/routes/api/**/+server.ts'
);
let fixture: Fixture;
let scriptId: number;
let betaId: number;
const token = 'ec_telemetry_0123456789abcdef0123456789abcdef';
beforeEach(async () => {
vi.resetModules();
vi.stubEnv('EC_TELEMETRY_TOKEN', token);
fixture = await newFixture();
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { script, version } = createScript({
name: 'HUD',
targetRelPath: 'hud.gsc',
content: Buffer.from('main(){}')
});
scriptId = script.id;
betaId = Number(
db()
.prepare(
"INSERT INTO betas (script_id, script_version_id, trigger_kind, state) VALUES (?, ?, 'manual', 'running')"
)
.run(script.id, version.id).lastInsertRowid
);
});
afterEach(async () => {
vi.unstubAllEnvs();
await fixture.cleanup();
});
function oversizedRequest(method: string, limit: number, declared: boolean) {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
};
if (declared) headers['Content-Length'] = String(limit + 1);
return new Request('http://localhost', {
method,
headers,
body: declared ? '{}' : ' '.repeat(limit + 1)
});
}
describe('JSON route size enforcement', () => {
it.each([
['autoscaling', 'POST'],
['betas', 'POST'],
['betas/[id]/promote', 'POST'],
['betas/[id]/retry', 'POST'],
['betas/[id]/test', 'POST'],
['betas/presets', 'POST'],
['blueprints', 'POST'],
['blueprints/[id]', 'PUT'],
['configs/inspect', 'POST'],
['deployment-collections', 'POST'],
['deployment-collections/preview', 'POST'],
['deployments/house-party', 'POST'],
['deployments/preview', 'POST'],
['drift', 'POST'],
['maintenance', 'POST'],
['maintenance/[id]', 'POST'],
['plugins/github/import', 'POST'],
['plutonium', 'POST'],
['provisioning', 'POST'],
['rebalancing', 'POST'],
['scripts/[id]/scope', 'POST'],
['server-keys', 'PUT'],
['server-keys', 'DELETE'],
['servers', 'POST'],
['settings', 'PUT'],
['settings', 'PATCH'],
['settings/retention', 'POST'],
['settings/retention/preview', 'POST'],
['telemetry', 'POST'],
['watchdog', 'POST'],
['watchdog/[id]', 'POST']
])(
'%s %s preserves HTTP 413 for declared and undeclared oversized bodies',
async (path, method) => {
if (path === 'betas/[id]/test') {
const { db } = await import('../../src/lib/server/db/index.js');
db()
.prepare('INSERT INTO beta_test_plans (beta_id, plan) VALUES (?, ?)')
.run(betaId, '{}');
}
const route = await routes[`../../src/routes/api/${path}/+server.ts`]();
for (const declared of [true, false]) {
await expect(
route[method]({
request: oversizedRequest(method, JSON_BODY_LIMIT, declared),
url: new URL('http://localhost'),
params: { id: String(path.startsWith('betas/') ? betaId : scriptId) }
})
).rejects.toMatchObject({ status: 413 });
}
}
);
it('retains bad-request responses for malformed JSON', async () => {
for (const path of ['servers', 'plugins/github/import', 'settings']) {
const method = path === 'settings' ? 'PUT' : 'POST';
const route = await routes[`../../src/routes/api/${path}/+server.ts`]();
const response = await route[method]({
request: new Request('http://localhost', { method, body: '{' }),
url: new URL('http://localhost'),
params: { id: '' }
});
expect(response.status).toBe(400);
}
});
});
describe('artifact upload route limits', () => {
it('preserves the malformed form error on beta revisions', async () => {
const { POST } = await routes['../../src/routes/api/betas/[id]/revisions/+server.ts']();
await expect(
POST({
request: new Request('http://localhost', { method: 'POST', body: 'invalid form' }),
url: new URL('http://localhost'),
params: { id: String(betaId) }
})
).rejects.toMatchObject({
status: 400,
body: { message: 'Request body must be valid form data.' }
});
});
it.each([
'scripts',
'configs',
'plugins',
'scripts/inspect',
'scripts/[id]/versions',
'betas/[id]/revisions'
])('%s rejects excessive declared bodies before creating versions', async (path) => {
const route = await routes[`../../src/routes/api/${path}/+server.ts`]();
const limit = path.startsWith('betas/') ? BETA_REVISION_FORM_LIMIT : ARTIFACT_FORM_LIMIT;
await expect(
route.POST({
request: oversizedRequest('POST', limit, true),
url: new URL('http://localhost'),
params: { id: String(scriptId) }
})
).rejects.toMatchObject({ status: 413 });
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT COUNT(*) AS count FROM script_versions').get()).toEqual({
count: 1
});
});
it('enforces the artifact limit for direct creation and version writes as well', async () => {
const { createScript, uploadNewVersion, listVersions, getScriptByName } =
await import('../../src/lib/server/fleet/scripts.js');
const content = Buffer.alloc(MAX_ARTIFACT_BYTES + 1);
expect(() =>
createScript({ name: 'Too big', targetRelPath: 'large.dll', kind: 'plugin', content })
).toThrow(/32 MiB/);
expect(() => uploadNewVersion(scriptId, content)).toThrow(/32 MiB/);
expect(getScriptByName('Too big')).toBeUndefined();
expect(listVersions(scriptId)).toHaveLength(1);
});
});
+145
View File
@@ -0,0 +1,145 @@
import { describe, expect, it, vi } from 'vitest';
import {
artifactFormBody,
boundedBody,
jsonBody,
BETA_REVISION_FORM_LIMIT
} from '../../src/lib/server/request-body.js';
import { MAX_ARTIFACT_BYTES } from '../../src/lib/artifact-limits.js';
function streamedRequest(chunks: Uint8Array[], headers?: HeadersInit) {
let index = 0;
const pull = vi.fn((controller: ReadableStreamDefaultController<Uint8Array>) => {
if (index === chunks.length) controller.close();
else controller.enqueue(chunks[index++]);
});
const cancel = vi.fn();
const request = new Request('http://localhost', {
method: 'POST',
headers,
body: new ReadableStream({ pull, cancel }, { highWaterMark: 0 }),
duplex: 'half'
} as RequestInit);
return { request, pull, cancel };
}
describe('bounded request bodies', () => {
it('rejects excessive Content-Length before reading any bytes', async () => {
const { request, pull, cancel } = streamedRequest([Buffer.from('{}')], {
'Content-Length': '11'
});
await expect(jsonBody(request, 10)).rejects.toMatchObject({ status: 413 });
expect(pull).not.toHaveBeenCalled();
expect(cancel).toHaveBeenCalledOnce();
expect(request.body?.locked).toBe(false);
});
it.each([undefined, '1'])(
'counts actual bytes with Content-Length %s and cancels before the remaining chunks',
async (length) => {
const { request, pull, cancel } = streamedRequest(
[Buffer.from('{"value":'), Buffer.from('"too long"'), Buffer.from('}')],
length ? { 'Content-Length': length } : undefined
);
await expect(jsonBody(request, 10)).rejects.toMatchObject({ status: 413 });
expect(pull).toHaveBeenCalledTimes(2);
expect(cancel).toHaveBeenCalledOnce();
expect(request.body?.locked).toBe(false);
}
);
it('accepts the exact UTF-8 byte limit across tiny chunks', async () => {
const bytes = Buffer.from('{"value":"é🍒"}');
const chunks = Array.from(bytes, (byte) => Uint8Array.of(byte));
await expect(jsonBody(streamedRequest(chunks).request, bytes.length)).resolves.toEqual({
value: 'é🍒'
});
await expect(
jsonBody(streamedRequest(chunks).request, bytes.length - 1)
).rejects.toMatchObject({
status: 413
});
});
it('preserves bytes across buffer blocks and a partial final block', async () => {
const bytes = Buffer.from('abc'.repeat(50_000));
const chunks = [bytes.subarray(0, 7), bytes.subarray(7, 70_000), bytes.subarray(70_000)];
await expect(boundedBody(streamedRequest(chunks).request, bytes.length)).resolves.toEqual(
bytes
);
});
it('keeps malformed and empty JSON distinct from size failures', async () => {
for (const body of ['', '{']) {
await expect(
jsonBody(new Request('http://localhost', { method: 'POST', body }))
).rejects.toBeInstanceOf(SyntaxError);
}
});
});
describe('bounded artifact forms', () => {
it('accepts exact request limits and rejects overflow before parsing the form', async () => {
const form = new FormData();
form.set('content', new File(['main(){}'], 'hud.gsc'));
form.set('notes', 'review');
const encoded = new Response(form);
const headers = encoded.headers;
const bytes = new Uint8Array(await encoded.arrayBuffer());
const request = () =>
streamedRequest([bytes.subarray(0, 5), bytes.subarray(5)], headers).request;
const parsed = await artifactFormBody(request(), bytes.length);
expect(parsed.get('notes')).toBe('review');
expect(await (parsed.get('content') as File).text()).toBe('main(){}');
await expect(artifactFormBody(request(), bytes.length - 1)).rejects.toMatchObject({
status: 413
});
await expect(
artifactFormBody(streamedRequest([Buffer.alloc(11)], headers).request, 10)
).rejects.toMatchObject({ status: 413 });
});
it('accepts a 32 MiB file plus multipart overhead', async () => {
const form = new FormData();
form.set('content', new File([new Uint8Array(MAX_ARTIFACT_BYTES)], 'plugin.dll'));
const parsed = await artifactFormBody(
new Request('http://localhost', { method: 'POST', body: form })
);
expect((parsed.get('content') as File).size).toBe(MAX_ARTIFACT_BYTES);
});
it.each(['file', 'text'])(
'rejects an oversized %s artifact within the overall form limit',
async (kind) => {
const form = new FormData();
if (kind === 'file')
form.set(
'content',
new File([new Uint8Array(MAX_ARTIFACT_BYTES + 1)], 'plugin.dll')
);
else form.set('text', 'é'.repeat(MAX_ARTIFACT_BYTES / 2 + 1));
await expect(
artifactFormBody(new Request('http://localhost', { method: 'POST', body: form }))
).rejects.toMatchObject({
status: 413,
body: { message: 'Artifacts must not exceed 32 MiB.' }
});
}
);
it('bounds the combined beta revision body even with no declared length', async () => {
const chunk = new Uint8Array(1024 * 1024);
const { request, cancel } = streamedRequest(Array(67).fill(chunk), {
'Content-Type': 'multipart/form-data; boundary=test'
});
await expect(artifactFormBody(request, BETA_REVISION_FORM_LIMIT)).rejects.toMatchObject({
status: 413
});
expect(cancel).toHaveBeenCalledOnce();
});
it('reports malformed multipart bodies as bad requests', async () => {
const request = new Request('http://localhost', { method: 'POST', body: 'invalid form' });
await expect(artifactFormBody(request)).rejects.toMatchObject({ status: 400 });
});
});