This commit is contained in:
@@ -32,6 +32,7 @@ RUN chmod +x /entrypoint.sh
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
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 SQLITE_TMPDIR=/data/.sqlite-tmp
|
||||
EXPOSE 3000
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ services:
|
||||
EC_SETUP_TOKEN: ${EC_SETUP_TOKEN:-}
|
||||
EC_TELEMETRY_TOKEN: ${EC_TELEMETRY_TOKEN:-}
|
||||
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'
|
||||
DB_PATH: /data/cherry.db
|
||||
GAME_FILES_ROOT: /data/game-files
|
||||
|
||||
@@ -657,10 +657,28 @@ an open Game Files page.
|
||||
|
||||
### Proxy limits and older uploads
|
||||
|
||||
The controller and reverse proxy must accept 8 MiB upload requests and folder
|
||||
manifests up to 32 MiB. Compose sets `BODY_SIZE_LIMIT=Infinity`; application routes
|
||||
still enforce these limits, declared file sizes and checksums. Set a proxy body
|
||||
limit of at least 32 MiB (for example `client_max_body_size 32m` in nginx).
|
||||
The container and Compose retain `BODY_SIZE_LIMIT=Infinity` for legacy source-host
|
||||
imports, which stream whole game files directly to disk and verify manifest sizes
|
||||
and checksums. Routes that buffer request bodies enforce limits before parsing:
|
||||
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
|
||||
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
|
||||
|
||||
@@ -40,7 +40,7 @@ GitHub release is imported from its detail page.
|
||||
## Limits
|
||||
|
||||
- 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.
|
||||
- Release tags have the same 80-character limit as manually entered versions.
|
||||
- GitHub rate limits, missing releases, download failures and checksum failures
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Keep managed artifacts within the host agent's MAX_FILE limit.
|
||||
export const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
|
||||
@@ -2,6 +2,7 @@ import { putFileBlob, blobContent } from '../db/file-blobs.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
import { configFileForGame, type ArtifactKind } from '../config.js';
|
||||
import { MAX_ARTIFACT_BYTES } from '../../artifact-limits.js';
|
||||
|
||||
export type { ArtifactKind };
|
||||
|
||||
@@ -72,6 +73,7 @@ export class ScriptError extends Error {
|
||||
| 'baseline-exists'
|
||||
| 'empty-overlay'
|
||||
| 'invalid-release-version'
|
||||
| 'too-large'
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
@@ -218,6 +220,8 @@ export function createScript(input: CreateScriptInput): {
|
||||
script: ScriptRow;
|
||||
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');
|
||||
const kind: ArtifactKind = input.kind ?? 'script';
|
||||
if (!isArtifactKind(kind)) throw new ScriptError(`invalid kind: ${input.kind}`, 'invalid-kind');
|
||||
@@ -316,6 +320,8 @@ export function uploadNewVersion(
|
||||
notes?: string,
|
||||
releaseVersion?: string
|
||||
): ScriptVersionRow {
|
||||
if (content.byteLength > MAX_ARTIFACT_BYTES)
|
||||
throw new ScriptError('Artifacts must not exceed 32 MiB.', 'too-large');
|
||||
const script = getScript(scriptId);
|
||||
if (!script) throw new ScriptError(`no such script (id=${scriptId})`, 'no-such-script');
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
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 = 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' &&
|
||||
Number.isSafeInteger(a.size) &&
|
||||
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.
|
||||
.map((a: any) => ({
|
||||
@@ -238,7 +238,7 @@ export async function downloadGithubAsset(
|
||||
502
|
||||
);
|
||||
}
|
||||
const content = await limitedBody(response, MAX_DLL_BYTES);
|
||||
const content = await limitedBody(response, MAX_ARTIFACT_BYTES);
|
||||
if (content.length !== asset.size)
|
||||
throw new GithubReleaseError(
|
||||
'Downloaded size differs from the release asset. Preview the release again.',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { jsonBody } from '$lib/server/request-body.js';
|
||||
import { isHttpError, json } from '@sveltejs/kit';
|
||||
import {
|
||||
saveScaling,
|
||||
deleteScaling,
|
||||
@@ -12,7 +13,7 @@ export function GET() {
|
||||
}
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const body = await jsonBody(request);
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body))
|
||||
throw new ScalingError('Provide a policy action.');
|
||||
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.');
|
||||
} catch (error) {
|
||||
if (isHttpError(error, 413)) throw error;
|
||||
return json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: error instanceof ScalingError ? error.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 { openBeta, BetaError } from '$lib/server/fleet/beta.js';
|
||||
import { betaScriptMetadata } from '$lib/server/fleet/library-metadata.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
@@ -56,7 +57,10 @@ export async function GET({ url }: { url: URL }) {
|
||||
// or the legacy form:
|
||||
// { scripts: [{ scriptId, scriptVersionId }], ... }
|
||||
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 }>;
|
||||
scripts?: Array<{ scriptId?: number; scriptVersionId?: number }>;
|
||||
servers?: string[];
|
||||
|
||||
@@ -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';
|
||||
|
||||
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 }) {
|
||||
const id = parseId(params.id);
|
||||
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')
|
||||
return json(
|
||||
{ ok: false, error: 'Review promotion targets before confirming.' },
|
||||
|
||||
@@ -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 { emit, type BetaStage } from '$lib/server/pipeline/bus.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' },
|
||||
{ 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 });
|
||||
|
||||
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 { ScriptError } from '$lib/server/fleet/scripts.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)
|
||||
return json({ ok: false, error: 'Invalid beta ID' }, { status: 400 });
|
||||
try {
|
||||
const form = await request.formData();
|
||||
const form = await artifactFormBody(request, BETA_REVISION_FORM_LIMIT);
|
||||
const expectedRevision = Number(form.get('expectedRevision'));
|
||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1)
|
||||
throw new Error('A reviewed revision is required.');
|
||||
@@ -35,6 +36,7 @@ export async function POST({ params, request }: { params: { id: string }; reques
|
||||
void kick();
|
||||
return json({ ok: true, result });
|
||||
} catch (error) {
|
||||
if (isHttpError(error)) throw error;
|
||||
return json(
|
||||
{ ok: false, error: (error as Error).message },
|
||||
{ status: error instanceof BetaError || error instanceof ScriptError ? 409 : 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 { releaseRestartRequest } from '$lib/server/provisioning/index.js';
|
||||
import { db } from '$lib/server/db/index.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))
|
||||
return json({ error: 'Beta is closed' }, { status: 409 });
|
||||
try {
|
||||
const body = await request.json();
|
||||
const body = await jsonBody(request);
|
||||
if (
|
||||
db()
|
||||
.prepare(
|
||||
@@ -148,6 +149,7 @@ export async function POST({ params, request }: { params: { id: string }; reques
|
||||
void kick();
|
||||
return json({ ok: true });
|
||||
} catch (error) {
|
||||
if (isHttpError(error, 413)) throw error;
|
||||
return json({ ok: false, 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 { db } from '$lib/server/db/index.js';
|
||||
import { builtInPlans, parseBetaPlan } from '$lib/beta-plan.js';
|
||||
export function GET() {
|
||||
@@ -14,7 +15,7 @@ export function GET() {
|
||||
}
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const body = await jsonBody(request);
|
||||
if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 80)
|
||||
throw new Error('Preset name must be 1–80 characters');
|
||||
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));
|
||||
return json({ ok: true });
|
||||
} catch (error) {
|
||||
if (isHttpError(error, 413)) throw error;
|
||||
return json({ ok: false, 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 { listBlueprints, saveBlueprint } from '$lib/server/fleet/launch-recipes.js';
|
||||
|
||||
export function GET() {
|
||||
@@ -7,11 +8,12 @@ export function GET() {
|
||||
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const body = await jsonBody(request);
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body))
|
||||
throw new Error('Provide a collection name and a launch recipe.');
|
||||
return json({ blueprint: saveBlueprint(body) }, { status: 201 });
|
||||
} catch (err) {
|
||||
if (isHttpError(err, 413)) throw err;
|
||||
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 { deleteBlueprint, updateBlueprint } from '$lib/server/fleet/launch-recipes.js';
|
||||
|
||||
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 }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const body = await jsonBody(request);
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body))
|
||||
throw new Error('Provide a collection name and map selections.');
|
||||
return json({ blueprint: updateBlueprint(params.id, body) });
|
||||
} catch (err) {
|
||||
if (isHttpError(err, 413)) throw err;
|
||||
return json({ error: (err as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { jsonBody } from '$lib/server/request-body.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 {
|
||||
getBaselineForScope,
|
||||
@@ -90,8 +91,9 @@ export async function GET({ url }: { url: URL }) {
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
body = await jsonBody(request);
|
||||
} catch (cause) {
|
||||
if (isHttpError(cause, 413)) throw cause;
|
||||
return json({ error: 'Invalid preview request.' }, { status: 400 });
|
||||
}
|
||||
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 { kickProvisioning } from '$lib/server/pipeline/worker.js';
|
||||
|
||||
@@ -8,7 +9,7 @@ export function GET() {
|
||||
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const body = await jsonBody(request);
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body))
|
||||
throw new Error('Provide a collection preview and its server keys.');
|
||||
const result = queueCollection(body);
|
||||
@@ -22,6 +23,7 @@ export async function POST({ request }: { request: Request }) {
|
||||
});
|
||||
return json(result, { status: result.collectionId ? 202 : 200 });
|
||||
} catch (err) {
|
||||
if (isHttpError(err, 413)) throw err;
|
||||
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';
|
||||
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const body = await jsonBody(request);
|
||||
return json(previewCollection(body?.blueprintId, body?.mode, body));
|
||||
} catch (err) {
|
||||
if (isHttpError(err, 413)) throw err;
|
||||
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 { kick } from '$lib/server/pipeline/worker.js';
|
||||
|
||||
@@ -7,7 +8,10 @@ export function GET() {
|
||||
}
|
||||
|
||||
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 (
|
||||
!Array.isArray(body?.servers) ||
|
||||
!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';
|
||||
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const body = await jsonBody(request);
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body))
|
||||
throw new Error('Provide a launch selection.');
|
||||
if (body.blueprintId !== undefined)
|
||||
@@ -12,6 +13,7 @@ export async function POST({ request }: { request: Request }) {
|
||||
);
|
||||
return json(previewCreation(body));
|
||||
} catch (err) {
|
||||
if (isHttpError(err, 413)) throw err;
|
||||
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 { previewDrift } from '$lib/server/fleet/drift.js';
|
||||
|
||||
// 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.
|
||||
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 ||
|
||||
(body.servers !== undefined &&
|
||||
|
||||
@@ -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 { pauseMaintenance, setMaintenancePolicy } from '$lib/server/maintenance/policies.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));
|
||||
}
|
||||
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))
|
||||
return json({ error: 'Invalid request.' }, { status: 400 });
|
||||
let notice: string;
|
||||
|
||||
@@ -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 { 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 });
|
||||
}
|
||||
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))
|
||||
return json({ error: 'Choose retry or dismiss.' }, { status: 400 });
|
||||
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 { GithubReleaseError } from '$lib/server/integrations/github-releases.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 }) {
|
||||
let input;
|
||||
try {
|
||||
input = await request.json();
|
||||
} catch {
|
||||
input = await jsonBody(request);
|
||||
} catch (cause) {
|
||||
if (isHttpError(cause, 413)) throw cause;
|
||||
return json({ error: 'Invalid JSON body.' }, { status: 400 });
|
||||
}
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input))
|
||||
|
||||
@@ -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 {
|
||||
cancelMaintenance,
|
||||
@@ -15,8 +16,9 @@ export function GET() {
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
body = await jsonBody(request);
|
||||
} catch (cause) {
|
||||
if (isHttpError(cause, 413)) throw cause;
|
||||
return json({ error: 'Invalid JSON.' }, { status: 400 });
|
||||
}
|
||||
if (!body || typeof body !== 'object')
|
||||
|
||||
@@ -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 { requestLifecycle } from '$lib/server/provisioning/index.js';
|
||||
import {
|
||||
@@ -32,7 +33,10 @@ export function GET({ url }: { url: URL }) {
|
||||
}
|
||||
|
||||
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.server !== 'string' ||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { jsonBody } from '$lib/server/request-body.js';
|
||||
import { isHttpError, json } from '@sveltejs/kit';
|
||||
import {
|
||||
RebalanceError,
|
||||
previewRebalance,
|
||||
@@ -12,7 +13,7 @@ export function GET() {
|
||||
}
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const body = await jsonBody(request);
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body))
|
||||
throw new RebalanceError('Provide a rebalance action.');
|
||||
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) });
|
||||
throw new RebalanceError('Choose a supported rebalance action.');
|
||||
} catch (error) {
|
||||
if (isHttpError(error, 413)) throw error;
|
||||
return json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: error instanceof RebalanceError ? error.status : 400 }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { artifactFormBody } from '$lib/server/request-body.js';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import {
|
||||
createScript,
|
||||
@@ -38,7 +39,7 @@ export async function GET({ url }: { url: URL }) {
|
||||
// name, scopeType, scopeValue, notes, releaseVersion, kind (defaults to 'script'),
|
||||
// content (file). Configs may alternatively send `text` as a form field.
|
||||
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 notes = form.get('notes') ? String(form.get('notes')) : undefined;
|
||||
let scopeType: ScopeType | null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { jsonBody } from '$lib/server/request-body.js';
|
||||
import { isHttpError, json } from '@sveltejs/kit';
|
||||
import {
|
||||
getScript,
|
||||
setScope,
|
||||
@@ -65,7 +66,10 @@ export async function POST({ params, request }: { params: { id: string }; reques
|
||||
const script = getScript(scriptId);
|
||||
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;
|
||||
scopeValue?: string | null;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { artifactFormBody } from '$lib/server/request-body.js';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { uploadNewVersion, getScript, ScriptError } from '$lib/server/fleet/scripts.js';
|
||||
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 file = form.get('content');
|
||||
if (!(file instanceof File))
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { artifactFormBody } from '$lib/server/request-body.js';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
|
||||
// Read-only inspection works on HTTP fleet consoles as well as secure origins.
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
const form = await request.formData();
|
||||
const form = await artifactFormBody(request);
|
||||
const file = form.get('content');
|
||||
if (!(file instanceof File) || file.size === 0) {
|
||||
return json({ error: 'Choose a non-empty script file.' }, { 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 { listServerKeys, saveServerKey, removeServerKey } from '$lib/server/fleet/server-keys.js';
|
||||
|
||||
export function GET() {
|
||||
@@ -6,7 +7,10 @@ export function GET() {
|
||||
}
|
||||
|
||||
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 {
|
||||
const id = saveServerKey(body?.game, body?.plutoKey, body?.label);
|
||||
return json({ ok: true, id });
|
||||
@@ -16,7 +20,10 @@ export async function PUT({ 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)
|
||||
return json({ error: 'Invalid key ID' }, { status: 400 });
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { jsonBody } from '$lib/server/request-body.js';
|
||||
import { fleetSnapshot } from '$lib/server/fleet/snapshot.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 type { GameMode, ZombieGame } from '$lib/server/fleet/map-aliases.js';
|
||||
|
||||
@@ -9,7 +10,10 @@ export function GET() {
|
||||
}
|
||||
|
||||
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;
|
||||
libraryId?: string;
|
||||
image?: string;
|
||||
|
||||
@@ -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 { fleetSettings, saveFleetSettings } from '$lib/server/settings.js';
|
||||
|
||||
@@ -9,8 +10,9 @@ export function GET() {
|
||||
export async function PUT({ request }: { request: Request }) {
|
||||
let settings;
|
||||
try {
|
||||
settings = validateFleetSettings(await request.json());
|
||||
settings = validateFleetSettings(await jsonBody(request));
|
||||
} catch (error) {
|
||||
if (isHttpError(error, 413)) throw error;
|
||||
return json({ error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
return json(saveFleetSettings(settings));
|
||||
@@ -18,7 +20,7 @@ export async function PUT({ request }: { request: Request }) {
|
||||
|
||||
export async function PATCH({ request }: { request: Request }) {
|
||||
try {
|
||||
const { section, values } = await request.json();
|
||||
const { section, values } = await jsonBody(request);
|
||||
const keys =
|
||||
section === 'Concurrency'
|
||||
? concurrencyKeys
|
||||
@@ -37,6 +39,7 @@ export async function PATCH({ request }: { request: Request }) {
|
||||
throw new Error('Provide all settings for one section.');
|
||||
return json(saveFleetSettings({ ...fleetSettings(), ...values }));
|
||||
} catch (error) {
|
||||
if (isHttpError(error, 413)) throw error;
|
||||
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 {
|
||||
retentionPreview,
|
||||
retentionControls,
|
||||
@@ -22,9 +23,10 @@ export function GET() {
|
||||
}
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
try {
|
||||
setRetentionControl(await request.json());
|
||||
setRetentionControl(await jsonBody(request));
|
||||
return json({ ok: true });
|
||||
} catch (error) {
|
||||
if (isHttpError(error, 413)) throw error;
|
||||
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 {
|
||||
retentionPreview,
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
import { gameFileRetentionPreview } from '$lib/server/game-files/index.js';
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
try {
|
||||
const settings = validateFleetSettings(await request.json());
|
||||
const settings = validateFleetSettings(await jsonBody(request));
|
||||
return json(
|
||||
{
|
||||
policies: [
|
||||
@@ -21,6 +22,7 @@ export async function POST({ request }: { request: Request }) {
|
||||
{ headers: { 'Cache-Control': 'no-store' } }
|
||||
);
|
||||
} catch (error) {
|
||||
if (isHttpError(error, 413)) throw error;
|
||||
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 { parseGameEvent, type GameTelemetry } from '$lib/telemetry.js';
|
||||
import { getServer } from '$lib/server/fleet/servers.js';
|
||||
import { saveTelemetry } from '$lib/server/fleet/telemetry.js';
|
||||
@@ -26,8 +27,9 @@ export async function POST({ request }: { request: Request }) {
|
||||
let value: unknown;
|
||||
let malformed = false;
|
||||
try {
|
||||
value = await request.json();
|
||||
} catch {
|
||||
value = await jsonBody(request);
|
||||
} catch (cause) {
|
||||
if (isHttpError(cause, 413)) throw cause;
|
||||
malformed = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { kick } from '$lib/server/pipeline/worker.js';
|
||||
|
||||
@@ -6,7 +7,10 @@ export function GET() {
|
||||
return json(watchdogOverview());
|
||||
}
|
||||
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' ||
|
||||
|
||||
@@ -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 { 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 });
|
||||
}
|
||||
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))
|
||||
return json({ error: 'Choose retry or dismiss.' }, { status: 400 });
|
||||
const result = updateWatchdogIncident(params.id, body.action);
|
||||
|
||||
@@ -97,7 +97,7 @@ describe('GitHub release lookup', () => {
|
||||
normal,
|
||||
{ ...normal, id: 201, name: 'source.zip' },
|
||||
{ ...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' }
|
||||
]
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user