Exposes sharp and ffprobe with their own vocabulary — operations are named what sharp names them, ffprobe fields come back spelled the way ffprobe spells them — so the tools' own documentation applies and there is no house dialect to learn. Safety comes from one architectural invariant rather than from filtering: the caller never supplies a path, a URL or a protocol. Input is an id we issued, output is a name we chose. That removes the entire class of attack that makes "just pass the flags through" a remote shell, which is precisely what makes it safe to open up everything else. docs/SECURITY.md has the reasoning and the specific attacks. The vocabulary is a data table, so the validator and docs/SHARP.md are generated from one source and cannot drift. Two things are deliberately absent from it: withMetadata, because that is how a client's GPS coordinates get published, and limitInputPixels, because a caller who could raise it could turn a 1 KB PNG into a 40 GB allocation. Verified against a running server, not asserted: traversal ids rejected, unknown operations and typo'd parameters rejected by name, out-of-range values rejected with the bound, a GPS-tagged photo coming out clean end to end, video probe, Range requests, and a rejected upload leaving nothing on disk. Found while building: config threw at import if AV_CLUB_TOKEN was unset, so generating documentation required a production secret. The token is read at boot in server.ts now — still a refusal to start, no longer a module-scope landmine. ffmpeg is designed in docs/API.md and not built yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
57 lines
1.8 KiB
TypeScript
57 lines
1.8 KiB
TypeScript
/**
|
|
* One error envelope for every failure, because a client parses failures the same way it parses
|
|
* successes. A route that answers with a bare string does not return the wrong shape — it breaks
|
|
* the parse. `error` is always an object.
|
|
*/
|
|
|
|
export const ERROR_STATUS = {
|
|
invalid_request: 400,
|
|
unauthorized: 401,
|
|
not_found: 404,
|
|
payload_too_large: 413,
|
|
unprocessable: 422,
|
|
timeout: 504,
|
|
server_error: 500,
|
|
} as const;
|
|
|
|
export type ErrorType = keyof typeof ERROR_STATUS;
|
|
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
readonly type: ErrorType,
|
|
message: string,
|
|
/** Dotted path to the offending field, e.g. `pipeline[1].resize.width`. */
|
|
readonly param?: string,
|
|
) {
|
|
super(message);
|
|
this.name = "ApiError";
|
|
}
|
|
|
|
toResponse(): Response {
|
|
return Response.json(
|
|
{ error: { message: this.message, type: this.type, param: this.param ?? null } },
|
|
{ status: ERROR_STATUS[this.type] },
|
|
);
|
|
}
|
|
}
|
|
|
|
export const invalid = (message: string, param?: string) => new ApiError("invalid_request", message, param);
|
|
export const notFound = (message = "No such asset") => new ApiError("not_found", message);
|
|
export const unprocessable = (message: string, param?: string) => new ApiError("unprocessable", message, param);
|
|
|
|
/**
|
|
* Turn anything thrown into a response.
|
|
*
|
|
* An unrecognised error is logged and answered with a generic 500 — the message may name a path or
|
|
* quote file contents, and neither belongs in a response body.
|
|
*/
|
|
export function toErrorResponse(error: unknown): Response {
|
|
if (error instanceof ApiError) return error.toResponse();
|
|
|
|
console.error("[av-club] unhandled:", error);
|
|
return Response.json(
|
|
{ error: { message: "Internal Server Error", type: "server_error", param: null } },
|
|
{ status: 500 },
|
|
);
|
|
}
|