diff --git a/apps/docs/src/locales/@vitnode/blog/pl.json b/apps/docs/src/locales/@vitnode/blog/pl.json
index 59f71db9c..619ca2b00 100644
--- a/apps/docs/src/locales/@vitnode/blog/pl.json
+++ b/apps/docs/src/locales/@vitnode/blog/pl.json
@@ -12,6 +12,8 @@
"content": "Treść",
"categoryId": "Kategoria",
"authorId": "Autor",
+ "coverImage": "Obraz wyróżniający",
+ "coverImageAlt": "Tekst alternatywny obrazu",
"status": "Status",
"publishedAt": "Opublikowano",
"updatedAt": "Zaktualizowano"
@@ -35,6 +37,9 @@
},
"form": {
"publish": "Publikacja",
+ "cover": {
+ "title": "Obraz wyróżniający"
+ },
"settings": {
"title": "Ustawienia artykułu"
}
diff --git a/apps/docs/src/locales/@vitnode/core/pl.json b/apps/docs/src/locales/@vitnode/core/pl.json
index 6c1886d04..c070966c0 100644
--- a/apps/docs/src/locales/@vitnode/core/pl.json
+++ b/apps/docs/src/locales/@vitnode/core/pl.json
@@ -191,6 +191,19 @@
"select_options": "Wybierz opcje",
"select_language": "Wybierz język",
"pick_color": "Wybierz kolor",
+ "file": {
+ "any_format": "Dowolny typ pliku",
+ "max_size": "Maksymalny rozmiar pliku: {size}",
+ "drop": "Przeciągnij plik tutaj",
+ "choose": "Wybierz plik",
+ "replace": "Zmień plik",
+ "remove": "Usuń plik",
+ "uploading": "Przesyłanie...",
+ "errors": {
+ "too_large": "Ten plik ma {size}. Maksimum to {max}.",
+ "wrong_format": "\"{value}\" nie jest akceptowanym formatem. Akceptowane: {formats}."
+ }
+ },
"go_to_prev_page": "Przejdź do poprzedniej strony",
"go_to_next_page": "Przejdź do następnej strony",
"errors": {
diff --git a/packages/config/eslint.react.config.mjs b/packages/config/eslint.react.config.mjs
index 9dcb7ab0b..039ebb0f8 100644
--- a/packages/config/eslint.react.config.mjs
+++ b/packages/config/eslint.react.config.mjs
@@ -3,6 +3,7 @@
import eslintReact from "@eslint-react/eslint-plugin";
import hooksPlugin from "eslint-plugin-react-hooks";
import reactYouMightNotNeedAnEffect from "eslint-plugin-react-you-might-not-need-an-effect";
+import jsxA11y from "eslint-plugin-jsx-a11y";
export default [
reactYouMightNotNeedAnEffect.configs.recommended,
@@ -25,6 +26,7 @@ export default [
{
plugins: {
"react-hooks": hooksPlugin,
+ "jsx-a11y": jsxA11y,
},
rules: {
"react/react-in-jsx-scope": "off",
diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api-bun/src/index.ts b/packages/create-vitnode-app/copy-of-vitnode-app/api-bun/src/index.ts
index 19cb3e2a3..9de1ea1b8 100644
--- a/packages/create-vitnode-app/copy-of-vitnode-app/api-bun/src/index.ts
+++ b/packages/create-vitnode-app/copy-of-vitnode-app/api-bun/src/index.ts
@@ -11,6 +11,6 @@ VitNodeAPI({
});
export default {
- port: 8080,
+ port: 8000,
fetch: app.fetch,
};
diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api/src/index.ts b/packages/create-vitnode-app/copy-of-vitnode-app/api/src/index.ts
index 28ac18442..47bcef308 100644
--- a/packages/create-vitnode-app/copy-of-vitnode-app/api/src/index.ts
+++ b/packages/create-vitnode-app/copy-of-vitnode-app/api/src/index.ts
@@ -1,10 +1,10 @@
-import { serve } from '@hono/node-server';
-import { OpenAPIHono } from '@hono/zod-openapi';
-import { VitNodeAPI } from '@vitnode/core/api/config';
+import { serve } from "@hono/node-server";
+import { OpenAPIHono } from "@hono/zod-openapi";
+import { VitNodeAPI } from "@vitnode/core/api/config";
-import { vitNodeApiConfig } from './vitnode.api.config.js';
+import { vitNodeApiConfig } from "./vitnode.api.config.js";
-const app = new OpenAPIHono().basePath('/api');
+const app = new OpenAPIHono().basePath("/api");
VitNodeAPI({
app,
@@ -14,10 +14,10 @@ VitNodeAPI({
serve(
{
fetch: app.fetch,
- port: 8080,
+ port: 8000,
},
info => {
- const initMessage = '\x1b[34m[VitNode]\x1b[0m';
+ const initMessage = "\x1b[34m[VitNode]\x1b[0m";
// eslint-disable-next-line no-console
console.log(
diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/monorepo/apps/web/.env.example b/packages/create-vitnode-app/copy-of-vitnode-app/monorepo/apps/web/.env.example
index 3f87cb318..7474481ea 100644
--- a/packages/create-vitnode-app/copy-of-vitnode-app/monorepo/apps/web/.env.example
+++ b/packages/create-vitnode-app/copy-of-vitnode-app/monorepo/apps/web/.env.example
@@ -1,4 +1,4 @@
-NEXT_PUBLIC_API_URL=http://localhost:8080
+NEXT_PUBLIC_API_URL=http://localhost:8000
# Optional. Set these to back the Next.js caches with Redis, so cached pages and
# `use cache` entries are shared between instances and a tag revalidation on one
diff --git a/packages/create-vitnode-app/src/create/create-vitnode.ts b/packages/create-vitnode-app/src/create/create-vitnode.ts
index 1c3546a0e..be84a1cae 100644
--- a/packages/create-vitnode-app/src/create/create-vitnode.ts
+++ b/packages/create-vitnode-app/src/create/create-vitnode.ts
@@ -287,10 +287,10 @@ export const createVitNode = async ({
// Update README.md with start URLs
let startUrlsText = "[http://localhost:3000](http://localhost:3000)";
if (mode === "onlyApi") {
- startUrlsText = "[http://localhost:8080](http://localhost:8080)";
+ startUrlsText = "[http://localhost:8000](http://localhost:8000)";
} else if (mode === "apiMonorepo") {
startUrlsText =
- "[http://localhost:3000](http://localhost:3000) for the Web app and [http://localhost:8080](http://localhost:8080) for the API";
+ "[http://localhost:3000](http://localhost:3000) for the Web app and [http://localhost:8000](http://localhost:8000) for the API";
}
readmeContent = readmeContent.replace("{{START_URLS}}", startUrlsText);
diff --git a/packages/vitnode/config/next.config.ts b/packages/vitnode/config/next.config.ts
index fb045867d..c651e5e55 100644
--- a/packages/vitnode/config/next.config.ts
+++ b/packages/vitnode/config/next.config.ts
@@ -54,6 +54,7 @@ const redisCacheHandlers = (): NextConfig => {
export const vitNodeNextConfig = (config: NextConfig): NextConfig =>
withNextIntl({
+ reactCompiler: true,
cacheComponents: true,
partialPrefetching: true,
...redisCacheHandlers(),
diff --git a/packages/vitnode/src/api/models/storage-image.test.ts b/packages/vitnode/src/api/models/storage-image.test.ts
index c4659d9fe..e15fee616 100644
--- a/packages/vitnode/src/api/models/storage-image.test.ts
+++ b/packages/vitnode/src/api/models/storage-image.test.ts
@@ -3,10 +3,11 @@
// on the server.
import type { Context } from "hono";
+import { HTTPException } from "hono/http-exception";
import sharp from "sharp";
import { describe, expect, it, vi } from "vitest";
-import { StorageModel } from "./storage";
+import { StorageImageUnprocessableError, StorageModel } from "./storage";
const makeCtx = (image?: { quality?: number; webp?: boolean }) => {
const upload = vi.fn(
@@ -15,7 +16,15 @@ const makeCtx = (image?: { quality?: number; webp?: boolean }) => {
url: `https://cdn.test/${key}`,
}),
);
- const insertValues = vi.fn().mockResolvedValue(undefined);
+ // `upload` returns the created `core_files` row now, so the insert has to
+ // resolve to something with an id - that identifier is what a file reference
+ // is made of.
+ // The argument is typed only so `insertValues.mock.calls[0][0]` is the recorded
+ // row rather than `never`; the body has no use for it.
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const insertValues = vi.fn((_values: Record) => ({
+ returning: vi.fn().mockResolvedValue([{ id: 1 }]),
+ }));
const store: Record = {
core: {
storage: {
@@ -139,6 +148,158 @@ describe("StorageModel image optimization", () => {
expect(insertValues.mock.calls[0][0].metadata).toEqual({});
});
+ /**
+ * Every one of these used to answer "Invalid or corrupt image file", which
+ * names no cause and no fix. The point of each assertion is the *specific*
+ * thing it says instead.
+ */
+ describe("when sharp refuses the image", () => {
+ const failing = async (buf: Buffer, name: string, type: string) => {
+ const { ctx } = makeCtx({ quality: 85 });
+
+ return await new StorageModel(ctx)
+ .upload({ file: fileFrom(buf, name, type), folder: "photos" })
+ .then(() => null)
+ .catch((error: unknown) => error);
+ };
+
+ it("names libvips' own reason when the bytes will not decode", async () => {
+ const error = await failing(
+ Buffer.from("this is not a png at all"),
+ "hero.png",
+ "image/png",
+ );
+
+ expect(error).toBeInstanceOf(HTTPException);
+ const { message, status } = error as HTTPException;
+ expect(status).toBe(400);
+ // The declared format, so a JPEG renamed `.png` reads as the mismatch it is.
+ expect(message).toContain("PNG");
+ expect(message).toContain("unsupported image format");
+ expect(message).toMatch(/truncated/i);
+ });
+
+ it("says empty rather than corrupt for an empty upload", async () => {
+ const error = await failing(Buffer.alloc(0), "hero.png", "image/png");
+
+ expect((error as HTTPException).message).toMatch(/empty/i);
+ });
+
+ /**
+ * A truncated PNG reads its header and then fails halfway through the pixel
+ * data, so this is the one case that only surfaces at encode time - and it
+ * really is a damaged file, unlike the case below.
+ */
+ it("reports a truncated file as damaged, with the size its header claims", async () => {
+ const whole = await sharp({
+ create: {
+ background: "#c33",
+ channels: 3,
+ height: 900,
+ noise: { mean: 128, sigma: 80, type: "gaussian" },
+ width: 900,
+ },
+ })
+ .png()
+ .toBuffer();
+
+ const error = await failing(
+ whole.subarray(0, Math.floor(whole.length * 0.4)),
+ "hero.png",
+ "image/png",
+ );
+
+ expect(error).toBeInstanceOf(HTTPException);
+ expect(error).not.toBeInstanceOf(StorageImageUnprocessableError);
+ const { message } = error as HTTPException;
+ expect(message).toContain("900\u00d7900");
+ expect(message).toMatch(/damaged/i);
+ expect(message).toContain("libpng");
+ });
+ });
+
+ /**
+ * WebP holds at most 16383 pixels per side, and an image taller than that is
+ * still a perfectly good PNG - so the conversion is what gets dropped, not the
+ * upload. 16384 is the real-world case: one pixel over.
+ */
+ describe("when the image is too large for WebP", () => {
+ const tooTall = async (): Promise =>
+ await sharp({
+ create: { background: "#369", channels: 3, height: 16384, width: 200 },
+ })
+ .png()
+ .toBuffer();
+
+ it("stores it in its own format instead of refusing it", async () => {
+ const { ctx, upload, insertValues } = makeCtx({ quality: 85 });
+
+ const stored = await new StorageModel(ctx).upload({
+ file: fileFrom(await tooTall(), "cover.png", "image/png"),
+ folder: "photos",
+ });
+
+ const call = upload.mock.calls[0][0];
+ expect((await sharp(call.body).metadata()).format).toBe("png");
+ expect(call.contentType).toBe("image/png");
+ // The key and the display name have to agree with the bytes, so neither
+ // may claim `.webp` when the conversion did not happen.
+ expect(call.key).toMatch(/\.png$/);
+ expect(stored.name).toBe("cover.png");
+ expect(stored.mimeType).toBe("image/png");
+ expect(insertValues.mock.calls[0][0].name).toBe("cover.png");
+ });
+
+ it("still measures it, and records why it stayed a PNG", async () => {
+ const { ctx, insertValues } = makeCtx({ quality: 85 });
+
+ await new StorageModel(ctx).upload({
+ file: fileFrom(await tooTall(), "cover.png", "image/png"),
+ folder: "photos",
+ metadata: { alt: "a tall thing" },
+ });
+
+ expect(insertValues.mock.calls[0][0].metadata).toEqual({
+ alt: "a tall thing",
+ dimensions: { width: 200, height: 16384 },
+ skippedConversion: "webp-dimension-limit",
+ });
+ });
+
+ it("says nothing about a skipped conversion when WebP was never asked for", async () => {
+ const { ctx, insertValues } = makeCtx({ quality: 85, webp: false });
+
+ await new StorageModel(ctx).upload({
+ file: fileFrom(await tooTall(), "cover.png", "image/png"),
+ folder: "photos",
+ });
+
+ expect(insertValues.mock.calls[0][0].metadata).toEqual({
+ dimensions: { width: 200, height: 16384 },
+ });
+ });
+
+ /**
+ * One pixel under the limit is converted as usual - the fallback must not
+ * quietly swallow every large image.
+ */
+ it("converts an image that fits exactly", async () => {
+ const fits = await sharp({
+ create: { background: "#369", channels: 3, height: 16383, width: 200 },
+ })
+ .png()
+ .toBuffer();
+ const { ctx, upload } = makeCtx({ quality: 85 });
+
+ await new StorageModel(ctx).upload({
+ file: fileFrom(fits, "cover.png", "image/png"),
+ folder: "photos",
+ });
+
+ expect(upload.mock.calls[0][0].contentType).toBe("image/webp");
+ });
+ });
+
it("leaves non-image files untouched even when image config is set", async () => {
const original = Buffer.from("just some text");
const { ctx, upload } = makeCtx({ quality: 40 });
diff --git a/packages/vitnode/src/api/models/storage.test.ts b/packages/vitnode/src/api/models/storage.test.ts
index c04df33ad..cc8bc7393 100644
--- a/packages/vitnode/src/api/models/storage.test.ts
+++ b/packages/vitnode/src/api/models/storage.test.ts
@@ -1,8 +1,9 @@
import type { Context } from "hono";
+import { HTTPException } from "hono/http-exception";
import { describe, expect, it, vi } from "vitest";
-import { StorageModel } from "./storage";
+import { STORAGE_FILE_IN_USE, StorageModel } from "./storage";
const makeCtx = (
overrides: { admin?: unknown; storage?: unknown } = {},
@@ -18,7 +19,14 @@ const makeCtx = (
Promise.resolve({ key, url: `https://cdn.test/${key}` }),
);
const del = vi.fn().mockResolvedValue(undefined);
- const insertValues = vi.fn().mockResolvedValue(undefined);
+ // `upload` returns the created `core_files` row, so the insert resolves to one:
+ // the id is what a Content Engine file column is going to hold.
+ // The argument is typed only so `insertValues.mock.calls[0][0]` is the recorded
+ // row rather than `never`; the body has no use for it.
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const insertValues = vi.fn((_values: Record) => ({
+ returning: vi.fn().mockResolvedValue([{ id: 11 }]),
+ }));
const store: Record = {
admin: "admin" in overrides ? overrides.admin : null,
core: {
@@ -40,16 +48,28 @@ const makeCtx = (
};
};
+/**
+ * A context whose `DELETE FROM core_files` behaves like the real one.
+ *
+ * `row` is what the delete returns - `undefined` for "no such file" - and
+ * `referenceError` makes the statement fail the way Postgres does when a content
+ * row or a revision pin still points at the file. The order the model does things
+ * in is what these tests are about, so `deleteReturning` is the spy that proves
+ * the database was asked *before* the blob was touched.
+ */
const makeDeleteCtx = (
row: undefined | { key: string },
- overrides: { storage?: unknown } = {},
+ overrides: { referenceError?: unknown; storage?: unknown } = {},
): {
ctx: Context;
del: ReturnType;
- deleteWhere: ReturnType;
+ deleteReturning: ReturnType;
} => {
const del = vi.fn().mockResolvedValue(undefined);
- const deleteWhere = vi.fn().mockResolvedValue(undefined);
+ const deleteReturning =
+ "referenceError" in overrides
+ ? vi.fn().mockRejectedValue(overrides.referenceError)
+ : vi.fn().mockResolvedValue(row ? [row] : []);
const store: Record = {
core: {
storage:
@@ -64,21 +84,16 @@ const makeDeleteCtx = (
},
},
db: {
- select: vi.fn(() => ({
- from: vi.fn(() => ({
- where: vi.fn(() => ({
- limit: vi.fn().mockResolvedValue(row ? [row] : []),
- })),
- })),
+ delete: vi.fn(() => ({
+ where: vi.fn(() => ({ returning: deleteReturning })),
})),
- delete: vi.fn(() => ({ where: deleteWhere })),
},
};
return {
ctx: { get: (k: string) => store[k] } as unknown as Context,
del,
- deleteWhere,
+ deleteReturning,
};
};
@@ -201,26 +216,31 @@ describe("StorageModel.delete", () => {
});
describe("StorageModel.deleteFile", () => {
- it("deletes the storage object then the database row", async () => {
+ it("deletes the database row first, then the storage object", async () => {
const key = "month_7_2026/avatars/x.png";
- const { ctx, del, deleteWhere } = makeDeleteCtx({ key });
+ const { ctx, del, deleteReturning } = makeDeleteCtx({ key });
await new StorageModel(ctx).deleteFile(1);
+ // The order is the contract: Postgres is what knows whether anything still
+ // references the file, so the row goes first and the bytes follow only once
+ // it is gone.
+ expect(deleteReturning).toHaveBeenCalledTimes(1);
expect(del).toHaveBeenCalledWith(key);
- expect(deleteWhere).toHaveBeenCalledTimes(1);
+ expect(deleteReturning.mock.invocationCallOrder[0]).toBeLessThan(
+ del.mock.invocationCallOrder[0],
+ );
});
it("throws 404 when the file does not exist", async () => {
- const { ctx, del, deleteWhere } = makeDeleteCtx(undefined);
+ const { ctx, del } = makeDeleteCtx(undefined);
await expect(new StorageModel(ctx).deleteFile(999)).rejects.toThrow();
expect(del).not.toHaveBeenCalled();
- expect(deleteWhere).not.toHaveBeenCalled();
});
it("still removes the row when no storage adapter is configured", async () => {
- const { ctx, del, deleteWhere } = makeDeleteCtx(
+ const { ctx, del, deleteReturning } = makeDeleteCtx(
{ key: "a/b.png" },
{ storage: undefined },
);
@@ -228,30 +248,96 @@ describe("StorageModel.deleteFile", () => {
await new StorageModel(ctx).deleteFile(1);
expect(del).not.toHaveBeenCalled();
- expect(deleteWhere).toHaveBeenCalledTimes(1);
+ expect(deleteReturning).toHaveBeenCalledTimes(1);
});
it("deletes when scoped to the owning user", async () => {
- const { ctx, del, deleteWhere } = makeDeleteCtx({ key: "a/b.png" });
+ const { ctx, del } = makeDeleteCtx({ key: "a/b.png" });
await new StorageModel(ctx).deleteFile(1, 7);
expect(del).toHaveBeenCalledWith("a/b.png");
- expect(deleteWhere).toHaveBeenCalledTimes(1);
});
it("throws 404 when the file is not owned by the user", async () => {
- // The scoped lookup returns nothing, mirroring a row owned by someone else.
- const { ctx, del, deleteWhere } = makeDeleteCtx(undefined);
+ // The scoped delete matches nothing, mirroring a row owned by someone else.
+ const { ctx, del } = makeDeleteCtx(undefined);
await expect(new StorageModel(ctx).deleteFile(1, 7)).rejects.toThrow();
expect(del).not.toHaveBeenCalled();
- expect(deleteWhere).not.toHaveBeenCalled();
});
- it("removes the row even when the storage delete fails", async () => {
+ it("keeps the blob and answers 409 FILE_IN_USE when still referenced", async () => {
+ const { ctx, del } = makeDeleteCtx(
+ { key: "a/b.png" },
+ {
+ referenceError: Object.assign(new Error("still referenced"), {
+ code: "23503",
+ }),
+ },
+ );
+
+ const error = await new StorageModel(ctx)
+ .deleteFile(1)
+ .catch((thrown: unknown) => thrown);
+
+ expect(error).toBeInstanceOf(HTTPException);
+ expect((error as HTTPException).status).toBe(409);
+ await expect(
+ (error as HTTPException).getResponse().json(),
+ ).resolves.toEqual({ code: STORAGE_FILE_IN_USE, id: 1 });
+ // The whole point: whoever is using this file still has a working file.
+ expect(del).not.toHaveBeenCalled();
+ });
+
+ it("answers 409 for the Postgres 17 restrict_violation code too", async () => {
+ // Postgres 17 reports `23001` where 16 reported `23503` for the same refused
+ // delete, so both have to mean "still referenced".
+ const { ctx, del } = makeDeleteCtx(
+ { key: "a/b.png" },
+ {
+ referenceError: Object.assign(new Error("restrict"), { code: "23001" }),
+ },
+ );
+
+ await expect(new StorageModel(ctx).deleteFile(1)).rejects.toMatchObject({
+ status: 409,
+ });
+ expect(del).not.toHaveBeenCalled();
+ });
+
+ it("reads the code through a Drizzle wrapper's cause chain", async () => {
+ const { ctx } = makeDeleteCtx(
+ { key: "a/b.png" },
+ {
+ referenceError: new Error("Failed query", {
+ cause: Object.assign(new Error("still referenced"), {
+ code: "23503",
+ }),
+ }),
+ },
+ );
+
+ await expect(new StorageModel(ctx).deleteFile(1)).rejects.toMatchObject({
+ status: 409,
+ });
+ });
+
+ it("rethrows a failure that is not a reference violation", async () => {
+ const { ctx, del } = makeDeleteCtx(
+ { key: "a/b.png" },
+ { referenceError: new Error("connection reset") },
+ );
+
+ await expect(new StorageModel(ctx).deleteFile(1)).rejects.toThrow(
+ "connection reset",
+ );
+ expect(del).not.toHaveBeenCalled();
+ });
+
+ it("keeps the row removed even when the storage delete fails", async () => {
const failing = vi.fn().mockRejectedValue(new Error("gone"));
- const { ctx, deleteWhere } = makeDeleteCtx(
+ const { ctx, deleteReturning } = makeDeleteCtx(
{ key: "a/b.png" },
{
storage: {
@@ -267,6 +353,8 @@ describe("StorageModel.deleteFile", () => {
await new StorageModel(ctx).deleteFile(1);
expect(failing).toHaveBeenCalledWith("a/b.png");
- expect(deleteWhere).toHaveBeenCalledTimes(1);
+ // The row is already gone, so an unreachable provider leaves an orphaned
+ // object rather than a file record nobody can remove.
+ expect(deleteReturning).toHaveBeenCalledTimes(1);
});
});
diff --git a/packages/vitnode/src/api/models/storage.ts b/packages/vitnode/src/api/models/storage.ts
index f0e9b8324..278694ae1 100644
--- a/packages/vitnode/src/api/models/storage.ts
+++ b/packages/vitnode/src/api/models/storage.ts
@@ -4,11 +4,13 @@ import { and, eq } from "drizzle-orm";
import { HTTPException } from "hono/http-exception";
import { core_files } from "@/database/files";
+import { isPgReferenceViolation } from "@/lib/api/pg-error";
import {
buildStorageKey,
generateStorageFileName,
replaceFileExtension,
} from "@/lib/api/upload";
+import { formatBytes } from "@/lib/format-bytes";
const DEFAULT_IMAGE_QUALITY = 85;
@@ -33,6 +35,31 @@ export interface StorageUploadResult {
url: string;
}
+/**
+ * What {@link StorageModel.upload} returns: the adapter's result plus the
+ * `core_files` row it just created.
+ *
+ * The adapter still returns only `{ key, url }` - it stores bytes and knows
+ * nothing about the database - so this is a separate type rather than a widened
+ * one. `id` is what a caller needs to *reference* the file: a Content Engine
+ * file column holds it, and without it every upload route would have to look the
+ * row back up by key.
+ *
+ * `dimensions` is `null` for a non-image and for an image the pipeline did not
+ * measure (SVG and GIF are deliberately not re-encoded).
+ */
+export interface StorageFileUploadResult extends StorageUploadResult {
+ dimensions: null | { height: number; width: number };
+ id: number;
+ mimeType: null | string;
+ /** The display name as stored, which a format conversion may have changed. */
+ name: string;
+ size: number;
+}
+
+/** Why {@link StorageModel.deleteFile} refused. */
+export const STORAGE_FILE_IN_USE = "FILE_IN_USE";
+
/**
* Present only on disk-backed adapters (e.g. Local). The Node entry point reads
* it to mount `serveStatic` for the stored files. Cloud adapters omit it.
@@ -75,8 +102,128 @@ interface ProcessedImage {
// New extension (incl. leading dot) when the format changed, else null.
extension: null | string;
mimeType: string;
+ /**
+ * Why the configured WebP conversion did not happen, when it was configured
+ * and did not - otherwise null.
+ *
+ * Recorded on the `core_files` row because the decision is otherwise invisible:
+ * an install with `storage.image.webp` whose library has one stray PNG among
+ * the WebPs looks like a bug until this says which rule spared it.
+ */
+ skippedConversion: null | string;
}
+/**
+ * The image decoded, and then could not be re-encoded because of a **format
+ * limit rather than the bytes**: WebP allows at most 16383 pixels per side, and
+ * a 20000px-wide PNG is a perfectly valid PNG.
+ *
+ * Its own class so a caller can tell this apart from a broken file - the upload
+ * route answers `CONTENT_FILE_UNPROCESSABLE` for it instead of
+ * `CONTENT_FILE_INVALID`. The distinction is the whole point: "corrupt" sends
+ * somebody off to re-export an image that was never damaged, while naming the
+ * pixel limit sends them to resize it, which is the thing that works.
+ */
+export class StorageImageUnprocessableError extends HTTPException {
+ constructor(message: string) {
+ super(400, { message });
+
+ this.name = "StorageImageUnprocessableError";
+ }
+}
+
+/** The largest side libwebp will encode. Anything over it is refused outright. */
+const WEBP_MAX_SIDE = 16383;
+
+/** Marks a row whose WebP conversion was skipped for the reason below. */
+const SKIPPED_WEBP_DIMENSIONS = "webp-dimension-limit";
+
+/**
+ * Whether WebP can hold an image this size at all.
+ *
+ * Unmeasured dimensions answer `false`: an image libvips could not size up is
+ * not one to pre-emptively give up converting, so the encoder stays the thing
+ * that decides.
+ */
+const exceedsWebpLimit = (
+ dimensions: null | { height: number; width: number },
+): boolean =>
+ dimensions !== null &&
+ (dimensions.width > WEBP_MAX_SIDE || dimensions.height > WEBP_MAX_SIDE);
+
+/** `image/png` -> `PNG`, for a sentence somebody reads rather than a header. */
+const imageFormatName = (mimeType: string): string =>
+ (mimeType.split("/")[1] ?? mimeType).toUpperCase();
+
+/**
+ * What sharp itself said, as a suffix - or nothing when it said nothing useful.
+ *
+ * libvips writes the actionable part of these ("Input buffer contains
+ * unsupported image format", "vipspng: libpng read error", "Input Buffer is
+ * empty"), and dropping it is what left an admin with a sentence that named no
+ * cause. Only the first line is kept, and it is capped, because the rest is a
+ * stack trace and this ends up in a form field.
+ */
+const reasonSuffix = (error: unknown): string => {
+ const first =
+ error instanceof Error
+ ? (error.message
+ .split("\n")[0]
+ ?.trim()
+ .replace(/[.:]+$/, "") ?? "")
+ : "";
+
+ return first === "" ? "" : `: ${first.slice(0, 160)}`;
+};
+
+/**
+ * Turns a failed re-encode into the most specific thing that can be said.
+ *
+ * Two outcomes, and both were previously "Invalid or corrupt image file":
+ *
+ * - **over the format's pixel limit** - sharp says "too large for the WebP
+ * format" and the file itself is fine. `exceedsWebpLimit` heads this off for
+ * every image libvips could measure, so reaching it means an unmeasured image
+ * or a limit of some other target format - a backstop, and it still has to say
+ * which limit rather than "corrupt";
+ * - **anything else** - the header read but the pixel data did not, which is a
+ * genuinely damaged or truncated file.
+ */
+const imageEncodeFailure = ({
+ dimensions,
+ error,
+ mimeType,
+ targetFormat,
+}: {
+ dimensions: null | { height: number; width: number };
+ error: unknown;
+ mimeType: string;
+ targetFormat: string;
+}): HTTPException => {
+ const size = dimensions
+ ? `${dimensions.width}\u00d7${dimensions.height} pixels`
+ : "this size";
+ const target = targetFormat.toUpperCase();
+ const tooLarge =
+ error instanceof Error &&
+ /too large for the .* format/i.test(error.message);
+
+ if (tooLarge) {
+ const limit =
+ targetFormat === "webp"
+ ? ` ${target} allows at most ${WEBP_MAX_SIDE} pixels per side.`
+ : "";
+
+ return new StorageImageUnprocessableError(
+ `This image is ${size}, which is too large to convert to ${target}.${limit} Resize it and upload it again.`,
+ );
+ }
+
+ return new HTTPException(400, {
+ message: `This ${imageFormatName(mimeType)} file is damaged${reasonSuffix(error)}. Its header reads as ${size}, but the image data could not be decoded - the file is most likely truncated or was cut short in transfer.`,
+ });
+};
+
export class StorageModel {
constructor(c: Context) {
this.c = c;
@@ -94,7 +241,13 @@ export class StorageModel {
): Promise {
const image = this.c.get("core")?.storage?.image;
if (!image || !PROCESSABLE_IMAGE_MIME_TYPES.has(mimeType)) {
- return { body, mimeType, extension: null, dimensions: null };
+ return {
+ body,
+ mimeType,
+ extension: null,
+ dimensions: null,
+ skippedConversion: null,
+ };
}
const quality = image.quality ?? DEFAULT_IMAGE_QUALITY;
@@ -107,42 +260,80 @@ export class StorageModel {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (err) {
throw new HTTPException(500, {
- message: "Image optimization library (sharp) failed to load",
+ message:
+ "The image optimization library (sharp) failed to load, so images cannot be processed. Install `sharp` on the API server, or remove `storage.image` from the API config to store images as they are uploaded.",
});
}
+ // Reading and re-encoding are caught separately because they fail for
+ // different reasons and only one of them means "bad file". A PNG that will
+ // not decode at all is broken; a PNG that decodes and then will not
+ // re-encode is usually too big for the target format, which is a limit
+ // rather than a fault. Collapsing both into "Invalid or corrupt image file"
+ // sent people off to re-export an image that was never corrupt.
+ let metadata;
try {
- const metadata = await sharp(body).metadata();
- if (!metadata.format) {
- return { body, mimeType, extension: null, dimensions: null };
- }
-
- const targetFormat = toWebp ? "webp" : metadata.format;
- const output = await sharp(body)
- .toFormat(targetFormat, { quality })
- .toBuffer();
+ metadata = await sharp(body).metadata();
+ } catch (error) {
+ throw new HTTPException(400, {
+ message: `Could not read this ${imageFormatName(mimeType)} file${reasonSuffix(error)}. It may be truncated, or another format saved under the wrong extension.`,
+ });
+ }
+ if (!metadata.format) {
return {
- body: output,
- mimeType: toWebp ? "image/webp" : mimeType,
- extension: toWebp ? ".webp" : null,
- dimensions:
- metadata.width && metadata.height
- ? { width: metadata.width, height: metadata.height }
- : null,
+ body,
+ mimeType,
+ extension: null,
+ dimensions: null,
+ skippedConversion: null,
};
- } catch {
- throw new HTTPException(400, {
- message: "Invalid or corrupt image file",
+ }
+
+ const dimensions =
+ metadata.width && metadata.height
+ ? { width: metadata.width, height: metadata.height }
+ : null;
+
+ // A 2944x16384 PNG is one pixel too tall for WebP and an entirely valid PNG,
+ // so it is stored as a PNG. Checked here rather than caught from the encoder
+ // because the right answer is not to refuse the upload: `storage.image.webp`
+ // asks for smaller files, and it does not follow from that that an image the
+ // format cannot hold should be rejected instead of kept as it arrived.
+ //
+ // Only the *format* is given up - never pixels. Downscaling to fit would be
+ // the other way to keep WebP, and it is not this function's call to make:
+ // nothing in the config asked for the image to be altered.
+ const asWebp = toWebp && !exceedsWebpLimit(dimensions);
+ const targetFormat = asWebp ? "webp" : metadata.format;
+
+ let output: Buffer;
+ try {
+ output = await sharp(body).toFormat(targetFormat, { quality }).toBuffer();
+ } catch (error) {
+ throw imageEncodeFailure({
+ dimensions,
+ error,
+ mimeType,
+ targetFormat,
});
}
+
+ return {
+ body: output,
+ mimeType: asWebp ? "image/webp" : mimeType,
+ extension: asWebp ? ".webp" : null,
+ dimensions,
+ skippedConversion: toWebp && !asWebp ? SKIPPED_WEBP_DIMENSIONS : null,
+ };
}
private requireProvider(): StorageApiPlugin {
const provider = this.c.get("core").storage?.adapter;
if (!provider) {
throw new HTTPException(500, {
- message: "Storage provider not found",
+ message:
+ "No storage adapter is configured, so there is nowhere to put this file. Set `storage.adapter` in the API config.",
});
}
@@ -154,11 +345,28 @@ export class StorageModel {
}
/**
- * Removes a stored file by its `core_files` id: deletes the underlying object
- * from the storage provider (best-effort - a missing object doesn't block the
- * record removal), then deletes the database row. Throws a 404 when no file
- * with that id exists. Pass `ownerId` to scope the delete to that user's files
- * (so a user can only remove their own uploads).
+ * Removes a stored file by its `core_files` id.
+ *
+ * **Database first, blob second**, and the order is the whole point. A Content
+ * Engine file column references this table with `ON DELETE RESTRICT`, and so
+ * does every retained revision's file pin - so the `DELETE` is what asks
+ * Postgres "is anything still using this?", and it is the only thing that can
+ * answer correctly under concurrency. Deleting the object first, as this used
+ * to, meant a referenced file lost its bytes and *then* had its removal
+ * refused: the article survived, pointing at a 404.
+ *
+ * So:
+ *
+ * - still referenced -> **409 `FILE_IN_USE`**, and the object is untouched;
+ * - row removed -> the object is deleted, best-effort (a missing object must
+ * not fail a delete that already committed);
+ * - no such row -> 404.
+ *
+ * An orphaned storage object is the failure this prefers. It costs disk; a
+ * content record pointing at bytes that are gone costs a broken page nobody
+ * can repair from the AdminCP.
+ *
+ * Pass `ownerId` to scope the delete to that user's own uploads.
*/
async deleteFile(id: number, ownerId?: number): Promise {
const db = this.c.get("db");
@@ -167,22 +375,33 @@ export class StorageModel {
? eq(core_files.id, id)
: and(eq(core_files.id, id), eq(core_files.userId, ownerId));
- const [row] = await db
- .select({ key: core_files.key })
- .from(core_files)
- .where(where)
- .limit(1);
+ let deleted: undefined | { key: string };
+ try {
+ [deleted] = await db
+ .delete(core_files)
+ .where(where)
+ .returning({ key: core_files.key });
+ } catch (error) {
+ if (!isPgReferenceViolation(error)) throw error;
- if (!row) {
+ // The bytes are still there, which is the point: whoever is using this
+ // file still has a working file.
+ throw new HTTPException(409, {
+ res: Response.json({ code: STORAGE_FILE_IN_USE, id }, { status: 409 }),
+ });
+ }
+
+ if (!deleted) {
throw new HTTPException(404, { message: "File not found" });
}
+ // After the commit, and best-effort: the row is gone either way, so a
+ // provider that is down leaves an orphaned object rather than a file record
+ // nobody can remove.
const provider = this.c.get("core").storage?.adapter;
if (provider) {
- await provider.delete(row.key).catch(() => undefined);
+ await provider.delete(deleted.key).catch(() => undefined);
}
-
- await db.delete(core_files).where(where);
}
getUrl(key: string): string {
@@ -196,17 +415,17 @@ export class StorageModel {
maxBytes,
metadata,
userId,
- }: StorageUploadOptions): Promise {
+ }: StorageUploadOptions): Promise {
const provider = this.requireProvider();
if (maxBytes !== undefined && file.size > maxBytes) {
throw new HTTPException(400, {
- message: `File exceeds the maximum size of ${maxBytes} bytes`,
+ message: `This file is ${formatBytes(file.size)}. The maximum is ${formatBytes(maxBytes)}.`,
});
}
if (allowedMimeTypes && !allowedMimeTypes.includes(file.type)) {
throw new HTTPException(400, {
- message: `Unsupported file type: ${file.type || "unknown"}`,
+ message: `"${file.type || "unknown"}" is not an accepted file type here. Accepted: ${allowedMimeTypes.join(", ")}.`,
});
}
@@ -239,16 +458,23 @@ export class StorageModel {
? userId
: (this.c.get("admin")?.user.id ?? this.c.get("user")?.id ?? null);
+ const mimeType = processed.mimeType || null;
+ const size = processed.body.length;
+
+ let created: undefined | { id: number };
try {
- await this.c
+ // `.returning()` so the caller gets the identifier a reference is made of.
+ // Looking the row back up by key would be a second statement answering a
+ // question this one already knows.
+ [created] = await this.c
.get("db")
.insert(core_files)
.values({
name: displayName,
key: result.key,
folder,
- mimeType: processed.mimeType || null,
- size: processed.body.length,
+ mimeType,
+ size,
userId: ownerId,
pluginId: this.c.get("plugin")?.id ?? null,
metadata: {
@@ -256,13 +482,32 @@ export class StorageModel {
...(processed.dimensions
? { dimensions: processed.dimensions }
: {}),
+ ...(processed.skippedConversion
+ ? { skippedConversion: processed.skippedConversion }
+ : {}),
},
- });
+ })
+ .returning({ id: core_files.id });
} catch (error) {
await provider.delete(result.key).catch(() => undefined);
throw error;
}
- return result;
+ if (!created) {
+ await provider.delete(result.key).catch(() => undefined);
+ throw new HTTPException(500, {
+ message:
+ "The file was stored but could not be recorded in the database, so it cannot be referenced. Nothing was kept - try again.",
+ });
+ }
+
+ return {
+ ...result,
+ dimensions: processed.dimensions,
+ id: created.id,
+ mimeType,
+ name: displayName,
+ size,
+ };
}
}
diff --git a/packages/vitnode/src/api/modules/admin/files/routes/delete.route.ts b/packages/vitnode/src/api/modules/admin/files/routes/delete.route.ts
index 08061f148..4534735e3 100644
--- a/packages/vitnode/src/api/modules/admin/files/routes/delete.route.ts
+++ b/packages/vitnode/src/api/modules/admin/files/routes/delete.route.ts
@@ -27,6 +27,15 @@ export const deleteFileAdminRoute = buildRoute({
},
description: "File not found",
},
+ 409: {
+ content: {
+ "application/json": {
+ schema: z.object({ code: z.string(), id: z.number() }),
+ },
+ },
+ description:
+ "Still referenced by content or by a retained revision, so the file was kept",
+ },
},
},
handler: async c => {
diff --git a/packages/vitnode/src/api/modules/users/files/routes/delete.route.ts b/packages/vitnode/src/api/modules/users/files/routes/delete.route.ts
index 712dadc4b..e5df477eb 100644
--- a/packages/vitnode/src/api/modules/users/files/routes/delete.route.ts
+++ b/packages/vitnode/src/api/modules/users/files/routes/delete.route.ts
@@ -30,6 +30,15 @@ export const deleteUserFileRoute = buildRoute({
},
description: "File not found",
},
+ 409: {
+ content: {
+ "application/json": {
+ schema: z.object({ code: z.string(), id: z.number() }),
+ },
+ },
+ description:
+ "Still referenced by content or by a retained revision, so the file was kept",
+ },
},
},
handler: async c => {
diff --git a/packages/vitnode/src/components/form/fields/file.tsx b/packages/vitnode/src/components/form/fields/file.tsx
new file mode 100644
index 000000000..d6f31a1a3
--- /dev/null
+++ b/packages/vitnode/src/components/form/fields/file.tsx
@@ -0,0 +1,403 @@
+"use client";
+
+import { useMutation } from "@tanstack/react-query";
+import {
+ FileIcon,
+ LoaderCircleIcon,
+ RotateCcwIcon,
+ TriangleAlertIcon,
+ UploadIcon,
+ XIcon,
+} from "lucide-react";
+import { useTranslations } from "next-intl";
+import React from "react";
+
+import type { FileRejectionReason } from "@/lib/file-constraints";
+
+import {
+ Attachment,
+ AttachmentAction,
+ AttachmentActions,
+ AttachmentContent,
+ AttachmentDescription,
+ AttachmentMedia,
+ AttachmentTitle,
+} from "@/components/ui/attachment";
+import { Button } from "@/components/ui/button";
+import { FormControl, FormMessage } from "@/components/ui/form";
+import {
+ fileAcceptAttribute,
+ fileFormatLabels,
+ validateFile,
+} from "@/lib/file-constraints";
+import { formatBytes } from "@/lib/format-bytes";
+import { cn } from "@/lib/utils";
+
+import type { ItemAutoFormComponentProps } from "../auto-form";
+
+import { AutoFormDesc } from "../common/desc";
+import { AutoFormLabel } from "../common/label";
+
+/**
+ * A stored file, as this input needs to describe one.
+ *
+ * Declared here rather than imported from the Content Engine on purpose: this is
+ * generic AutoForm infrastructure, and a form field that reached into
+ * `@/content` for a type would make every hand-written form depend on the
+ * Content Engine to upload a file. The Content Engine's own
+ * `ContentFileDescriptor` is structurally this, so it passes straight in.
+ */
+export interface AutoFormFileValue {
+ height?: number;
+ id: number;
+ mimeType?: null | string;
+ name: string;
+ size: number;
+ url: string;
+ width?: number;
+}
+
+export interface AutoFormFileProps extends ItemAutoFormComponentProps {
+ /**
+ * Accepted extensions, lowercase with a leading dot. Display and `accept` only
+ * - whoever owns `onUpload` is what actually enforces them.
+ */
+ allowedExtensions?: readonly string[];
+ allowedMimeTypes?: readonly string[];
+ /** What the field currently holds, on an edit form. */
+ file?: AutoFormFileValue | null;
+ label?: React.ReactNode;
+ /**
+ * The ceiling, in bytes. **Required**, because the line that shows it is not
+ * optional: an uploader that does not say how big a file may be is one people
+ * discover the limit of by failing.
+ */
+ maxBytes: number;
+ /**
+ * Sends one file somewhere and comes back with its descriptor.
+ *
+ * Injected rather than built in, which is what keeps this component generic:
+ * the Content Engine passes its generated multipart route, and a hand-written
+ * form passes whatever it has. Its rejection message is shown verbatim, so it
+ * should be written for the person who picked the file.
+ */
+ onUpload: (file: File) => Promise;
+}
+
+/**
+ * An upload failure that knows which rule refused it.
+ *
+ * A structural check rather than an `instanceof`: whoever owns `onUpload` builds
+ * the error, and this component must not have to know about their error class to
+ * read the one field it can act on.
+ */
+const rejectionReasonOf = (error: unknown): FileRejectionReason | undefined => {
+ const reason = (error as null | { reason?: unknown })?.reason;
+
+ return reason === "extension" || reason === "mimeType" || reason === "size"
+ ? reason
+ : undefined;
+};
+
+const isImage = (file: AutoFormFileValue): boolean =>
+ (file.mimeType ?? "").startsWith("image/");
+
+/**
+ * A single-file uploader for `AutoForm`.
+ *
+ * The form's value is the stored file's **identifier**, never the bytes: the
+ * upload is its own `multipart/form-data` request through `onUpload`, and what
+ * lands in `field.value` is what the surrounding JSON mutation will send. So a
+ * form holding an image is the same size as one holding a number.
+ *
+ * The constraint line above the drop zone is **not conditional**. Allowed
+ * formats and maximum size are shown whether or not anything has gone wrong,
+ * because "5 MB" is information somebody needs *before* choosing a file - a
+ * validation error that says it afterwards is a worse version of the same
+ * sentence.
+ */
+export const AutoFormFile = ({
+ allowedExtensions,
+ allowedMimeTypes,
+ description,
+ field,
+ file: initialFile,
+ label,
+ labelRight,
+ maxBytes,
+ onUpload,
+ otherProps: { isOptional },
+ // Only the language-aware inputs implement this - dropped here so it never
+ // lands on the DOM element below. A file is never localized.
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ multiLang,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ itemParams,
+}: AutoFormFileProps) => {
+ const t = useTranslations("core.global.file");
+ const inputRef = React.useRef(null);
+ const [file, setFile] = React.useState(
+ initialFile ?? null,
+ );
+ const [rejected, setRejected] = React.useState(null);
+ const [isDragging, setIsDragging] = React.useState(false);
+
+ // One object, read by all three: the constraint line, the `accept` attribute
+ // and the pre-flight check. It is the same shape the server validates against,
+ // which is why the UI cannot advertise a rule the API does not enforce.
+ const constraints = { allowedExtensions, allowedMimeTypes, maxBytes };
+ const formats = fileFormatLabels(constraints);
+ const accept = fileAcceptAttribute(constraints);
+
+ const upload = useMutation({
+ mutationFn: onUpload,
+ // No retry: an upload is not idempotent from the person's point of view -
+ // a silent second attempt spends their bandwidth again and can leave two
+ // stored objects where they asked for one.
+ retry: false,
+ onSuccess: uploaded => {
+ setFile(uploaded);
+ setRejected(null);
+ field.onChange(uploaded.id);
+ },
+ });
+
+ /**
+ * What went wrong, in the most specific words available.
+ *
+ * Three sources, in order of how much they know:
+ *
+ * 1. `rejected` - the pre-flight check, already translated;
+ * 2. a server rejection that named a rule this component can restate in the
+ * reader's own language, using the field's own limits and the file they
+ * actually picked;
+ * 3. the server's own message, verbatim.
+ *
+ * The last one matters more than it looks. "Storage provider not found" and
+ * "Invalid or corrupt image file" are exactly what somebody needs to read, and
+ * replacing either with "the upload failed, please try again" is how an editor
+ * ends up retrying a misconfiguration for ten minutes.
+ */
+ const errorMessage = React.useMemo(() => {
+ if (rejected !== null) return rejected;
+ if (!(upload.error instanceof Error)) return null;
+
+ const reason = rejectionReasonOf(upload.error);
+ const attempted = upload.variables;
+
+ if (reason === "size" && attempted) {
+ return t("errors.too_large", {
+ max: formatBytes(maxBytes),
+ size: formatBytes(attempted.size),
+ });
+ }
+ if (reason !== undefined && attempted) {
+ return t("errors.wrong_format", {
+ formats: formats.join(", "),
+ value:
+ reason === "mimeType" && attempted.type !== ""
+ ? attempted.type
+ : attempted.name,
+ });
+ }
+
+ return upload.error.message;
+ }, [formats, maxBytes, rejected, t, upload.error, upload.variables]);
+
+ const pick = (chosen: File | undefined) => {
+ if (!chosen) return;
+
+ // A courtesy, not a check: the server runs the same three rules again and is
+ // the one that decides. It exists so picking a 40 MB video for a 5 MB field
+ // costs nothing instead of costing the upload - and it is the *same*
+ // function, so it cannot disagree about what would have been refused.
+ const rejection = validateFile(constraints, {
+ mimeType: chosen.type,
+ name: chosen.name,
+ size: chosen.size,
+ });
+ if (rejection) {
+ upload.reset();
+ setRejected(
+ rejection.reason === "size"
+ ? t("errors.too_large", {
+ max: formatBytes(maxBytes),
+ size: rejection.value,
+ })
+ : t("errors.wrong_format", {
+ formats: formats.join(", "),
+ value: rejection.value,
+ }),
+ );
+
+ return;
+ }
+
+ setRejected(null);
+ upload.mutate(chosen);
+ };
+
+ const remove = () => {
+ upload.reset();
+ setRejected(null);
+ setFile(null);
+ // `null` rather than `undefined`: a nullable file column is blanked by
+ // sending `null`, and `undefined` would be dropped from the payload and
+ // leave the stored value in place.
+ field.onChange(null);
+ };
+
+ const openPicker = () => inputRef.current?.click();
+
+ const state = upload.isPending
+ ? "uploading"
+ : errorMessage !== null
+ ? "error"
+ : file
+ ? "done"
+ : "idle";
+
+ return (
+ <>
+ {!!label && (
+
+ {label}
+
+ )}
+
+ {/*
+ The constraints, always. Rendered above the control rather than as help
+ text underneath it, because they are what somebody reads before they act.
+ */}
+
+
+
+ {!!description && {description}}
+
+ >
+ );
+};
diff --git a/packages/vitnode/src/components/ui/editor.tsx b/packages/vitnode/src/components/ui/editor.tsx
index 1ee434da8..8323f096c 100644
--- a/packages/vitnode/src/components/ui/editor.tsx
+++ b/packages/vitnode/src/components/ui/editor.tsx
@@ -1,8 +1,7 @@
"use client";
-import type React from "react";
-
import { EditorContent, useEditor } from "@tiptap/react";
+import React from "react";
import { tiptapExtensions } from "@/components/tiptap/extension";
import { TipTapToolbar } from "@/components/tiptap/toolbar/tiptap-toolbar";
@@ -10,18 +9,20 @@ import { cn } from "@/lib/utils";
import { Loader } from "./loader";
-export const Editor = ({
+type EditorProps = Omit, "onChange"> & {
+ disableScroll?: boolean;
+ onChange?: (value: string) => void;
+ value?: string;
+};
+
+const TipTapEditor = ({
className,
disableScroll,
value = "",
onChange,
onBlur,
...props
-}: Omit, "onChange"> & {
- disableScroll?: boolean;
- onChange?: (value: string) => void;
- value?: string;
-}) => {
+}: EditorProps) => {
const editor = useEditor({
extensions: tiptapExtensions,
editorProps: {
@@ -56,3 +57,26 @@ export const Editor = ({
);
};
+
+const subscribeNever = () => () => {};
+const getIsHydrated = () => true;
+const getIsHydratedOnServer = () => false;
+
+/**
+ * `useEditor` tags every instance with an id derived from `Math.random()` as it
+ * renders, and a prerender cannot evaluate that - Next refuses to bake an
+ * unstable value into a route's static shell. `immediatelyRender: false` means
+ * the editor has no instance on the server anyway, so hold the hook back until
+ * the browser has hydrated and let the shell keep the loader.
+ */
+export const Editor = (props: EditorProps) => {
+ const isHydrated = React.useSyncExternalStore(
+ subscribeNever,
+ getIsHydrated,
+ getIsHydratedOnServer,
+ );
+
+ if (!isHydrated) return ;
+
+ return ;
+};
diff --git a/packages/vitnode/src/content/admin/spec.ts b/packages/vitnode/src/content/admin/spec.ts
index a7a526473..83216ad10 100644
--- a/packages/vitnode/src/content/admin/spec.ts
+++ b/packages/vitnode/src/content/admin/spec.ts
@@ -25,6 +25,17 @@ import { humanizeFieldName } from "./labels";
* schema from it with {@link buildFormSchemaFromSpec}.
*/
export interface ContentFormFieldSpec {
+ /**
+ * `file` fields only: the extensions the field accepts, normalised.
+ *
+ * Carried on the spec rather than re-derived in the browser, so the constraint
+ * line the uploader always shows, the `accept` attribute it sets and the check
+ * the upload route runs are three readings of **one** descriptor. There is no
+ * second place for them to disagree.
+ */
+ allowedExtensions?: string[];
+ /** `file` fields only: the media types the field accepts, lowercased. */
+ allowedMimeTypes?: string[];
defaultValue?: boolean | null | number | string;
description?: string;
display?: "radio" | "select";
@@ -49,6 +60,8 @@ export interface ContentFormFieldSpec {
*/
localized?: boolean;
max?: number;
+ /** `file` fields only: the largest upload the field accepts, in bytes. */
+ maxBytes?: number;
/** Upper bound on a repeatable's rows. */
maxItems?: number;
maxLength?: number;
@@ -104,6 +117,15 @@ export interface ContentFormSpec {
*/
defaultLocale: null | string;
fields: ContentFormFieldSpec[];
+ /**
+ * The content type's AdminCP module segment, e.g. `posts`.
+ *
+ * The form needs it to address the generated upload route from the browser -
+ * `/api/{pluginId}/admin/content/{permissionModule}/uploads/{field}` - and it is
+ * already the path segment every admin content request goes through, so this
+ * publishes nothing new.
+ */
+ permissionModule: string;
pluginId: string;
/**
* How to group the fields, or empty for one flat form.
@@ -190,6 +212,17 @@ const projectFormField = (
value,
})),
};
+ case "file":
+ return {
+ ...base,
+ ...(fieldValue.allowedExtensions
+ ? { allowedExtensions: fieldValue.allowedExtensions }
+ : {}),
+ ...(fieldValue.allowedMimeTypes
+ ? { allowedMimeTypes: fieldValue.allowedMimeTypes }
+ : {}),
+ maxBytes: fieldValue.maxBytes,
+ };
case "group":
case "repeatable":
return {
@@ -281,6 +314,7 @@ export const buildContentFormSpec = ({
defaultLocale: definition.localization.enabled
? definition.localization.defaultLocale
: null,
+ permissionModule: definition.permissionModule,
pluginId,
titleField: definition.admin.titleField,
// One form, shared and localized fields alike, in the order they were
@@ -413,6 +447,12 @@ const baseFieldSchema = (spec: ContentFormFieldSpec): z.ZodType => {
? z.enum(values as [string, ...string[]])
: z.string();
}
+ // What the form holds for a file is the `core_files.id` the API takes, which
+ // is also what the mutation sends: the upload happens through its own
+ // multipart route and hands the identifier back, so nothing binary is ever
+ // part of this schema or of the JSON body built from it.
+ case "file":
+ return z.number().int().positive();
case "group":
return leafObjectSchema(spec);
case "number": {
diff --git a/packages/vitnode/src/content/admin/upload.test.ts b/packages/vitnode/src/content/admin/upload.test.ts
new file mode 100644
index 000000000..b8bbe7179
--- /dev/null
+++ b/packages/vitnode/src/content/admin/upload.test.ts
@@ -0,0 +1,241 @@
+// @vitest-environment node
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const fetches: { formData?: FormData; method: string; path?: string }[] = [];
+let next: (() => Promise) | null = null;
+
+/**
+ * The real `rawApiFetch`, minus the network - including the one behaviour that
+ * shaped this suite: it **throws** on a 500 rather than returning, and its
+ * message carries the response body after a newline.
+ */
+vi.mock("../../lib/fetcher/raw", () => ({
+ rawApiFetch: async ({
+ formData,
+ method,
+ path,
+ }: {
+ formData?: FormData;
+ method: string;
+ path?: string;
+ }) => {
+ fetches.push({ formData, method, path });
+ const response = await (next?.() ??
+ Promise.resolve(new Response(null, { status: 200 })));
+
+ if (response.status === 500) {
+ const text = await response.text();
+ throw new Error(
+ `500 - http://localhost:3000/api/x\n${text.trim() === "" ? response.statusText : text}`,
+ );
+ }
+
+ return response;
+ },
+}));
+
+const { ContentUploadError, uploadContentFile } = await import("./upload");
+
+const SPEC = { permissionModule: "posts", pluginId: "@vitnode/blog" };
+
+const upload = async () =>
+ await uploadContentFile({
+ field: "coverImage",
+ file: new File([new Uint8Array(8)], "hero.png", { type: "image/png" }),
+ spec: SPEC,
+ });
+
+const failing = (
+ body: BodyInit | null,
+ status: number,
+ headers?: Record,
+) => {
+ next = async () =>
+ await Promise.resolve(new Response(body, { headers, status }));
+};
+
+const rejection = async (): Promise<
+ InstanceType
+> => {
+ const error = await upload().then(
+ () => null,
+ (thrown: unknown) => thrown,
+ );
+
+ expect(error).toBeInstanceOf(ContentUploadError);
+
+ return error as InstanceType;
+};
+
+const json = (body: unknown, status: number) =>
+ failing(JSON.stringify(body), status, {
+ "content-type": "application/json",
+ });
+
+beforeEach(() => {
+ fetches.length = 0;
+ next = null;
+});
+
+describe("a successful upload", () => {
+ it("posts multipart to the field's own route and parses the descriptor", async () => {
+ json(
+ {
+ height: 900,
+ id: 42,
+ mimeType: "image/webp",
+ name: "hero.webp",
+ size: 1024,
+ url: "https://cdn.test/hero.webp",
+ width: 1600,
+ },
+ 200,
+ );
+
+ const descriptor = await upload();
+
+ expect(descriptor).toMatchObject({ id: 42, name: "hero.webp" });
+ expect(fetches[0]).toMatchObject({
+ method: "post",
+ path: "/uploads/coverImage",
+ });
+ expect(fetches[0].formData?.get("file")).toBeInstanceOf(File);
+ });
+});
+
+/**
+ * Why this suite exists.
+ *
+ * The first version read the body with `response.json()` and fell back to "The
+ * upload failed. Please try again." for anything else. Hono renders an
+ * `HTTPException`'s message as **plain text**, so every guard outside the route's
+ * own body - the storage adapter, the image pipeline, rate limiting, the admin
+ * session gate - arrived as text, failed the JSON parse, and was replaced by a
+ * sentence that told the person nothing and invited them to retry a
+ * misconfiguration.
+ */
+describe("what a refused upload says", () => {
+ it("uses the route's own JSON rejection, code included", async () => {
+ json(
+ {
+ code: "CONTENT_FILE_TOO_LARGE",
+ message: "This file is 9 MB. The maximum is 5 MB.",
+ },
+ 400,
+ );
+
+ const error = await rejection();
+
+ expect(error.code).toBe("CONTENT_FILE_TOO_LARGE");
+ expect(error.message).toBe("This file is 9 MB. The maximum is 5 MB.");
+ });
+
+ it("marks the three rules the uploader can restate in the reader's language", async () => {
+ for (const [code, reason] of [
+ ["CONTENT_FILE_TOO_LARGE", "size"],
+ ["CONTENT_FILE_MIME_TYPE_NOT_ALLOWED", "mimeType"],
+ ["CONTENT_FILE_EXTENSION_NOT_ALLOWED", "extension"],
+ ] as const) {
+ json({ code, message: "…" }, 400);
+
+ expect((await rejection()).reason).toBe(reason);
+ }
+ });
+
+ it("leaves everything else for the server's own words", async () => {
+ // Nothing the browser could say about these is an improvement.
+ for (const code of [
+ "CONTENT_FILE_STORAGE_UNAVAILABLE",
+ "CONTENT_FILE_INVALID",
+ "CONTENT_FILE_FORBIDDEN",
+ "CONTENT_FILE_FIELD_UNKNOWN",
+ ]) {
+ json({ code, message: "Storage provider not found" }, 400);
+
+ const error = await rejection();
+ expect(error.reason).toBeUndefined();
+ expect(error.message).toBe("Storage provider not found");
+ }
+ });
+
+ it("reads a plain-text HTTPException message", async () => {
+ // The case that produced the generic sentence.
+ failing("Invalid or corrupt image file", 400);
+
+ const error = await rejection();
+
+ expect(error.message).toBe("Invalid or corrupt image file");
+ expect(error.message).not.toMatch(/try again/i);
+ });
+
+ it("reads the `error` key the core middleware answers with", async () => {
+ json({ error: "Too many requests" }, 429);
+
+ expect((await rejection()).message).toBe("Too many requests");
+ });
+
+ it("says the platform refused an oversized body on a 413", async () => {
+ // No `maxBytes` check ran - the body never reached the route - so there is
+ // no JSON to read and the status is the only fact available.
+ failing(null, 413);
+
+ expect((await rejection()).message).toMatch(/too large/i);
+ });
+
+ it("names the session and the permission cases", async () => {
+ failing(null, 401);
+ expect((await rejection()).message).toMatch(/session/i);
+
+ failing(null, 403);
+ expect((await rejection()).message).toMatch(/permission/i);
+ });
+
+ it("points at the storage configuration on a 5xx with no body", async () => {
+ failing(null, 502);
+
+ expect((await rejection()).message).toMatch(/storage adapter/i);
+ });
+
+ it("surfaces the body of a 500, which the fetcher throws rather than returns", async () => {
+ failing("Storage provider not found", 500);
+
+ const error = await rejection();
+
+ expect(error.message).toBe("Storage provider not found");
+ // Not the URL the fetcher prefixes its throw with.
+ expect(error.message).not.toContain("http://");
+ });
+
+ it("does not show a proxy's HTML error page", async () => {
+ failing("502 Bad Gateway", 502);
+
+ const error = await rejection();
+
+ expect(error.message).not.toContain("<");
+ expect(error.message).toMatch(/storage adapter/i);
+ });
+
+ it("clamps a message longer than it needs to be", async () => {
+ failing(`Boom: ${"x".repeat(900)}`, 400);
+
+ const error = await rejection();
+
+ expect(error.message.length).toBeLessThanOrEqual(401);
+ expect(error.message.endsWith("…")).toBe(true);
+ expect(error.message.startsWith("Boom:")).toBe(true);
+ });
+
+ it("ignores a body far too long to be a message at all", async () => {
+ // Kilobytes of text is a stack trace or a dumped page, not a sentence
+ // somebody wrote for this moment - so the status is the more honest answer.
+ failing("x".repeat(5000), 400);
+
+ expect((await rejection()).message).toMatch(/HTTP 400/);
+ });
+
+ it("falls back to the status only when the body is empty", async () => {
+ failing("", 400);
+
+ expect((await rejection()).message).toMatch(/HTTP 400/);
+ });
+});
diff --git a/packages/vitnode/src/content/admin/upload.ts b/packages/vitnode/src/content/admin/upload.ts
new file mode 100644
index 000000000..f1fe20cec
--- /dev/null
+++ b/packages/vitnode/src/content/admin/upload.ts
@@ -0,0 +1,207 @@
+import type { FileRejectionReason } from "../../lib/file-constraints";
+import type { ContentFileDescriptor } from "../files";
+import type { ContentFormSpec } from "./spec";
+
+import { rawApiFetch } from "../../lib/fetcher/raw";
+import { contentFileRejectionReason, zodContentFileDescriptor } from "../files";
+
+/**
+ * The address of a content type's generated upload route.
+ *
+ * Built from the spec the form already has rather than from a route literal,
+ * because the AdminCP content screen does not know at compile time which plugin
+ * module it is talking to - the same reason `contentApiFetch` exists on the
+ * server side.
+ */
+export const contentUploadPath = (
+ spec: Pick,
+ field: string,
+): { module: string; path: string } => ({
+ module: `content/${spec.permissionModule}`,
+ path: `/uploads/${field}`,
+});
+
+/** A refused upload, as the route reports it. */
+export interface ContentUploadRejection {
+ code: string;
+ message: string;
+ /**
+ * The rule that refused it, when the browser can say it better.
+ *
+ * Present only for the three codes the uploader can render itself from the
+ * field's own limits, in the reader's own language. Absent for everything else
+ * - a misconfigured adapter, a corrupt file, a permission problem - where the
+ * server's own sentence is the most useful thing anybody has.
+ */
+ reason?: FileRejectionReason;
+}
+
+export class ContentUploadError extends Error {
+ constructor({ code, message, reason }: ContentUploadRejection) {
+ super(message);
+
+ this.name = "ContentUploadError";
+ this.code = code;
+ this.reason = reason;
+ }
+
+ readonly code: string;
+ readonly reason: FileRejectionReason | undefined;
+}
+
+/** Long enough for any message the API writes, short enough not to be a page. */
+const MAX_MESSAGE_LENGTH = 400;
+
+const clamp = (value: string): string =>
+ value.length > MAX_MESSAGE_LENGTH
+ ? `${value.slice(0, MAX_MESSAGE_LENGTH).trimEnd()}…`
+ : value;
+
+/**
+ * What a status alone is worth saying, when the body said nothing usable.
+ *
+ * A last resort, and each one still names something actionable. `413` is the
+ * important one: a body rejected by the platform - a proxy, a serverless
+ * function's own limit - never reaches the route, so no `maxBytes` check ran and
+ * there is no JSON to read. "Too large for this server" is the honest reading.
+ */
+const fromStatus = (status: number): string => {
+ if (status === 413) {
+ return "That file was rejected as too large before it reached the server. It may be over the hosting platform's own upload limit.";
+ }
+ if (status === 401) return "Your session has expired. Sign in again.";
+ if (status === 403) {
+ return "You do not have permission to upload here.";
+ }
+ if (status === 404) {
+ return "This content type has no upload route. Rebuild the plugin and try again.";
+ }
+ if (status >= 500) {
+ return `The server could not store the file (HTTP ${status}). Check the storage adapter configuration.`;
+ }
+
+ return `The upload was refused (HTTP ${status}).`;
+};
+
+/**
+ * Reads whatever the route actually said.
+ *
+ * Three layers, in order, because a failing upload can answer in three shapes
+ * and the *first* version of this only understood one of them:
+ *
+ * 1. **JSON `{ code, message }`** - what the generated route answers with.
+ * 2. **Plain text** - what Hono renders an `HTTPException` message as, which is
+ * every guard outside the route's own body: rate limiting, CSRF, the admin
+ * session gate. Discarding it is what turned "Storage provider not found"
+ * into "please try again", and left an admin retrying a misconfiguration.
+ * 3. **The status** - for an HTML error page from a proxy, or an empty body.
+ *
+ * `error` is read as well as `message`: the core error middleware answers with
+ * `{ error }`, and a body that names the problem should be shown whichever key
+ * it arrived under.
+ */
+const readRejection = async (
+ response: Response,
+): Promise => {
+ const raw = await response.text().catch(() => "");
+ const fallback = { code: `HTTP_${response.status}` };
+
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ if (parsed !== null && typeof parsed === "object") {
+ const body = parsed as {
+ code?: unknown;
+ error?: unknown;
+ message?: unknown;
+ };
+ const message =
+ typeof body.message === "string" && body.message !== ""
+ ? body.message
+ : typeof body.error === "string" && body.error !== ""
+ ? body.error
+ : null;
+
+ if (message !== null) {
+ const code = typeof body.code === "string" ? body.code : fallback.code;
+
+ return {
+ code,
+ message: clamp(message),
+ ...(contentFileRejectionReason(code) === undefined
+ ? {}
+ : { reason: contentFileRejectionReason(code) }),
+ };
+ }
+ }
+ } catch {
+ // Not JSON. The text branch below is the interesting one.
+ }
+
+ const text = raw.trim();
+ // An HTML error page is a proxy talking, not the API - showing its markup
+ // would be worse than saying nothing about it.
+ if (text !== "" && !text.startsWith("<") && text.length < 2000) {
+ return { ...fallback, message: clamp(text) };
+ }
+
+ return { ...fallback, message: fromStatus(response.status) };
+};
+
+/**
+ * Uploads one file for one `file` field and returns its descriptor.
+ *
+ * **This is the only path binary data takes.** It is a `multipart/form-data`
+ * `POST` from the browser to the generated API route, driven by TanStack Query -
+ * not a Server Action. A Server Action body is a serialised RSC payload, so an
+ * image would be encoded into a string, buffered whole in the Next.js process
+ * and capped by a platform body limit that has nothing to do with the field's
+ * `maxBytes`. The content mutation that follows is ordinary JSON carrying the
+ * identifier this returns.
+ *
+ * A failure becomes a {@link ContentUploadError} carrying the reason the server
+ * gave - never a generic sentence when the server wrote a specific one.
+ */
+export const uploadContentFile = async ({
+ field,
+ file,
+ spec,
+}: {
+ field: string;
+ file: File;
+ spec: Pick;
+}): Promise => {
+ const formData = new FormData();
+ formData.append("file", file);
+
+ let response: Response;
+ try {
+ response = await rawApiFetch({
+ ...contentUploadPath(spec, field),
+ formData,
+ method: "post",
+ options: { credentials: "include" },
+ pluginId: spec.pluginId,
+ prefixPath: "/admin",
+ });
+ } catch (error) {
+ // `rawApiFetch` throws rather than returning on a 500, and its message
+ // carries the response body after a newline. The body is the part worth
+ // reading - the URL in front of it is not something to show an editor.
+ const detail =
+ error instanceof Error ? error.message.split("\n").pop() : "";
+
+ throw new ContentUploadError({
+ code: "HTTP_500",
+ message:
+ detail !== undefined && detail.trim() !== ""
+ ? clamp(detail.trim())
+ : fromStatus(500),
+ });
+ }
+
+ if (!response.ok) throw new ContentUploadError(await readRejection(response));
+
+ // Parsed rather than cast: this value goes straight into the form, and the
+ // descriptor is the one shape every surface agrees on.
+ return zodContentFileDescriptor.parse(await response.json());
+};
diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts
index 2a360ef8f..850ee4c99 100644
--- a/packages/vitnode/src/content/const.ts
+++ b/packages/vitnode/src/content/const.ts
@@ -126,6 +126,73 @@ const localizedFieldKinds: ReadonlySet = new Set(
export const isLocalizableFieldKind = (kind: string): boolean =>
localizedFieldKinds.has(kind);
+// ---------------------------------------------------------------------------
+// File fields
+// ---------------------------------------------------------------------------
+
+/**
+ * A normalised extension rule: a leading dot, then lowercase letters or digits.
+ *
+ * One segment only, because `getFileExtension` reads one - a rule spelled
+ * `.tar.gz` would never match a file called `archive.tar.gz`, which is a silent
+ * "nothing is allowed" rather than the strict allowlist somebody wrote.
+ */
+export const CONTENT_FILE_EXTENSION_PATTERN = /^\.[a-z0-9]+$/;
+
+/** `type/subtype`, lowercased. Parameters (`; charset=`) are not a file type. */
+export const CONTENT_FILE_MIME_PATTERN =
+ /^[a-z0-9][a-z0-9!#$&^_+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
+
+/**
+ * The storage folder every Content Engine upload lands in.
+ *
+ * One folder rather than one per content type: the folder is a path segment in
+ * the object key, and a content type id holds a dot - which `sanitizeFolder`
+ * refuses, for good reason. Ownership is already recorded on the `core_files`
+ * row, so the key does not have to carry it.
+ */
+export const CONTENT_FILE_FOLDER = "content";
+
+/**
+ * Machine-readable reasons a file was refused - at upload, and again on save.
+ *
+ * One list for both, because they are the same four questions asked twice: the
+ * upload route asks them of the file in the request, and a content mutation asks
+ * them of the `core_files` row an identifier names. A client that can act on
+ * "too big" at upload time can act on it either way.
+ */
+export const CONTENT_FILE_CODES = {
+ extension: "CONTENT_FILE_EXTENSION_NOT_ALLOWED",
+ /** The role may view this content type but not write it. */
+ forbidden: "CONTENT_FILE_FORBIDDEN",
+ /** The bytes were unreadable - a truncated or corrupt image. */
+ invalid: "CONTENT_FILE_INVALID",
+ mimeType: "CONTENT_FILE_MIME_TYPE_NOT_ALLOWED",
+ missing: "CONTENT_FILE_NOT_FOUND",
+ size: "CONTENT_FILE_TOO_LARGE",
+ /**
+ * The install cannot store anything: no adapter configured, or the image
+ * pipeline failed to load.
+ *
+ * A configuration fault rather than a bad file, and the person uploading needs
+ * to be told which - "please try again" would have them try for ever.
+ */
+ storage: "CONTENT_FILE_STORAGE_UNAVAILABLE",
+ /** The URL named a field this content type does not have, or that is not a file. */
+ unknownField: "CONTENT_FILE_FIELD_UNKNOWN",
+ /**
+ * The image was read, and then could not be re-encoded - a limit of the target
+ * format rather than anything wrong with the file.
+ *
+ * WebP allows at most 16383 pixels per side, so an install with
+ * `storage.image.webp` refuses a 20000px-wide PNG here - a PNG that is
+ * entirely valid, and that resizing fixes. Separate from `invalid` because
+ * that one says "corrupt", and telling somebody their good file is corrupt
+ * sends them to re-export it instead of to resize it.
+ */
+ unprocessable: "CONTENT_FILE_UNPROCESSABLE",
+} as const;
+
/** Appended to the base table name to get the generated translation table. */
export const CONTENT_TRANSLATION_TABLE_SUFFIX = "_translations";
@@ -250,6 +317,12 @@ export const CONTENT_PUBLIC_EXPOSABLE_KINDS = [
"boolean",
"dateTime",
"enum",
+ // A file is exposable, and what crosses is the normalised descriptor rather
+ // than the `core_files.id` the column holds: an identifier is useless to a
+ // reader with no route to resolve it, while the descriptor is already the
+ // allowlisted shape. `user` stays absent - publishing a person is a decision
+ // `core_users` gets to make, not a side effect of an article having an author.
+ "file",
"number",
"relation",
"slug",
diff --git a/packages/vitnode/src/content/define-admin.ts b/packages/vitnode/src/content/define-admin.ts
index 60755eedf..d659aa664 100644
--- a/packages/vitnode/src/content/define-admin.ts
+++ b/packages/vitnode/src/content/define-admin.ts
@@ -284,6 +284,40 @@ export const resolveAdmin = (
new Set(columnFieldNames),
);
+ // A file column holds a `core_files.id`, so an `ORDER BY` on it sorts by upload
+ // order - a fact about the files table, not about the records, and one that
+ // moves whenever a file is replaced. The same reasoning rules it out as a title
+ // or a colour: both are things a person reads off a row, and an integer is
+ // neither.
+ const fileColumn = (label: string, names: readonly string[]): void => {
+ const found = names.find(name => fields[name]?.kind === "file");
+ if (found === undefined) return;
+
+ throw new ContentEngineError(
+ `${label} names the file field "${found}". Its column holds a \`core_files.id\`, which is an upload order rather than anything anybody chose - it can be shown as a cell, but not ordered by, titled by or coloured by.`,
+ { contentTypeId: id },
+ );
+ };
+ fileColumn("admin.list.orderableFields", orderableFields);
+ fileColumn(
+ "admin.list.defaultOrderBy",
+ admin.list?.defaultOrderBy === undefined
+ ? []
+ : [String(admin.list.defaultOrderBy)],
+ );
+ fileColumn(
+ "admin.titleField",
+ admin.titleField === undefined || admin.titleField === null
+ ? []
+ : [String(admin.titleField)],
+ );
+ fileColumn(
+ "admin.colorField",
+ admin.colorField === undefined || admin.colorField === null
+ ? []
+ : [admin.colorField],
+ );
+
// A published/draft badge is the first thing anyone looks for, so it leads
// the default column list. Advanced fields are absent by default: a to-many
// relation and a repeatable are each an extra query, and defaulting them into
diff --git a/packages/vitnode/src/content/define-fields.ts b/packages/vitnode/src/content/define-fields.ts
index 412bbecb4..e1cfc3c99 100644
--- a/packages/vitnode/src/content/define-fields.ts
+++ b/packages/vitnode/src/content/define-fields.ts
@@ -2,6 +2,7 @@ import type {
AnyContentTypeDefinition,
ContentFieldDescriptor,
ContentFieldMap,
+ ContentFileField,
} from "./types";
import {
@@ -14,6 +15,11 @@ import {
systemFields,
} from "./define-shared";
import { ContentEngineError } from "./errors";
+import {
+ assertContentFileMaxBytes,
+ normalizeContentFileExtensions,
+ normalizeContentFileMimeTypes,
+} from "./files";
/** A slug can only be derived from a field that holds a single line of text. */
const SLUG_SOURCE_KINDS = new Set(["text"]);
@@ -35,6 +41,10 @@ const hasWritableFallback = (fieldValue: ContentFieldDescriptor): boolean => {
// A sourced slug has no column default and is not required, but it is always
// writable: the service derives it from the source field.
if (fieldValue.kind === "slug") return fieldValue.source !== undefined;
+ // A file has no default and cannot have one: a column default would be a
+ // `core_files.id` written into the definition, pointing at a row that exists on
+ // one installation and not on the next. `assertField` says so in words.
+ if (fieldValue.kind === "file") return false;
return fieldValue.defaultValue !== undefined;
};
@@ -78,6 +88,7 @@ const FIELD_KINDS = new Set([
"boolean",
"dateTime",
"enum",
+ "file",
"group",
"number",
"relation",
@@ -102,11 +113,93 @@ export const assertFieldKind = (
}
};
+/**
+ * Everything a `field.file()` descriptor has to satisfy, re-checked here.
+ *
+ * `field.file` already normalises and validates - this is the same rules applied
+ * to a descriptor that skipped the builder, and the only place the error carries
+ * the content type id. The normalisers are idempotent, so running them twice
+ * costs nothing and proves the stored arrays really are normalised: a hand-built
+ * `{ kind: "file", allowedExtensions: ["GIF"] }` would otherwise be compared
+ * against `.gif` and match nothing.
+ */
+const assertFileField = (
+ id: string,
+ name: string,
+ fieldValue: ContentFileField,
+): void => {
+ const withField = (run: () => void): void => {
+ try {
+ run();
+ } catch (error) {
+ throw new ContentEngineError(
+ `Field "${name}": ${error instanceof Error ? error.message.replace("[Content Engine] ", "") : String(error)}`,
+ { contentTypeId: id },
+ );
+ }
+ };
+
+ withField(() => {
+ assertContentFileMaxBytes(fieldValue.maxBytes);
+ });
+
+ if (fieldValue.allowedExtensions !== undefined) {
+ withField(() => {
+ const normalized = normalizeContentFileExtensions(
+ fieldValue.allowedExtensions ?? [],
+ );
+ if (normalized.join(",") !== fieldValue.allowedExtensions?.join(",")) {
+ throw new ContentEngineError(
+ `allowedExtensions is not normalised (${fieldValue.allowedExtensions?.join(", ")}). Build the field with \`field.file()\`, which lowercases and dot-prefixes every entry.`,
+ );
+ }
+ });
+ }
+
+ if (fieldValue.allowedMimeTypes !== undefined) {
+ withField(() => {
+ const normalized = normalizeContentFileMimeTypes(
+ fieldValue.allowedMimeTypes ?? [],
+ );
+ if (normalized.join(",") !== fieldValue.allowedMimeTypes?.join(",")) {
+ throw new ContentEngineError(
+ `allowedMimeTypes is not normalised (${fieldValue.allowedMimeTypes?.join(", ")}). Build the field with \`field.file()\`, which lowercases every entry.`,
+ );
+ }
+ });
+ }
+
+ // Refused here rather than only by `resolveContentLocalization`, so the message
+ // names the reason instead of listing the kinds that may be localized: one file
+ // per language would need a translation column holding a foreign key, a
+ // per-locale upload route and a per-locale deletion rule - and a cover image is
+ // one image whatever language the caption is in. Translate the *alt text*.
+ if (fieldValue.localized === true) {
+ throw new ContentEngineError(
+ `File field "${name}" is \`localized: true\`, which is not supported. A file is one object with one storage key; pair it with a localized text field for the alt text or caption instead.`,
+ { contentTypeId: id },
+ );
+ }
+
+ if (!fieldValue.required && !fieldValue.nullable) {
+ throw new ContentEngineError(
+ `File field "${name}" is neither required nor nullable, and a file field can have no default - a \`core_files.id\` baked into a definition would point at a different row on every installation. Add \`nullable: true\` (the builder's default) or \`required: true\`.`,
+ { contentTypeId: id },
+ );
+ }
+};
+
export const assertField = (
id: string,
name: string,
fieldValue: ContentFieldDescriptor,
): void => {
+ // Before the generic writability check below, which would otherwise tell the
+ // author to add a default value a file field structurally cannot have.
+ if (fieldValue.kind === "file") {
+ assertFileField(id, name, fieldValue);
+ }
+
if (!fieldValue.required && !fieldValue.nullable) {
if (!hasWritableFallback(fieldValue)) {
throw new ContentEngineError(
diff --git a/packages/vitnode/src/content/define-public-api.ts b/packages/vitnode/src/content/define-public-api.ts
index 2f4695f0c..52b0db391 100644
--- a/packages/vitnode/src/content/define-public-api.ts
+++ b/packages/vitnode/src/content/define-public-api.ts
@@ -288,6 +288,18 @@ export const resolvePublicApi = (
{ contentTypeId: id },
);
}
+ // A file column holds a `core_files.id`, so ordering by one orders by upload
+ // order - a fact about the files table rather than about the records, and one
+ // that changes meaning the moment a file is replaced. Order by `publishedAt`.
+ const fileOrderable = declaredOrderable.find(
+ name => resolveFieldTarget(fields, name)?.descriptor.kind === "file",
+ );
+ if (fileOrderable !== undefined) {
+ throw new ContentEngineError(
+ `publicApi.orderableFields includes the file field "${fileOrderable}". Its column holds a \`core_files.id\`, so ordering by it orders by when the file happened to be uploaded - which says nothing about the records and changes when a file is replaced.`,
+ { contentTypeId: id },
+ );
+ }
// A localized column is not on the base table, and ordering by one would not
// just be awkward to generate - it would be wrong. The list a reader pages
// through would reshuffle itself for every language, and a fallback set would
diff --git a/packages/vitnode/src/content/fields.ts b/packages/vitnode/src/content/fields.ts
index 73dfb6a45..17f4fd72f 100644
--- a/packages/vitnode/src/content/fields.ts
+++ b/packages/vitnode/src/content/fields.ts
@@ -3,6 +3,7 @@ import type {
ContentBooleanField,
ContentDateTimeField,
ContentEnumField,
+ ContentFileField,
ContentGroupField,
ContentNumberField,
ContentOnDelete,
@@ -16,6 +17,11 @@ import type {
} from "./types";
import { ContentEngineError } from "./errors";
+import {
+ assertContentFileMaxBytes,
+ normalizeContentFileExtensions,
+ normalizeContentFileMimeTypes,
+} from "./files";
interface SharedArgs<
TRequired extends boolean = false,
@@ -197,6 +203,74 @@ const dateTime = <
kind: "dateTime",
});
+/**
+ * One stored file, referenced by its `core_files` row.
+ *
+ * ```ts
+ * coverImage: field.file({
+ * maxBytes: 5 * 1024 * 1024,
+ * allowedExtensions: [".jpg", ".jpeg", ".png", ".webp", ".avif"],
+ * allowedMimeTypes: ["image/jpeg", "image/png", "image/webp", "image/avif"],
+ * })
+ * ```
+ *
+ * The column is an `integer` foreign key with `ON DELETE RESTRICT`, so Postgres
+ * itself refuses to delete a file an article still points at. Nothing about the
+ * file - not the name, not the URL, not the storage key - is copied onto the
+ * content row: one fact, in one place.
+ *
+ * **`maxBytes` is required.** There is no unlimited Content Engine file field:
+ * the ceiling is the only thing between a form and an upload that fills the
+ * disk, and a default would be a number nobody chose applied to every field in
+ * every plugin. It is checked here, at definition time, so a bad value is an
+ * import-time error rather than a request that succeeds until it does not.
+ *
+ * `allowedExtensions` and `allowedMimeTypes` are **two** rules, and a strict
+ * field states both: the first is what the file is *called*, the second is what
+ * the client *declared* the bytes are. Both have to match, so `picture.gif`
+ * carrying `image/png` is refused by a GIF-only field - which is precisely the
+ * case an extension-only check waves through. Extensions are normalised, so
+ * `GIF`, `.gif` and `.Gif` are one rule.
+ *
+ * `nullable` defaults to **true**, like `field.user`: a cover image is something
+ * a record may not have yet, and a `NOT NULL` file column would mean no article
+ * can exist before somebody uploads one. Pass `nullable: false` with
+ * `required: true` for a field that genuinely must carry a file.
+ *
+ * There is no `localized` argument and no `multiple` one. A per-language file is
+ * out of scope (`localized: true` is refused at definition time); a gallery is a
+ * different feature with its own ordering and its own deletion rules.
+ */
+const file = <
+ TRequired extends boolean = false,
+ TNullable extends boolean = true,
+>(
+ args: SharedArgs & {
+ allowedExtensions?: readonly string[];
+ allowedMimeTypes?: readonly string[];
+ maxBytes: number;
+ },
+): ContentFileField => {
+ // Destructured rather than spread: the arguments accept `readonly string[]` so
+ // an `as const` list is ergonomic, and the descriptor stores the normalised
+ // mutable copy. Spreading `args` would carry the readonly originals through.
+ const { allowedExtensions, allowedMimeTypes, maxBytes, ...rest } = args;
+
+ return {
+ ...rest,
+ nullable: (args.nullable ?? true) as TNullable,
+ required: (args.required ?? false) as TRequired,
+ ...(allowedExtensions
+ ? { allowedExtensions: normalizeContentFileExtensions(allowedExtensions) }
+ : {}),
+ ...(allowedMimeTypes
+ ? { allowedMimeTypes: normalizeContentFileMimeTypes(allowedMimeTypes) }
+ : {}),
+ kind: "file",
+ maxBytes: assertContentFileMaxBytes(maxBytes),
+ };
+};
+
/**
* A reference to a VitNode user.
*
@@ -431,6 +505,7 @@ export const field = {
boolean,
dateTime,
enum: enumField,
+ file,
group,
number,
relation,
diff --git a/packages/vitnode/src/content/file-field.test.ts b/packages/vitnode/src/content/file-field.test.ts
new file mode 100644
index 000000000..7320d128f
--- /dev/null
+++ b/packages/vitnode/src/content/file-field.test.ts
@@ -0,0 +1,355 @@
+// @vitest-environment node
+import { getTableName } from "drizzle-orm";
+import { getTableConfig } from "drizzle-orm/pg-core";
+import { describe, expect, it } from "vitest";
+
+import { core_files } from "@/database/files";
+
+import { buildContentFormSpec, buildFormSchemaFromSpec } from "./admin/spec";
+import { defineContentType } from "./define";
+import { field } from "./fields";
+import { createContentTable } from "./server/table";
+
+/**
+ * A definition builder for the *rejection* cases only.
+ *
+ * The arguments are deliberately loose - every case below asserts on the error
+ * `defineContentType` throws, so nothing reads the result and precise inference
+ * would only be in the way. `articleType` is declared directly for that reason.
+ */
+const articleWith = (
+ fields: Parameters[0]["fields"],
+ extra: Partial[0]> = {},
+) =>
+ defineContentType({
+ id: "example.file-article",
+ tableName: "example_file_articles",
+ fields,
+ ...extra,
+ });
+
+const coverImage = field.file({
+ maxBytes: 5 * 1024 * 1024,
+ allowedExtensions: [".jpg", ".jpeg", ".png", ".webp", ".avif"],
+ allowedMimeTypes: ["image/jpeg", "image/png", "image/webp", "image/avif"],
+});
+
+const articleType = defineContentType({
+ id: "example.file-article",
+ tableName: "example_file_articles",
+ fields: {
+ title: field.text({ required: true }),
+ coverImage,
+ },
+});
+
+describe("the generated column", () => {
+ const config = getTableConfig(createContentTable(articleType));
+
+ it("is a nullable integer, not a URL or a key", () => {
+ const column = config.columns.find(item => item.name === "coverImage");
+
+ expect(column?.getSQLType()).toBe("integer");
+ expect(column?.notNull).toBe(false);
+ // Nothing about the file is copied onto the row.
+ expect(config.columns.map(item => item.name)).not.toContain(
+ "coverImageUrl",
+ );
+ expect(config.columns.map(item => item.name)).not.toContain(
+ "coverImageKey",
+ );
+ });
+
+ it("references core_files with ON DELETE RESTRICT", () => {
+ const [foreignKey] = config.foreignKeys
+ .map(fk => {
+ const reference = fk.reference();
+
+ return {
+ columns: reference.columns.map(item => item.name),
+ onDelete: fk.onDelete,
+ onUpdate: fk.onUpdate,
+ table: getTableName(reference.foreignTable),
+ targets: reference.foreignColumns.map(item => item.name),
+ };
+ })
+ .filter(fk => fk.columns.includes("coverImage"));
+
+ expect(foreignKey).toEqual({
+ columns: ["coverImage"],
+ // The whole deletion-safety story: Postgres refuses, so
+ // `StorageModel.deleteFile` can answer 409 instead of orphaning a record.
+ onDelete: "restrict",
+ onUpdate: "cascade",
+ table: getTableName(core_files),
+ targets: ["id"],
+ });
+ });
+
+ it("is indexed, because RESTRICT scans the child side on every delete", () => {
+ expect(config.indexes.map(item => item.config.name)).toContain(
+ "example_file_articles_cover_image_idx",
+ );
+ });
+
+ it("needs no `references` entry - the engine resolves core_files itself", () => {
+ // A `relation` demands a thunk; a `file` has exactly one possible target.
+ expect(() => createContentTable(articleType)).not.toThrow();
+ });
+});
+
+describe("what a file field may not be", () => {
+ it("rejects `localized: true`", () => {
+ expect(() =>
+ articleWith(
+ {
+ title: field.text({ localized: true, required: true }),
+ cover: {
+ ...coverImage,
+ localized: true,
+ } as unknown as typeof coverImage,
+ },
+ { localization: { enabled: true, defaultLocale: "en" } },
+ ),
+ ).toThrow(/`localized: true`, which is not supported/);
+ });
+
+ it("rejects it as an orderable column", () => {
+ expect(() =>
+ articleWith(
+ { title: field.text({ required: true }), coverImage },
+ { admin: { list: { orderableFields: ["coverImage"] } } },
+ ),
+ ).toThrow(/admin\.list\.orderableFields names the file field/);
+ });
+
+ it("rejects it as the default ordering", () => {
+ expect(() =>
+ articleWith(
+ { title: field.text({ required: true }), coverImage },
+ { admin: { list: { defaultOrderBy: "coverImage" } } },
+ ),
+ ).toThrow(/admin\.list\.defaultOrderBy names the file field/);
+ });
+
+ it("rejects it as the title or the colour", () => {
+ expect(() =>
+ articleWith(
+ { title: field.text({ required: true }), coverImage },
+ { admin: { titleField: "coverImage" } },
+ ),
+ ).toThrow(/admin\.titleField names the file field/);
+ expect(() =>
+ articleWith(
+ { title: field.text({ required: true }), coverImage },
+ { admin: { colorField: "coverImage" } },
+ ),
+ ).toThrow(/admin\.colorField names the file field/);
+ });
+
+ it("rejects it as a searchable column", () => {
+ expect(() =>
+ articleWith(
+ { title: field.text({ required: true }), coverImage },
+ { admin: { list: { searchableFields: ["coverImage"] } } },
+ ),
+ ).toThrow(/not a text, textarea or slug field/);
+ });
+
+ it("rejects it as a group or repeatable leaf", () => {
+ expect(() =>
+ articleWith({
+ title: field.text({ required: true }),
+ seo: field.group({
+ fields: { cover: coverImage as never },
+ }),
+ }),
+ ).toThrow(/file/);
+ });
+
+ it("rejects it as a public filter or sort", () => {
+ const publicApi = {
+ enabled: true as const,
+ path: "articles",
+ fields: ["slug", "coverImage", "publishedAt"] as never,
+ };
+
+ expect(() =>
+ articleWith(
+ {
+ title: field.text({ required: true }),
+ slug: field.slug({ source: "title" }),
+ coverImage,
+ },
+ {
+ publication: { enabled: true },
+ publicApi: { ...publicApi, orderableFields: ["coverImage"] as never },
+ },
+ ),
+ ).toThrow(/orderableFields includes the file field/);
+
+ expect(() =>
+ articleWith(
+ {
+ title: field.text({ required: true }),
+ slug: field.slug({ source: "title" }),
+ coverImage,
+ },
+ {
+ publication: { enabled: true },
+ publicApi: {
+ ...publicApi,
+ filterableFields: ["coverImage"] as never,
+ },
+ },
+ ),
+ ).toThrow(/not an equality-filterable field/);
+ });
+});
+
+describe("the form spec", () => {
+ const spec = buildContentFormSpec({
+ definition: articleType,
+ labelEnum: (name, value) => value,
+ labelField: name => name,
+ pluginId: "@vitnode/example",
+ });
+ const fileSpec = spec.fields.find(item => item.name === "coverImage");
+
+ /**
+ * The constraint line in the AdminCP, the `accept` attribute and the server's
+ * own check all read these three values. They come off one descriptor, so
+ * there is no second place for them to disagree.
+ */
+ it("carries the descriptor's own limits, normalised", () => {
+ expect(fileSpec).toMatchObject({
+ allowedExtensions: [".jpg", ".jpeg", ".png", ".webp", ".avif"],
+ allowedMimeTypes: ["image/jpeg", "image/png", "image/webp", "image/avif"],
+ kind: "file",
+ maxBytes: 5_242_880,
+ nullable: true,
+ required: false,
+ });
+ });
+
+ it("carries the module the upload route lives under", () => {
+ expect(spec.permissionModule).toBe(articleType.permissionModule);
+ });
+
+ it("never carries a default - a baked-in file id would be meaningless", () => {
+ expect(fileSpec?.defaultValue).toBeUndefined();
+ });
+
+ it("holds the identifier, so nothing binary is in the form schema", () => {
+ const schema = buildFormSchemaFromSpec(spec);
+
+ expect(schema.safeParse({ title: "Hi", coverImage: 42 }).success).toBe(
+ true,
+ );
+ expect(schema.safeParse({ title: "Hi", coverImage: null }).success).toBe(
+ true,
+ );
+ // A form with no cover chosen is valid: the field is nullable.
+ expect(schema.safeParse({ title: "Hi" }).success).toBe(true);
+ expect(schema.safeParse({ title: "Hi", coverImage: 0 }).success).toBe(
+ false,
+ );
+ expect(schema.safeParse({ title: "Hi", coverImage: -3 }).success).toBe(
+ false,
+ );
+ expect(
+ schema.safeParse({ title: "Hi", coverImage: "data:image/png;base64," })
+ .success,
+ ).toBe(false);
+ });
+
+ it("prefills the stored identifier when editing", () => {
+ const schema = buildFormSchemaFromSpec(spec, {
+ coverImage: 42,
+ title: "Hi",
+ });
+
+ expect(schema.parse({ title: "Hi" })).toMatchObject({ coverImage: 42 });
+ });
+});
+
+describe("the public projection", () => {
+ const publicType = defineContentType({
+ id: "example.public-file-article",
+ tableName: "example_public_file_articles",
+ fields: {
+ title: field.text({ required: true }),
+ slug: field.slug({ source: "title" }),
+ coverImage,
+ },
+ publication: { enabled: true },
+ publicApi: {
+ enabled: true,
+ path: "public-file-articles",
+ fields: ["slug", "coverImage", "publishedAt"],
+ },
+ });
+
+ it("exposes the normalised descriptor rather than the identifier", () => {
+ const parsed = publicType.schemas.publicSelect.safeParse({
+ coverImage: {
+ height: 900,
+ id: 42,
+ mimeType: "image/webp",
+ name: "cover.webp",
+ size: 245123,
+ url: "https://cdn.test/cover.webp",
+ width: 1600,
+ },
+ publishedAt: new Date(0),
+ slug: "hello",
+ });
+
+ expect(parsed.success).toBe(true);
+ });
+
+ it("refuses the identifier on its own", () => {
+ expect(
+ publicType.schemas.publicSelect.safeParse({
+ coverImage: 42,
+ publishedAt: new Date(0),
+ slug: "hello",
+ }).success,
+ ).toBe(false);
+ });
+
+ it("refuses the storage key, the uploader and the metadata bag", () => {
+ for (const leak of [
+ { key: "month_8_2026/content/x.webp" },
+ { userId: 7 },
+ { pluginId: "@vitnode/blog" },
+ { metadata: { dimensions: { height: 1, width: 1 } } },
+ { folder: "content" },
+ ]) {
+ expect(
+ publicType.schemas.publicSelect.safeParse({
+ coverImage: {
+ id: 42,
+ mimeType: "image/webp",
+ name: "cover.webp",
+ size: 1,
+ url: "https://cdn.test/cover.webp",
+ ...leak,
+ },
+ publishedAt: new Date(0),
+ slug: "hello",
+ }).success,
+ ).toBe(false);
+ }
+ });
+
+ it("accepts null for a nullable file field", () => {
+ expect(
+ publicType.schemas.publicSelect.safeParse({
+ coverImage: null,
+ publishedAt: new Date(0),
+ slug: "hello",
+ }).success,
+ ).toBe(true);
+ });
+});
diff --git a/packages/vitnode/src/content/files.test.ts b/packages/vitnode/src/content/files.test.ts
new file mode 100644
index 000000000..9ae9a6281
--- /dev/null
+++ b/packages/vitnode/src/content/files.test.ts
@@ -0,0 +1,402 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ fileAcceptAttribute,
+ fileFormatLabels,
+ validateFile,
+} from "../lib/file-constraints";
+import { formatBytes } from "../lib/format-bytes";
+import { defineContentType } from "./define";
+import { field } from "./fields";
+import {
+ assertContentFileMaxBytes,
+ contentFileAccept,
+ contentFileConstraints,
+ contentFileFormatLabels,
+ normalizeContentFileExtension,
+ normalizeContentFileExtensions,
+ normalizeContentFileMimeTypes,
+ validateContentFile,
+} from "./files";
+
+const gif = {
+ allowedExtensions: [".gif"],
+ allowedMimeTypes: ["image/gif"],
+ maxBytes: 10 * 1024 * 1024,
+};
+
+describe("field.file - maxBytes", () => {
+ it("is required at definition time", () => {
+ // `field.file({})` is a compile error too - `maxBytes` is not optional on the
+ // argument type - so this is the JavaScript caller and the widened value.
+ expect(() => field.file({} as unknown as { maxBytes: number })).toThrow(
+ /needs `maxBytes`/,
+ );
+ });
+
+ it("rejects zero and negative ceilings", () => {
+ for (const maxBytes of [0, -1, -1024]) {
+ expect(() => field.file({ maxBytes })).toThrow(
+ /must be greater than zero/,
+ );
+ }
+ });
+
+ it("rejects a non-integer or non-finite ceiling", () => {
+ expect(() => field.file({ maxBytes: 1.5 })).toThrow(
+ /not a whole number of bytes/,
+ );
+ expect(() => field.file({ maxBytes: Number.POSITIVE_INFINITY })).toThrow(
+ /needs `maxBytes`/,
+ );
+ expect(() => field.file({ maxBytes: Number.NaN })).toThrow(
+ /needs `maxBytes`/,
+ );
+ });
+
+ it("accepts a positive integer and stores it verbatim", () => {
+ expect(field.file({ maxBytes: 5 * 1024 * 1024 }).maxBytes).toBe(5_242_880);
+ expect(assertContentFileMaxBytes(1)).toBe(1);
+ });
+
+ it("leaves no way to declare an unlimited file field", () => {
+ // Every route out is covered above; this states the intent so a future
+ // `maxBytes?: number` has to delete a test rather than slip through.
+ const descriptor = field.file({ maxBytes: 1 });
+
+ expect(typeof descriptor.maxBytes).toBe("number");
+ expect(descriptor.maxBytes).toBeGreaterThan(0);
+ });
+});
+
+describe("field.file - nullability", () => {
+ it("defaults to nullable, because a record may not have a file yet", () => {
+ expect(field.file({ maxBytes: 1 }).nullable).toBe(true);
+ expect(field.file({ maxBytes: 1 }).required).toBe(false);
+ });
+
+ it("is refused when neither required nor nullable", () => {
+ expect(() =>
+ defineContentType({
+ id: "example.no-fallback",
+ tableName: "example_no_fallback",
+ fields: {
+ title: field.text({ required: true }),
+ cover: field.file({ maxBytes: 1, nullable: false }),
+ },
+ }),
+ ).toThrow(/neither required nor nullable/);
+ });
+});
+
+describe("allowedExtensions normalization", () => {
+ it("folds .gif, GIF and .GIF onto one rule", () => {
+ expect(normalizeContentFileExtension(".gif")).toBe(".gif");
+ expect(normalizeContentFileExtension("GIF")).toBe(".gif");
+ expect(normalizeContentFileExtension(".Gif")).toBe(".gif");
+ expect(normalizeContentFileExtension(" .GIF ")).toBe(".gif");
+ });
+
+ it("deduplicates the folded rules", () => {
+ expect(normalizeContentFileExtensions(["GIF", ".gif", ".Gif"])).toEqual([
+ ".gif",
+ ]);
+ });
+
+ it("keeps declaration order", () => {
+ expect(
+ field.file({
+ maxBytes: 1,
+ allowedExtensions: [".JPG", "jpeg", ".png"],
+ }).allowedExtensions,
+ ).toEqual([".jpg", ".jpeg", ".png"]);
+ });
+
+ it("rejects an empty string, a bare dot and whitespace", () => {
+ for (const value of ["", ".", " ", "..", ". "]) {
+ expect(() => normalizeContentFileExtension(value)).toThrow(
+ /not a file extension/,
+ );
+ }
+ });
+
+ it("rejects a compound extension, which would match nothing", () => {
+ // `getFileExtension("archive.tar.gz")` is `.gz`, so a `.tar.gz` rule would be
+ // an allowlist that silently refuses every file.
+ expect(() => normalizeContentFileExtension(".tar.gz")).toThrow(
+ /not a file extension/,
+ );
+ });
+
+ it("rejects anything that is not a string", () => {
+ for (const value of [1, null, undefined, {}, ["a"]]) {
+ expect(() => normalizeContentFileExtension(value)).toThrow(
+ /must be a string/,
+ );
+ }
+ });
+
+ it("rejects an empty list, which would refuse every file", () => {
+ expect(() => normalizeContentFileExtensions([])).toThrow(
+ /would refuse every file/,
+ );
+ expect(() => field.file({ maxBytes: 1, allowedExtensions: [] })).toThrow(
+ /would refuse every file/,
+ );
+ });
+
+ it("leaves the option absent when it is not declared", () => {
+ expect(field.file({ maxBytes: 1 }).allowedExtensions).toBeUndefined();
+ });
+});
+
+describe("allowedMimeTypes normalization", () => {
+ it("lowercases and trims", () => {
+ expect(normalizeContentFileMimeTypes([" Image/GIF "])).toEqual([
+ "image/gif",
+ ]);
+ });
+
+ it("rejects wildcards, parameters and malformed types", () => {
+ for (const value of [
+ "image/*",
+ "*/*",
+ "image",
+ "image/gif; charset=utf-8",
+ "",
+ "/gif",
+ "image/",
+ ]) {
+ expect(() => normalizeContentFileMimeTypes([value])).toThrow(
+ /not a media type/,
+ );
+ }
+ });
+
+ it("rejects an empty list", () => {
+ expect(() => normalizeContentFileMimeTypes([])).toThrow(
+ /would refuse every file/,
+ );
+ });
+});
+
+describe("validateContentFile", () => {
+ it("accepts a file that satisfies every rule", () => {
+ expect(
+ validateContentFile(gif, {
+ mimeType: "image/gif",
+ name: "banner.gif",
+ size: 1024,
+ }),
+ ).toBeNull();
+ });
+
+ it("refuses a file over maxBytes", () => {
+ const rejection = validateContentFile(gif, {
+ mimeType: "image/gif",
+ name: "banner.gif",
+ size: gif.maxBytes + 1,
+ });
+
+ expect(rejection?.code).toBe("CONTENT_FILE_TOO_LARGE");
+ // The message carries both sizes in human units, because "10485760 bytes" is
+ // not a sentence anybody can act on.
+ expect(rejection?.message).toContain("10 MB");
+ });
+
+ it("accepts a file exactly at maxBytes", () => {
+ expect(
+ validateContentFile(gif, {
+ mimeType: "image/gif",
+ name: "banner.gif",
+ size: gif.maxBytes,
+ }),
+ ).toBeNull();
+ });
+
+ it("refuses a disallowed media type", () => {
+ expect(
+ validateContentFile(gif, {
+ mimeType: "image/png",
+ name: "banner.gif",
+ size: 10,
+ })?.code,
+ ).toBe("CONTENT_FILE_MIME_TYPE_NOT_ALLOWED");
+ });
+
+ it("refuses a disallowed extension", () => {
+ expect(
+ validateContentFile(gif, {
+ mimeType: "image/gif",
+ name: "banner.png",
+ size: 10,
+ })?.code,
+ ).toBe("CONTENT_FILE_EXTENSION_NOT_ALLOWED");
+ });
+
+ /**
+ * The case an extension-only check waves through, and the reason a strict field
+ * states both lists: a PNG renamed to `.gif` still declares `image/png`.
+ */
+ it("refuses a file whose extension matches but whose media type does not", () => {
+ expect(
+ validateContentFile(gif, {
+ mimeType: "image/png",
+ name: "picture.gif",
+ size: 10,
+ })?.code,
+ ).toBe("CONTENT_FILE_MIME_TYPE_NOT_ALLOWED");
+ });
+
+ it("refuses a file whose media type matches but whose extension does not", () => {
+ expect(
+ validateContentFile(gif, {
+ mimeType: "image/gif",
+ name: "picture.png",
+ size: 10,
+ })?.code,
+ ).toBe("CONTENT_FILE_EXTENSION_NOT_ALLOWED");
+ });
+
+ it("compares case-insensitively on both sides", () => {
+ expect(
+ validateContentFile(gif, {
+ mimeType: "IMAGE/GIF",
+ name: "BANNER.GIF",
+ size: 10,
+ }),
+ ).toBeNull();
+ });
+
+ it("treats a missing media type as not allowed when a list is declared", () => {
+ expect(
+ validateContentFile(gif, {
+ mimeType: null,
+ name: "banner.gif",
+ size: 10,
+ })?.code,
+ ).toBe("CONTENT_FILE_MIME_TYPE_NOT_ALLOWED");
+ });
+
+ it("checks only what is declared", () => {
+ // Size alone: any name, any type.
+ const sizeOnly = { maxBytes: 100 };
+
+ expect(
+ validateContentFile(sizeOnly, {
+ mimeType: null,
+ name: "whatever",
+ size: 100,
+ }),
+ ).toBeNull();
+ expect(
+ validateContentFile(sizeOnly, {
+ mimeType: null,
+ name: "whatever",
+ size: 101,
+ })?.code,
+ ).toBe("CONTENT_FILE_TOO_LARGE");
+ });
+});
+
+describe("contentFileAccept", () => {
+ it("lists extensions and media types together", () => {
+ expect(contentFileAccept(gif)).toBe(".gif,image/gif");
+ });
+
+ it("is undefined when the field constrains neither", () => {
+ expect(contentFileAccept({ maxBytes: 1 })).toBeUndefined();
+ });
+
+ it("is built from the same descriptor the server validates against", () => {
+ const descriptor = field.file({
+ maxBytes: 10,
+ allowedExtensions: ["GIF"],
+ allowedMimeTypes: ["Image/GIF"],
+ });
+
+ expect(contentFileAccept(contentFileConstraints(descriptor))).toBe(
+ ".gif,image/gif",
+ );
+ });
+});
+
+describe("one implementation of the rules", () => {
+ /**
+ * The drift guard for requirement "the UI constraints come from the same field
+ * spec the server validates against".
+ *
+ * `AutoFormFile` imports `lib/file-constraints` and the Content Engine
+ * re-exports the very same functions, so a rule cannot be changed on one side.
+ * Identity, not behaviour: a second implementation that happened to agree today
+ * is exactly what this exists to refuse.
+ */
+ it("shares the accept and format helpers with the form field", () => {
+ expect(contentFileAccept).toBe(fileAcceptAttribute);
+ expect(contentFileFormatLabels).toBe(fileFormatLabels);
+ });
+
+ it("maps the shared rejection reason onto the engine's own code", () => {
+ const shared = validateFile(gif, {
+ mimeType: "image/png",
+ name: "x.gif",
+ size: 1,
+ });
+ const content = validateContentFile(gif, {
+ mimeType: "image/png",
+ name: "x.gif",
+ size: 1,
+ });
+
+ expect(shared?.reason).toBe("mimeType");
+ expect(content?.code).toBe("CONTENT_FILE_MIME_TYPE_NOT_ALLOWED");
+ // Same sentence, so the browser and the API tell the same story.
+ expect(content?.message).toBe(shared?.message);
+ });
+});
+
+describe("human-readable sizes", () => {
+ /** The units the constraint line actually shows people. */
+ it("reads as the field author wrote it", () => {
+ expect(formatBytes(512 * 1024)).toBe("512 KB");
+ expect(formatBytes(5 * 1024 * 1024)).toBe("5 MB");
+ expect(formatBytes(20 * 1024 * 1024)).toBe("20 MB");
+ expect(formatBytes(1024 * 1024 * 1024)).toBe("1 GB");
+ });
+});
+
+describe("contentFileFormatLabels", () => {
+ it("prefers extensions, uppercased and dot-free", () => {
+ expect(
+ contentFileFormatLabels({
+ allowedExtensions: [".jpg", ".jpeg", ".png", ".webp", ".avif"],
+ allowedMimeTypes: ["image/jpeg", "image/png"],
+ maxBytes: 1,
+ }),
+ ).toEqual(["JPG", "JPEG", "PNG", "WEBP", "AVIF"]);
+ });
+
+ it("never shows a raw media type when it has an extension to show", () => {
+ const labels = contentFileFormatLabels({
+ allowedExtensions: [".pdf"],
+ allowedMimeTypes: ["application/pdf"],
+ maxBytes: 1,
+ });
+
+ expect(labels).toEqual(["PDF"]);
+ expect(labels.join(",")).not.toContain("/");
+ });
+
+ it("falls back to the media subtype when there are no extensions", () => {
+ expect(
+ contentFileFormatLabels({
+ allowedMimeTypes: ["application/pdf", "image/gif"],
+ maxBytes: 1,
+ }),
+ ).toEqual(["PDF", "GIF"]);
+ });
+
+ it("is empty when the field constrains neither", () => {
+ expect(contentFileFormatLabels({ maxBytes: 1 })).toEqual([]);
+ });
+});
diff --git a/packages/vitnode/src/content/files.ts b/packages/vitnode/src/content/files.ts
new file mode 100644
index 000000000..674bd01bf
--- /dev/null
+++ b/packages/vitnode/src/content/files.ts
@@ -0,0 +1,285 @@
+import { z } from "zod";
+
+import type {
+ FileCandidate,
+ FileConstraints,
+ FileRejectionReason,
+} from "../lib/file-constraints";
+import type { ContentFileField } from "./types";
+
+import {
+ fileAcceptAttribute,
+ fileFormatLabels,
+ validateFile,
+} from "../lib/file-constraints";
+import {
+ CONTENT_FILE_CODES,
+ CONTENT_FILE_EXTENSION_PATTERN,
+ CONTENT_FILE_MIME_PATTERN,
+} from "./const";
+import { ContentEngineError } from "./errors";
+
+/**
+ * A stored file, as every surface is allowed to see it.
+ *
+ * The allowlist *is* the type: `key`, `userId`, `pluginId` and the raw
+ * `metadata` bag are absent, so a projection cannot leak the object's storage
+ * address or who uploaded it by forwarding "the file row". `width` and `height`
+ * are present only for an image the storage pipeline measured.
+ */
+export interface ContentFileDescriptor {
+ height?: number;
+ id: number;
+ mimeType: null | string;
+ name: string;
+ size: number;
+ url: string;
+ width?: number;
+}
+
+/**
+ * The response and projection schema for {@link ContentFileDescriptor}.
+ *
+ * `strictObject`, so a key added to `core_files` cannot reach a client by being
+ * spread into a descriptor somewhere: it would fail the parse the generated
+ * route runs, which is the loud version of a leak.
+ */
+export const zodContentFileDescriptor = z.strictObject({
+ height: z.number().int().positive().optional(),
+ id: z.number().int().positive(),
+ mimeType: z.string().nullable(),
+ name: z.string(),
+ size: z.number().int().nonnegative(),
+ url: z.string(),
+ width: z.number().int().positive().optional(),
+});
+
+export type ContentFileCode =
+ (typeof CONTENT_FILE_CODES)[keyof typeof CONTENT_FILE_CODES];
+
+/** Why a file was refused, in a shape both the upload and the save can answer. */
+export interface ContentFileRejection {
+ code: ContentFileCode;
+ /** Written for the person who picked the file - nothing internal in it. */
+ message: string;
+}
+
+/**
+ * The 400 body a **content write** answers when a file identifier is refused.
+ *
+ * `field` is what the upload route's own rejection does not need and this one
+ * cannot do without: an upload is for one named field already in the URL, while
+ * a save carries every field at once, so without it a form knows a file was
+ * refused but not which input to say so under.
+ *
+ * `code` is a plain string rather than an enum of the four a reference check can
+ * produce - `CONTENT_FILE_NOT_FOUND`, `CONTENT_FILE_TOO_LARGE`,
+ * `CONTENT_FILE_MIME_TYPE_NOT_ALLOWED`, `CONTENT_FILE_EXTENSION_NOT_ALLOWED` -
+ * for the same reason the upload route's is: a client that does not recognise a
+ * code shows `message`, so a new one must not break the parse it arrives in.
+ */
+export const zodContentFileReferenceRejection = z.strictObject({
+ code: z.string(),
+ field: z.string(),
+ message: z.string(),
+});
+
+/**
+ * One extension rule, normalised.
+ *
+ * `GIF`, `.gif` and `.Gif` all become `.gif`, so the rule an author writes and
+ * the extension a browser hands over are compared in one vocabulary. Anything
+ * that cannot be an extension is a definition-time error rather than a rule that
+ * silently matches nothing.
+ */
+export const normalizeContentFileExtension = (value: unknown): string => {
+ if (typeof value !== "string") {
+ throw new ContentEngineError(
+ `allowedExtensions holds ${typeof value === "object" ? "an object" : `a ${typeof value}`}. Every entry must be a string like ".gif".`,
+ );
+ }
+
+ const trimmed = value.trim().toLowerCase();
+ const dotted = trimmed.startsWith(".") ? trimmed : `.${trimmed}`;
+
+ if (!CONTENT_FILE_EXTENSION_PATTERN.test(dotted)) {
+ throw new ContentEngineError(
+ `allowedExtensions has the entry "${value}", which is not a file extension. Write one dot-prefixed segment of letters or digits, e.g. ".gif" - case does not matter, and a bare "gif" is accepted too.`,
+ );
+ }
+
+ return dotted;
+};
+
+/**
+ * Every extension rule, normalised and deduplicated.
+ *
+ * Deduplication is what makes `["GIF", ".gif"]` one rule rather than two - the
+ * author wrote the same thing twice, which is a typo rather than a decision.
+ * An **empty** list is refused: it reads as an allowlist and behaves as a
+ * blocklist of everything, and a field nobody can upload to is never what
+ * somebody meant.
+ */
+export const normalizeContentFileExtensions = (
+ values: readonly unknown[],
+): string[] => {
+ if (values.length === 0) {
+ throw new ContentEngineError(
+ "allowedExtensions is empty, which would refuse every file. Omit the option to allow any extension, or list the ones you mean.",
+ );
+ }
+
+ return [...new Set(values.map(normalizeContentFileExtension))];
+};
+
+/** One MIME rule, lowercased and checked for the `type/subtype` shape. */
+export const normalizeContentFileMimeType = (value: unknown): string => {
+ if (typeof value !== "string") {
+ throw new ContentEngineError(
+ `allowedMimeTypes holds ${typeof value === "object" ? "an object" : `a ${typeof value}`}. Every entry must be a string like "image/gif".`,
+ );
+ }
+
+ const normalized = value.trim().toLowerCase();
+
+ if (!CONTENT_FILE_MIME_PATTERN.test(normalized)) {
+ throw new ContentEngineError(
+ `allowedMimeTypes has the entry "${value}", which is not a media type. Write "type/subtype", e.g. "image/gif" - no wildcards and no parameters.`,
+ );
+ }
+
+ return normalized;
+};
+
+export const normalizeContentFileMimeTypes = (
+ values: readonly unknown[],
+): string[] => {
+ if (values.length === 0) {
+ throw new ContentEngineError(
+ "allowedMimeTypes is empty, which would refuse every file. Omit the option to allow any type, or list the ones you mean.",
+ );
+ }
+
+ return [...new Set(values.map(normalizeContentFileMimeType))];
+};
+
+/**
+ * Checks `maxBytes`, which every file field has to declare.
+ *
+ * There is deliberately no unlimited Content Engine file field: the ceiling is
+ * the only thing standing between a form and an upload that fills the disk, and
+ * a default would be a number nobody chose applied to every field in every
+ * plugin.
+ */
+export const assertContentFileMaxBytes = (value: unknown): number => {
+ if (typeof value !== "number" || !Number.isFinite(value)) {
+ throw new ContentEngineError(
+ "field.file() needs `maxBytes` - the largest upload it will accept, in bytes. There is no unlimited file field.",
+ );
+ }
+
+ if (!Number.isInteger(value)) {
+ throw new ContentEngineError(
+ `field.file() has \`maxBytes: ${value}\`, which is not a whole number of bytes.`,
+ );
+ }
+
+ if (value <= 0) {
+ throw new ContentEngineError(
+ `field.file() has \`maxBytes: ${value}\`. It must be greater than zero - a field that accepts nothing is not a field.`,
+ );
+ }
+
+ return value;
+};
+
+/**
+ * The three rules a file is checked against, whoever is asking.
+ *
+ * An alias rather than a second declaration: the rules live in
+ * `lib/file-constraints`, with no Content Engine behind them, because
+ * `AutoFormFile` checks the very same three things in the browser and a form
+ * field must not have to import the Content Engine to do it.
+ */
+export type ContentFileConstraints = FileConstraints;
+
+/** One file's identity, as either side of the wire can describe it. */
+export type ContentFileCandidate = FileCandidate;
+
+/**
+ * Checks a file against a field's constraints, or returns `null`.
+ *
+ * A thin mapping over {@link validateFile}, which is the **one** implementation
+ * of the rules - shared with `AutoFormFile`, so the browser's pre-flight check
+ * and the server's authoritative one cannot answer differently. All this adds is
+ * the machine-readable code, which is a Content Engine contract rather than a
+ * property of files.
+ */
+export const validateContentFile = (
+ constraints: ContentFileConstraints,
+ file: ContentFileCandidate,
+): ContentFileRejection | null => {
+ const rejection = validateFile(constraints, file);
+ if (!rejection) return null;
+
+ return {
+ code: CONTENT_FILE_CODES[rejection.reason],
+ message: rejection.message,
+ };
+};
+
+/**
+ * The `accept` attribute for a native file picker.
+ *
+ * UX only - see {@link fileAcceptAttribute}. The server validates the same three
+ * rules again, whatever a dialog let through.
+ */
+export const contentFileAccept = fileAcceptAttribute;
+
+/**
+ * The formats a field accepts, as somebody would say them out loud.
+ *
+ * `JPG, PNG, WEBP` rather than raw media types - see {@link fileFormatLabels},
+ * which the AdminCP constraint line reads through the very same function.
+ */
+export const contentFileFormatLabels = fileFormatLabels;
+
+/**
+ * The rule a rejection code came from, or `undefined`.
+ *
+ * The inverse of the mapping in {@link validateContentFile}, and it exists for
+ * the browser: a rejection that arrives over the wire is a code and an English
+ * sentence, and the uploader would rather render its *own* translated sentence -
+ * built from the field's own limits, which it already has.
+ *
+ * Anything the client cannot improve on comes back `undefined`, and the server's
+ * message is shown verbatim. That is the right default: "Storage provider not
+ * found" is far more use to an admin than any sentence this side could invent.
+ */
+export const contentFileRejectionReason = (
+ code: string,
+): FileRejectionReason | undefined => {
+ switch (code) {
+ case CONTENT_FILE_CODES.extension:
+ return "extension";
+ case CONTENT_FILE_CODES.mimeType:
+ return "mimeType";
+ case CONTENT_FILE_CODES.size:
+ return "size";
+ default:
+ return undefined;
+ }
+};
+
+/** The constraints of one descriptor, without the rest of it. */
+export const contentFileConstraints = (
+ fieldValue: ContentFileField,
+): ContentFileConstraints => ({
+ ...(fieldValue.allowedExtensions
+ ? { allowedExtensions: fieldValue.allowedExtensions }
+ : {}),
+ ...(fieldValue.allowedMimeTypes
+ ? { allowedMimeTypes: fieldValue.allowedMimeTypes }
+ : {}),
+ maxBytes: fieldValue.maxBytes,
+});
diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts
index 4686d50fc..918440a9c 100644
--- a/packages/vitnode/src/content/index.ts
+++ b/packages/vitnode/src/content/index.ts
@@ -34,6 +34,12 @@ export type {
ContentFormSpec,
ContentSectionLabeller,
} from "./admin/spec";
+export {
+ ContentUploadError,
+ contentUploadPath,
+ uploadContentFile,
+} from "./admin/upload";
+export type { ContentUploadRejection } from "./admin/upload";
export {
contentDeliveryRedirectTag,
contentDeliverySitemapTag,
@@ -91,6 +97,10 @@ export {
CONTENT_DELIVERY_TITLE_KINDS,
CONTENT_EDITORIAL_FIELDS,
CONTENT_ENUM_DEFAULT_LENGTH,
+ CONTENT_FILE_CODES,
+ CONTENT_FILE_EXTENSION_PATTERN,
+ CONTENT_FILE_FOLDER,
+ CONTENT_FILE_MIME_PATTERN,
CONTENT_FILTERABLE_FIELD_KINDS,
CONTENT_LOCALE_MAX_LENGTH,
CONTENT_LOCALE_PATTERN,
@@ -203,6 +213,26 @@ export type {
ContentUpdatedPayload,
} from "./events";
export { field } from "./fields";
+export {
+ assertContentFileMaxBytes,
+ contentFileAccept,
+ contentFileConstraints,
+ contentFileFormatLabels,
+ contentFileRejectionReason,
+ normalizeContentFileExtension,
+ normalizeContentFileExtensions,
+ normalizeContentFileMimeType,
+ normalizeContentFileMimeTypes,
+ validateContentFile,
+ zodContentFileDescriptor,
+} from "./files";
+export type {
+ ContentFileCandidate,
+ ContentFileCode,
+ ContentFileConstraints,
+ ContentFileDescriptor,
+ ContentFileRejection,
+} from "./files";
export { clampWithFingerprint, fingerprint } from "./fingerprint";
export {
contentIndexName,
diff --git a/packages/vitnode/src/content/indexes.ts b/packages/vitnode/src/content/indexes.ts
index 100f0eab3..ce7da4f1c 100644
--- a/packages/vitnode/src/content/indexes.ts
+++ b/packages/vitnode/src/content/indexes.ts
@@ -171,7 +171,7 @@ const named = (
* 1. `indexes` declared on the content type,
* 2. `field.text({ unique: true })` and every `field.slug()`, which is always
* unique - a slug is a URL, and two rows cannot share one,
- * 3. every foreign key (`relation` and `user` fields),
+ * 3. every foreign key (`relation`, `user` and `file` fields),
* 4. `createdAt` and `updatedAt`, which back the default ordering,
* 5. `(status, publishedAt)` when publication is enabled - one composite index
* serving both the published predicate and the default public ordering.
@@ -238,7 +238,12 @@ export const resolveContentIndexes = ({
...fieldEntries
.filter(
([, fieldValue]) =>
- fieldValue.kind === "relation" || fieldValue.kind === "user",
+ // A `file` is here for the same reason the other two are: Postgres does
+ // not index the child side of a foreign key by itself, and
+ // `ON DELETE RESTRICT` scans it on every attempt to delete a file.
+ fieldValue.kind === "file" ||
+ fieldValue.kind === "relation" ||
+ fieldValue.kind === "user",
)
.map(([name]) => named(tableName, { on: [name] })),
...CONTENT_SYSTEM_FIELDS.filter(name => name !== "id").map(name =>
diff --git a/packages/vitnode/src/content/schemas.ts b/packages/vitnode/src/content/schemas.ts
index d948015d5..25a0e4b17 100644
--- a/packages/vitnode/src/content/schemas.ts
+++ b/packages/vitnode/src/content/schemas.ts
@@ -32,6 +32,7 @@ import {
CONTENT_SYSTEM_FIELDS,
isFilterableFieldKind,
} from "./const";
+import { zodContentFileDescriptor } from "./files";
import {
contentLocalizationDisabled,
partitionContentFields,
@@ -212,6 +213,11 @@ const baseSelectSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => {
return z.date();
case "enum":
return z.enum(fieldValue.values);
+ // The `core_files.id` the column holds. The admin surfaces resolve the
+ // descriptor beside the row (`files`), and the public projection replaces the
+ // identifier with it - neither is what is *stored*, which is one integer.
+ case "file":
+ return referenceSchema();
case "group": {
const inner = contentInnerFields(fieldValue);
@@ -287,6 +293,9 @@ const applyPresence = (
if (
fieldValue.kind !== "dateTime" &&
+ // A file field has no default and cannot have one: a `core_files.id` in a
+ // definition would name a different row on every installation.
+ fieldValue.kind !== "file" &&
fieldValue.kind !== "group" &&
fieldValue.kind !== "relation" &&
fieldValue.kind !== "repeatable" &&
@@ -624,6 +633,18 @@ const publicSelectShape = (
if (name === "publishedAt") return [name, z.date().nullable()];
const fieldValue = fields[name];
+ // A public reader has no route to resolve a `core_files.id` through, so
+ // exposing one would be exposing nothing. The descriptor is the
+ // allowlisted shape - no storage key, no uploader, no metadata bag - and
+ // `resolveContentRowFiles` puts it on the row before the projector runs.
+ if (fieldValue.kind === "file") {
+ return [
+ name,
+ fieldValue.nullable
+ ? zodContentFileDescriptor.nullable()
+ : zodContentFileDescriptor,
+ ];
+ }
if (fieldValue.kind === "relation") {
// A to-many relation is a list of identifiers rather than a list of
// `{ id }` objects: the single-relation wrapper exists so a `null`
diff --git a/packages/vitnode/src/content/server/column-builders.ts b/packages/vitnode/src/content/server/column-builders.ts
index 99ae5a475..da6c0599a 100644
--- a/packages/vitnode/src/content/server/column-builders.ts
+++ b/packages/vitnode/src/content/server/column-builders.ts
@@ -222,6 +222,27 @@ export const buildContentColumn = ({
}),
{ defaultValue: fieldValue.defaultValue, nullable },
);
+ case "file": {
+ if (!reference) {
+ throw new ContentEngineError(
+ `Field "${name}" is a file reference but the \`core_files\` column was not resolved. This is an internal error.`,
+ { contentTypeId },
+ );
+ }
+
+ // RESTRICT, always, and not a per-field choice. `cascade` would delete an
+ // article because somebody tidied up the Files screen, and `set null`
+ // would blank a cover image with nothing to show it ever had one. Refusing
+ // the *file* deletion is the only outcome that loses nothing - and it is
+ // what makes `StorageModel.deleteFile` able to answer 409 rather than
+ // leaving a content row pointing at bytes that are gone.
+ const column = integer().references(reference, {
+ onDelete: "restrict",
+ onUpdate: "cascade",
+ });
+
+ return nullable ? column : column.notNull();
+ }
case "number":
return withModifiers(fieldValue.integer ? integer() : doublePrecision(), {
defaultValue: fieldValue.defaultValue,
diff --git a/packages/vitnode/src/content/server/editorial-service.ts b/packages/vitnode/src/content/server/editorial-service.ts
index d6010bb38..a72f7fd5c 100644
--- a/packages/vitnode/src/content/server/editorial-service.ts
+++ b/packages/vitnode/src/content/server/editorial-service.ts
@@ -51,6 +51,11 @@ import {
applyContentDeliveryWrite,
contentSlugHistoryFor,
} from "./delivery-writes";
+import {
+ assertContentFileReferences,
+ ContentFileReferenceError,
+ contentSnapshotFileIds,
+} from "./files";
import {
changedPathsToColumns,
diffChangedPaths,
@@ -530,19 +535,26 @@ export const createContentEditorialService = <
? ((row.__collections as Record | undefined) ?? {})
: ((await store?.load(itemId, tx)) ?? {});
+ const snapshot = contentRevisionSnapshot(definition, {
+ ...row,
+ ...collections,
+ version,
+ });
+
return await revisions.capture(tx, {
actor,
changedFields,
+ // Pinned in the same statement batch as the revision: a snapshot naming a
+ // file has to keep that file undeletable for as long as it is retained, or
+ // "restore version 3" restores a broken image. Empty for every content
+ // type with no file fields.
+ fileIds: contentSnapshotFileIds(definition, snapshot),
itemId,
operation,
restoredFromRevisionId,
// Stamped with the version the record now holds, which for a delete is the
// one it would have had - see `remove` below.
- snapshot: contentRevisionSnapshot(definition, {
- ...row,
- ...collections,
- version,
- }),
+ snapshot,
version,
});
};
@@ -876,6 +888,11 @@ export const createContentEditorialService = <
await transact(options, async tx => {
const parsed = schemas.create.parse(values) as Record;
+ // The same re-check the plain service runs: a file id in a payload is an
+ // assignment, and an upload that succeeded for one field proves nothing
+ // about another.
+ await assertContentFileReferences(c, definition, parsed, tx);
+
const [row] = await tx
.insert(table)
.values(toInsertColumns(fields, withCreateSlugs(parsed)))
@@ -1062,6 +1079,25 @@ export const createContentEditorialService = <
}
const patch = withUpdateSlugs(prepared.patch);
+
+ // A restore is the one write whose file ids come from the past, so a
+ // rejection means the field's rules changed after the snapshot was taken -
+ // "this version no longer fits", not "your payload is wrong". The pin on
+ // the revision guarantees the file still *exists*; whether it still
+ // satisfies a `maxBytes` somebody has since lowered is a different
+ // question, and the answer is the same 422 a dropped column gets.
+ try {
+ await assertContentFileReferences(c, definition, patch, tx);
+ } catch (error) {
+ if (!(error instanceof ContentFileReferenceError)) throw error;
+
+ throw new ContentRevisionNotRestorable({
+ contentTypeId,
+ fields: [error.field],
+ revisionId,
+ });
+ }
+
const changedPaths = diffChangedPaths(fields, current, patch);
const changedCollections = (await store?.diff(tx, id, patch)) ?? [];
const changedFields = [
@@ -1191,6 +1227,8 @@ export const createContentEditorialService = <
};
}
+ await assertContentFileReferences(c, definition, patch, tx);
+
// The version guard runs **first**, before a single junction or child
// row is touched. That ordering is the whole concurrency story: a writer
// holding a stale `expectedVersion` fails here and leaves the
diff --git a/packages/vitnode/src/content/server/file-reference-http-errors.test.ts b/packages/vitnode/src/content/server/file-reference-http-errors.test.ts
new file mode 100644
index 000000000..acdc5f788
--- /dev/null
+++ b/packages/vitnode/src/content/server/file-reference-http-errors.test.ts
@@ -0,0 +1,420 @@
+// @vitest-environment node
+import type { Context, MiddlewareHandler } from "hono";
+
+import { OpenAPIHono } from "@hono/zod-openapi";
+import { HTTPException } from "hono/http-exception";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { testFilePostContentType } from "@/tests/content-fixtures";
+
+import { CONTENT_FILE_CODES } from "../const";
+import { defineContentType } from "../define";
+import { ContentInputError } from "../errors";
+import { field } from "../fields";
+import { ContentFileReferenceError } from "./files";
+import { buildContentLocalizedAdminRoutes } from "./localized-admin-routes";
+import { createContentModel } from "./model";
+import { buildContentRoutes } from "./routes";
+
+vi.mock("../../api/lib/check-staff-permission", () => ({
+ assertStaffPermission: async () => {
+ await Promise.resolve();
+ },
+ checkStaffPermission: async () => await Promise.resolve(true),
+}));
+
+/**
+ * The composite half of the contract needs a content type that is **both**
+ * localized and holds a file, which no shared fixture is - a file field is
+ * always shared, so this is the one combination that exercises a save writing
+ * the base row and its translations in one transaction while carrying a file
+ * identifier.
+ */
+const localizedFileContentType = defineContentType({
+ id: "test.localized-file",
+ tableName: "test_localized_files",
+ localization: { enabled: true, defaultLocale: "en", fallback: "default" },
+ fields: {
+ title: field.text({ localized: true, required: true, maxLength: 200 }),
+ slug: field.slug({ localized: true, source: "title" }),
+ cover: field.file({
+ maxBytes: 5 * 1024 * 1024,
+ allowedExtensions: [".png"],
+ allowedMimeTypes: ["image/png"],
+ }),
+ },
+ admin: {
+ titleField: "title",
+ list: { columns: ["cover"] },
+ },
+});
+
+const filePosts = createContentModel(testFilePostContentType);
+const localizedFiles = createContentModel(localizedFileContentType);
+
+const PLUGIN_ID = "@vitnode/example";
+
+const adminUser = {
+ avatarColor: "000000",
+ birthday: null,
+ createdAt: new Date("2026-01-01T00:00:00.000Z"),
+ email: "test@test.com",
+ emailVerified: true,
+ id: 1,
+ language: "en",
+ name: "Test",
+ nameCode: "test",
+ newsletter: false,
+ roleId: 1,
+};
+
+/**
+ * The rejection the reference check raises, as the service raises it.
+ *
+ * Constructed rather than provoked through a stubbed `core_files` read: what is
+ * under test is the boundary between the error and the response, and the four
+ * codes are already validated against real descriptors in `files.test.ts`.
+ */
+const rejection = (
+ code: (typeof CONTENT_FILE_CODES)[keyof typeof CONTENT_FILE_CODES],
+ field_: string,
+ message: string,
+) =>
+ new ContentFileReferenceError({
+ code,
+ contentTypeId: "test.file-post",
+ field: field_,
+ message,
+ });
+
+/**
+ * The real `app.onError`, copied from `VitNodeAPI`.
+ *
+ * The point of a structured 400 is that `HTTPException.getResponse()` survives
+ * the trip out, so the handler that has to return it verbatim is part of what is
+ * being tested - asserting on `getResponse()` alone would pass even if the app
+ * flattened the body on the way out.
+ */
+const withErrorHandler = (app: OpenAPIHono): OpenAPIHono => {
+ app.onError((error, c) => {
+ if (error instanceof HTTPException) return error.getResponse();
+
+ return c.text("Internal Server Error", 500);
+ });
+
+ return app;
+};
+
+/** The generated admin routes over a service that throws whatever is given. */
+const harness = () => {
+ const service = {
+ advanced: vi.fn().mockResolvedValue({}),
+ advancedFields: vi.fn().mockResolvedValue({}),
+ create: vi.fn(),
+ delete: vi.fn(),
+ findById: vi.fn(),
+ findDetail: vi.fn(),
+ findMany: vi.fn(),
+ findRowById: vi.fn().mockResolvedValue({ id: 7 }),
+ options: vi.fn(),
+ publish: vi.fn(),
+ relations: {},
+ repeatable: {},
+ unpublish: vi.fn(),
+ update: vi.fn(),
+ };
+
+ vi.spyOn(filePosts, "service").mockReturnValue(service);
+
+ const app = withErrorHandler(new OpenAPIHono());
+ const context: MiddlewareHandler = async (c, next) => {
+ c.set("admin", { user: adminUser } as unknown as Context["var"]["admin"]);
+ c.set("events", {
+ emit: async () => await Promise.resolve({ failures: [], listeners: 0 }),
+ } as unknown as Context["var"]["events"]);
+ c.set("db", {
+ select: () => ({
+ from: () => ({ where: async () => await Promise.resolve([]) }),
+ }),
+ } as unknown as Context["var"]["db"]);
+ await next();
+ };
+ app.use("*", context);
+
+ for (const { handler, route } of buildContentRoutes(filePosts, {
+ pluginId: PLUGIN_ID,
+ })) {
+ app.openapi(route, handler);
+ }
+
+ return { app, service };
+};
+
+/** The composite pair over a stubbed service, inside a stubbed transaction. */
+const localizedHarness = () => {
+ const service = {
+ advanced: vi.fn().mockResolvedValue({}),
+ advancedFields: vi.fn().mockResolvedValue({}),
+ create: vi.fn(),
+ findById: vi.fn().mockResolvedValue({ id: 7, version: 3 }),
+ findRowById: vi.fn().mockResolvedValue({ id: 7, version: 3 }),
+ relations: {},
+ repeatable: {},
+ update: vi.fn(),
+ };
+ const translations = {
+ create: vi.fn(),
+ findManyForItem: vi.fn().mockResolvedValue([]),
+ findManyRowsForItem: vi.fn().mockResolvedValue([]),
+ update: vi.fn(),
+ };
+
+ vi.spyOn(localizedFiles, "service").mockReturnValue(service as never);
+ vi.spyOn(
+ localizedFiles as unknown as { translationService: unknown },
+ "translationService",
+ "get",
+ ).mockReturnValue(() => translations);
+
+ const app = withErrorHandler(new OpenAPIHono());
+ const context: MiddlewareHandler = async (c, next) => {
+ c.set("admin", { user: adminUser } as unknown as Context["var"]["admin"]);
+ c.set("events", {
+ emit: async () => await Promise.resolve({ failures: [], listeners: 0 }),
+ } as unknown as Context["var"]["events"]);
+ c.set("db", {
+ transaction: async (run: (handle: unknown) => Promise) =>
+ await run({}),
+ } as unknown as Context["var"]["db"]);
+ await next();
+ };
+ app.use("*", context);
+
+ for (const { handler, route } of buildContentLocalizedAdminRoutes(
+ localizedFiles,
+ { pluginId: PLUGIN_ID },
+ )) {
+ app.openapi(route, handler);
+ }
+
+ return { app, service };
+};
+
+const send = async (
+ app: OpenAPIHono,
+ path: string,
+ method: string,
+ body: unknown,
+): Promise<{ body: unknown; contentType: null | string; status: number }> => {
+ const response = await app.request(path, {
+ body: JSON.stringify(body),
+ headers: { "Content-Type": "application/json" },
+ method,
+ });
+ const text = await response.text();
+
+ return {
+ body: ((): unknown => {
+ try {
+ return JSON.parse(text);
+ } catch {
+ return text;
+ }
+ })(),
+ contentType: response.headers.get("content-type"),
+ status: response.status,
+ };
+};
+
+beforeEach(() => {
+ vi.restoreAllMocks();
+});
+
+/**
+ * A refused file identifier has to reach the client as `{ code, field, message }`.
+ *
+ * `ContentFileReferenceError` extends `ContentInputError`, and the generic branch
+ * of the mapper answers a `ContentInputError` with `message` alone - so before
+ * this, every one of the four reasons arrived as prose with no code and, worse,
+ * no field. A save carries every field at once: without `field` a form knows a
+ * file was refused and cannot say which input to put the message under.
+ */
+describe("a refused file identifier on a content write", () => {
+ const cases = [
+ [
+ CONTENT_FILE_CODES.missing,
+ "cover",
+ 'File 99 does not exist, so "cover" cannot point at it.',
+ ],
+ [
+ CONTENT_FILE_CODES.size,
+ "cover",
+ 'File 42 cannot be used for "cover": This file is 8 MB. The maximum is 5 MB.',
+ ],
+ [
+ CONTENT_FILE_CODES.mimeType,
+ "animation",
+ 'File 42 cannot be used for "animation": "image/png" is not an accepted file type. Accepted: image/gif.',
+ ],
+ [
+ CONTENT_FILE_CODES.extension,
+ "document",
+ 'File 42 cannot be used for "document": ".png" is not an accepted file extension. Accepted: .pdf.',
+ ],
+ ] as const;
+
+ describe.each(cases)("%s", (code, fieldName, message) => {
+ it("is a structured 400 on create", async () => {
+ const { app, service } = harness();
+ service.create.mockRejectedValue(rejection(code, fieldName, message));
+
+ const response = await send(app, "/", "POST", {
+ title: "Hello",
+ slug: "hello",
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.contentType).toContain("application/json");
+ expect(response.body).toEqual({ code, field: fieldName, message });
+ });
+
+ it("is a structured 400 on update", async () => {
+ const { app, service } = harness();
+ service.update.mockRejectedValue(rejection(code, fieldName, message));
+
+ const response = await send(app, "/7", "PUT", { title: "Hello" });
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({ code, field: fieldName, message });
+ });
+ });
+
+ it("names the field for a composite create", async () => {
+ const { app, service } = localizedHarness();
+ service.create.mockRejectedValue(
+ rejection(CONTENT_FILE_CODES.mimeType, "cover", "Not a PNG."),
+ );
+
+ const response = await send(app, "/localized", "POST", {
+ translations: [{ locale: "en", values: { slug: "hello", title: "Hi" } }],
+ values: { cover: 42 },
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({
+ code: "CONTENT_FILE_MIME_TYPE_NOT_ALLOWED",
+ field: "cover",
+ message: "Not a PNG.",
+ });
+ });
+
+ it("names the field for a composite update", async () => {
+ const { app, service } = localizedHarness();
+ service.update.mockRejectedValue(
+ rejection(CONTENT_FILE_CODES.size, "cover", "Too big."),
+ );
+
+ const response = await send(app, "/7/localized", "PUT", {
+ expectedVersion: 3,
+ translations: [],
+ values: { cover: 42 },
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({
+ code: "CONTENT_FILE_TOO_LARGE",
+ field: "cover",
+ message: "Too big.",
+ });
+ });
+
+ /**
+ * `Error.message` carries `[Content Engine] : ` for the log's
+ * benefit, and an editor must never be shown it. The body reads `detail`, so
+ * this is the assertion that keeps it reading `detail`.
+ */
+ it("keeps the internal prefix and the content type id out of the body", async () => {
+ const { app, service } = harness();
+ service.create.mockRejectedValue(
+ rejection(CONTENT_FILE_CODES.size, "cover", "This file is too big."),
+ );
+
+ const response = await send(app, "/", "POST", {
+ title: "Hello",
+ slug: "hello",
+ });
+
+ expect(JSON.stringify(response.body)).not.toContain("Content Engine");
+ expect(JSON.stringify(response.body)).not.toContain("test.file-post");
+ expect(response.body).toMatchObject({ message: "This file is too big." });
+ });
+
+ /**
+ * The guard on the branch above it: only a file rejection gains a body. Every
+ * other `ContentInputError` keeps the plain-text 400 it has always answered,
+ * so no existing client starts reading JSON where there is none.
+ */
+ it("leaves every other input error as plain text", async () => {
+ const { app, service } = harness();
+ service.create.mockRejectedValue(
+ new ContentInputError("Provide the slug explicitly."),
+ );
+
+ const response = await send(app, "/", "POST", {
+ title: "Hello",
+ slug: "hello",
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body).toBe("[Content Engine] Provide the slug explicitly.");
+ });
+});
+
+describe("the OpenAPI contract for that 400", () => {
+ /**
+ * The declared 400 body of one generated route.
+ *
+ * The model is taken as `never` because `buildContentRoutes` is invariant in
+ * its definition: a concrete `ContentModel` is not assignable to the
+ * `AnyContentTypeDefinition` its parameter names, and every call site here has
+ * a concrete one.
+ */
+ const body400 = (model: never, method: string, path: string) => {
+ const entry = buildContentRoutes(model, { pluginId: PLUGIN_ID }).find(
+ item => item.route.method === method && item.route.path === path,
+ );
+
+ return (
+ entry?.route.responses?.[400] as
+ undefined | { content?: Record }
+ )?.content?.["application/json"]?.schema;
+ };
+
+ it("declares the JSON body on create and update", () => {
+ expect(body400(filePosts as never, "post", "/")).toBeDefined();
+ expect(body400(filePosts as never, "put", "/{id}")).toBeDefined();
+ });
+
+ it("declares it on the composite pair", () => {
+ expect(
+ body400(localizedFiles as never, "post", "/localized"),
+ ).toBeDefined();
+ expect(
+ body400(localizedFiles as never, "put", "/{id}/localized"),
+ ).toBeDefined();
+ });
+
+ /**
+ * A content type with no file field cannot produce this body, so it must not
+ * advertise one - the same rule `uniqueConflict` follows for a non-editorial
+ * content type.
+ */
+ it("says nothing about it for a content type with no file field", async () => {
+ const { testPostContentType } = await import("@/tests/content-fixtures");
+ const plain = createContentModel(testPostContentType, {
+ references: { category: () => filePosts.table.id },
+ });
+
+ expect(body400(plain as never, "post", "/")).toBeUndefined();
+ });
+});
diff --git a/packages/vitnode/src/content/server/file-revisions.test.ts b/packages/vitnode/src/content/server/file-revisions.test.ts
new file mode 100644
index 000000000..038b25817
--- /dev/null
+++ b/packages/vitnode/src/content/server/file-revisions.test.ts
@@ -0,0 +1,269 @@
+// @vitest-environment node
+import type { Context } from "hono";
+
+import { getTableName } from "drizzle-orm";
+import { getTableConfig } from "drizzle-orm/pg-core";
+import { describe, expect, it } from "vitest";
+
+import {
+ core_content_file_refs,
+ core_content_revisions,
+} from "@/database/content";
+import { core_files } from "@/database/files";
+import {
+ testEditorialPostContentType,
+ testFilePostContentType,
+} from "@/tests/content-fixtures";
+
+import type { ContentDatabase } from "./service";
+
+import { contentSnapshotFileIds } from "./files";
+import { contentRevisionSnapshot } from "./revision-snapshot";
+import { createContentRevisionsModel } from "./revisions-model";
+
+const PLUGIN_ID = "@vitnode/example";
+
+/**
+ * A transaction stand-in that records what was inserted into which table and
+ * what was deleted.
+ *
+ * The pin insert and the retention prune happen inside `capture`, in one
+ * transaction, so a stub at this level is what proves the ordering: the pins
+ * exist before anything is pruned, and there is no unpinning step at all.
+ */
+const makeTx = () => {
+ const inserts: { table: string; values: unknown }[] = [];
+ const deletes: string[] = [];
+
+ const tx = {
+ delete: (table: object) => {
+ deletes.push(getTableName(table as never));
+
+ return { where: async () => await Promise.resolve(undefined) };
+ },
+ insert: (table: object) => ({
+ values: (values: unknown) => {
+ inserts.push({ table: getTableName(table as never), values });
+
+ return {
+ returning: async () => await Promise.resolve([{ id: 500 }]),
+ };
+ },
+ }),
+ } as unknown as ContentDatabase;
+
+ return { deletes, inserts, tx };
+};
+
+const model = createContentRevisionsModel({
+ c: { get: () => undefined } as unknown as Context,
+ definition: testEditorialPostContentType,
+ pluginId: PLUGIN_ID,
+});
+
+const capture = async (
+ fileIds: number[] | undefined,
+ version = 1,
+): Promise> => {
+ const harness = makeTx();
+
+ await model.capture(harness.tx, {
+ actor: { type: "staff", userId: 1 },
+ changedFields: ["cover"],
+ ...(fileIds === undefined ? {} : { fileIds }),
+ itemId: 7,
+ operation: "update",
+ snapshot: {} as never,
+ version,
+ });
+
+ return harness;
+};
+
+const pins = (harness: ReturnType) =>
+ harness.inserts.filter(
+ entry => entry.table === getTableName(core_content_file_refs),
+ );
+
+describe("core_content_file_refs", () => {
+ const config = getTableConfig(core_content_file_refs);
+ const foreignKeys = config.foreignKeys.map(fk => {
+ const reference = fk.reference();
+
+ return {
+ column: reference.columns[0]?.name,
+ onDelete: fk.onDelete,
+ table: getTableName(reference.foreignTable),
+ };
+ });
+
+ /**
+ * The pin's whole job. `RESTRICT` towards the file is what refuses the
+ * deletion; `CASCADE` from the revision is what releases it again when
+ * retention prunes the revision - with no code in between.
+ */
+ it("refuses a file deletion and releases it when the revision goes", () => {
+ expect(foreignKeys).toEqual(
+ expect.arrayContaining([
+ {
+ column: "fileId",
+ onDelete: "restrict",
+ table: getTableName(core_files),
+ },
+ {
+ column: "revisionId",
+ onDelete: "cascade",
+ table: getTableName(core_content_revisions),
+ },
+ ]),
+ );
+ });
+
+ it("holds one pin per (revision, file) pair", () => {
+ expect(config.indexes.map(item => item.config.name)).toContain(
+ "core_content_file_refs_unique",
+ );
+ expect(
+ config.indexes.find(
+ item => item.config.name === "core_content_file_refs_unique",
+ )?.config.unique,
+ ).toBe(true);
+ });
+
+ it("indexes the file side, which RESTRICT scans on every delete", () => {
+ expect(config.indexes.map(item => item.config.name)).toContain(
+ "core_content_file_refs_file_id_idx",
+ );
+ });
+
+ it("copies nothing from the file", () => {
+ expect(config.columns.map(item => item.name).sort()).toEqual([
+ "createdAt",
+ "fileId",
+ "id",
+ "revisionId",
+ ]);
+ });
+});
+
+describe("revision capture", () => {
+ it("pins every file the snapshot names, to the revision it just wrote", async () => {
+ const harness = await capture([1, 2]);
+
+ expect(pins(harness)[0].values).toEqual([
+ { fileId: 1, revisionId: 500 },
+ { fileId: 2, revisionId: 500 },
+ ]);
+ });
+
+ it("writes one statement, not one per file", async () => {
+ expect(pins(await capture([1, 2, 3]))).toHaveLength(1);
+ });
+
+ it("deduplicates, so the unique index is never the thing that fails", async () => {
+ expect(pins(await capture([1, 1, 2]))[0].values).toEqual([
+ { fileId: 1, revisionId: 500 },
+ { fileId: 2, revisionId: 500 },
+ ]);
+ });
+
+ it("writes nothing for a revision that names no file", async () => {
+ expect(pins(await capture([]))).toHaveLength(0);
+ expect(pins(await capture(undefined))).toHaveLength(0);
+ });
+
+ /**
+ * Ordering, stated as a test: the pins go in before the retention prune, so
+ * there is no window in which the new revision exists unpinned - and the prune
+ * is what releases the *old* pins, through the cascade.
+ */
+ it("pins before it prunes", async () => {
+ const harness = await capture(
+ [1],
+ testEditorialPostContentType.editorial.revisions.retention + 5,
+ );
+
+ expect(pins(harness)).toHaveLength(1);
+ expect(harness.deletes).toEqual([getTableName(core_content_revisions)]);
+ // Nothing deletes pins directly - the cascade does it.
+ expect(harness.deletes).not.toContain(getTableName(core_content_file_refs));
+ });
+
+ it("prunes nothing while the record is inside the retention window", async () => {
+ const harness = await capture([1], 2);
+
+ expect(harness.deletes).toEqual([]);
+ });
+});
+
+describe("the pinning lifecycle", () => {
+ /**
+ * The scenario the mechanism exists for, spelled out against the two facts
+ * that implement it:
+ *
+ * article -> file A, revision v1 pins A
+ * article -> file B, revision v2 pins B; the column no longer guards A
+ * deleting A is refused <- the v1 pin, ON DELETE RESTRICT
+ * v1 is pruned by retention <- the pin cascades away
+ * deleting A now succeeds <- nothing references it
+ */
+ it("keeps the previous file pinned after the field moves on", async () => {
+ const first = await capture([1], 1);
+ const second = await capture([2], 2);
+
+ // v1 still names file 1 even though the row now points at file 2.
+ expect(pins(first)[0].values).toEqual([{ fileId: 1, revisionId: 500 }]);
+ expect(pins(second)[0].values).toEqual([{ fileId: 2, revisionId: 500 }]);
+ // And neither capture deleted a pin - only a pruned revision can.
+ expect(first.deletes).not.toContain(getTableName(core_content_file_refs));
+ expect(second.deletes).not.toContain(getTableName(core_content_file_refs));
+ });
+});
+
+describe("the ids a snapshot yields", () => {
+ /**
+ * The composition the editorial service relies on: it builds the snapshot,
+ * reads the file ids straight back out of it, and hands both to `capture`. So
+ * the pins can only ever name files the snapshot actually recorded.
+ */
+ it("comes from the snapshot itself, not from the request payload", () => {
+ const row = {
+ animation: 2,
+ cover: 1,
+ createdAt: new Date("2026-08-01T00:00:00.000Z"),
+ document: null,
+ id: 7,
+ slug: "hello",
+ status: "draft",
+ title: "Hello",
+ updatedAt: new Date("2026-08-01T00:00:00.000Z"),
+ version: 3,
+ };
+
+ const snapshot = contentRevisionSnapshot(testFilePostContentType, row);
+
+ expect(snapshot.fields).toMatchObject({ animation: 2, cover: 1 });
+ expect(contentSnapshotFileIds(testFilePostContentType, snapshot)).toEqual([
+ 1, 2,
+ ]);
+ });
+
+ it("yields nothing for a record whose file fields are empty", () => {
+ const snapshot = contentRevisionSnapshot(testFilePostContentType, {
+ animation: null,
+ cover: null,
+ createdAt: new Date(0),
+ document: null,
+ id: 7,
+ slug: "hello",
+ status: "draft",
+ title: "Hello",
+ updatedAt: new Date(0),
+ version: 1,
+ });
+
+ expect(contentSnapshotFileIds(testFilePostContentType, snapshot)).toEqual(
+ [],
+ );
+ });
+});
diff --git a/packages/vitnode/src/content/server/file-upload-route.test.ts b/packages/vitnode/src/content/server/file-upload-route.test.ts
new file mode 100644
index 000000000..a9e817764
--- /dev/null
+++ b/packages/vitnode/src/content/server/file-upload-route.test.ts
@@ -0,0 +1,456 @@
+// Node, not jsdom: a real `FormData` with a real `File` in it only survives the
+// trip through `app.request` under Node's own multipart implementation - jsdom's
+// globals produce "Malformed FormData".
+// @vitest-environment node
+import type { Context, MiddlewareHandler } from "hono";
+
+import { OpenAPIHono } from "@hono/zod-openapi";
+import { HTTPException } from "hono/http-exception";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { StorageImageUnprocessableError } from "@/api/models/storage";
+import {
+ testFilePostContentType,
+ testPostContentType,
+} from "@/tests/content-fixtures";
+
+import { createContentModel } from "./model";
+import { buildContentRoutes } from "./routes";
+
+const permissions = { create: true, edit: true, view: true };
+
+vi.mock("../../api/lib/check-staff-permission", () => ({
+ assertStaffPermission: async () => {
+ await Promise.resolve();
+ },
+ checkStaffPermission: async (
+ _c: unknown,
+ { permission }: { permission: string },
+ ) =>
+ await Promise.resolve(
+ permission === "can_create"
+ ? permissions.create
+ : permission === "can_edit"
+ ? permissions.edit
+ : permissions.view,
+ ),
+}));
+
+const files = createContentModel(testFilePostContentType);
+const plain = createContentModel(testPostContentType, {
+ references: {
+ category: () => createContentModel(testPostContentType).table.id,
+ },
+});
+
+const PLUGIN_ID = "@vitnode/example";
+
+/**
+ * The generated routes with the storage model stubbed.
+ *
+ * `upload` echoes what a real one would return - including the *stored* name,
+ * which is the interesting knob: `storedAs` lets a case pretend the image
+ * pipeline re-encoded a PNG to WebP, which is exactly the mismatch the route has
+ * to catch before the identifier ever reaches a content row.
+ */
+const harness = ({
+ storageThrows,
+ storedAs,
+ storedMimeType,
+ storedSize,
+}: {
+ /** Mimics `StorageModel`, which speaks in `HTTPException`s. */
+ storageThrows?: HTTPException;
+ storedAs?: string;
+ storedMimeType?: string;
+ storedSize?: number;
+} = {}) => {
+ const upload = vi.fn(async ({ file }: { file: File }) => {
+ if (storageThrows) throw storageThrows;
+
+ return await Promise.resolve({
+ dimensions: { height: 900, width: 1600 },
+ id: 42,
+ key: "month_8_2026/content/42",
+ mimeType: storedMimeType ?? (file.type === "" ? null : file.type),
+ name: storedAs ?? file.name,
+ size: storedSize ?? file.size,
+ url: "https://cdn.test/month_8_2026/content/42",
+ });
+ });
+ const deleteFile = vi.fn().mockResolvedValue(undefined);
+
+ const app = new OpenAPIHono();
+ const middleware: MiddlewareHandler = async (c, next) => {
+ c.set("storage", {
+ deleteFile,
+ upload,
+ } as unknown as Context["var"]["storage"]);
+ c.set("admin", { user: { id: 1 } } as unknown as Context["var"]["admin"]);
+ await next();
+ };
+ app.use("*", middleware);
+
+ for (const { handler, route } of buildContentRoutes(files, {
+ pluginId: PLUGIN_ID,
+ })) {
+ app.openapi(route, handler);
+ }
+
+ return { app, deleteFile, upload };
+};
+
+const post = async (
+ app: OpenAPIHono,
+ field: string,
+ file: File,
+): Promise => {
+ const body = new FormData();
+ body.append("file", file);
+
+ return await app.request(`/uploads/${field}`, { body, method: "POST" });
+};
+
+const fileOf = (name: string, type: string, bytes = 8): File =>
+ new File([new Uint8Array(bytes)], name, { type });
+
+describe("the generated upload route", () => {
+ beforeEach(() => {
+ permissions.create = true;
+ permissions.edit = true;
+ permissions.view = true;
+ });
+
+ it("is not mounted for a content type with no file field", () => {
+ const paths = buildContentRoutes(plain, { pluginId: PLUGIN_ID }).map(
+ entry => `${entry.route.method} ${entry.route.path}`,
+ );
+
+ expect(paths.some(path => path.includes("/uploads/"))).toBe(false);
+ });
+
+ it("is mounted once, addressed by field, for a content type with three", () => {
+ const uploads = buildContentRoutes(files, { pluginId: PLUGIN_ID }).filter(
+ entry => entry.route.path.includes("/uploads/"),
+ );
+
+ expect(uploads).toHaveLength(1);
+ expect(uploads[0].route.path).toBe("/uploads/{field}");
+ expect(uploads[0].route.method).toBe("post");
+ });
+
+ it("stores a GIF for the GIF-only field and returns its descriptor", async () => {
+ const { app, upload } = harness();
+
+ const res = await post(app, "animation", fileOf("banner.gif", "image/gif"));
+
+ expect(res.status).toBe(200);
+ await expect(res.json()).resolves.toEqual({
+ height: 900,
+ id: 42,
+ mimeType: "image/gif",
+ name: "banner.gif",
+ size: 8,
+ url: "https://cdn.test/month_8_2026/content/42",
+ width: 1600,
+ });
+ // The field's own ceiling and allowlist reach the adapter too.
+ expect(upload.mock.calls[0][0]).toMatchObject({
+ allowedMimeTypes: ["image/gif"],
+ folder: "content",
+ maxBytes: 10 * 1024 * 1024,
+ });
+ });
+
+ it("refuses a PNG for the GIF-only field before uploading anything", async () => {
+ const { app, upload } = harness();
+
+ const res = await post(app, "animation", fileOf("shot.png", "image/png"));
+
+ expect(res.status).toBe(400);
+ await expect(res.json()).resolves.toMatchObject({
+ code: "CONTENT_FILE_MIME_TYPE_NOT_ALLOWED",
+ });
+ expect(upload).not.toHaveBeenCalled();
+ });
+
+ /**
+ * The case an extension-only check waves through: the file is *called* `.gif`
+ * and the browser still declares what it is.
+ */
+ it("refuses a PNG renamed to .gif, because the media type is wrong", async () => {
+ const { app, upload } = harness();
+
+ const res = await post(
+ app,
+ "animation",
+ fileOf("renamed.gif", "image/png"),
+ );
+
+ expect(res.status).toBe(400);
+ await expect(res.json()).resolves.toMatchObject({
+ code: "CONTENT_FILE_MIME_TYPE_NOT_ALLOWED",
+ });
+ expect(upload).not.toHaveBeenCalled();
+ });
+
+ it("refuses a real GIF whose extension is wrong", async () => {
+ const { app, upload } = harness();
+
+ const res = await post(app, "animation", fileOf("banner.png", "image/gif"));
+
+ expect(res.status).toBe(400);
+ await expect(res.json()).resolves.toMatchObject({
+ code: "CONTENT_FILE_EXTENSION_NOT_ALLOWED",
+ });
+ expect(upload).not.toHaveBeenCalled();
+ });
+
+ it("refuses a GIF over 10 MB without spending the bandwidth", async () => {
+ const { app, upload } = harness();
+
+ const res = await post(
+ app,
+ "animation",
+ fileOf("huge.gif", "image/gif", 10 * 1024 * 1024 + 1),
+ );
+
+ expect(res.status).toBe(400);
+ await expect(res.json()).resolves.toMatchObject({
+ code: "CONTENT_FILE_TOO_LARGE",
+ });
+ expect(upload).not.toHaveBeenCalled();
+ });
+
+ it("accepts a PDF for the PDF field and a JPG for the image field", async () => {
+ const { app } = harness();
+
+ await expect(
+ post(app, "document", fileOf("spec.pdf", "application/pdf")).then(
+ res => res.status,
+ ),
+ ).resolves.toBe(200);
+ await expect(
+ post(app, "cover", fileOf("hero.jpg", "image/jpeg")).then(
+ res => res.status,
+ ),
+ ).resolves.toBe(200);
+ });
+
+ it("refuses a PDF for the image field", async () => {
+ const { app } = harness();
+
+ const res = await post(app, "cover", fileOf("spec.pdf", "application/pdf"));
+
+ expect(res.status).toBe(400);
+ });
+
+ it("refuses a field that is not a file field", async () => {
+ const { app, upload } = harness();
+
+ for (const field of ["title", "slug", "nope"]) {
+ const res = await post(app, field, fileOf("x.gif", "image/gif"));
+
+ expect(res.status).toBe(400);
+ }
+ expect(upload).not.toHaveBeenCalled();
+ });
+
+ /**
+ * With `storage.image` configured, VitNode re-encodes images to WebP - so a
+ * `.png` upload is *stored* as `.webp`. A field that allows only `.gif` (or
+ * only `.png`) has to hear about that now rather than at save time, and the
+ * file this request created is removed on the way out.
+ */
+ it("refuses and deletes a file the storage pipeline converted out of the allowlist", async () => {
+ const { app, deleteFile } = harness({
+ storedAs: "banner.webp",
+ storedMimeType: "image/webp",
+ });
+
+ const res = await post(app, "animation", fileOf("banner.gif", "image/gif"));
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { code: string; message: string };
+ expect(body.code).toBe("CONTENT_FILE_MIME_TYPE_NOT_ALLOWED");
+ expect(body.message).toContain("banner.webp");
+ expect(body.message).toContain("re-encodes");
+ expect(body.message).toContain("original format");
+ expect(deleteFile).toHaveBeenCalledWith(42);
+ });
+
+ it("accepts a PNG converted to WebP when the field allows WebP", async () => {
+ const { app, deleteFile } = harness({
+ storedAs: "hero.webp",
+ storedMimeType: "image/webp",
+ });
+
+ const res = await post(app, "cover", fileOf("hero.png", "image/png"));
+
+ expect(res.status).toBe(200);
+ expect(deleteFile).not.toHaveBeenCalled();
+ });
+
+ it("refuses a stored file that came back larger than the ceiling", async () => {
+ const { app, deleteFile } = harness({ storedSize: 6 * 1024 * 1024 });
+
+ const res = await post(app, "cover", fileOf("hero.jpg", "image/jpeg"));
+
+ expect(res.status).toBe(400);
+ expect(deleteFile).toHaveBeenCalledWith(42);
+ });
+
+ /**
+ * Every refusal has to arrive as JSON.
+ *
+ * Hono renders an `HTTPException`'s message as plain text, and the browser
+ * cannot tell that from a proxy's HTML error page - so a bare exception here
+ * became "The upload failed. Please try again." in the AdminCP, which told the
+ * editor nothing and invited them to retry a misconfiguration.
+ */
+ describe("the shape of a refusal", () => {
+ const bodyOf = async (res: Response) =>
+ (await res.json()) as { code?: string; message?: string };
+
+ it("names the field when the URL does not name a file field", async () => {
+ const { app } = harness();
+
+ const res = await post(app, "title", fileOf("x.gif", "image/gif"));
+ const body = await bodyOf(res);
+
+ expect(res.status).toBe(400);
+ expect(body.code).toBe("CONTENT_FILE_FIELD_UNKNOWN");
+ expect(body.message).toContain("title");
+ });
+
+ it("says which permission is missing rather than just Forbidden", async () => {
+ permissions.create = false;
+ permissions.edit = false;
+ const { app } = harness();
+
+ const res = await post(app, "animation", fileOf("a.gif", "image/gif"));
+ const body = await bodyOf(res);
+
+ expect(res.status).toBe(403);
+ expect(body.code).toBe("CONTENT_FILE_FORBIDDEN");
+ expect(body.message).toMatch(/permission/i);
+ });
+
+ it("passes a corrupt-image failure through with its own words", async () => {
+ const { app } = harness({
+ storageThrows: new HTTPException(400, {
+ message: "Invalid or corrupt image file",
+ }),
+ });
+
+ const res = await post(app, "cover", fileOf("hero.jpg", "image/jpeg"));
+ const body = await bodyOf(res);
+
+ expect(res.status).toBe(400);
+ expect(body.code).toBe("CONTENT_FILE_INVALID");
+ expect(body.message).toBe("Invalid or corrupt image file");
+ });
+
+ it("separates an unconvertible image from a corrupt one", async () => {
+ const { app } = harness({
+ storageThrows: new StorageImageUnprocessableError(
+ "This image is 20000\u00d7400 pixels, which is too large to convert to WEBP. WEBP allows at most 16383 pixels per side. Resize it and upload it again.",
+ ),
+ });
+
+ const res = await post(app, "cover", fileOf("hero.jpg", "image/jpeg"));
+ const body = await bodyOf(res);
+
+ expect(res.status).toBe(400);
+ expect(body.code).toBe("CONTENT_FILE_UNPROCESSABLE");
+ expect(body.message).toContain("16383");
+ });
+
+ it("says the install cannot store anything, and which way", async () => {
+ // The case an editor would otherwise retry for ever: nothing is wrong with
+ // their file.
+ for (const message of [
+ "Storage provider not found",
+ "Image optimization library (sharp) failed to load",
+ ]) {
+ const { app } = harness({
+ storageThrows: new HTTPException(500, { message }),
+ });
+
+ const res = await post(app, "cover", fileOf("hero.jpg", "image/jpeg"));
+ const body = await bodyOf(res);
+
+ expect(res.status).toBe(500);
+ expect(body.code).toBe("CONTENT_FILE_STORAGE_UNAVAILABLE");
+ expect(body.message).toBe(message);
+ }
+ });
+
+ it("answers JSON for every refusal, never bare text", async () => {
+ const cases: (() => Promise)[] = [
+ async () =>
+ await post(harness().app, "title", fileOf("a.gif", "image/gif")),
+ async () =>
+ await post(
+ harness().app,
+ "cover",
+ fileOf("spec.pdf", "application/pdf"),
+ ),
+ async () =>
+ await post(
+ harness({
+ storageThrows: new HTTPException(500, { message: "nope" }),
+ }).app,
+ "cover",
+ fileOf("hero.jpg", "image/jpeg"),
+ ),
+ ];
+
+ for (const run of cases) {
+ const res = await run();
+
+ expect(res.ok).toBe(false);
+ // `.json()` rejecting is exactly what produced the generic message.
+ await expect(res.json()).resolves.toMatchObject({
+ code: expect.any(String),
+ message: expect.any(String),
+ });
+ }
+ });
+ });
+
+ describe("permissions", () => {
+ it("accepts a role that may only create", async () => {
+ permissions.edit = false;
+ const { app } = harness();
+
+ await expect(
+ post(app, "animation", fileOf("a.gif", "image/gif")).then(
+ res => res.status,
+ ),
+ ).resolves.toBe(200);
+ });
+
+ it("accepts a role that may only edit", async () => {
+ permissions.create = false;
+ const { app } = harness();
+
+ await expect(
+ post(app, "animation", fileOf("a.gif", "image/gif")).then(
+ res => res.status,
+ ),
+ ).resolves.toBe(200);
+ });
+
+ it("refuses a read-only role", async () => {
+ permissions.create = false;
+ permissions.edit = false;
+ const { app, upload } = harness();
+
+ const res = await post(app, "animation", fileOf("a.gif", "image/gif"));
+
+ expect(res.status).toBe(403);
+ expect(upload).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/vitnode/src/content/server/files.test.ts b/packages/vitnode/src/content/server/files.test.ts
new file mode 100644
index 000000000..0f9a5c593
--- /dev/null
+++ b/packages/vitnode/src/content/server/files.test.ts
@@ -0,0 +1,405 @@
+// @vitest-environment node
+import type { Context } from "hono";
+
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ testFilePostContentType,
+ testPostContentType,
+} from "@/tests/content-fixtures";
+
+import type { ContentFileReferenceError } from "./files";
+
+import { ContentInputError } from "../errors";
+import {
+ assertContentFileReferences,
+ contentFileFields,
+ contentSnapshotFileIds,
+ resolveContentFileDescriptors,
+ resolveContentPublicRowFiles,
+ withContentRowFiles,
+} from "./files";
+
+/** One `core_files` row as the batched read selects it. */
+const fileRow = (
+ id: number,
+ overrides: Partial<{
+ key: string;
+ metadata: Record;
+ mimeType: null | string;
+ name: string;
+ size: number;
+ }> = {},
+) => ({
+ id,
+ key: `month_8_2026/content/${id}.webp`,
+ metadata: {},
+ mimeType: "image/webp",
+ name: `cover-${id}.webp`,
+ size: 1024,
+ ...overrides,
+});
+
+/**
+ * A context whose `SELECT ... FROM core_files WHERE id IN (...)` returns `rows`.
+ *
+ * `select` is a spy so the tests can assert the *number of statements*: one per
+ * page is the whole point of the batched read, and a regression to one per row
+ * would still pass every value assertion.
+ */
+const makeCtx = (
+ rows: ReturnType[],
+ { hasAdapter = true }: { hasAdapter?: boolean } = {},
+) => {
+ const where = vi.fn().mockResolvedValue(rows);
+ const select = vi.fn(() => ({ from: vi.fn(() => ({ where })) }));
+ const store: Record = {
+ core: {
+ storage: hasAdapter
+ ? { adapter: { delete: vi.fn(), getUrl: vi.fn(), upload: vi.fn() } }
+ : undefined,
+ },
+ db: { select },
+ storage: { getUrl: (key: string) => `https://cdn.test/${key}` },
+ };
+
+ return {
+ ctx: { get: (k: string) => store[k] } as unknown as Context,
+ select,
+ };
+};
+
+describe("contentFileFields", () => {
+ it("finds every file field, and nothing else", () => {
+ expect(Object.keys(contentFileFields(testFilePostContentType))).toEqual([
+ "cover",
+ "animation",
+ "document",
+ ]);
+ });
+
+ it("is empty for a content type that declares none", () => {
+ expect(contentFileFields(testPostContentType)).toEqual({});
+ });
+});
+
+describe("resolveContentFileDescriptors", () => {
+ it("reads a whole page in one statement", async () => {
+ const { ctx, select } = makeCtx([fileRow(1), fileRow(2)]);
+
+ const byId = await resolveContentFileDescriptors(ctx, [1, 2, 1, 2]);
+
+ expect(select).toHaveBeenCalledTimes(1);
+ expect([...byId.keys()]).toEqual([1, 2]);
+ });
+
+ it("issues no statement for an empty or invalid id list", async () => {
+ const { ctx, select } = makeCtx([]);
+
+ expect((await resolveContentFileDescriptors(ctx, [])).size).toBe(0);
+ expect((await resolveContentFileDescriptors(ctx, [0, -1, 1.5])).size).toBe(
+ 0,
+ );
+ expect(select).not.toHaveBeenCalled();
+ });
+
+ it("projects the allowlisted shape and nothing else", async () => {
+ const { ctx } = makeCtx([
+ fileRow(1, {
+ metadata: { dimensions: { height: 900, width: 1600 }, secret: "x" },
+ }),
+ ]);
+
+ const descriptor = (await resolveContentFileDescriptors(ctx, [1])).get(1);
+
+ expect(descriptor).toEqual({
+ height: 900,
+ id: 1,
+ mimeType: "image/webp",
+ name: "cover-1.webp",
+ size: 1024,
+ url: "https://cdn.test/month_8_2026/content/1.webp",
+ width: 1600,
+ });
+ // The key was read to build the URL and then dropped; the metadata bag never
+ // travels.
+ expect(Object.keys(descriptor ?? {})).not.toContain("key");
+ expect(Object.keys(descriptor ?? {})).not.toContain("metadata");
+ });
+
+ it("omits the dimensions for a file nothing measured", async () => {
+ const { ctx } = makeCtx([
+ fileRow(1, { mimeType: "application/pdf", name: "spec.pdf" }),
+ ]);
+
+ const descriptor = (await resolveContentFileDescriptors(ctx, [1])).get(1);
+
+ expect(descriptor).not.toHaveProperty("width");
+ expect(descriptor).not.toHaveProperty("height");
+ });
+
+ it("answers an empty URL rather than throwing with no adapter", async () => {
+ const { ctx } = makeCtx([fileRow(1)], { hasAdapter: false });
+
+ expect((await resolveContentFileDescriptors(ctx, [1])).get(1)?.url).toBe(
+ "",
+ );
+ });
+});
+
+describe("withContentRowFiles", () => {
+ it("attaches the descriptors beside the row, keeping the identifier", async () => {
+ const { ctx, select } = makeCtx([fileRow(1), fileRow(2)]);
+
+ const [row] = await withContentRowFiles(ctx, testFilePostContentType, [
+ { animation: 2, cover: 1, document: null, id: 7 },
+ ]);
+
+ // The form's value stays the identifier it will submit back.
+ expect(row.cover).toBe(1);
+ expect(row.files.cover?.name).toBe("cover-1.webp");
+ expect(row.files.animation?.id).toBe(2);
+ expect(row.files.document).toBeNull();
+ expect(select).toHaveBeenCalledTimes(1);
+ });
+
+ it("reads one statement for a whole page of rows", async () => {
+ const { ctx, select } = makeCtx([fileRow(1), fileRow(2), fileRow(3)]);
+
+ const rows = await withContentRowFiles(
+ ctx,
+ testFilePostContentType,
+ [1, 2, 3].map(id => ({ animation: null, cover: id, document: null, id })),
+ );
+
+ expect(rows).toHaveLength(3);
+ expect(select).toHaveBeenCalledTimes(1);
+ });
+
+ it("issues no statement for a content type with no file fields", async () => {
+ const { ctx, select } = makeCtx([]);
+
+ const [row] = await withContentRowFiles(ctx, testPostContentType, [
+ { id: 7, title: "Hi" },
+ ]);
+
+ expect(row.files).toEqual({});
+ expect(select).not.toHaveBeenCalled();
+ });
+});
+
+describe("resolveContentPublicRowFiles", () => {
+ it("replaces the exposed identifier with its descriptor", async () => {
+ const { ctx } = makeCtx([fileRow(1)]);
+
+ const [row] = await resolveContentPublicRowFiles(
+ ctx,
+ testFilePostContentType,
+ [{ cover: 1, id: 7, slug: "hello" }],
+ );
+
+ expect(row.cover).toMatchObject({ id: 1, name: "cover-1.webp" });
+ });
+
+ /**
+ * `animation` and `document` are declared but not in `publicApi.fields`, so
+ * they are never selected in the first place - and must not be resolved here
+ * either, which would make the allowlist advisory.
+ */
+ it("resolves only the fields the allowlist exposes", async () => {
+ const { ctx, select } = makeCtx([fileRow(1)]);
+
+ const [row] = await resolveContentPublicRowFiles(
+ ctx,
+ testFilePostContentType,
+ [{ animation: 2, cover: 1, id: 7 }],
+ );
+
+ expect(row.animation).toBe(2);
+ expect(select).toHaveBeenCalledTimes(1);
+ });
+
+ it("leaves a row alone when the content type exposes no file", async () => {
+ const { ctx, select } = makeCtx([]);
+ const rows = [{ id: 7, title: "Hi" }];
+
+ expect(
+ await resolveContentPublicRowFiles(ctx, testPostContentType, rows),
+ ).toBe(rows);
+ expect(select).not.toHaveBeenCalled();
+ });
+
+ it("answers null for a file that no longer exists", async () => {
+ const { ctx } = makeCtx([]);
+
+ const [row] = await resolveContentPublicRowFiles(
+ ctx,
+ testFilePostContentType,
+ [{ cover: 99, id: 7 }],
+ );
+
+ expect(row.cover).toBeNull();
+ });
+});
+
+describe("assertContentFileReferences", () => {
+ const ok = async (
+ values: Record,
+ rows: ReturnType[],
+ ) => {
+ const { ctx } = makeCtx(rows);
+
+ return await assertContentFileReferences(
+ ctx,
+ testFilePostContentType,
+ values,
+ );
+ };
+
+ const rejection = async (
+ values: Record,
+ rows: ReturnType[],
+ ) => {
+ const { ctx } = makeCtx(rows);
+
+ return await assertContentFileReferences(
+ ctx,
+ testFilePostContentType,
+ values,
+ )
+ .then(() => null)
+ .catch((error: unknown) => error as ContentFileReferenceError);
+ };
+
+ it("accepts a file that fits the field it is assigned to", async () => {
+ await expect(
+ ok({ cover: 1 }, [fileRow(1, { name: "hero.webp" })]),
+ ).resolves.toBeUndefined();
+ });
+
+ it("issues no statement when the payload names no file", async () => {
+ const { ctx, select } = makeCtx([]);
+
+ await assertContentFileReferences(ctx, testFilePostContentType, {
+ title: "Hi",
+ });
+
+ expect(select).not.toHaveBeenCalled();
+ });
+
+ it("issues no statement for a content type with no file fields", async () => {
+ const { ctx, select } = makeCtx([]);
+
+ await assertContentFileReferences(ctx, testPostContentType, { cover: 1 });
+
+ expect(select).not.toHaveBeenCalled();
+ });
+
+ it("refuses an identifier with no row behind it", async () => {
+ const error = await rejection({ cover: 99 }, []);
+
+ expect(error?.code).toBe("CONTENT_FILE_NOT_FOUND");
+ expect(error?.field).toBe("cover");
+ });
+
+ /**
+ * The attack this exists for: uploading a PDF through the `document` field's
+ * route - where it is perfectly valid - and then assigning its id to
+ * `animation`, which accepts GIF only. A successful upload is not a valid
+ * assignment.
+ */
+ it("refuses an existing PDF assigned to a GIF-only field", async () => {
+ const error = await rejection({ animation: 5 }, [
+ fileRow(5, { mimeType: "application/pdf", name: "spec.pdf" }),
+ ]);
+
+ expect(error?.code).toBe("CONTENT_FILE_MIME_TYPE_NOT_ALLOWED");
+ expect(error?.field).toBe("animation");
+ });
+
+ it("refuses an existing PNG assigned to a GIF-only field", async () => {
+ const error = await rejection({ animation: 6 }, [
+ fileRow(6, { mimeType: "image/png", name: "shot.png" }),
+ ]);
+
+ expect(error?.code).toBe("CONTENT_FILE_MIME_TYPE_NOT_ALLOWED");
+ });
+
+ it("refuses a file whose extension matches but whose type does not", async () => {
+ const error = await rejection({ animation: 7 }, [
+ fileRow(7, { mimeType: "image/png", name: "renamed.gif" }),
+ ]);
+
+ expect(error?.code).toBe("CONTENT_FILE_MIME_TYPE_NOT_ALLOWED");
+ });
+
+ it("refuses a file whose type matches but whose extension does not", async () => {
+ const error = await rejection({ animation: 8 }, [
+ fileRow(8, { mimeType: "image/gif", name: "renamed.png" }),
+ ]);
+
+ expect(error?.code).toBe("CONTENT_FILE_EXTENSION_NOT_ALLOWED");
+ });
+
+ it("refuses a file that outgrew the field's ceiling", async () => {
+ const error = await rejection({ animation: 9 }, [
+ fileRow(9, {
+ mimeType: "image/gif",
+ name: "huge.gif",
+ size: 10 * 1024 * 1024 + 1,
+ }),
+ ]);
+
+ expect(error?.code).toBe("CONTENT_FILE_TOO_LARGE");
+ });
+
+ it("answers with a 400-shaped error the routes already map", async () => {
+ const error = await rejection({ cover: 99 }, []);
+
+ // `ContentInputError` is what `rethrowAsHttpError` turns into a 400 with the
+ // message intact, so this needs no new error channel.
+ expect(error).toBeInstanceOf(ContentInputError);
+ expect(error?.message).not.toContain("core_files");
+ });
+
+ it("checks every named field, not just the first", async () => {
+ const error = await rejection({ animation: 6, cover: 1 }, [
+ fileRow(1),
+ fileRow(6, { mimeType: "image/png", name: "shot.png" }),
+ ]);
+
+ expect(error?.field).toBe("animation");
+ });
+});
+
+describe("contentSnapshotFileIds", () => {
+ it("finds the file ids a snapshot names", () => {
+ expect(
+ contentSnapshotFileIds(testFilePostContentType, {
+ fields: { animation: 2, cover: 1, document: null, title: "Hi" },
+ }),
+ ).toEqual([1, 2]);
+ });
+
+ it("deduplicates, so one pin exists per file", () => {
+ expect(
+ contentSnapshotFileIds(testFilePostContentType, {
+ fields: { animation: 1, cover: 1, document: 1 },
+ }),
+ ).toEqual([1]);
+ });
+
+ it("is empty for a content type with no file fields", () => {
+ expect(
+ contentSnapshotFileIds(testPostContentType, { fields: { title: "Hi" } }),
+ ).toEqual([]);
+ });
+
+ it("is empty for a snapshot that names no file", () => {
+ expect(
+ contentSnapshotFileIds(testFilePostContentType, {
+ fields: { cover: null },
+ }),
+ ).toEqual([]);
+ });
+});
diff --git a/packages/vitnode/src/content/server/files.ts b/packages/vitnode/src/content/server/files.ts
new file mode 100644
index 000000000..5da5551ea
--- /dev/null
+++ b/packages/vitnode/src/content/server/files.ts
@@ -0,0 +1,366 @@
+import type { Context } from "hono";
+
+import { inArray } from "drizzle-orm";
+
+import type { StorageFileUploadResult } from "../../api/models/storage";
+import type {
+ ContentFileConstraints,
+ ContentFileDescriptor,
+ ContentFileRejection,
+} from "../files";
+import type { AnyContentTypeDefinition, ContentFileField } from "../types";
+import type { ContentDatabase } from "./service";
+
+import { core_files } from "../../database/files";
+import { parseImageDimensions } from "../../lib/api/upload";
+import { CONTENT_FILE_CODES } from "../const";
+import { ContentInputError } from "../errors";
+import { contentFileConstraints, validateContentFile } from "../files";
+import { partitionContentFields } from "../localization";
+
+/**
+ * The file fields of a content type, by name.
+ *
+ * Always shared - `localized: true` is refused on a file field - so this reads
+ * the shared half of the partition and nothing else. An empty object for every
+ * content type that declares none, which is what lets every caller below be a
+ * cheap early return rather than a conditional at the call site.
+ */
+export const contentFileFields = (
+ definition: AnyContentTypeDefinition,
+): Record => {
+ const { sharedFields } = partitionContentFields(definition.fields);
+ const files: Record = {};
+
+ for (const [name, fieldValue] of Object.entries(sharedFields)) {
+ if (fieldValue.kind === "file") files[name] = fieldValue;
+ }
+
+ return files;
+};
+
+/** The columns a descriptor is built from. Never `key`, and never `metadata`. */
+const fileSelection = {
+ id: core_files.id,
+ key: core_files.key,
+ metadata: core_files.metadata,
+ mimeType: core_files.mimeType,
+ name: core_files.name,
+ size: core_files.size,
+};
+
+interface ContentFileRow {
+ id: number;
+ key: string;
+ metadata: null | Record;
+ mimeType: null | string;
+ name: string;
+ size: number;
+}
+
+/**
+ * One `core_files` row, reduced to the shape every surface may see.
+ *
+ * The allowlist is the function: `key` is read to build the URL and then
+ * dropped, `metadata` is read for the pixel dimensions and then dropped, and
+ * `userId` and `pluginId` are never selected at all. So "forward the file row"
+ * is not something a caller can do by accident.
+ *
+ * `url` is `""` when the install has no storage adapter configured. Every row in
+ * `core_files` was uploaded through one, so this only happens after an adapter is
+ * removed - and an empty string is the honest answer, where `getUrl` would throw
+ * a 500 into the middle of an otherwise fine list response.
+ */
+const toDescriptor = (
+ row: ContentFileRow,
+ url: (key: string) => string,
+): ContentFileDescriptor => {
+ const dimensions = parseImageDimensions(row.metadata);
+
+ return {
+ id: row.id,
+ mimeType: row.mimeType,
+ name: row.name,
+ size: row.size,
+ url: url(row.key),
+ ...(dimensions
+ ? { height: dimensions.height, width: dimensions.width }
+ : {}),
+ };
+};
+
+/**
+ * Reads the descriptors for a set of `core_files` ids, in one statement.
+ *
+ * One query for a whole page, never one per row: a list of twenty articles with a
+ * cover image each is one `WHERE id IN (...)`. An id with no row is simply absent
+ * from the map, which every caller reads as "no file" - a deleted file cannot
+ * happen while a content row points at it, but a *snapshot* may name one.
+ */
+export const resolveContentFileDescriptors = async (
+ c: Context,
+ ids: readonly number[],
+ tx?: ContentDatabase,
+): Promise