av-club keeps doing exactly what it did — sharp, ffmpeg and ffprobe behind one API, and the invariant that the caller never names a path. What changes is how it is reached and who decides who may reach it. `src/` is `sidecar/`, because that is the name the platform looks for: `sidecar/index.ts` present means "this plugin ships a process", and presence is the declaration for everything structural here. `manifest.ts` at the root is the other half of that rule. api/router.ts is a proxy and nothing else. the platform authenticates, checks the `av-club` permission, and forwards — so the sidecar now accepts a request carrying `X-Officer-User` and no longer demands its own bearer token from callers who came that way. it binds LOOPBACK to make that trustworthy, which is the same boundary caldav's DAV door already relies on. AV_CLUB_TOKEN stays, and still guards anything calling the port directly: fofos/platform is the stated first consumer and is not Officer. THREE THINGS HAD TO GO, and each one broke it in a way worth writing down. package.json and bun.lock: a package.json inside a plugin is a package boundary, so bun stops walking up for the platform's tsconfig and `@@/sidecar/connect` stops resolving. the sidecar died on its first import, fifteen times. npm dependencies go in the manifest instead, where npm-deps.ts intersects them across plugins into one plugins/node_modules — which is also how this surfaced a real conflict: sharp ^0.33.5 (atelier) and ^0.35.3 here do not overlap, since a 0.x caret pins the minor. atelier moved up; it calls one method. tsconfig.json: same failure one layer earlier, shadowing the platform's `@@/*` paths so api/router.ts would not mount. no other plugin has one. no screens, deliberately. this is an API and a body of documentation; its callers are a commander watching a folder, an agent, or curl. a dock tile would advertise a page with nothing on it. verified through /api/av-club with an agent key: a 3000x2000 jpeg uploaded, probed, and resized to a 1200x800 avif — 166 KB to 1 KB — without the caller ever holding av-club's own token. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
157 lines
5.5 KiB
TypeScript
157 lines
5.5 KiB
TypeScript
import { rm } from "node:fs/promises";
|
|
import { join, resolve, sep } from "node:path";
|
|
import sharp from "sharp";
|
|
import { ASSETS_DIR, MAX_INPUT_PIXELS } from "../config";
|
|
import { invalid, notFound, unprocessable } from "../errors";
|
|
import { ffprobe } from "../probe/ffprobe";
|
|
|
|
/**
|
|
* The asset store — and the place docs/SECURITY.md's central invariant is enforced.
|
|
*
|
|
* A caller never supplies a path. They supply an ID we issued, and this module is the only thing
|
|
* that turns an ID into a path. Everything downstream receives a path this module produced.
|
|
*/
|
|
|
|
export type AssetKind = "image" | "video" | "audio";
|
|
|
|
export type Asset = {
|
|
id: string;
|
|
bytes: number;
|
|
sha256: string;
|
|
kind: AssetKind;
|
|
format: string;
|
|
width?: number;
|
|
height?: number;
|
|
durationSeconds?: number;
|
|
createdAt: string;
|
|
};
|
|
|
|
/** `as_` plus 32 lowercase hex. Anchored, fixed length, no separators — nothing here can traverse. */
|
|
const ID_PATTERN = /^as_[0-9a-f]{32}$/;
|
|
|
|
export function newAssetId(): string {
|
|
return `as_${crypto.randomUUID().replaceAll("-", "")}`;
|
|
}
|
|
|
|
/**
|
|
* Resolve an ID to its directory, or throw.
|
|
*
|
|
* Two checks, deliberately, because they answer different questions:
|
|
*
|
|
* 1. Does the ID match the pattern we issue? — about the caller's INTENT
|
|
* 2. Is the resolved path still inside the store? — about what actually HAPPENED
|
|
*
|
|
* The second is not redundant. It is the one that still holds if the pattern is ever loosened, if
|
|
* ASSETS_DIR contains a symlink, or if some future refactor builds the path differently. Cheap, and
|
|
* the failure it prevents is "read any file on the machine".
|
|
*/
|
|
function assetDir(id: string): string {
|
|
if (!ID_PATTERN.test(id)) {
|
|
throw invalid(`Not a valid asset id: ${JSON.stringify(id.slice(0, 64))}`, "id");
|
|
}
|
|
|
|
const dir = resolve(join(ASSETS_DIR, id));
|
|
if (dir !== join(ASSETS_DIR, id) || !dir.startsWith(ASSETS_DIR + sep)) {
|
|
throw invalid("Not a valid asset id", "id");
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
export const contentPath = (id: string): string => join(assetDir(id), "content");
|
|
const metaPath = (id: string): string => join(assetDir(id), "meta.json");
|
|
|
|
export async function readAsset(id: string): Promise<Asset> {
|
|
const file = Bun.file(metaPath(id));
|
|
if (!(await file.exists())) throw notFound();
|
|
return (await file.json()) as Asset;
|
|
}
|
|
|
|
export async function assetExists(id: string): Promise<boolean> {
|
|
return Bun.file(metaPath(id)).exists();
|
|
}
|
|
|
|
export async function deleteAsset(id: string): Promise<void> {
|
|
await rm(assetDir(id), { recursive: true, force: true });
|
|
}
|
|
|
|
/**
|
|
* Identify a file by INSPECTING IT, never from its name or the Content-Type a browser attached.
|
|
*
|
|
* Order matters: ffprobe will happily report a PNG as a video stream in an `image2` container, so
|
|
* sharp is asked first and images are settled before ffprobe is consulted at all.
|
|
*/
|
|
async function identify(path: string): Promise<Pick<Asset, "kind" | "format" | "width" | "height" | "durationSeconds">> {
|
|
try {
|
|
const meta = await sharp(path, {
|
|
limitInputPixels: MAX_INPUT_PIXELS,
|
|
// `unlimited` is what gets past libheif's iref-reference cap, which iPhone HEICs carrying
|
|
// burst or depth data routinely exceed — sharp reports it as "corrupt header", which it is not.
|
|
// It loosens libvips' own buffer guards, so `limitInputPixels` is passed EXPLICITLY alongside:
|
|
// verified that the pixel limit still rejects a decompression bomb with unlimited set.
|
|
unlimited: true,
|
|
}).metadata();
|
|
if (meta.format) {
|
|
return { kind: "image", format: meta.format, width: meta.width, height: meta.height };
|
|
}
|
|
} catch {
|
|
// Not an image sharp can read. Fall through — it may well be a video.
|
|
}
|
|
|
|
const probe = await ffprobe(path).catch(() => null);
|
|
const streams = probe?.streams ?? [];
|
|
|
|
const video = streams.find((s) => s.codec_type === "video");
|
|
const audio = streams.find((s) => s.codec_type === "audio");
|
|
const duration = probe?.format?.duration ? Number(probe.format.duration) : undefined;
|
|
const format = probe?.format?.format_name?.split(",")[0] ?? "unknown";
|
|
|
|
if (video) {
|
|
return {
|
|
kind: "video",
|
|
format,
|
|
width: typeof video.width === "number" ? video.width : undefined,
|
|
height: typeof video.height === "number" ? video.height : undefined,
|
|
durationSeconds: duration,
|
|
};
|
|
}
|
|
if (audio) return { kind: "audio", format, durationSeconds: duration };
|
|
|
|
throw unprocessable(
|
|
"That file could not be read as an image, video or audio file. It may be corrupt, or it may not be media at all.",
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Store bytes and return the asset.
|
|
*
|
|
* The ID is issued here, so a caller can never influence where a file lands. Identification runs
|
|
* AFTER the write because both sharp and ffprobe want a path — a file that turns out not to be
|
|
* media is written and then removed rather than being buffered twice.
|
|
*/
|
|
export async function putAsset(bytes: ArrayBuffer | Uint8Array): Promise<Asset> {
|
|
const id = newAssetId();
|
|
const path = contentPath(id);
|
|
|
|
await Bun.write(path, bytes);
|
|
|
|
try {
|
|
const identified = await identify(path);
|
|
const buffer = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
|
|
const asset: Asset = {
|
|
id,
|
|
bytes: buffer.byteLength,
|
|
sha256: new Bun.CryptoHasher("sha256").update(buffer).digest("hex"),
|
|
createdAt: new Date().toISOString(),
|
|
...identified,
|
|
};
|
|
|
|
await Bun.write(metaPath(id), JSON.stringify(asset, null, 2));
|
|
return asset;
|
|
} catch (error) {
|
|
// Do not leave the bytes of something we refused sitting in the store.
|
|
await deleteAsset(id).catch(() => {});
|
|
throw error;
|
|
}
|
|
}
|