This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 { 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.',
|
||||||
|
|||||||
@@ -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 {
|
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 }
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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.' },
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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' ||
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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' ||
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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' }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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