ffmpeg can work on a file where it already is
adds a path mode to /v1/ffmpeg:
{ input: "as_…" } → stored as a new asset, 201
{ inputPath: "/…", outputPath: "/…" } → written where you said, 200 { path, bytes }
THIS REVISES THE INVARIANT IN docs/SECURITY.md, deliberately, and that document now
says so at the top rather than being quietly contradicted by the code.
it used to read "the caller never supplies a path, for input or output". that was
right for an upload service and av-club is not one: both of its real consumers —
a right-click on ~/Movies/thing.mkv, and a commander watching an upload folder —
already have the file, and obeying the rule meant copying gigabytes in and back out
to convert something that never moved. a rule that expensive gets worked around,
and the workaround is where the hole would have been.
so it is narrower now and says what it was always for. argv never a shell,
-protocol_whitelist file, filters allowlisted, work bounded — all unchanged. what
is gone is only the claim that a path never appears, replaced by one small module
every path goes through:
absolute (a relative path resolves against a cwd the caller cannot see)
realpath FIRST, then check — checking the path as written and opening the
resolved one is the classic way this fails
inside as `root` or `root + sep`, never a bare startsWith, which accepts
/home/user-backup for a root of /home/user
for an output the PARENT is resolved, and must already exist
errors never say which of "outside" or "missing" it was, or this becomes an
oracle for what exists elsewhere on the disk
VERIFIED against the running service, not reasoned about: `..` traversal, an
absolute path outside the root, a write outside the root, a relative path, a
symlink pointing into /etc, and a write THROUGH a symlinked directory — all
refused, nothing written outside the root.
the output is moved into place at the END. ffmpeg encodes into a temp dir, so a job
killed by the timeout or a restart leaves the caller's directory as it was. pointing
ffmpeg at the destination would leave a truncated file where a good one might have
been — and a task that then deleted its source would have destroyed something.
rename first, copy on EXDEV, which is the normal case rather than the error one
since tmp is often tmpfs.
docs/API.md said this endpoint was "designed, not yet built" while it was routed and
working. that is fixed too: it was read as a gap and nearly rebuilt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+59
-8
@@ -1,9 +1,10 @@
|
||||
import { join } from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdtemp, rename, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { JOB_TIMEOUT_MS } from "../config";
|
||||
import { invalid, unprocessable, ApiError } from "../errors";
|
||||
import { contentPath, putAsset, readAsset, type Asset } from "../assets/store";
|
||||
import { safeInputPath, safeOutputPath } from "../paths";
|
||||
import {
|
||||
AUDIO_FILTERS,
|
||||
FORMATS,
|
||||
@@ -24,6 +25,10 @@ import {
|
||||
|
||||
export type FfmpegRequest = {
|
||||
input?: unknown;
|
||||
/** A path on disk, instead of `input`. Confined by `safeInputPath`. */
|
||||
inputPath?: unknown;
|
||||
/** Where to write. Only with `inputPath`; without it the result is stored as an asset. */
|
||||
outputPath?: unknown;
|
||||
preset?: unknown;
|
||||
inputOptions?: unknown;
|
||||
outputOptions?: unknown;
|
||||
@@ -108,12 +113,37 @@ function filterChain(table: Record<string, Record<string, FfmpegParam>>, raw: un
|
||||
.join(",");
|
||||
}
|
||||
|
||||
/** Run ffmpeg on an asset and store the result as a new asset. The input is never modified. */
|
||||
export async function runFfmpeg(body: FfmpegRequest): Promise<Asset> {
|
||||
if (typeof body.input !== "string") throw invalid("input must be an asset id", "input");
|
||||
export type FfmpegResult = Asset | { path: string; bytes: number };
|
||||
|
||||
const source = await readAsset(body.input);
|
||||
if (source.kind === "image") {
|
||||
/**
|
||||
* Run ffmpeg, either on a stored asset or on a file already on disk.
|
||||
*
|
||||
* TWO MODES, and exactly one of them per call:
|
||||
*
|
||||
* { input: "as_…" } → result stored as a new asset
|
||||
* { inputPath: "/…", outputPath: "/…" } → result written where the caller said
|
||||
*
|
||||
* The second exists because both real consumers already have the file: a right-click in the file
|
||||
* browser, and a commander watching a folder. See `sidecar/paths.ts` for what confines them.
|
||||
*
|
||||
* **The input is never modified in either mode.** ffmpeg writes to a temp file and it is moved into
|
||||
* place at the end, so a job that dies halfway leaves the original untouched rather than a truncated
|
||||
* output where a good file used to be. Deleting a source is the caller's business and not this
|
||||
* service's — the tasks do it themselves, after checking the result.
|
||||
*/
|
||||
export async function runFfmpeg(body: FfmpegRequest): Promise<FfmpegResult> {
|
||||
const byPath = body.inputPath !== undefined;
|
||||
if (byPath && body.input !== undefined) {
|
||||
throw invalid("give either input or inputPath, not both", "input");
|
||||
}
|
||||
if (!byPath && typeof body.input !== "string") throw invalid("input must be an asset id", "input");
|
||||
|
||||
const sourcePath = byPath ? await safeInputPath(body.inputPath, "inputPath") : null;
|
||||
const destination = body.outputPath !== undefined ? await safeOutputPath(body.outputPath, "outputPath") : null;
|
||||
if (destination && !byPath) throw invalid("outputPath needs inputPath", "outputPath");
|
||||
|
||||
const source = byPath ? null : await readAsset(body.input as string);
|
||||
if (source && source.kind === "image") {
|
||||
throw invalid(`Asset ${source.id} is an image. Use /v1/sharp for images.`, "input");
|
||||
}
|
||||
|
||||
@@ -163,7 +193,7 @@ export async function runFfmpeg(body: FfmpegRequest): Promise<Asset> {
|
||||
// lock behind "the caller never names a path".
|
||||
"-protocol_whitelist", "file",
|
||||
...inputArgs,
|
||||
"-i", contentPath(source.id),
|
||||
"-i", sourcePath ?? contentPath(source!.id),
|
||||
...(videoChain ? ["-vf", videoChain] : []),
|
||||
...(audioChain ? ["-af", audioChain] : []),
|
||||
...presetOptions,
|
||||
@@ -192,7 +222,28 @@ export async function runFfmpeg(body: FfmpegRequest): Promise<Asset> {
|
||||
throw unprocessable(`ffmpeg failed: ${stderr.trim().split("\n").at(-1) ?? `exit ${code}`}`);
|
||||
}
|
||||
|
||||
return await putAsset(await Bun.file(output).arrayBuffer());
|
||||
if (!destination) return await putAsset(await Bun.file(output).arrayBuffer());
|
||||
|
||||
/*
|
||||
Into place at the END, never written there directly.
|
||||
|
||||
ffmpeg has been encoding into a temp directory this whole time, so a job that is killed — by the
|
||||
timeout above, by a restart, by the machine — leaves the caller's directory exactly as it was. The
|
||||
alternative, pointing ffmpeg at the destination, means a crash leaves a truncated file sitting
|
||||
where a good one might have been, and a task that then deletes its source has destroyed something.
|
||||
|
||||
`rename` first because it is atomic when both sides are on one filesystem. They usually are not —
|
||||
the temp dir is often tmpfs and the destination is somebody's home — so EXDEV is expected rather
|
||||
than exceptional, and the copy is the normal path rather than the error path.
|
||||
*/
|
||||
try {
|
||||
await rename(output, destination);
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code !== "EXDEV") throw error;
|
||||
await Bun.write(destination, Bun.file(output));
|
||||
}
|
||||
|
||||
return { path: destination, bytes: (await stat(destination)).size };
|
||||
} finally {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user