From 03d3ad92a1ee44270322d406e59ac82298892e17 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Fri, 21 Aug 2026 10:41:38 +0200 Subject: [PATCH 1/6] feat(content-engine): Add field.file() with generated multipart uploads `field.file()` stores one file by reference: the column is an `integer` foreign key into `core_files` with `ON DELETE RESTRICT`, and nothing about the file is copied onto the content row. - `maxBytes` is mandatory - there is intentionally no unlimited file field, and a zero, negative, fractional or infinite value is a definition-time error. - `allowedExtensions` and `allowedMimeTypes` are two independent rules; with both set, both must match, so a PNG renamed to `.gif` is refused. Extensions normalise, so `GIF`, `.gif` and `.Gif` are one rule. - One generated multipart route per content type, `POST /admin/content/{module}/uploads/{field}`, driven from the browser by TanStack Query. No binary ever crosses a Server Action; the content mutation stays JSON carrying the identifier. - A save re-validates the referenced `core_files` row, so uploading a PDF for one field cannot assign it to a GIF-only one. - `StorageModel.deleteFile` now deletes the row first and the blob second: still referenced answers 409 `FILE_IN_USE` with the bytes intact. - Retained revisions pin the files their snapshots name in `core_content_file_refs`, so an old revision stays restorable and pruning releases the file through the cascade. - Reusable `AutoFormFile` with drag & drop, replace, remove, image preview and a constraint line that always shows the allowed formats and maximum size - read from the same descriptor the server validates against. - Blog articles gain a shared `coverImage` and a localized `coverImageAlt`; `example.article` gains a GIF-only `animation` field as the strict reference. Co-Authored-By: Claude Opus 5 (1M context) --- .../docs/dev/content-engine/fields.mdx | 214 + apps/docs/content/docs/dev/storage/index.mdx | 152 +- .../migration.sql | 19 + .../snapshot.json | 7611 +++++++++++++++++ apps/docs/src/locales/@vitnode/blog/pl.json | 5 + apps/docs/src/locales/@vitnode/core/pl.json | 13 + .../src/api/models/storage-image.test.ts | 10 +- .../vitnode/src/api/models/storage.test.ts | 144 +- packages/vitnode/src/api/models/storage.ts | 116 +- .../admin/files/routes/delete.route.ts | 9 + .../users/files/routes/delete.route.ts | 9 + .../src/components/form/fields/file.tsx | 347 + packages/vitnode/src/content/admin/spec.ts | 40 + packages/vitnode/src/content/admin/upload.ts | 96 + packages/vitnode/src/content/const.ts | 48 + packages/vitnode/src/content/define-admin.ts | 34 + packages/vitnode/src/content/define-fields.ts | 93 + .../vitnode/src/content/define-public-api.ts | 12 + packages/vitnode/src/content/fields.ts | 75 + .../vitnode/src/content/file-field.test.ts | 355 + packages/vitnode/src/content/files.test.ts | 402 + packages/vitnode/src/content/files.ts | 234 + packages/vitnode/src/content/index.ts | 29 + packages/vitnode/src/content/indexes.ts | 9 +- packages/vitnode/src/content/schemas.ts | 21 + .../src/content/server/column-builders.ts | 21 + .../src/content/server/editorial-service.ts | 48 +- .../src/content/server/file-revisions.test.ts | 269 + .../content/server/file-upload-route.test.ts | 330 + .../vitnode/src/content/server/files.test.ts | 405 + packages/vitnode/src/content/server/files.ts | 355 + .../vitnode/src/content/server/http-errors.ts | 31 +- packages/vitnode/src/content/server/index.ts | 10 + .../server/localized-public-service.ts | 14 +- .../src/content/server/public-routes.ts | 10 +- .../src/content/server/public-service.ts | 14 +- .../src/content/server/revisions-model.ts | 34 +- packages/vitnode/src/content/server/routes.ts | 195 +- .../vitnode/src/content/server/service.ts | 9 + packages/vitnode/src/content/server/table.ts | 12 + packages/vitnode/src/content/types.ts | 55 +- packages/vitnode/src/database/content.ts | 58 + packages/vitnode/src/lib/api/pg-error.ts | 47 + packages/vitnode/src/lib/api/upload.ts | 28 +- packages/vitnode/src/lib/file-constraints.ts | 153 + packages/vitnode/src/lib/file-extension.ts | 35 + packages/vitnode/src/locales/en.json | 16 + .../vitnode/src/tests/content-fixtures.ts | 47 + .../views/content/actions/content-form.tsx | 22 + .../views/content/lib/field-component.tsx | 43 + .../views/admin/views/content/table/cells.tsx | 44 + .../blog/src/content/content-types.test.ts | 79 + plugins/blog/src/content/post.ts | 54 +- plugins/blog/src/locales/en.json | 5 + .../src/views/admin/article/form-layout.tsx | 13 + plugins/example/src/const.ts | 5 + plugins/example/src/content/article.ts | 34 + .../example/src/content/file-fields.test.ts | 104 + plugins/example/src/database/tables.test.ts | 4 + plugins/example/src/locales/en.json | 1 + 60 files changed, 12586 insertions(+), 115 deletions(-) create mode 100644 apps/docs/migrations/20260821081509_add_content_file_fields/migration.sql create mode 100644 apps/docs/migrations/20260821081509_add_content_file_fields/snapshot.json create mode 100644 packages/vitnode/src/components/form/fields/file.tsx create mode 100644 packages/vitnode/src/content/admin/upload.ts create mode 100644 packages/vitnode/src/content/file-field.test.ts create mode 100644 packages/vitnode/src/content/files.test.ts create mode 100644 packages/vitnode/src/content/files.ts create mode 100644 packages/vitnode/src/content/server/file-revisions.test.ts create mode 100644 packages/vitnode/src/content/server/file-upload-route.test.ts create mode 100644 packages/vitnode/src/content/server/files.test.ts create mode 100644 packages/vitnode/src/content/server/files.ts create mode 100644 packages/vitnode/src/lib/api/pg-error.ts create mode 100644 packages/vitnode/src/lib/file-constraints.ts create mode 100644 packages/vitnode/src/lib/file-extension.ts create mode 100644 plugins/example/src/content/file-fields.test.ts diff --git a/apps/docs/content/docs/dev/content-engine/fields.mdx b/apps/docs/content/docs/dev/content-engine/fields.mdx index 89c896505..50ff68edf 100644 --- a/apps/docs/content/docs/dev/content-engine/fields.mdx +++ b/apps/docs/content/docs/dev/content-engine/fields.mdx @@ -19,6 +19,7 @@ Content Engine provides field descriptors exported from `@vitnode/core/content` | `field.user()` | `integer()` (FK to `core_users`) | `z.number()` | User Picker Combobox | | `field.user({ multiple: true })` | Junction table (FK to `core_users`) | `z.array(z.number())` | Multi-select People Combobox | | `field.slug()` | `varchar(length)` | `z.string()` | Slug Input + Auto-Generator | +| `field.file()` | `integer()` (FK to `core_files`, `ON DELETE RESTRICT`) | `z.number()` | Uploader (drag & drop + picker) | --- @@ -131,6 +132,218 @@ categories: field.relation({ min: 1, multiple: true, target: () => categoryType + +### Step 5: Attach a File + +`field.file()` stores **one** file, by reference: the column is an `integer` +foreign key into `core_files` with `ON DELETE RESTRICT`, and nothing about the +file - not the name, not the URL, not the storage key - is copied onto the +content row. + +```ts title="src/content/article.ts" +export const articleContentType = defineContentType({ + id: "example.article", + tableName: "example_articles", + fields: { + title: field.text({ required: true }), + coverImage: field.file({ // [!code ++:9] + maxBytes: 5 * 1024 * 1024, // 5 MB - required + allowedExtensions: [".jpg", ".jpeg", ".png", ".webp", ".avif"], + allowedMimeTypes: [ + "image/jpeg", + "image/png", + "image/webp", + "image/avif", + ], + }), + }, +}); +``` + +You need no `references` entry for it. There is one files table in an +installation, so the engine resolves it for you - exactly as it does for +`field.user()`. + +#### `maxBytes` - mandatory, always + + +Every file field must state its ceiling. `field.file({})` does not compile, and a +value that is zero, negative, fractional or infinite is refused at **definition +time** - when your plugin is imported, not when somebody uploads: + +```ts +field.file({ maxBytes: 0 }); // ✗ must be greater than zero +field.file({ maxBytes: 1.5 }); // ✗ not a whole number of bytes +field.file({ maxBytes: 5 * 1024 * 1024 }); // ✓ +``` + +A default here would be a number nobody chose, applied to every field in every +plugin - and the ceiling is the only thing between a form and an upload that +fills your disk. + + +#### `allowedExtensions` - what the file is *called* + +Extensions are case-insensitive and normalised to lowercase with a leading dot, +so `GIF`, `.gif` and `.Gif` are **one** rule: + +```ts +animation: field.file({ + maxBytes: 10 * 1024 * 1024, + allowedExtensions: [".gif"], + allowedMimeTypes: ["image/gif"], +}) +``` + +An empty array, an empty string, a bare `"."` and a compound `".tar.gz"` are all +definition-time errors. (`.tar.gz` because only the last segment is ever read, so +the rule would silently match nothing.) + +#### `allowedMimeTypes` - what the bytes are *declared* to be + +A separate rule, not the same one spelled twice. **If both are configured, both +must match:** + +| File name | Declared type | GIF-only field | +| :--- | :--- | :--- | +| `banner.gif` | `image/gif` | ✅ accepted | +| `banner.png` | `image/png` | ❌ wrong extension *and* wrong type | +| `banner.gif` | `image/png` | ❌ a PNG renamed to `.gif` | +| `banner.png` | `image/gif` | ❌ right bytes, wrong name | + +That third row is the whole point. **Never rely on the extension alone** - it is +a string somebody chose. For a strict field, state both. + +```ts +document: field.file({ + maxBytes: 20 * 1024 * 1024, + allowedExtensions: [".pdf"], + allowedMimeTypes: ["application/pdf"], +}) +``` + +#### Image processing changes the stored format + +With [`storage.image`](/docs/dev/storage#image-optimization) configured, VitNode +re-encodes uploaded images to WebP - so a `hero.png` is *stored* as `hero.webp`. +Include the converted format in both allowlists, or the upload route will refuse +the file it just created and tell you why: + +```ts +allowedExtensions: [".jpg", ".jpeg", ".png", ".webp", ".avif"], +allowedMimeTypes: ["image/jpeg", "image/png", "image/webp", "image/avif"], +``` + +GIF and SVG are never re-encoded, which is why a GIF-only field needs no extra +entries. + +#### The AdminCP shows the constraints, always + +Every generated file field renders its allowed formats and its maximum size +above the drop zone - not only when something goes wrong: + +```text +Cover image + +JPG, JPEG, PNG, WEBP, AVIF +Maximum file size: 5 MB + +┌──────────────────────────────┐ +│ Drop a file here │ +│ or │ +│ [ Choose file ] │ +└──────────────────────────────┘ +``` + +The formats and the size come **from the field descriptor** - the same object the +API validates against - so the screen cannot advertise a rule the server does not +enforce. Extensions are preferred over media types in that line, because +`JPG, PNG, WEBP` is what a person recognises and `image/jpeg, image/png` is not. +The native picker's `accept` gets both (`.gif,image/gif`), which is UX only: the +server checks everything again. + +#### Uploads never go through a Server Action + + +Content Engine binary uploads use TanStack Query and multipart API routes. +Next.js Server Actions must **not** be used for file transfer. + + +The flow is: + +```text +AutoFormFile + → TanStack Query useMutation() + → FormData + → POST /api/{pluginId}/admin/content/{module}/uploads/{field} + → StorageModel.upload() + → core_files + → field.onChange(file.id) +``` + +The upload route is generated for you, once per content type, addressed by field +name. It resolves the field, requires `can_create` **or** `can_edit`, enforces +`maxBytes`, `allowedMimeTypes` and `allowedExtensions`, stores the file through +the ordinary storage adapter, and returns a normalised descriptor: + +```json +{ + "id": 42, + "name": "cover.webp", + "url": "https://…", + "mimeType": "image/webp", + "size": 245123, + "width": 1600, + "height": 900 +} +``` + +The content mutation that follows is ordinary JSON: + +```json +{ "coverImage": 42 } +``` + +No base64, no binary through a Server Action, and the form value is the same size +whether the image is 4 KB or 4 MB. + +#### Uploading is not the same as assigning + +A successful upload proves the file was valid **for the field it was uploaded +for**. When a mutation arrives carrying `{ "coverImage": 42 }`, the engine reads +that `core_files` row and checks it again - it exists, it is within `maxBytes`, +its media type is allowed, its extension is allowed. So this is refused: + +```ts +animation: field.file({ + maxBytes: 10 * 1024 * 1024, + allowedExtensions: [".gif"], + allowedMimeTypes: ["image/gif"], +}) +``` + +```json +{ "animation": 42 } // 42 is the PDF you uploaded for `document` → 400 +``` + +#### What a file field may not be + +| | | +| :--- | :--- | +| `localized: true` | ❌ refused - a file is one object with one storage key. Pair it with a localized `field.text()` for the alt text. | +| `multiple` | Not in this release. One file per field. | +| orderable / `titleField` / `colorField` | ❌ refused - the column holds an upload order, which is not something anybody chose. | +| filterable / searchable | ❌ refused by kind. | +| `publicApi.fields` | ✅ allowed - it crosses as the descriptor above, never as the identifier, and never with the storage key, the uploader or the metadata bag. | + +#### List columns + +A file field named in `admin.list.columns` renders a thumbnail (for an image) or a +file icon, plus the stored file name - never the raw identifier. A +`columns..cell` override in `buildPlugin` still wins. + + + --- @@ -143,3 +356,4 @@ For nested structures, arrays, relationships, and multi-language support: - **`field.structured()`**: Stores nested Zod objects in a Postgres `jsonb` column. - **`field.repeatable()`**: Stores arrays of items in a Postgres `jsonb` column. - **`localized: true`**: Marks text or textarea fields for translation. See [Localization](/docs/dev/content-engine/localization). +- **`field.file()`**: References one stored file. See [Storage](/docs/dev/storage#content-engine-file-fields) for how the row, `core_files` and the adapter fit together. diff --git a/apps/docs/content/docs/dev/storage/index.mdx b/apps/docs/content/docs/dev/storage/index.mdx index ea14f6a3a..1150f0954 100644 --- a/apps/docs/content/docs/dev/storage/index.mdx +++ b/apps/docs/content/docs/dev/storage/index.mdx @@ -10,7 +10,13 @@ and every upload returns a public URL. VitNode does **not** ship a generic upload endpoint - you build your own route (in your app or plugin) so you control auth, validation, and where files go. The `c.get("storage").upload()` helper does the heavy lifting: it builds the dated -key, validates the file, and returns the URL. +key, validates the file, records it in `core_files`, and returns the new row's id +alongside the URL. + +The one exception is the [Content Engine](/docs/dev/content-engine): declare a +`field.file()` and the upload route, the validation and the AdminCP uploader are +generated for you - see [Content Engine file +fields](#content-engine-file-fields). Storage is **optional**. Without an adapter configured, `c.get("storage")` throws and the AdminCP → System → Integrations "Storage" card shows as inactive. @@ -64,6 +70,138 @@ export const vitNodeApiConfig = buildApiConfig({ }); ``` +## Content Engine file fields + +If your content lives in a [Content Engine](/docs/dev/content-engine) content +type, you do **not** write an upload endpoint at all. Declare a +[`field.file()`](/docs/dev/content-engine/fields) and the engine generates one: + +```ts title="plugins/blog/src/content/post.ts" +coverImage: field.file({ + maxBytes: 5 * 1024 * 1024, + allowedExtensions: [".jpg", ".jpeg", ".png", ".webp", ".avif"], + allowedMimeTypes: ["image/jpeg", "image/png", "image/webp", "image/avif"], +}), +coverImageAlt: field.text({ localized: true, nullable: true, maxLength: 255 }), +``` + +That is the blog's real cover image, and it is worth reading as a pair: the +**file is shared** and the **alt text is per language**. One image, one storage +key, one row in `core_files` - and a description of it in every language somebody +reads the article in. + +### Three layers, one fact each + +```text +Content row blog_posts.coverImage = 42 + └─ an integer foreign key, ON DELETE RESTRICT + ↓ +core_files id 42 · name · key · mimeType · size · metadata + └─ the record of the object + ↓ +Storage adapter month_8_2026/content/.webp + └─ the bytes +``` + +Nothing is duplicated between the layers. The content row does not hold a URL, a +storage key or a copy of the size, so replacing the image is one write and there +is no second copy to go stale. The URL is built on read, from the adapter that is +configured *now*. + +### Deleting a file that is in use + +`ON DELETE RESTRICT` is what makes that safe, and +`c.get("storage").deleteFile(id)` is written to take advantage of it: it deletes +the **database row first** and only then the object. + +| Situation | Result | +| :--- | :--- | +| Still referenced by content or by a retained revision | `409 FILE_IN_USE`, and the object is untouched | +| Row removed | The object is deleted (best-effort) | +| No such file | `404` | + +The order matters. Deleting the object first would mean a referenced file loses +its bytes and *then* has its removal refused - the article survives, pointing at +a 404. VitNode prefers an orphaned storage object to a content record pointing at +missing bytes: one costs disk, the other costs a broken page nobody can repair +from the AdminCP. + +### Revisions keep old files alive + +A revision's snapshot is JSONB, so `{ "coverImage": 42 }` inside it is a number +Postgres knows nothing about. Change an article's cover and the row's own foreign +key stops guarding the old image - which would make it deletable while every +retained revision still names it, and "restore version 3" would restore a broken +image. + +So each revision pins the files its snapshot names, in +`core_content_file_refs`: + +```text +article → file A revision v1 pins A +article → file B revision v2 pins B; the column no longer guards A +delete A refused — the v1 pin references core_files ON DELETE RESTRICT +v1 pruned the pin cascades away with the revision +delete A succeeds — nothing references it any more +``` + +There is no unpinning code: the pin references the revision `ON DELETE CASCADE`, +so retention pruning releases it in the same statement that removes the revision. + +### Uploads are multipart, never a Server Action + + +Content Engine binary uploads use TanStack Query and multipart API routes. +Next.js Server Actions must **not** be used for file transfer - a Server Action +body is a serialised RSC payload, so an image crosses as a string, buffered +whole, under a platform body limit that has nothing to do with the field's +`maxBytes`. + + +```text +POST /api/{pluginId}/admin/content/{module}/uploads/{field} +``` + +One route per content type, addressed by field name. It requires `can_create` or +`can_edit`, enforces the field's `maxBytes`, `allowedMimeTypes` and +`allowedExtensions`, calls the same `c.get("storage").upload()` every hand-written +route uses, and returns a descriptor the form drops straight into its value: + +```json +{ + "id": 42, + "name": "cover.webp", + "url": "https://…", + "mimeType": "image/webp", + "size": 245123, + "width": 1600, + "height": 900 +} +``` + +`key`, `userId`, `pluginId` and the raw `metadata` bag are never in it - not on +the admin side and not in a public response. + + + With `image` enabled above, a PNG is stored as WebP - so `.webp` and + `image/webp` have to be in the field's allowlists, which is why the blog's cover + image lists them. If the converted format is not allowed, the upload route + refuses the file it just created, removes it, and says so. + + +### The AdminCP shows the limits + +Every generated file field renders its allowed formats and its maximum size above +the drop zone, always - read from the field descriptor, which is the same object +the server validates against: + +```text +Cover image + +JPG, JPEG, PNG, WEBP, AVIF +Maximum file size: 5 MB +``` + ## Create your own upload endpoint Define a route that accepts `multipart/form-data`, then call @@ -129,9 +267,17 @@ export const uploadAvatarRoute = buildRoute({ }); ``` +`upload()` returns more than the URL: `{ id, key, url, name, mimeType, size, +dimensions }`. `id` is the new `core_files` row - the identifier a +[`field.file()`](/docs/dev/content-engine/fields) column holds, and the one to +store if you are modelling a reference yourself. `name` is the *stored* name, +which a WebP conversion may have changed. + Register it in a module with `buildModule` and add that module to your plugin — -see [Plugins](/docs/dev/plugins). You can also `c.get("storage").delete(key)` and -`c.get("storage").getUrl(key)`. +see [Plugins](/docs/dev/plugins). You can also `c.get("storage").delete(key)`, +`c.get("storage").getUrl(key)` and `c.get("storage").deleteFile(id)` - the last +of which refuses with `409 FILE_IN_USE` rather than orphaning whatever still +points at the file. diff --git a/apps/docs/migrations/20260821081509_add_content_file_fields/migration.sql b/apps/docs/migrations/20260821081509_add_content_file_fields/migration.sql new file mode 100644 index 000000000..2a06648d3 --- /dev/null +++ b/apps/docs/migrations/20260821081509_add_content_file_fields/migration.sql @@ -0,0 +1,19 @@ +CREATE TABLE "core_content_file_refs" ( + "id" serial PRIMARY KEY, + "revisionId" integer NOT NULL, + "fileId" integer NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_content_file_refs" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "blog_posts" ADD COLUMN "coverImage" integer;--> statement-breakpoint +ALTER TABLE "blog_posts_translations" ADD COLUMN "coverImageAlt" varchar(255);--> statement-breakpoint +ALTER TABLE "example_articles" ADD COLUMN "animation" integer;--> statement-breakpoint +CREATE UNIQUE INDEX "core_content_file_refs_unique" ON "core_content_file_refs" ("revisionId","fileId");--> statement-breakpoint +CREATE INDEX "core_content_file_refs_file_id_idx" ON "core_content_file_refs" ("fileId");--> statement-breakpoint +CREATE INDEX "blog_posts_cover_image_idx" ON "blog_posts" ("coverImage");--> statement-breakpoint +CREATE INDEX "example_articles_animation_idx" ON "example_articles" ("animation");--> statement-breakpoint +ALTER TABLE "core_content_file_refs" ADD CONSTRAINT "core_content_file_refs_5get6SfhBPHr_fkey" FOREIGN KEY ("revisionId") REFERENCES "core_content_revisions"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "core_content_file_refs" ADD CONSTRAINT "core_content_file_refs_fileId_core_files_id_fkey" FOREIGN KEY ("fileId") REFERENCES "core_files"("id") ON DELETE RESTRICT ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "blog_posts" ADD CONSTRAINT "blog_posts_coverImage_core_files_id_fkey" FOREIGN KEY ("coverImage") REFERENCES "core_files"("id") ON DELETE RESTRICT ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "example_articles" ADD CONSTRAINT "example_articles_animation_core_files_id_fkey" FOREIGN KEY ("animation") REFERENCES "core_files"("id") ON DELETE RESTRICT ON UPDATE CASCADE; \ No newline at end of file diff --git a/apps/docs/migrations/20260821081509_add_content_file_fields/snapshot.json b/apps/docs/migrations/20260821081509_add_content_file_fields/snapshot.json new file mode 100644 index 000000000..f58b8f959 --- /dev/null +++ b/apps/docs/migrations/20260821081509_add_content_file_fields/snapshot.json @@ -0,0 +1,7611 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "14c067b3-663a-4b57-b81c-695fc106cc77", + "prevIds": [ + "7734935b-4c55-40b8-ba19-41961e485bb4" + ], + "ddl": [ + { + "isRlsEnabled": true, + "name": "core_admin_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_file_refs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_revisions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_schedules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_slug_history", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_cron", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_dashboard", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages_words", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_logs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_index", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_secrets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions_known_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_confirm_emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_forgot_password", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_secondary_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_author_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_category_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_related_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "operation", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "changedFields", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'system'", + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorUserId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "restoredFromRevisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scheduledFor", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "generation", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "effectsError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "retiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nextRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "schedule", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "widgets", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "folder", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mimeType", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "size", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'UTC'", + "generated": null, + "identity": null, + "name": "timezone", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "time24", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tableName", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "variable", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'GET'", + "generated": null, + "identity": null, + "name": "method", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'localhost'", + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "500", + "generated": null, + "identity": null, + "name": "statusCode", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "test123", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "queue", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "root", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "guest", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowUploadFiles", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalMaxStorage", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxStorageForSubmit", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + }, + "identity": null, + "name": "search_vector", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isPublic", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "indexedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nameCode", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarColor", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailVerified", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "birthday", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImage", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "friendlyUrl", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImageAlt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_token_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "revisionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_file_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_item_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_translation_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_actor_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduledFor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_shared_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_locale_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_dashboard_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lang_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_status_available_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "search_vector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_search_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_author_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_item_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_language_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isPublic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_is_public_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ipAddress", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_known_devices_ip_address_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "nameCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_name_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "email", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_email_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverImage", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_cover_image_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_code_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "revisionId" + ], + "schemaTo": "public", + "tableTo": "core_content_revisions", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_file_refs_5get6SfhBPHr_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "core_content_file_refs_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "actorUserId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": false, + "columns": [ + "createdBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_schedules_createdBy_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_dashboard_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_languages_words_languageCode_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_logs_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_search_index_authorId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "core_users_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "language" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET DEFAULT", + "name": "core_users_language_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_forgot_password_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_categories_translations_itemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_categories_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "coverImage" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_coverImage_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_relatedItemId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_category_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_category_id_relatedItemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_categories_itemId_example_advanced_ar", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_categories_relatedItemId_example_cate", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_itemId_example_advan", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_relatedItemId_exampl", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_translations_itemId_example_advanced_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_translations_languageId_core_language", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_articles_author_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_category_example_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_localized_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_localized_articles_translations_itemId_example_localize", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_localized_articles_translations_languageId_core_languag", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "userId", + "roleId" + ], + "nameExplicit": false, + "name": "core_users_secondary_roles_userId_roleId_pk", + "entityType": "pks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_categories_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_author_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_category_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_posts_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_related_articles_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_localized_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_permissions_pkey", + "schema": "public", + "table": "core_admin_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_sessions_pkey", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_file_refs_pkey", + "schema": "public", + "table": "core_content_file_refs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_revisions_pkey", + "schema": "public", + "table": "core_content_revisions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_schedules_pkey", + "schema": "public", + "table": "core_content_schedules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_slug_history_pkey", + "schema": "public", + "table": "core_content_slug_history", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_cron_pkey", + "schema": "public", + "table": "core_cron", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_dashboard_pkey", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_pkey", + "schema": "public", + "table": "core_files", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_pkey", + "schema": "public", + "table": "core_languages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_words_pkey", + "schema": "public", + "table": "core_languages_words", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_logs_pkey", + "schema": "public", + "table": "core_logs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderators_permissions_pkey", + "schema": "public", + "table": "core_moderators_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_queue_pkey", + "schema": "public", + "table": "core_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_roles_pkey", + "schema": "public", + "table": "core_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_search_index_pkey", + "schema": "public", + "table": "core_search_index", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_secrets_pkey", + "schema": "public", + "table": "core_secrets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_pkey", + "schema": "public", + "table": "core_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_known_devices_pkey", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_pkey", + "schema": "public", + "table": "core_users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_confirm_emails_pkey", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_forgot_password_pkey", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_categories_pkey", + "schema": "public", + "table": "blog_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_pkey", + "schema": "public", + "table": "example_advanced_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_faq_pkey", + "schema": "public", + "table": "example_advanced_articles_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_articles_pkey", + "schema": "public", + "table": "example_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_categories_pkey", + "schema": "public", + "table": "example_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_localized_articles_pkey", + "schema": "public", + "table": "example_localized_articles", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": [ + "itemType", + "itemId", + "languageCode" + ], + "nullsNotDistinct": false, + "name": "core_search_index_item_unique", + "entityType": "uniques", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_admin_sessions_token_unique", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_admin_dashboard_userId_unique", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "key" + ], + "nullsNotDistinct": false, + "name": "core_files_key_unique", + "schema": "public", + "table": "core_files", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "code" + ], + "nullsNotDistinct": false, + "name": "core_languages_code_unique", + "schema": "public", + "table": "core_languages", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_sessions_token_unique", + "schema": "public", + "table": "core_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "publicId" + ], + "nullsNotDistinct": false, + "name": "core_sessions_known_devices_publicId_unique", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "nameCode" + ], + "nullsNotDistinct": false, + "name": "core_users_nameCode_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "name" + ], + "nullsNotDistinct": false, + "name": "core_users_name_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "core_users_email_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_confirm_emails_token_unique", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_userId_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_token_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + } + ], + "renames": [] +} \ No newline at end of file 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/vitnode/src/api/models/storage-image.test.ts b/packages/vitnode/src/api/models/storage-image.test.ts index c4659d9fe..5c7189412 100644 --- a/packages/vitnode/src/api/models/storage-image.test.ts +++ b/packages/vitnode/src/api/models/storage-image.test.ts @@ -15,7 +15,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: { 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..c8b3d6613 100644 --- a/packages/vitnode/src/api/models/storage.ts +++ b/packages/vitnode/src/api/models/storage.ts @@ -4,6 +4,7 @@ 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, @@ -33,6 +34,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. @@ -154,11 +180,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 +210,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; + + // 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 (!row) { + 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,7 +250,7 @@ export class StorageModel { maxBytes, metadata, userId, - }: StorageUploadOptions): Promise { + }: StorageUploadOptions): Promise { const provider = this.requireProvider(); if (maxBytes !== undefined && file.size > maxBytes) { @@ -239,16 +293,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: { @@ -257,12 +318,27 @@ export class StorageModel { ? { dimensions: processed.dimensions } : {}), }, - }); + }) + .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 uploaded file could not be recorded", + }); + } + + 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..70181ff8a --- /dev/null +++ b/packages/vitnode/src/components/form/fields/file.tsx @@ -0,0 +1,347 @@ +"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 { + 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; +} + +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); + }, + }); + + const errorMessage = + rejected ?? (upload.error instanceof Error ? upload.error.message : null); + + 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. + */} +
+ + {formats.length > 0 ? formats.join(", ") : t("any_format")} + + + {t("max_size", { size: formatBytes(maxBytes) })} + +
+ + +
+ { + pick(event.target.files?.[0]); + // Cleared so choosing the same file twice still fires `change`. + event.target.value = ""; + }} + ref={inputRef} + tabIndex={-1} + type="file" + /> + + {file && !upload.isPending ? ( + + + {isImage(file) ? ( + // Decorative: the file name is right beside it as real text. + + ) : ( + + )} + + + {file.name} + + {formatBytes(file.size)} + + + + + + + + + + + + ) : ( +
{ + event.preventDefault(); + setIsDragging(false); + }} + onDragOver={event => { + event.preventDefault(); + setIsDragging(true); + }} + onDrop={event => { + event.preventDefault(); + setIsDragging(false); + pick(event.dataTransfer.files[0]); + }} + > + {upload.isPending ? ( + <> + + + {t("uploading")} + + + ) : ( + <> + + + {t("drop")} + + + + )} +
+ )} + + {errorMessage !== null && ( +

+ + {errorMessage} +

+ )} +
+
+ + {!!description && {description}} + + + ); +}; 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.ts b/packages/vitnode/src/content/admin/upload.ts new file mode 100644 index 000000000..f03b1da92 --- /dev/null +++ b/packages/vitnode/src/content/admin/upload.ts @@ -0,0 +1,96 @@ +import type { ContentFileDescriptor } from "../files"; +import type { ContentFormSpec } from "./spec"; + +import { rawApiFetch } from "../../lib/fetcher/raw"; +import { 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; +} + +export class ContentUploadError extends Error { + constructor({ code, message }: ContentUploadRejection) { + super(message); + + this.name = "ContentUploadError"; + this.code = code; + } + + readonly code: string; +} + +/** + * 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 4xx with a JSON body becomes a {@link ContentUploadError} whose message is + * the one the server wrote - it names the field's own limits, so it is worth + * showing verbatim. + */ +export const uploadContentFile = async ({ + field, + file, + spec, +}: { + field: string; + file: File; + spec: Pick; +}): Promise => { + const formData = new FormData(); + formData.append("file", file); + + const response = await rawApiFetch({ + ...contentUploadPath(spec, field), + formData, + method: "post", + options: { credentials: "include" }, + pluginId: spec.pluginId, + prefixPath: "/admin", + }); + + if (!response.ok) { + const rejection: unknown = await response.json().catch(() => null); + const parsed = + rejection !== null && + typeof rejection === "object" && + typeof (rejection as { message?: unknown }).message === "string" + ? (rejection as ContentUploadRejection) + : null; + + throw new ContentUploadError( + parsed ?? { + code: `HTTP_${response.status}`, + message: "The upload failed. Please try again.", + }, + ); + } + + // 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..09a44ef32 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -126,6 +126,48 @@ 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", + mimeType: "CONTENT_FILE_MIME_TYPE_NOT_ALLOWED", + missing: "CONTENT_FILE_NOT_FOUND", + size: "CONTENT_FILE_TOO_LARGE", +} as const; + /** Appended to the base table name to get the generated translation table. */ export const CONTENT_TRANSLATION_TABLE_SUFFIX = "_translations"; @@ -250,6 +292,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..7a0f7f4c1 --- /dev/null +++ b/packages/vitnode/src/content/files.ts @@ -0,0 +1,234 @@ +import { z } from "zod"; + +import type { FileCandidate, FileConstraints } 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; +} + +/** + * 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 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..9986fab13 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,25 @@ export type { ContentUpdatedPayload, } from "./events"; export { field } from "./fields"; +export { + assertContentFileMaxBytes, + contentFileAccept, + contentFileConstraints, + contentFileFormatLabels, + 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-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..820933d73 --- /dev/null +++ b/packages/vitnode/src/content/server/file-upload-route.test.ts @@ -0,0 +1,330 @@ +// 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 { beforeEach, describe, expect, it, vi } from "vitest"; + +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 = ({ + storedAs, + storedMimeType, + storedSize, +}: { + storedAs?: string; + storedMimeType?: string; + storedSize?: number; +} = {}) => { + const upload = vi.fn( + async ({ file }: { file: File }) => + 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-encoded"); + 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); + }); + + 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..867082baf --- /dev/null +++ b/packages/vitnode/src/content/server/files.ts @@ -0,0 +1,355 @@ +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> => { + const unique = [...new Set(ids.filter(id => Number.isInteger(id) && id > 0))]; + if (unique.length === 0) return new Map(); + + const rows = await (tx ?? c.get("db")) + .select(fileSelection) + .from(core_files) + .where(inArray(core_files.id, unique)); + + const hasAdapter = !!c.get("core").storage?.adapter; + const storage = c.get("storage"); + const url = (key: string): string => (hasAdapter ? storage.getUrl(key) : ""); + + return new Map( + (rows as ContentFileRow[]).map(row => [row.id, toDescriptor(row, url)]), + ); +}; + +/** The file ids one set of values actually names, ignoring absent and null. */ +const fileIdsOf = ( + names: readonly string[], + values: Record, +): number[] => + names + .map(name => values[name]) + .filter((value): value is number => typeof value === "number" && value > 0); + +/** + * Attaches each row's resolved file descriptors under `files`. + * + * A **sibling** of the row rather than a replacement of the column, which is the + * opposite of what the public projection does - and deliberately: an admin row is + * what the edit form opens on, and the form submits `coverImage: 42` back. Keeping + * the identifier as the value and the descriptor beside it means the form has both + * without converting either way. + * + * `files` is `{}` for a content type with no file fields, so every generated list + * and detail response that had no files before is byte-identical. + */ +export const withContentRowFiles = async ( + c: Context, + definition: AnyContentTypeDefinition, + rows: readonly TRow[], +): Promise< + (TRow & { files: Record })[] +> => { + const names = Object.keys(contentFileFields(definition)); + if (names.length === 0) { + return rows.map(row => ({ ...row, files: {} })); + } + + const byId = await resolveContentFileDescriptors( + c, + rows.flatMap(row => fileIdsOf(names, row as Record)), + ); + + return rows.map(row => { + const values = row as Record; + + return { + ...row, + files: Object.fromEntries( + names.map(name => { + const id = values[name]; + + return [name, typeof id === "number" ? (byId.get(id) ?? null) : null]; + }), + ), + }; + }); +}; + +/** + * Replaces every exposed file id on a **public** row with its descriptor. + * + * In place of the column rather than beside it, because a public reader has no + * route that turns a `core_files.id` into anything: there is no public files API + * and there should not be one. The descriptor is already the allowlisted shape, + * so the projector needs no file-specific branch - it forwards whatever the + * column holds, exactly as it does for a string. + * + * Only the fields `publicApi.fields` names. A file field the allowlist leaves out + * is not selected in the first place, so there is nothing here to resolve. + */ +export const resolveContentPublicRowFiles = async ( + c: Context, + definition: AnyContentTypeDefinition, + rows: Record[], +): Promise[]> => { + if (!definition.publicApi.enabled || rows.length === 0) return rows; + + const files = contentFileFields(definition); + const names = definition.publicApi.fields.filter( + name => files[name] !== undefined, + ); + if (names.length === 0) return rows; + + const byId = await resolveContentFileDescriptors( + c, + rows.flatMap(row => fileIdsOf(names, row)), + ); + + return rows.map(row => ({ + ...row, + ...Object.fromEntries( + names.map(name => { + const id = row[name]; + + return [name, typeof id === "number" ? (byId.get(id) ?? null) : null]; + }), + ), + })); +}; + +/** + * Re-checks every file a write names against the field that will hold it. + * + * A successful upload is **not** validation of an assignment. The upload route + * checked the file it received against the field it was uploaded for; this checks + * the `core_files` row an identifier names against the field it is being written + * to - which is a different question, and the one that stops + * `{ animation: }` from being + * stored by a hand-written request. + * + * Four questions, the same four the upload asked: does the row exist, is it + * within `maxBytes`, is its media type allowed, and is its extension allowed. + * `validateContentFile` is the one implementation of the last three, so the + * answers cannot differ between the two moments. + * + * A no-op - not one statement - for a content type with no file fields, and for a + * payload that mentions none of them. + */ +export const assertContentFileReferences = async ( + c: Context, + definition: AnyContentTypeDefinition, + values: Record, + tx?: ContentDatabase, +): Promise => { + const files = contentFileFields(definition); + const named = Object.keys(files).filter( + name => typeof values[name] === "number", + ); + if (named.length === 0) return; + + const byId = await resolveContentFileDescriptors( + c, + named.map(name => values[name] as number), + tx, + ); + + for (const name of named) { + const id = values[name] as number; + const descriptor = byId.get(id); + + if (!descriptor) { + throw new ContentFileReferenceError({ + code: CONTENT_FILE_CODES.missing, + contentTypeId: definition.id, + field: name, + message: `File ${id} does not exist, so "${name}" cannot point at it.`, + }); + } + + const rejection = validateContentFile( + contentFileConstraints(files[name]), + descriptor, + ); + if (rejection) { + throw new ContentFileReferenceError({ + code: rejection.code, + contentTypeId: definition.id, + field: name, + message: `File ${id} cannot be used for "${name}": ${rejection.message}`, + }); + } + } +}; + +/** + * A file identifier a content write may not store. + * + * A `ContentInputError`, so the generated routes already answer 400 with the + * message - which names the field and the reason and nothing internal. `code` is + * carried so a caller that wants to point at the field can, exactly as + * `ContentAdvancedInputError` does for a missing relation target. + */ +export class ContentFileReferenceError extends ContentInputError { + constructor({ + code, + contentTypeId, + field, + message, + }: { + code: ContentFileRejection["code"]; + contentTypeId: string; + field: string; + message: string; + }) { + super(message, { contentTypeId }); + + this.name = "ContentFileReferenceError"; + this.code = code; + this.field = field; + } + + readonly code: ContentFileRejection["code"]; + readonly field: string; +} + +/** + * The `core_files` ids a revision snapshot names. + * + * What the revision pin table is built from: a snapshot records + * `{ coverImage: 42 }`, and 42 has to stay deletable-refusing for as long as that + * snapshot is retained - the content row's own foreign key stops protecting it the + * moment the field is pointed somewhere else. + */ +export const contentSnapshotFileIds = ( + definition: AnyContentTypeDefinition, + snapshot: { fields?: Record }, +): number[] => { + const names = Object.keys(contentFileFields(definition)); + if (names.length === 0) return []; + + return [...new Set(fileIdsOf(names, snapshot.fields ?? {}))]; +}; + +/** + * The descriptor for a file that was just uploaded. + * + * Built from what `StorageModel.upload` returns rather than by reading the row + * back: the insert already returned the id, and the upload already knows the + * stored name, media type, size and pixel dimensions. One fewer round trip, and + * the same allowlisted shape a later read produces. + */ +export const contentFileDescriptorFromUpload = ( + result: StorageFileUploadResult, +): ContentFileDescriptor => ({ + id: result.id, + mimeType: result.mimeType, + name: result.name, + size: result.size, + url: result.url, + ...(result.dimensions + ? { height: result.dimensions.height, width: result.dimensions.width } + : {}), +}); + +/** Constraints of one file field, for a route that has the descriptor. */ +export const contentFileFieldConstraints = ( + fieldValue: ContentFileField, +): ContentFileConstraints => contentFileConstraints(fieldValue); diff --git a/packages/vitnode/src/content/server/http-errors.ts b/packages/vitnode/src/content/server/http-errors.ts index 98db8ebac..2b2443c5b 100644 --- a/packages/vitnode/src/content/server/http-errors.ts +++ b/packages/vitnode/src/content/server/http-errors.ts @@ -8,6 +8,7 @@ import type { } from "../conflicts"; import type { ContentScheduleCode } from "../schedules"; +import { PG_ERROR_CODES, pgErrorCode } from "../../lib/api/pg-error"; import { CONTENT_CONFLICT_CODES, CONTENT_DELIVERY_CODES, @@ -21,28 +22,12 @@ import { ContentVersionConflict, } from "../errors"; -/** Postgres error codes the engine translates into a useful HTTP status. */ -const FOREIGN_KEY_VIOLATION = "23503"; -const UNIQUE_VIOLATION = "23505"; -const NOT_NULL_VIOLATION = "23502"; -const RESTRICT_VIOLATION = "23001"; - -/** - * Digs the Postgres error code out of whatever the driver threw. - * - * Drizzle wraps driver failures in a `DrizzleQueryError` whose own `code` is - * undefined and whose `cause` holds the real error, so reading `error.code` - * alone would turn every constraint violation into a 500. - */ -const errorCode = (error: unknown, depth = 0): string | undefined => { - if (typeof error !== "object" || error === null || depth > 3) - return undefined; - - const { cause, code } = error as { cause?: unknown; code?: unknown }; - if (typeof code === "string" && code !== "") return code; - - return errorCode(cause, depth + 1); -}; +const { + foreignKeyViolation: FOREIGN_KEY_VIOLATION, + notNullViolation: NOT_NULL_VIOLATION, + restrictViolation: RESTRICT_VIOLATION, + uniqueViolation: UNIQUE_VIOLATION, +} = PG_ERROR_CODES; /** * A JSON error body, carried on the exception itself. @@ -169,7 +154,7 @@ export const rethrowAsHttpError = ( throw new HTTPException(400, { message: error.message }); } - switch (errorCode(error)) { + switch (pgErrorCode(error)) { case FOREIGN_KEY_VIOLATION: throw new HTTPException(action === "delete" ? 409 : 400, { message: diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts index 628b5cc72..27b87c1eb 100644 --- a/packages/vitnode/src/content/server/index.ts +++ b/packages/vitnode/src/content/server/index.ts @@ -74,6 +74,16 @@ export { reportContentEventFailures, } from "./effects-log"; export { emitContentEvent } from "./emit"; +export { + assertContentFileReferences, + contentFileDescriptorFromUpload, + contentFileFields, + ContentFileReferenceError, + contentSnapshotFileIds, + resolveContentFileDescriptors, + resolveContentPublicRowFiles, + withContentRowFiles, +} from "./files"; export { contentConflict, contentUnprocessable, diff --git a/packages/vitnode/src/content/server/localized-public-service.ts b/packages/vitnode/src/content/server/localized-public-service.ts index ea66b7034..6ff95d325 100644 --- a/packages/vitnode/src/content/server/localized-public-service.ts +++ b/packages/vitnode/src/content/server/localized-public-service.ts @@ -28,6 +28,7 @@ import { ContentEngineError } from "../errors"; import { partitionContentFields } from "../localization"; import { isContentReferenceCollection, splitContentFieldPath } from "../paths"; import { publicOrderableColumns } from "../registry"; +import { resolveContentPublicRowFiles } from "./files"; import { findContentLanguage } from "./language-resolver"; import { clampContentPublicPageSize, @@ -203,9 +204,16 @@ export const createContentLocalizedPublicService = < rows: readonly Record[], ): Promise[]> => { const nested = rows.map(nestContentPublicRow); - if (publicCollections.length === 0 || nested.length === 0) return nested; + if (nested.length === 0) return nested; - const ids = nested + // One batch for the whole page, and only for the file fields the allowlist + // exposes. The identifier is replaced by the descriptor here rather than in + // the projector, so the projector stays the one place that decides *what* is + // public and this stays the one place that decides what it looks like. + const withFiles = await resolveContentPublicRowFiles(c, definition, nested); + if (publicCollections.length === 0) return withFiles; + + const ids = withFiles .map(row => row.id) .filter((id): id is number => typeof id === "number"); // Only the collections the allowlist actually exposes: querying a private @@ -217,7 +225,7 @@ export const createContentLocalizedPublicService = < publicCollections, ); - return nested.map(row => ({ + return withFiles.map(row => ({ ...row, ...(typeof row.id === "number" ? loaded?.get(row.id) : undefined), })); diff --git a/packages/vitnode/src/content/server/public-routes.ts b/packages/vitnode/src/content/server/public-routes.ts index cebe1bb41..900a2bbb3 100644 --- a/packages/vitnode/src/content/server/public-routes.ts +++ b/packages/vitnode/src/content/server/public-routes.ts @@ -39,6 +39,7 @@ import { } from "../paths"; import { publicOrderableColumns } from "../registry"; import { buildContentDeliveryRoutes } from "./delivery-routes"; +import { resolveContentPublicRowFiles } from "./files"; import { findContentLanguage, listContentLanguages } from "./language-resolver"; import { contentPreviewSecret } from "./preview-link"; import { verifyContentPreviewToken } from "./preview-token"; @@ -450,9 +451,16 @@ export const buildContentPublicRoutes = < : null; if (localized && !translated) throw notFound(); + // The same file resolution the published reads do, through the same + // function: a preview that showed a bare `core_files.id` where the live + // page shows a descriptor would be previewing a different response. + const [withFiles] = await resolveContentPublicRowFiles(c, definition, [ + { ...row, ...translated }, + ]); + return c.json( { - ...project({ ...row, ...translated }), + ...project(withFiles), ...(localized ? { locale: resolved.locale } : {}), }, 200, diff --git a/packages/vitnode/src/content/server/public-service.ts b/packages/vitnode/src/content/server/public-service.ts index c60fc4138..c50a5723a 100644 --- a/packages/vitnode/src/content/server/public-service.ts +++ b/packages/vitnode/src/content/server/public-service.ts @@ -28,6 +28,7 @@ import { ContentEngineError } from "../errors"; import { isContentReferenceCollection, splitContentFieldPath } from "../paths"; import { publicOrderableColumns } from "../registry"; import { groupPublicLeafPaths } from "../schemas"; +import { resolveContentPublicRowFiles } from "./files"; import { publicationColumns, publishedCondition } from "./publication"; import { buildFilterCondition, @@ -380,9 +381,16 @@ export const createContentPublicService = < rows: readonly Record[], ): Promise[]> => { const nested = rows.map(nestContentPublicRow); - if (publicCollections.length === 0 || nested.length === 0) return nested; + if (nested.length === 0) return nested; - const ids = nested + // One batch for the whole page, and only for the file fields the allowlist + // exposes. The identifier is replaced by the descriptor here rather than in + // the projector, so the projector stays the one place that decides *what* is + // public and this stays the one place that decides what it looks like. + const withFiles = await resolveContentPublicRowFiles(c, definition, nested); + if (publicCollections.length === 0) return withFiles; + + const ids = withFiles .map(row => row.id) .filter((id): id is number => typeof id === "number"); // Only the collections the allowlist actually exposes: querying a private @@ -394,7 +402,7 @@ export const createContentPublicService = < publicCollections, ); - return nested.map(row => ({ + return withFiles.map(row => ({ ...row, ...(typeof row.id === "number" ? loaded?.get(row.id) : undefined), })); diff --git a/packages/vitnode/src/content/server/revisions-model.ts b/packages/vitnode/src/content/server/revisions-model.ts index bea4c4643..4a5287579 100644 --- a/packages/vitnode/src/content/server/revisions-model.ts +++ b/packages/vitnode/src/content/server/revisions-model.ts @@ -13,7 +13,10 @@ import type { import type { AnyContentTypeDefinition } from "../types"; import type { ContentDatabase } from "./service"; -import { core_content_revisions } from "../../database/content"; +import { + core_content_file_refs, + core_content_revisions, +} from "../../database/content"; import { core_roles } from "../../database/roles"; import { core_users } from "../../database/users"; @@ -22,6 +25,20 @@ export interface ContentRevisionCaptureInput< > { actor: ContentActor; changedFields: readonly string[]; + /** + * `core_files` ids this snapshot names, pinned alongside the revision. + * + * The snapshot is JSONB, so the ids inside it are numbers Postgres knows + * nothing about: without a pin, pointing the field at a different file would + * make the old one deletable and every retained revision naming it a broken + * restore. The pin's own foreign key refuses the deletion instead, and + * cascades away when retention prunes the revision - see + * `core_content_file_refs`. + * + * Empty or absent for every content type with no file fields, which is what + * keeps this one statement rather than one per capture. + */ + fileIds?: readonly number[]; itemId: number; operation: ContentRevisionOperation; restoredFromRevisionId?: number; @@ -162,10 +179,25 @@ export const createContentRevisionsModel = < }) .returning({ id: core_content_revisions.id }); + // Before the prune, and in the same transaction: the pins are what stop a + // file this snapshot names from being deleted, and a window in which the + // revision exists unpinned is a window in which it can be broken. + const fileIds = [...new Set(input.fileIds ?? [])]; + if (fileIds.length > 0) { + await tx + .insert(core_content_file_refs) + .values(fileIds.map(fileId => ({ fileId, revisionId: row.id }))); + } + // Versions are strictly increasing and unique per record, so "everything // at or below `newVersion - retention`" is exactly the set outside the // window - one indexed range delete, in the same transaction, with no // background job to depend on. + // + // The pruned revisions' file pins go with them: the pin references the + // revision `ON DELETE CASCADE`, so the last pin on a file disappearing is + // exactly the moment that file becomes deletable again. There is no + // unpinning code to forget to run. const keepFrom = input.version - retention; if (keepFrom > 0) { await tx diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts index 8c56891c4..501f28776 100644 --- a/packages/vitnode/src/content/server/routes.ts +++ b/packages/vitnode/src/content/server/routes.ts @@ -12,6 +12,7 @@ import type { import type { AnyContentModel, ContentModel } from "./model"; import type { ContentPreviewTarget } from "./preview-target"; +import { checkStaffPermission } from "../../api/lib/check-staff-permission"; import { buildRoute } from "../../api/lib/route"; import { zodPaginationPageInfo, @@ -26,6 +27,7 @@ import { } from "../conflicts"; import { CONTENT_ACTOR_TYPES, + CONTENT_FILE_FOLDER, CONTENT_LOCALE_MAX_LENGTH, CONTENT_OPTIONS_LIMIT, CONTENT_PERMISSIONS, @@ -33,11 +35,21 @@ import { CONTENT_SCHEDULE_ACTIONS, CONTENT_SCHEDULE_STATUSES, } from "../const"; +import { + contentFileConstraints, + validateContentFile, + zodContentFileDescriptor, +} from "../files"; import { partitionContentFields } from "../localization"; import { orderableColumns } from "../registry"; import { resolveContentActor } from "./actor"; import { contentEditorialEffects } from "./editorial-effects"; import { emitContentEvent } from "./emit"; +import { + contentFileDescriptorFromUpload, + contentFileFields, + withContentRowFiles, +} from "./files"; import { withHttpErrors } from "./http-errors"; import { findContentLanguage } from "./language-resolver"; import { buildContentLocalizedAdminRoutes } from "./localized-admin-routes"; @@ -129,6 +141,29 @@ export const buildContentRoutes = < const localized = definition.localization.enabled; + /** + * The file fields, by name. Empty for every content type without one - which is + * exactly when the upload route is not mounted and no `files` key is added to + * any response, so every existing content type's API is untouched. + */ + const fileFields = contentFileFields(definition); + const hasFileFields = Object.keys(fileFields).length > 0; + + /** + * The resolved descriptor of each file field, keyed by field name. + * + * Beside the row rather than replacing the column: the form's value is the + * identifier it will submit back, and the descriptor is what the uploader + * previews and the list cell renders. `null` for a field holding no file. + */ + const zodFiles = z.record(z.string(), zodContentFileDescriptor.nullable()); + + const withFiles = async ( + c: Context, + rows: readonly TRow[], + ): Promise => + hasFileFields ? await withContentRowFiles(c, definition, rows) : [...rows]; + /** * One row's state in the language the list is being viewed in. * @@ -172,10 +207,12 @@ export const buildContentRoutes = < */ const detailRow = schemas.selectObject.extend({ labels: zodLabels, + ...(hasFileFields ? { files: zodFiles } : {}), ...schemas.advancedSelect.shape, }); const listRow = schemas.selectObject.extend({ labels: zodLabels, + ...(hasFileFields ? { files: zodFiles } : {}), ...(localized ? { translation: zodRowTranslation.optional() } : {}), }); const publicationResponse = z.object({ @@ -349,7 +386,18 @@ export const buildContentRoutes = < query: { cursor, first, last, search }, }); - return c.json(await withRowTranslations(c, data, raw.locale), 200); + const withTranslations = await withRowTranslations(c, data, raw.locale); + + // One `WHERE id IN (...)` for the whole page, never one per row - the same + // shape the translation join above uses, and skipped entirely by a content + // type with no file fields. + return c.json( + { + ...withTranslations, + edges: await withFiles(c, withTranslations.edges), + }, + 200, + ); }, }); @@ -496,6 +544,142 @@ export const buildContentRoutes = < }, }); + /** + * The generated binary endpoint: one `multipart/form-data` upload per `file` + * field. + * + * It exists because a Content Engine mutation is JSON - `{ "coverImage": 42 }` + * - and always will be. Bytes travel here, once, and the mutation carries an + * identifier. Nothing is base64-encoded and no binary goes through a Next.js + * Server Action: a Server Action body is a serialised RSC payload, so a + * five-megabyte image becomes a five-megabyte string that is buffered whole, + * with no progress, no streaming and a platform body limit that is not the + * field's `maxBytes`. + * + * The field is resolved from the URL, so there is exactly one upload route per + * content type rather than one per field, and its **descriptor is the + * authority**: `maxBytes`, `allowedMimeTypes` and `allowedExtensions` are read + * off the same object the save-time check and the AdminCP constraint line read. + * They cannot drift, because there is only one of them. + * + * Gated on `can_view` by the middleware and on `can_create` **or** `can_edit` + * in the handler. One permission would be wrong either way: a create-only role + * could not upload a cover for the article it is allowed to create, and an + * edit-only role could not replace one. + */ + const uploadForm = z.object({ + file: z.instanceof(File).openapi({ format: "binary", type: "string" }), + }); + + const zodFileRejection = z.object({ + code: z.string(), + message: z.string(), + }); + + const upload = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "post", + path: "/uploads/{field}", + description: `Upload a file for one ${name} file field`, + request: { + params: z.object({ field: z.string() }), + body: { + required: true, + content: { "multipart/form-data": { schema: uploadForm } }, + }, + }, + responses: { + 200: jsonResponse( + zodContentFileDescriptor, + "File stored and recorded in core_files", + ), + 400: jsonResponse( + zodFileRejection, + "Not a file field, or the file failed the field's own rules", + ), + 403: { description: "Not allowed to write this content type" }, + }, + }, + handler: async c => { + const field = c.req.param("field"); + const fieldValue = fileFields[field]; + if (!fieldValue) { + throw new HTTPException(400, { + message: `"${field}" is not a file field on this content type.`, + }); + } + + // Either write permission is enough - see the doc comment. + const allowed = await Promise.all( + [CONTENT_PERMISSIONS.create, CONTENT_PERMISSIONS.edit].map( + async permission => + await checkStaffPermission(c, { + module, + permission, + plugin: pluginId, + type: "admin", + }), + ), + ); + if (!allowed.some(Boolean)) { + throw new HTTPException(403, { message: "Forbidden" }); + } + + // Re-parsed rather than read through `c.req.valid("form")`, which cannot + // infer through a generic route config - the same reason `readJson` exists. + // Hono caches the parsed body, so this is not a second read of the stream. + const { file } = uploadForm.parse(await c.req.parseBody()); + + const constraints = contentFileConstraints(fieldValue); + + // Before a single byte reaches the storage adapter: size, then media type, + // then extension, every configured rule having to pass. + const rejected = validateContentFile(constraints, { + mimeType: file.type === "" ? null : file.type, + name: file.name, + size: file.size, + }); + if (rejected) return c.json(rejected, 400); + + const stored = await c.get("storage").upload({ + file, + folder: CONTENT_FILE_FOLDER, + // Handed to the adapter too, as defence in depth: a direct + // `storage.upload` from somewhere else should not be able to exceed what + // this field declares. + ...(constraints.allowedMimeTypes + ? { allowedMimeTypes: [...constraints.allowedMimeTypes] } + : {}), + maxBytes: constraints.maxBytes, + metadata: { contentTypeId: definition.id, field }, + }); + + const descriptor = contentFileDescriptorFromUpload(stored); + + // The same rules again, against what was actually **stored**. An install + // with `storage.image.webp` re-encodes a PNG to WebP, so the stored name + // ends `.webp` - and a field that allows only `.png` has to say so now, + // loudly, rather than accepting the upload and refusing the save. The file + // is removed because this request created it and nothing else refers to it. + const stale = validateContentFile(constraints, descriptor); + if (stale) { + await c.get("storage").deleteFile(stored.id); + + return c.json( + { + code: stale.code, + message: `${stale.message} The stored file is "${descriptor.name}" (${descriptor.mimeType ?? "unknown type"}) - image processing may have re-encoded it, in which case this field's allowlist has to include the converted format.`, + }, + 400, + ); + } + + return c.json(descriptor, 200); + }, + }); + const detail = buildRoute({ pluginId, adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, @@ -523,7 +707,11 @@ export const buildContentRoutes = < // Two queries per collection field, and none at all for a content type // that declares none - `advanced` is the no-op store then. Spread after // the row because a collection is never one of its columns. - return c.json({ ...row, ...(await service.advanced(id)) }, 200); + const [withFileDescriptors] = await withFiles(c, [ + { ...row, ...(await service.advanced(id)) }, + ]); + + return c.json(withFileDescriptors, 200); }, }); @@ -1461,6 +1649,9 @@ export const buildContentRoutes = < ? [publicationRoute("publish"), publicationRoute("unpublish")] : []), ...(editorial ? [revisionList, revisionDetail, restore] : []), + // Mounted only for a content type that declares a file field, so nothing + // else gains a binary endpoint it has no use for. + ...(hasFileFields ? [upload] : []), ...(previewEnabled ? [previewToken] : []), ...(definition.delivery.enabled ? [deliveryDetail] : []), ...(definition.editorial.scheduling.enabled diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts index bc7739c1f..b858f0a70 100644 --- a/packages/vitnode/src/content/server/service.ts +++ b/packages/vitnode/src/content/server/service.ts @@ -49,6 +49,7 @@ import { buildContentRepeatableOperations, contentCollectionKinds, } from "./collection-api"; +import { assertContentFileReferences } from "./files"; import { findContentLanguage } from "./language-resolver"; import { buildFilterCondition, @@ -847,6 +848,12 @@ export const createContentService = < // untrusted object and Drizzle. Only the parsed result is written. const parsed = schemas.create.parse(values) as Record; + // A successful upload is not a valid assignment: the file this id names + // was checked against the field it was uploaded for, and this checks it + // against the field it is being written to. No statement at all for a + // content type with no file fields. + await assertContentFileReferences(c, definition, parsed, tx); + const [row] = await tx .insert(table) .values(toInsertColumns(fields, withCreateSlugs(parsed))) @@ -1150,6 +1157,8 @@ export const createContentService = < }; } + await assertContentFileReferences(c, definition, patch, tx); + if (changedCollections.length > 0) await store?.write(tx, id, patch); // `updatedAt` has to move even when only a collection changed: it is diff --git a/packages/vitnode/src/content/server/table.ts b/packages/vitnode/src/content/server/table.ts index fc5ee93db..cb5693041 100644 --- a/packages/vitnode/src/content/server/table.ts +++ b/packages/vitnode/src/content/server/table.ts @@ -24,6 +24,7 @@ import type { ContentTableFor, } from "./types"; +import { core_files } from "../../database/files"; import { core_users } from "../../database/users"; import { CONTENT_EDITORIAL_FIELDS, CONTENT_PUBLICATION_FIELDS } from "../const"; import { ContentEngineError } from "../errors"; @@ -92,6 +93,17 @@ const resolveReference = ( () => core_users.id, ); } + // Resolved by the engine, exactly like a `user` field: there is one files + // table in an installation, so asking a plugin to name it in `references` + // would be a line of boilerplate with one correct value. + if (fieldValue.kind === "file") { + return checkedReference( + contentTypeId, + name, + () => getTableName(core_files), + () => core_files.id, + ); + } if (fieldValue.kind !== "relation") return undefined; const thunk = references[name]; diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index 1ace6b4ce..e0230643e 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -16,6 +16,7 @@ import type { CONTENT_SYSTEM_FIELDS, CONTENT_TRANSLATION_SYSTEM_FIELDS, } from "./const"; +import type { ContentFileDescriptor } from "./files"; import type { ContentSchemas } from "./schemas"; export type ContentSystemField = (typeof CONTENT_SYSTEM_FIELDS)[number]; @@ -174,6 +175,44 @@ export interface ContentEnumField< values: TValues; } +/** + * A reference to one stored file in `core_files`. + * + * The column is an `integer` foreign key with `ON DELETE RESTRICT`, and that is + * the whole storage model: the row holds an identifier, `core_files` holds the + * name, the size, the media type and the storage key, and the storage adapter + * holds the bytes. Nothing about the file is copied onto the content row, so a + * renamed or re-encoded object is not two facts that can disagree. + * + * `maxBytes` is **not** optional. A file field with no ceiling is a form that + * accepts a disk image, and a default here would be a number nobody chose + * applied to every field in every plugin. + * + * `allowedMimeTypes` and `allowedExtensions` are two rules rather than one + * spelled twice: the first is what the client *declared* the bytes are, the + * second is what the file is *called*. A `picture.gif` carrying `image/png` + * passes an extension-only check, which is why a strict field states both and + * both have to match. + */ +export interface ContentFileField< + TRequired extends boolean = boolean, + TNullable extends boolean = boolean, +> extends ContentFieldShared { + /** + * Accepted file-name extensions, normalised to lowercase with a leading dot. + * + * Normalised by `field.file`, so `GIF`, `.gif` and `.Gif` are one rule. + * Omitted, any extension is accepted - the MIME list, the size and the storage + * adapter are still in force. + */ + allowedExtensions?: string[]; + /** Accepted media types, lowercased. Omitted, any type is accepted. */ + allowedMimeTypes?: string[]; + kind: "file"; + /** Largest accepted upload, in bytes. Required, finite, and greater than zero. */ + maxBytes: number; +} + export interface ContentDateTimeField< TRequired extends boolean = boolean, TNullable extends boolean = boolean, @@ -359,6 +398,7 @@ export type ContentFieldDescriptor = | ContentBooleanField | ContentDateTimeField | ContentEnumField + | ContentFileField | ContentGroupField | ContentNumberField | ContentRelationField @@ -436,7 +476,7 @@ type ScalarFieldValue = TField extends { kind: "boolean" } ? Date : TField extends { values: readonly (infer TValue)[] } ? TValue - : TField extends { kind: "number" | "relation" | "user" } + : TField extends { kind: "file" | "number" | "relation" | "user" } ? number : string; @@ -447,7 +487,7 @@ type ScalarFieldInput = TField extends { kind: "boolean" } ? string : TField extends { values: readonly (infer TValue)[] } ? TValue - : TField extends { kind: "number" | "relation" | "user" } + : TField extends { kind: "file" | "number" | "relation" | "user" } ? number : string; @@ -2314,7 +2354,16 @@ type ContentPublicValue = TName extends "id" ? TFields[TName] extends { nullable: true } ? ContentPublicRelation | null : ContentPublicRelation - : ContentFieldValue + : // A file crosses as the normalised descriptor rather than as the + // `core_files.id` the column holds: an identifier is useless to an + // anonymous reader, who has no route to resolve it through, and the + // descriptor is already the allowlisted shape - no key, no uploader, + // no metadata bag. + TFields[TName] extends { kind: "file" } + ? TFields[TName] extends { nullable: true } + ? ContentFileDescriptor | null + : ContentFileDescriptor + : ContentFieldValue : never; /** The dotted paths in an allowlist, grouped by the field they belong to. */ diff --git a/packages/vitnode/src/database/content.ts b/packages/vitnode/src/database/content.ts index 3b8bee212..74cba561d 100644 --- a/packages/vitnode/src/database/content.ts +++ b/packages/vitnode/src/database/content.ts @@ -16,6 +16,7 @@ import { CONTENT_SCHEDULE_STATUSES, CONTENT_SLUG_DEFAULT_LENGTH, } from "../content/const"; +import { core_files } from "./files"; import { core_users } from "./users"; /** @@ -132,6 +133,63 @@ export const core_content_revisions = camelCase.table.withRLS( export type ContentRevisionRow = typeof core_content_revisions.$inferSelect; +/** + * Which stored files a retained revision still needs. + * + * The problem it solves: a revision's snapshot is JSONB, so `{ coverImage: 42 }` + * inside it is a *number* as far as Postgres is concerned - no foreign key, no + * protection. The moment an editor points the article at a different image, the + * content row's own key stops guarding the old one, and deleting it would leave + * every retained revision naming bytes that are gone. "Restore version 3" would + * then restore a broken image. + * + * One row per (revision, file), and the two references do all the work: + * + * - **to `core_files`, `ON DELETE RESTRICT`** - Postgres itself refuses to delete + * a file a revision still names, which is what lets `StorageModel.deleteFile` + * answer 409 rather than orphaning a content record. + * - **to `core_content_revisions`, `ON DELETE CASCADE`** - retention pruning is a + * single range `DELETE` in the write's own transaction, and the pins go with it. + * Nothing has to remember to unpin, and the last pin disappearing is exactly + * the moment the file becomes deletable again. + * + * Deliberately **narrow**: two integers and a timestamp. No metadata is copied + * from the file, no content type id is repeated - the revision it hangs off + * already knows all of that, and a second copy is a second thing that can be + * wrong. + */ +export const core_content_file_refs = camelCase.table.withRLS( + "core_content_file_refs", + t => ({ + id: t.serial().primaryKey(), + revisionId: t + .integer() + .notNull() + .references(() => core_content_revisions.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + fileId: t + .integer() + .notNull() + .references(() => core_files.id, { + onDelete: "restrict", + onUpdate: "cascade", + }), + createdAt: t.timestamp().notNull().defaultNow(), + }), + t => [ + // One pin per pair, so re-capturing the same state twice cannot double-count + // - and so "is this file still needed?" is an index lookup rather than a scan. + uniqueIndex("core_content_file_refs_unique").on(t.revisionId, t.fileId), + // Postgres does not index the child side of a foreign key on its own, and + // `ON DELETE RESTRICT` scans this one on every attempt to delete a file. + index("core_content_file_refs_file_id_idx").on(t.fileId), + ], +); + +export type ContentFileRefRow = typeof core_content_file_refs.$inferSelect; + /** * Pending and past scheduled transitions, for content types with * `editorial.scheduling`. diff --git a/packages/vitnode/src/lib/api/pg-error.ts b/packages/vitnode/src/lib/api/pg-error.ts new file mode 100644 index 000000000..517eacc08 --- /dev/null +++ b/packages/vitnode/src/lib/api/pg-error.ts @@ -0,0 +1,47 @@ +/** Postgres error codes VitNode translates into a useful HTTP status. */ +export const PG_ERROR_CODES = { + foreignKeyViolation: "23503", + notNullViolation: "23502", + /** + * `restrict_violation`. Postgres 17 reports this for a `NO ACTION`/`RESTRICT` + * foreign key where earlier majors reported `23503`, so anything that acts on + * "still referenced" has to accept both. + */ + restrictViolation: "23001", + uniqueViolation: "23505", +} as const; + +/** + * Digs the Postgres error code out of whatever the driver threw. + * + * Drizzle wraps driver failures in a `DrizzleQueryError` whose own `code` is + * undefined and whose `cause` holds the real error, so reading `error.code` + * alone would turn every constraint violation into a 500. The depth limit is + * there because a `cause` chain is attacker-adjacent data in the sense that + * matters here: it is arbitrary and can be cyclic. + */ +export const pgErrorCode = (error: unknown, depth = 0): string | undefined => { + if (typeof error !== "object" || error === null || depth > 3) { + return undefined; + } + + const { cause, code } = error as { cause?: unknown; code?: unknown }; + if (typeof code === "string" && code !== "") return code; + + return pgErrorCode(cause, depth + 1); +}; + +/** + * Whether a driver failure means "another row still points at this one". + * + * Both codes, for the reason `restrictViolation` documents: the same refused + * delete reports one on Postgres 17 and the other on 16. + */ +export const isPgReferenceViolation = (error: unknown): boolean => { + const code = pgErrorCode(error); + + return ( + code === PG_ERROR_CODES.foreignKeyViolation || + code === PG_ERROR_CODES.restrictViolation + ); +}; diff --git a/packages/vitnode/src/lib/api/upload.ts b/packages/vitnode/src/lib/api/upload.ts index 611b54591..16106cc26 100644 --- a/packages/vitnode/src/lib/api/upload.ts +++ b/packages/vitnode/src/lib/api/upload.ts @@ -1,6 +1,15 @@ import { getMonth, getYear } from "date-fns"; import { randomUUID } from "node:crypto"; +import { getFileExtension, replaceFileExtension } from "../file-extension"; + +/** + * Re-exported rather than defined here: `AutoFormFile` runs the same extension + * check in the browser, and this module cannot cross that boundary - it imports + * `node:crypto` at the top level. + */ +export { getFileExtension, replaceFileExtension }; + const FOLDER_PATTERN = /^[a-z0-9][a-z0-9_-]*$/i; /** @@ -25,15 +34,6 @@ export const sanitizeFolder = (folder: string): string => { return folder; }; -export const getFileExtension = (fileName: string): string => { - const lastDot = fileName.lastIndexOf("."); - if (lastDot <= 0 || lastDot === fileName.length - 1) { - return ""; - } - - return fileName.slice(lastDot).toLowerCase(); -}; - /** * Collision-free stored file name: a random UUID keeps the original extension * but discards the user-provided name, so no lookups or races are needed. Pass @@ -47,16 +47,6 @@ export const generateStorageFileName = ( return `${randomUUID()}${extension ?? getFileExtension(originalName)}`; }; -export const replaceFileExtension = ( - fileName: string, - extension: string, -): string => { - const current = getFileExtension(fileName); - const base = current ? fileName.slice(0, -current.length) : fileName; - - return `${base}${extension}`; -}; - export const buildStorageKey = ({ fileName, folder, diff --git a/packages/vitnode/src/lib/file-constraints.ts b/packages/vitnode/src/lib/file-constraints.ts new file mode 100644 index 000000000..557482056 --- /dev/null +++ b/packages/vitnode/src/lib/file-constraints.ts @@ -0,0 +1,153 @@ +import { getFileExtension } from "./file-extension"; +import { formatBytes } from "./format-bytes"; + +/** + * The three rules an upload is checked against. + * + * Its own module, with no Content Engine and no Node built-ins behind it, + * because the same three questions are asked in three places and there must be + * exactly one answer to each: + * + * - in the browser, before the upload starts, so picking a 40 MB video for a + * 5 MB field costs nothing; + * - in the upload route, on the file that arrived; + * - and again on the `core_files` row a content mutation names. + * + * The server is authoritative - the browser copy is a courtesy - but they cannot + * *disagree*, which is what a second implementation would eventually do. + */ +export interface FileConstraints { + /** Lowercase, leading dot. Omitted, any extension is accepted. */ + allowedExtensions?: readonly string[]; + /** Lowercased media types. Omitted, any type is accepted. */ + allowedMimeTypes?: readonly string[]; + /** Largest accepted upload, in bytes. Never optional. */ + maxBytes: number; +} + +/** + * One file's identity, as either side of the wire can describe it. + * + * `name` because the extension rule is about the file name, `mimeType` because + * the MIME rule is about the declared content type. They are checked + * **independently**: a `picture.gif` that is really a PNG passes the first and + * fails the second, which is exactly the case an extension-only check misses. + */ +export interface FileCandidate { + mimeType: null | string | undefined; + name: string; + size: number; +} + +export type FileRejectionReason = "extension" | "mimeType" | "size"; + +export interface FileRejection { + /** English, written for the person who picked the file. */ + message: string; + reason: FileRejectionReason; + /** The offending value - the size, the media type or the extension. */ + value: string; +} + +/** + * Checks a file against a set of constraints, or returns `null`. + * + * Size first, then media type, then extension, and **every configured rule has + * to pass**. With both lists set, `picture.gif` declared `image/png` is refused, + * and so is `picture.png` declared `image/gif`. + */ +export const validateFile = ( + { allowedExtensions, allowedMimeTypes, maxBytes }: FileConstraints, + file: FileCandidate, +): FileRejection | null => { + if (file.size > maxBytes) { + return { + message: `This file is ${formatBytes(file.size)}. The maximum is ${formatBytes(maxBytes)}.`, + reason: "size", + value: formatBytes(file.size), + }; + } + + if (allowedMimeTypes) { + const mimeType = (file.mimeType ?? "").trim().toLowerCase(); + if (!allowedMimeTypes.includes(mimeType)) { + const shown = mimeType === "" ? "unknown" : mimeType; + + return { + message: `"${shown}" is not an accepted file type. Accepted: ${allowedMimeTypes.join(", ")}.`, + reason: "mimeType", + value: shown, + }; + } + } + + if (allowedExtensions) { + const extension = getFileExtension(file.name); + if (!allowedExtensions.includes(extension)) { + const shown = extension === "" ? file.name : extension; + + return { + message: `"${shown}" is not an accepted file extension. Accepted: ${allowedExtensions.join(", ")}.`, + reason: "extension", + value: shown, + }; + } + } + + return null; +}; + +/** + * The formats a field accepts, as somebody would say them out loud. + * + * `JPG, PNG, WEBP` rather than `image/jpeg, image/png, image/webp`: the person + * choosing a file recognises the first and has no use for the second. Extensions + * win when the field declares them; otherwise the media subtypes stand in, which + * is still a word (`PDF`, `GIF`) rather than a header value. + * + * Empty when the field constrains neither - the UI then says "any file type" + * rather than inventing a list. + */ +export const fileFormatLabels = ({ + allowedExtensions, + allowedMimeTypes, +}: FileConstraints): string[] => { + if (allowedExtensions && allowedExtensions.length > 0) { + return [ + ...new Set( + allowedExtensions.map(extension => + extension.replace(/^\./, "").toUpperCase(), + ), + ), + ]; + } + + if (allowedMimeTypes && allowedMimeTypes.length > 0) { + return [ + ...new Set( + allowedMimeTypes.map(mimeType => + (mimeType.split("/")[1] ?? mimeType).toUpperCase(), + ), + ), + ]; + } + + return []; +}; + +/** + * The native picker's `accept` filter: extensions and media types together. + * + * Both, because the two rules are independent - a picker that knew only one of + * them would either hide files the field accepts or offer files it does not. + * `accept` is **UX only**: it filters a dialog, it does not check anything, and + * a drag-and-drop or a hand-written request bypasses it entirely. + */ +export const fileAcceptAttribute = ({ + allowedExtensions, + allowedMimeTypes, +}: FileConstraints): string | undefined => { + const values = [...(allowedExtensions ?? []), ...(allowedMimeTypes ?? [])]; + + return values.length > 0 ? values.join(",") : undefined; +}; diff --git a/packages/vitnode/src/lib/file-extension.ts b/packages/vitnode/src/lib/file-extension.ts new file mode 100644 index 000000000..88526ee2f --- /dev/null +++ b/packages/vitnode/src/lib/file-extension.ts @@ -0,0 +1,35 @@ +/** + * File-name extension helpers, with no Node built-ins behind them. + * + * Their own module because both halves of the Content Engine need them: the + * upload route reads an extension off a `File` on the server, and + * `AutoFormFile` reads it off the same file in the browser to say "that is not + * one of the allowed formats" before spending anybody's bandwidth. `lib/api/upload` + * re-exports these rather than keeping a second copy, so the two answers cannot + * drift. + */ + +/** + * The extension of a file name, lowercased and including the leading dot. + * + * `""` when there is none - a dotfile (`.env`) has no extension either, which is + * why the dot has to be past the first character. + */ +export const getFileExtension = (fileName: string): string => { + const lastDot = fileName.lastIndexOf("."); + if (lastDot <= 0 || lastDot === fileName.length - 1) { + return ""; + } + + return fileName.slice(lastDot).toLowerCase(); +}; + +export const replaceFileExtension = ( + fileName: string, + extension: string, +): string => { + const current = getFileExtension(fileName); + const base = current ? fileName.slice(0, -current.length) : fileName; + + return `${base}${extension}`; +}; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index a34948fda..1c3209471 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -204,6 +204,19 @@ "select_options": "Select options", "select_language": "Select language", "pick_color": "Pick a color", + "file": { + "any_format": "Any file type", + "max_size": "Maximum file size: {size}", + "drop": "Drop a file here", + "choose": "Choose file", + "replace": "Replace file", + "remove": "Remove file", + "uploading": "Uploading...", + "errors": { + "too_large": "That file is {size}. The maximum is {max}.", + "wrong_format": "\"{value}\" is not an accepted format. Accepted: {formats}." + } + }, "go_to_prev_page": "Go to previous page", "go_to_next_page": "Go to next page", "errors": { @@ -485,6 +498,9 @@ "group": { "enabled": "Has a value" }, + "file": { + "unavailable": "Uploads are not available on this form." + }, "list": { "add": "Add {label}", "empty": "Nothing here yet.", diff --git a/packages/vitnode/src/tests/content-fixtures.ts b/packages/vitnode/src/tests/content-fixtures.ts index f74cdd00b..8d14c3fca 100644 --- a/packages/vitnode/src/tests/content-fixtures.ts +++ b/packages/vitnode/src/tests/content-fixtures.ts @@ -654,3 +654,50 @@ export const testSectionedContentType = defineContentType({ }, }, }); + +/** + * The file-field reference fixture: one image field and one strict GIF field. + * + * Two of them on purpose. `cover` is the ordinary case - several formats, both + * allowlists stated - and `animation` is the strict one, where a single + * extension and a single media type both have to match, so a PNG renamed to + * `.gif` is refused. Between them every branch of `validateContentFile` is + * reachable from a real definition rather than from a hand-built descriptor. + * + * `publicApi` exposes `cover` and withholds `animation`, which is what proves a + * file field is allowlisted like every other kind: the descriptor for one is in + * the public response and the other is not fetched at all. + */ +export const testFilePostContentType = defineContentType({ + id: "test.file-post", + tableName: "test_file_posts", + fields: { + title: field.text({ required: true, maxLength: 200 }), + slug: field.slug({ source: "title" }), + cover: field.file({ + maxBytes: 5 * 1024 * 1024, + allowedExtensions: [".jpg", ".jpeg", ".png", ".webp", ".avif"], + allowedMimeTypes: ["image/jpeg", "image/png", "image/webp", "image/avif"], + }), + animation: field.file({ + maxBytes: 10 * 1024 * 1024, + allowedExtensions: [".gif"], + allowedMimeTypes: ["image/gif"], + }), + document: field.file({ + maxBytes: 20 * 1024 * 1024, + allowedExtensions: [".pdf"], + allowedMimeTypes: ["application/pdf"], + }), + }, + publication: { enabled: true }, + publicApi: { + enabled: true, + path: "file-posts", + fields: ["title", "slug", "cover", "publishedAt"], + }, + admin: { + titleField: "title", + list: { columns: ["cover", "title", "status", "updatedAt"] }, + }, +}); diff --git a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx index e5711dfa4..d3a26892c 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx @@ -8,6 +8,7 @@ import { toast } from "sonner"; import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; import type { ContentFormSpec } from "@/content/admin/spec"; +import type { ContentFileDescriptor } from "@/content/files"; import type { ContentFormLayout } from "@/lib/plugin"; import { AutoForm, type AutoFormOnSubmit } from "@/components/form/auto-form"; @@ -22,6 +23,7 @@ import { contentTitleFromValues, isReferenceKind, } from "@/content/admin/spec"; +import { uploadContentFile } from "@/content/admin/upload"; import { usePathname, useRouter } from "@/lib/navigation"; import type { ContentConflictState } from "./conflict-notice"; @@ -231,6 +233,17 @@ const ContentFormFields = ({ null, ); + /** + * The record's already-stored file descriptors, keyed by field name. + * + * Carried beside the row by the generated detail and list responses rather + * than folded into the column, so the form's *value* stays the identifier it + * will submit while the uploader still has a name, a size and a URL to + * preview. Empty while creating. + */ + const files = data?.files as + Record | undefined; + const localizedFields = React.useMemo( () => contentLocalizedFieldNames(spec), [spec], @@ -492,6 +505,7 @@ const ContentFormFields = ({ return ( await loadContentOptionsAction( spec.contentTypeId, @@ -501,6 +515,14 @@ const ContentFormFields = ({ ) } spec={fieldSpec} + // A plain client-side `fetch` of `multipart/form-data`, driven by + // TanStack Query inside `AutoFormFile`. Deliberately **not** a Server + // Action: a Server Action body is a serialised RSC payload, so an + // image would cross as a string, buffered whole, under a platform + // body limit that is not the field's `maxBytes`. + uploadFile={async ({ field, file }) => + await uploadContentFile({ field, file, spec }) + } {...props} /> ); diff --git a/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx b/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx index a2f2e51be..e4d9ad20f 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx +++ b/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx @@ -2,9 +2,11 @@ import { useTranslations } from "next-intl"; import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; import type { ContentFormFieldSpec } from "@/content/admin/spec"; +import type { ContentFileDescriptor } from "@/content/files"; import { AutoFormCombobox } from "@/components/form/fields/combobox"; import { AutoFormDateTime } from "@/components/form/fields/date-time"; +import { AutoFormFile } from "@/components/form/fields/file"; import { AutoFormInput } from "@/components/form/fields/input"; import { AutoFormNullableNumber } from "@/components/form/fields/nullable-number"; import { AutoFormRadioGroup } from "@/components/form/fields/radio-group"; @@ -52,13 +54,34 @@ export type ContentOptionsLoader = (args: { }) => Promise; export interface ContentFieldProps extends ItemAutoFormComponentProps { + /** + * The resolved descriptors of the record's file fields, keyed by field name. + * + * Read off the row's `files` sibling, which is what lets an edit form open + * showing the cover image it already has rather than an empty drop zone. Absent + * while creating, and absent for a content type with no file fields. + */ + files?: Record; loadOptions: ContentOptionsLoader; spec: ContentFormFieldSpec; + /** + * Sends one file to the content type's generated multipart route. + * + * Supplied by the form rather than built here, so this component stays a + * renderer: the upload is a `POST` of `multipart/form-data` driven by TanStack + * Query, and never a Server Action. + */ + uploadFile?: (args: { + field: string; + file: File; + }) => Promise; } export const ContentField = ({ + files, loadOptions, spec, + uploadFile, ...rest }: ContentFieldProps) => { const t = useTranslations("core.content.form"); @@ -95,6 +118,26 @@ export const ContentField = ({ ); } + // The uploader. `maxBytes` is not optional on a `file` descriptor, so the + // fallback is unreachable - it exists because the *spec* type is shared by + // every kind and cannot say that. + case "file": + return ( + + uploadFile + ? await uploadFile({ field: spec.name, file }) + : Promise.reject(new Error(t("file.unavailable"))) + } + {...props} + /> + ); + // The three Stage 6 editors. Each one controls the nested value the API // takes, so nothing is flattened on submit and nothing re-nested on load. case "group": diff --git a/packages/vitnode/src/views/admin/views/content/table/cells.tsx b/packages/vitnode/src/views/admin/views/content/table/cells.tsx index ee69f5a5f..46d4cc2ba 100644 --- a/packages/vitnode/src/views/admin/views/content/table/cells.tsx +++ b/packages/vitnode/src/views/admin/views/content/table/cells.tsx @@ -2,16 +2,26 @@ import { CheckIcon, CircleCheckIcon, FileClockIcon, + FileIcon, MinusIcon, } from "lucide-react"; import type { ContentColumnSpec } from "@/content/admin/spec"; +import type { ContentFileDescriptor } from "@/content/files"; import type { ContentLabels } from "@/content/server/service"; import { DateFormat } from "@/components/date-format"; import { Badge } from "@/components/ui/badge"; export interface ContentRowData extends Record { + /** + * The resolved descriptor of each file field, keyed by field name. + * + * Carried beside the row by the generated list response, which is why a file + * cell can show a thumbnail and a name rather than the `core_files.id` the + * column actually holds. Absent for a content type with no file fields. + */ + files?: Record; id: number; labels: ContentLabels; /** @@ -77,6 +87,40 @@ export const ContentCell = ({ }) => { const value = contentCellValue(row, spec); + // Before the generic emptiness check: what the column holds is an identifier, + // and a raw `42` in a list is worse than useless - it is the one thing an + // editor cannot recognise. An image gets its own thumbnail; everything else + // gets an icon and its file name. + if (spec.kind === "file") { + const file = row.files?.[spec.name] ?? null; + if (!file) return ; + + const image = (file.mimeType ?? "").startsWith("image/"); + + return ( + + + {image ? ( + // Decorative: the file name is right beside it as real text, so an + // alt would repeat it to a screen reader for no gain. + + ) : ( + + )} + + {file.name} + + ); + } + if (spec.kind === "relation" || spec.kind === "user") { const label = row.labels[spec.name]; diff --git a/plugins/blog/src/content/content-types.test.ts b/plugins/blog/src/content/content-types.test.ts index 511da2efd..55aae236d 100644 --- a/plugins/blog/src/content/content-types.test.ts +++ b/plugins/blog/src/content/content-types.test.ts @@ -161,9 +161,88 @@ describe("blog content types", () => { "title", "friendlyUrl", "content", + "coverImage", + "coverImageAlt", ]); }); + /** + * The cover image, as a pair: one **shared** file and one **localized** + * description of it. That split is the whole point - the image is the same + * image in every language, and the alt text is not. + */ + describe("the cover image", () => { + const cover = blogPostContentType.fields.coverImage; + + it("is one shared file, never per language", () => { + expect(cover.kind).toBe("file"); + expect(cover.localized).toBeFalsy(); + }); + + it("states a ceiling, because every file field has to", () => { + expect(cover).toMatchObject({ maxBytes: 5 * 1024 * 1024 }); + }); + + it("constrains the extension and the media type independently", () => { + // Both, so a `hero.png` declared `image/gif` is refused - which an + // extension-only check would store. + expect(cover).toMatchObject({ + allowedExtensions: [".jpg", ".jpeg", ".png", ".webp", ".avif"], + allowedMimeTypes: [ + "image/jpeg", + "image/png", + "image/webp", + "image/avif", + ], + }); + }); + + it("allows the format the image pipeline converts to", () => { + // With `storage.image` on, an upload is stored as WebP whatever was + // chosen - so a field that left `.webp` out would accept the upload and + // then refuse the save. + expect(cover).toMatchObject({ + allowedExtensions: expect.arrayContaining([".webp"]), + allowedMimeTypes: expect.arrayContaining(["image/webp"]), + }); + }); + + it("is optional, so an article can exist before its image does", () => { + expect(cover.nullable).toBe(true); + expect(cover.required).toBe(false); + }); + + it("describes itself per language", () => { + const alt = blogPostContentType.fields.coverImageAlt; + + expect(alt.kind).toBe("text"); + expect(alt.localized).toBe(true); + expect(alt.nullable).toBe(true); + }); + + it("is publicly readable, alt text included", () => { + expect(blogPostContentType.publicApi.fields).toContain("coverImage"); + expect(blogPostContentType.publicApi.fields).toContain("coverImageAlt"); + }); + + it("is neither sortable nor filterable", () => { + expect(blogPostContentType.admin.list.orderableFields).not.toContain( + "coverImage", + ); + expect(blogPostContentType.publicApi.filterableFields).not.toContain( + "coverImage", + ); + expect(blogPostContentType.publicApi.orderableFields).not.toContain( + "coverImage", + ); + }); + + it("shows in the list beside the title, not instead of it", () => { + expect(blogPostContentType.admin.list.columns).toContain("coverImage"); + expect(blogPostContentType.admin.list.columns[0]).toBe("title"); + }); + }); + it("keeps the storage model: a base row plus a translation table", () => { // The field-level language switchers change how localization is *edited*. // Where it lives has not moved. diff --git a/plugins/blog/src/content/post.ts b/plugins/blog/src/content/post.ts index 9b4ef1403..d4a5b0a61 100644 --- a/plugins/blog/src/content/post.ts +++ b/plugins/blog/src/content/post.ts @@ -118,6 +118,47 @@ export const blogPostContentType = defineContentType({ source: "title", }), content: field.textarea({ localized: true, required: true }), + + /** + * The article's cover image - **shared**, and one file. + * + * The column is a `core_files.id` with `ON DELETE RESTRICT`, so Postgres + * itself refuses to delete an image an article is still using, and nothing + * about the file is copied onto the row: no URL, no storage key, no size. One + * fact in one place, which is what makes replacing the image a single write. + * + * `maxBytes` is mandatory on every file field and this one says five + * megabytes. Both allowlists are stated, and they have to *both* match: a + * `hero.png` declared `image/gif` is refused, which an extension-only check + * would wave through. + * + * `.webp` is in the extension list for a reason worth knowing: with + * `storage.image` configured, VitNode re-encodes uploaded images to WebP, so + * the stored file is `hero.webp` whatever was chosen. A field that allowed + * only `.png` would accept the upload and then refuse the save. + * + * Not localized, and it cannot be: a cover image is one image whatever + * language somebody reads the article in. The *alt text* is the part that + * differs, and that is the field below. + */ + coverImage: field.file({ + maxBytes: 5 * 1024 * 1024, + allowedExtensions: [".jpg", ".jpeg", ".png", ".webp", ".avif"], + allowedMimeTypes: ["image/jpeg", "image/png", "image/webp", "image/avif"], + }), + /** + * What a screen reader says instead of the cover image - **per language**. + * + * The pairing is the point: one shared file, one localized description of it. + * `nullable: true` because an article with no cover has nothing to describe, + * and because alt text is written after the image is chosen rather than at + * the same moment. + */ + coverImageAlt: field.text({ + localized: true, + nullable: true, + maxLength: 255, + }), }, /** @@ -143,6 +184,13 @@ export const blogPostContentType = defineContentType({ "friendlyUrl", "content", "categoryId", + // A file crosses the public boundary as the normalised descriptor - `{ id, + // name, url, mimeType, size, width, height }` - and never as the + // `core_files.id` the column holds: a reader has no route to resolve an + // identifier through, and the storage key, the uploader and the metadata + // bag are not part of the shape. + "coverImage", + "coverImageAlt", "publishedAt", ], searchableFields: ["title", "content"], @@ -211,7 +259,11 @@ export const blogPostContentType = defineContentType({ // authors are here: both are sets on generated junction tables, and a // list that loaded them would issue a query per row. The form carries // them, which is where they are edited anyway. - columns: ["title", "status", "publishedAt", "updatedAt"], + // The title still leads - it is what somebody scans a list by. `coverImage` + // sits beside it and renders as a thumbnail plus the stored file name, + // never as the identifier the column holds: a raw `42` is the one thing an + // editor cannot recognise. + columns: ["title", "coverImage", "status", "publishedAt", "updatedAt"], }, }, }); diff --git a/plugins/blog/src/locales/en.json b/plugins/blog/src/locales/en.json index 9cf1e3881..e4a95ecb7 100644 --- a/plugins/blog/src/locales/en.json +++ b/plugins/blog/src/locales/en.json @@ -12,6 +12,8 @@ "content": "Content", "categoryId": "Categories", "authorId": "Authors", + "coverImage": "Cover image", + "coverImageAlt": "Cover image alt text", "status": "Status", "publishedAt": "Published", "updatedAt": "Updated" @@ -35,6 +37,9 @@ }, "form": { "publish": "Publish", + "cover": { + "title": "Cover image" + }, "settings": { "title": "Article settings" } diff --git a/plugins/blog/src/views/admin/article/form-layout.tsx b/plugins/blog/src/views/admin/article/form-layout.tsx index df06b6ef2..2013587f9 100644 --- a/plugins/blog/src/views/admin/article/form-layout.tsx +++ b/plugins/blog/src/views/admin/article/form-layout.tsx @@ -30,6 +30,19 @@ export const BlogArticleFormLayout = () => { + {/* + The cover image and its alt text, together and in that order: the file + is shared and the description of it is per language, and writing the + second before choosing the first is not how anybody works. The + uploader's own constraint line - "JPG, PNG, WEBP, AVIF" and "Maximum + file size: 5 MB" - comes from the field descriptor, so this layout + neither states nor can contradict it. + */} + + + + + diff --git a/plugins/example/src/const.ts b/plugins/example/src/const.ts index 3c060e719..4708ade42 100644 --- a/plugins/example/src/const.ts +++ b/plugins/example/src/const.ts @@ -52,4 +52,9 @@ export const EXAMPLE_MIGRATIONS = [ // actually produces. `NULL` has to mean "indexable" identically in the metadata // and in the sitemap predicate, and only a nullable column can prove it. "add_example_article_no_index_flag", + // File fields: `example_articles.animation`, an integer foreign key into + // `core_files` with `ON DELETE RESTRICT`. Core arrives with it - + // `core_content_file_refs` is what pins the files a retained revision names - + // so the shared table has to exist before anything can reference it. + "add_content_file_fields", ]; diff --git a/plugins/example/src/content/article.ts b/plugins/example/src/content/article.ts index 1941a3f9b..d0c0fe25b 100644 --- a/plugins/example/src/content/article.ts +++ b/plugins/example/src/content/article.ts @@ -29,6 +29,33 @@ export const articleContentType = defineContentType({ */ noIndex: field.boolean({ nullable: true }), author: field.user(), + /** + * The **extension-only** reference: a GIF, and nothing else. + * + * Both allowlists name exactly one thing, and both have to match, which is + * what makes this the interesting case: + * + * - `banner.gif` declared `image/gif` -> accepted; + * - `banner.png` declared `image/png` -> refused, wrong extension *and* wrong + * type; + * - a PNG **renamed** to `banner.gif` -> refused, because the browser still + * declares `image/png`. An extension-only check would store it; + * - a real GIF over 10 MB -> refused, before a byte reaches the adapter. + * + * A GIF is also the format that proves the storage pipeline is not quietly + * rewriting the rules: `sharp` deliberately does not re-encode GIF, so the + * stored file keeps its extension and its animation. An allowlist of `.png` + * would *not* be safe in the same way - with `storage.image` on, a PNG is + * converted to WebP, and the field would have to allow `.webp` too. + * + * Nullable, which is `field.file`'s default: an article without an animation + * is the ordinary case. + */ + animation: field.file({ + maxBytes: 10 * 1024 * 1024, + allowedExtensions: [".gif"], + allowedMimeTypes: ["image/gif"], + }), category: field.relation({ required: true, onDelete: "restrict", @@ -47,6 +74,10 @@ export const articleContentType = defineContentType({ "excerpt", "featured", "category", + // Publicly exposed as the normalised descriptor - `{ id, name, url, + // mimeType, size, width, height }` - never as the `core_files.id` the + // column holds, and never with the storage key or the uploader. + "animation", // Public because delivery projects it: `robots: { index: false }` is // rendered into the page, so the field behind it has to be one the public // API would already have said out loud. @@ -125,6 +156,9 @@ export const articleContentType = defineContentType({ "code", "category", "author", + // Rendered as a thumbnail and the stored file name, not as the + // identifier the column holds. + "animation", "publishedAt", "updatedAt", ], diff --git a/plugins/example/src/content/file-fields.test.ts b/plugins/example/src/content/file-fields.test.ts new file mode 100644 index 000000000..ca1ce4ffd --- /dev/null +++ b/plugins/example/src/content/file-fields.test.ts @@ -0,0 +1,104 @@ +// @vitest-environment node +import { + contentFileAccept, + contentFileConstraints, + contentFileFormatLabels, + validateContentFile, +} from "@vitnode/core/content"; +import { describe, expect, it } from "vitest"; + +import { articleContentType } from "./article"; + +/** + * The extension-only reference field, exercised as a matrix. + * + * `example.article.animation` states exactly one extension and exactly one media + * type, which makes it the field where "both rules have to match" is visible: a + * PNG renamed to `.gif` passes the filename check and fails the type check, and + * that is the only reason it is refused. + * + * A GIF is also the format that proves the storage pipeline is not quietly + * changing the rules - `sharp` never re-encodes GIF, so the stored file keeps + * its extension. + */ +const animation = articleContentType.fields.animation; +const constraints = contentFileConstraints(animation); + +const check = (name: string, mimeType: null | string, size = 1024) => + validateContentFile(constraints, { mimeType, name, size }); + +describe("example.article.animation - the GIF-only field", () => { + it("is declared with one extension, one media type and a ceiling", () => { + expect(animation).toMatchObject({ + allowedExtensions: [".gif"], + allowedMimeTypes: ["image/gif"], + kind: "file", + maxBytes: 10 * 1024 * 1024, + }); + }); + + it("accepts a GIF", () => { + expect(check("banner.gif", "image/gif")).toBeNull(); + }); + + it("accepts a GIF whatever the case of its name", () => { + expect(check("BANNER.GIF", "image/gif")).toBeNull(); + }); + + it("refuses a PNG", () => { + expect(check("shot.png", "image/png")?.code).toBe( + "CONTENT_FILE_MIME_TYPE_NOT_ALLOWED", + ); + }); + + it("refuses a PNG renamed to .gif, because the media type is wrong", () => { + expect(check("renamed.gif", "image/png")?.code).toBe( + "CONTENT_FILE_MIME_TYPE_NOT_ALLOWED", + ); + }); + + it("refuses a GIF whose name says otherwise", () => { + expect(check("renamed.png", "image/gif")?.code).toBe( + "CONTENT_FILE_EXTENSION_NOT_ALLOWED", + ); + }); + + it("refuses a GIF over 10 MB", () => { + expect(check("huge.gif", "image/gif", 10 * 1024 * 1024 + 1)?.code).toBe( + "CONTENT_FILE_TOO_LARGE", + ); + }); + + it("accepts a GIF of exactly 10 MB", () => { + expect(check("edge.gif", "image/gif", 10 * 1024 * 1024)).toBeNull(); + }); + + it("refuses a file with no declared type at all", () => { + expect(check("banner.gif", null)?.code).toBe( + "CONTENT_FILE_MIME_TYPE_NOT_ALLOWED", + ); + }); + + it("tells the browser to offer both the extension and the type", () => { + expect(contentFileAccept(constraints)).toBe(".gif,image/gif"); + }); + + it("says GIF, not image/gif", () => { + expect(contentFileFormatLabels(constraints)).toEqual(["GIF"]); + }); + + it("is optional, sortable by nothing and filterable by nothing", () => { + expect(animation.nullable).toBe(true); + expect(articleContentType.admin.list.orderableFields).not.toContain( + "animation", + ); + expect(articleContentType.publicApi.filterableFields).not.toContain( + "animation", + ); + }); + + it("is exposed publicly, and shown in the list", () => { + expect(articleContentType.publicApi.fields).toContain("animation"); + expect(articleContentType.admin.list.columns).toContain("animation"); + }); +}); diff --git a/plugins/example/src/database/tables.test.ts b/plugins/example/src/database/tables.test.ts index b4ff9c183..5df34014e 100644 --- a/plugins/example/src/database/tables.test.ts +++ b/plugins/example/src/database/tables.test.ts @@ -171,6 +171,10 @@ describe("example_articles", () => { it("indexes the foreign keys, the timestamps, the declared composite and publication", () => { expect([...indexNames(articles)].sort(byName)).toEqual([ + // `field.file` is a foreign key like the other two, and Postgres does not + // index the child side on its own - `ON DELETE RESTRICT` scans it on every + // attempt to delete a file. + "example_articles_animation_idx", "example_articles_author_idx", "example_articles_category_idx", "example_articles_code_key", diff --git a/plugins/example/src/locales/en.json b/plugins/example/src/locales/en.json index 37a5f9a78..ab55a1bc7 100644 --- a/plugins/example/src/locales/en.json +++ b/plugins/example/src/locales/en.json @@ -12,6 +12,7 @@ "excerpt": "Excerpt", "views": "Views", "featured": "Featured", + "animation": "Animation", "author": "Author", "category": "Category", "updatedAt": "Updated" From c5ae8bee91e84abd9192eeccc58025feccadcc59 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Fri, 21 Aug 2026 11:10:21 +0200 Subject: [PATCH 2/6] fix(content-engine): Keep expectedVersion in step, and raise conflicts as a dialog A page-mode form stays mounted across its own saves, but `expectedVersion` was only ever read once - on mount - and after a conflict was resolved. So the second save of a session guarded on a version the record had already left behind, and a solo editor got "someone else saved this first" about their own previous click. The translation half of this was already handled: the composite save reads every translation back so the next one holds their new versions. The base row's version was simply not reported. `editContentAction` and `editLocalizedContentAction` now return it, and the form advances on it - plus on any newer `data.version` the server hands down, which covers a publish, an unpublish or a restore moving the version while the form is open. Forwards only, so a stale list row cannot drag the precondition back and a reloaded conflict cannot be un-resolved. The notice is now an alert dialog rather than a banner above the fields. A save that did not happen is not something to notice later, and on a form long enough to scroll the banner could be off screen entirely - which is exactly how somebody presses Save, sees nothing, and presses it again. All three rules survive: nothing typed is discarded (the form stays mounted behind the overlay), nothing is overwritten automatically, and no field is merged. Co-Authored-By: Claude Opus 5 (1M context) --- packages/config/eslint.react.config.mjs | 2 + .../views/content/actions/conflict-notice.tsx | 109 +++++++++++++----- .../views/content/actions/content-form.tsx | 43 ++++++- .../content/actions/mutation-api.server.ts | 34 +++++- .../content/actions/mutation-api.test.ts | 39 +++++++ 5 files changed, 188 insertions(+), 39 deletions(-) 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/vitnode/src/views/admin/views/content/actions/conflict-notice.tsx b/packages/vitnode/src/views/admin/views/content/actions/conflict-notice.tsx index 240e2f187..b86c08736 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/conflict-notice.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/conflict-notice.tsx @@ -7,7 +7,16 @@ import React from "react"; import type { ContentFormSpec } from "@/content/admin/spec"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; export interface ContentConflictState { @@ -35,9 +44,9 @@ const asText = (value: unknown): string => { }; /** - * What another session changed while this dialog was open. + * What another session changed while this form was open. * - * Compared against the values the dialog *opened* with, not against what the + * Compared against the values the form *opened* with, not against what the * editor has typed since - the question being answered is "what did I not see", * and mixing in unsaved edits would answer a different one. */ @@ -57,7 +66,7 @@ const RemoteChanges = ({ if (changed.length === 0) return null; return ( -
    +
      {changed.map(field => (
    • {field.label} @@ -73,51 +82,89 @@ const RemoteChanges = ({ }; /** - * The lost-update banner. + * The lost-update dialog. * - * Three rules, and the reason this is a banner rather than a toast: + * A **modal** rather than a banner above the form, and that is the point: a save + * that did not happen is not something to notice later. A notice inline with the + * fields competes with the fields, and on a page-mode form long enough to scroll + * it can be off screen entirely - so the one outcome the editor must not get is + * exactly the one they got: pressing Save, seeing nothing change, and pressing it + * again. * - * 1. **Nothing the editor typed is discarded.** The form stays mounted; only - * this notice appears above it. + * Three rules survive the change, and they are why this is a dialog rather than a + * toast: + * + * 1. **Nothing the editor typed is discarded.** The form stays mounted behind + * the overlay; this is a portal, and closing it returns them to every value + * they had. * 2. **Nothing is overwritten automatically.** Reloading shows what changed and - * arms the submit button with the *new* version - saving again is then a + * arms the next save with the *new* version - saving again is then a * deliberate second click, not a silent clobber. * 3. **No field merging.** Deciding which side of a conflicting paragraph wins * is the editor's call, and guessing it is worse than asking. */ export const ConflictNotice = ({ conflict, + onDismiss, onReload, opened, spec, }: { conflict: ContentConflictState; + /** + * Clears the conflict, so the next one can raise the dialog again. + * + * Closing is not "resolved": the editor still holds unsaved values, and + * whether to overwrite is a decision they make with the Save button. + */ + onDismiss: () => void; onReload: () => Promise; opened: Record; spec: ContentFormSpec; }) => { const t = useTranslations("core.content.conflict"); + const tGlobal = useTranslations("core.global"); const [loading, setLoading] = React.useState(false); + const reloaded = conflict.latest !== undefined; return ( - - - {t("title")} - - {conflict.latest ? ( - <> -

      {t("reloaded", { version: conflict.currentVersion })}

      - { + if (!open) onDismiss(); + }} + open + > + + + {/* Amber rather than destructive: nothing was lost, and nothing is + about to be - the save simply did not happen. The same palette the + `warning` alert variant uses, since there is no token for it. */} + + - - ) : ( - <> -

      {t("desc", { version: conflict.currentVersion })}

      +
      + {t("title")} + + {reloaded + ? t("reloaded", { version: conflict.currentVersion }) + : t("desc", { version: conflict.currentVersion })} + +
      + + {conflict.latest ? ( + + ) : null} + + + {/* + Not an `AlertDialogAction`: that one closes the dialog, and this + button's whole job is to replace the dialog's contents with what + changed. Only the acknowledgement below closes. + */} + {!reloaded && ( - - )} -
      -
      + )} + + {tGlobal("close")} + + + + ); }; diff --git a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx index d3a26892c..a51aa9059 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx @@ -250,12 +250,42 @@ const ContentFormFields = ({ ); const localized = localizedFields.length > 0; - // The version this form opened with, and the one every save is checked - // against - until a conflict is resolved, which replaces it with the version - // the editor has now actually seen. + /** + * The version every save is checked against. + * + * It starts as the version the form opened with and then has to **keep up**, + * which is the whole subtlety here: a page-mode form stays mounted across its + * own saves, so holding the opening version for the life of the component + * means the second save of a session guards on a version the record has + * already left behind - and gets a conflict banner naming an editor who does + * not exist. + * + * Two things move it, and each covers what the other misses: + * + * - the mutation result, immediately, which closes the window between a save + * returning and fresh server data arriving; + * - a newer `data.version` from the server, which covers every *other* way the + * version moves while this form is open - a publish, an unpublish, a restore + * from the history dialog. + * + * Only ever forwards. A stale row - a dialog opened from a list rendered + * before the last write - must not drag the precondition backwards, and a + * conflict that has been reloaded must not be un-resolved by one. + */ const [expectedVersion, setExpectedVersion] = React.useState(() => typeof data?.version === "number" ? data.version : undefined, ); + const serverVersion = + typeof data?.version === "number" ? data.version : undefined; + if ( + serverVersion !== undefined && + expectedVersion !== undefined && + serverVersion > expectedVersion + ) { + // Derived from a prop during render on purpose: an effect would leave one + // render - and therefore one possible submit - guarding on the old version. + setExpectedVersion(serverVersion); + } /** * What every language held when the form opened. @@ -435,6 +465,12 @@ const ContentFormFields = ({ return; } + // The record just moved forward a version, and this form is still mounted - + // so the next save has to guard on the version this one produced. Set before + // the `push` below, because the fresh server row arrives asynchronously and a + // second submit in between would otherwise send the version we just spent. + if (mutation.version !== undefined) setExpectedVersion(mutation.version); + // This record is somebody else's picker option. A new category has to appear // in the article form, and a renamed one has to read as its new name - // neither happens on its own, because the query client outlives the @@ -550,6 +586,7 @@ const ContentFormFields = ({ {conflict && data ? ( setConflict(null)} onReload={onReload} opened={data} spec={spec} diff --git a/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts index 433c0aab9..cc029274e 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts @@ -107,8 +107,29 @@ interface MutationResult { unchanged?: boolean; /** `CONTENT_REVISION_NOT_RESTORABLE`, naming the fields that no longer fit. */ unprocessable?: ContentUnprocessable; + /** + * The version the record holds **after** this write. + * + * Read back so a form that stays open - page mode - can guard its next save on + * the version it just created rather than on the one it opened with. Without + * it, the second save of a session sends a version the record left behind and + * gets a conflict banner naming an editor who does not exist. + * + * The translation half of the same problem was already handled by + * `translations`; this is the base row's. + */ + version?: number; } +/** + * The version off a mutation response, when the row carries one. + * + * `undefined` for a content type with no `editorial`, which has no version to + * send and no conflict to have. + */ +const versionOf = (row?: Record): number | undefined => + typeof row?.version === "number" ? row.version : undefined; + /** Reads whatever structured error the API sent, if any. */ const failure = (result: { error?: string; @@ -384,7 +405,7 @@ export const editContentAction = async ( before: localesBefore, }); - return {}; + return { version: versionOf(result.data) }; }; /** One language's half of a composite save, as the form assembled it. */ @@ -515,7 +536,10 @@ export const editLocalizedContentAction = async ( before: localesBefore, }); - return { translations: await readTranslations(definition, pluginId, id) }; + return { + translations: await readTranslations(definition, pluginId, id), + version: versionOf(result.data), + }; }; /** @@ -624,7 +648,7 @@ export const restoreContentRevisionAction = async ( id: number, revisionId: number, expectedVersion: number, -): Promise => { +): Promise => { const { definition, pluginId } = resolve(contentTypeId); // Same as an edit: the old slug has to be known before the write, or a @@ -655,9 +679,7 @@ export const restoreContentRevisionAction = async ( before: localesBefore, }); - const version = result.data?.row.version; - - return { version: typeof version === "number" ? version : undefined }; + return { version: versionOf(result.data?.row) }; }; export interface ContentPreviewLink { diff --git a/packages/vitnode/src/views/admin/views/content/actions/mutation-api.test.ts b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.test.ts index 44da8218a..4d2f900d9 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/mutation-api.test.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.test.ts @@ -589,6 +589,45 @@ describe("editorial", () => { }); }); + /** + * The precondition a page-mode form's *next* save has to guard on. + * + * A page-mode form stays mounted across its own saves, so if the write does not + * report the version it produced, the second save sends the version the record + * has already left behind - and the editor gets "someone else saved this first" + * about their own previous click. Nobody else is involved. + */ + it("reports the version the record holds after the write", async () => { + responses = [ + { data: editorialRow, status: 200 }, + { data: { ...editorialRow, version: 5 }, status: 200 }, + ]; + + const result = await editContentAction( + "test.editorial", + 7, + { title: "Changed" }, + 4, + ); + + expect(result.version).toBe(5); + expect(result.error).toBeUndefined(); + }); + + it("reports no version for a content type that has none", async () => { + definition = testPostContentType; + responses = [ + { data: { id: 7, slug: "hello", status: "draft" }, status: 200 }, + { data: { id: 7, slug: "hello", status: "draft" }, status: 200 }, + ]; + + const result = await editContentAction("test.post", 7, { + title: "Changed", + }); + + expect(result.version).toBeUndefined(); + }); + it("sends a bare body for a content type without the workflow", async () => { definition = testPostContentType; responses = [ From 5a9347c2ef16693d63c94ee88ee6c2ae9debd9c3 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Fri, 21 Aug 2026 14:58:37 +0200 Subject: [PATCH 3/6] fix(content-engine): Repair the conflict description's ICU message `core.content.conflict.desc` was written as `{name, select, other {record}}` - a select with only an `other` branch, so it required a `name` argument and then ignored it, always rendering the word "record". The dialog passed only `{ version }`. A missing ICU argument is not a blank in next-intl. It is a FORMATTING_ERROR, and the fallback it renders is the key path - so an editor whose save had just been refused read the literal string `core.content.conflict.desc` where the explanation should have been. The one screen that has to explain itself said nothing. The message now takes `{name}` and the dialog passes the content type's singular label, matching every other message in the namespace ("Add a new {name}.", "{name} has been updated."). The copy also says the save did not go through, which the old wording never did - it said the record "moved" and that nothing was lost, leaving "press Save again" as the obvious next move. The test asserts the thing that actually broke: that each message's placeholders are exactly the arguments its call site passes. Reverting either half of this fix fails it. Co-Authored-By: Claude Opus 5 (1M context) --- packages/vitnode/src/locales/en.json | 2 +- .../content/actions/conflict-messages.test.ts | 131 ++++++++++++++++++ .../views/content/actions/conflict-notice.tsx | 12 +- .../views/content/actions/content-form.tsx | 1 + 4 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 packages/vitnode/src/views/admin/views/content/actions/conflict-messages.test.ts diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 1c3209471..6550b708d 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -512,7 +512,7 @@ }, "conflict": { "title": "Someone else saved this first", - "desc": "This {name, select, other {record}} moved to version {version} while you were editing. Nothing you typed has been lost.", + "desc": "{name} moved to version {version} while you were editing, so your save did not go through. Nothing you typed has been lost.", "reload": "Show what changed", "reloaded": "Now at version {version}. Saving replaces the changes below with yours." }, diff --git a/packages/vitnode/src/views/admin/views/content/actions/conflict-messages.test.ts b/packages/vitnode/src/views/admin/views/content/actions/conflict-messages.test.ts new file mode 100644 index 000000000..84cb03d9d --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/conflict-messages.test.ts @@ -0,0 +1,131 @@ +// @vitest-environment node +import { createTranslator } from "next-intl"; +import { describe, expect, it } from "vitest"; + +import messages from "@/locales/en.json"; + +/** + * Every message the conflict dialog formats, with exactly the arguments it + * passes. + * + * This exists because of a bug that reached a user. `desc` was written as + * `{name, select, other {record}}` - a `select` with only an `other` branch, so + * it *required* a `name` argument and then ignored it, always rendering the word + * "record". The dialog passed only `{ version }`. + * + * A missing ICU argument is not a blank in next-intl. It is a `FORMATTING_ERROR`, + * and the fallback it renders is the **key path** - so an editor whose save was + * refused read the literal string `core.content.conflict.desc` where the + * explanation should have been. Nothing crashed, nothing was logged where anybody + * would see it, and the one screen that has to explain itself said nothing. + * + * So the assertion is not "the key exists" - it did exist. It is "the message + * formats with the arguments the caller actually has". + */ +const format = ( + key: string, + values: Record, +): { errors: string[]; text: string } => { + const errors: string[] = []; + // Widened on purpose: the key is computed at runtime here, and next-intl's + // typed signature narrows the values parameter to `undefined` for a key it + // cannot see. The point of this suite is what happens at *format* time. + const t = createTranslator({ + locale: "en", + messages, + onError: error => errors.push(error.code), + }) as unknown as ( + key: string, + values?: Record, + ) => string; + + return { errors, text: t(`core.content.conflict.${key}`, values) }; +}; + +/** The arguments each message is given at its one call site. */ +const CALLS: { key: string; values: Record }[] = [ + { key: "title", values: {} }, + { key: "desc", values: { name: "Article", version: 5 } }, + { key: "reloaded", values: { version: 5 } }, + { key: "reload", values: {} }, +]; + +/** + * The argument names an ICU message needs. + * + * Deliberately naive about nesting - these four messages are flat - and + * deliberately *including* the ones a `select` or a `plural` keys on, because + * those are exactly the mandatory arguments the original bug forgot. + */ +const placeholdersOf = (message: string): string[] => + [ + ...new Set( + [...message.matchAll(/\{\s*(\w+)\s*(?:,|\})/g)].map(match => match[1]), + ), + ].sort(); + +describe("the conflict dialog's messages", () => { + /** + * The root cause, stated directly: the call site and the message have to agree + * about the arguments. + * + * The original bug was not a missing key and not a typo - it was a message that + * needed `name` and a caller that passed only `version`. Nothing in the type + * system connects those two, so this is the thing that has to. + */ + it.each(CALLS)( + "$key needs exactly the arguments it is passed", + ({ key, values }) => { + const message = ( + messages.core.content.conflict as Record + )[key]; + + expect(placeholdersOf(message)).toEqual(Object.keys(values).sort()); + }, + ); + + it.each(CALLS)( + "formats $key with the arguments it is given", + ({ key, values }) => { + const { errors, text } = format(key, values); + + expect(errors).toEqual([]); + // The tell-tale of a formatting failure: next-intl renders the key path. + expect(text).not.toContain("core.content.conflict"); + expect(text.trim()).not.toBe(""); + }, + ); + + it("names the record rather than calling it a record", () => { + const { text } = format("desc", { name: "Article", version: 5 }); + + expect(text).toContain("Article"); + expect(text).toContain("5"); + }); + + it("says the save did not happen, which is the part that was missing", () => { + // The old copy said the record "moved" and that nothing was lost, but never + // that the save had been refused - so the obvious next move was to press Save + // again, which is exactly what it should not be. + const { text } = format("desc", { name: "Article", version: 5 }); + + expect(text).toMatch(/did not go through/); + }); + + /** + * The guard against the shape that caused this. + * + * A `select` whose only branch is `other` cannot vary its output, so it buys + * nothing and costs a mandatory argument. If one is ever wanted, it needs real + * branches - and a caller that passes the value they key on. + */ + it("uses no single-branch select anywhere in the block", () => { + const conflict = messages.core.content.conflict as Record; + + for (const [key, message] of Object.entries(conflict)) { + expect(message, key).not.toMatch( + /\{\s*\w+\s*,\s*select\s*,\s*other\s*\{/, + ); + } + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/actions/conflict-notice.tsx b/packages/vitnode/src/views/admin/views/content/actions/conflict-notice.tsx index b86c08736..c152b6117 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/conflict-notice.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/conflict-notice.tsx @@ -105,12 +105,22 @@ const RemoteChanges = ({ */ export const ConflictNotice = ({ conflict, + name, onDismiss, onReload, opened, spec, }: { conflict: ContentConflictState; + /** + * The content type's singular label - "Article", not "record". + * + * Required rather than optional, and that is deliberate: `desc` is an ICU + * message with a `{name}` placeholder, and a missing argument is not a blank in + * next-intl - it is a formatting error, and the reader gets the literal string + * `core.content.conflict.desc` where the sentence should be. + */ + name: string; /** * Clears the conflict, so the next one can raise the dialog again. * @@ -149,7 +159,7 @@ export const ConflictNotice = ({ {reloaded ? t("reloaded", { version: conflict.currentVersion }) - : t("desc", { version: conflict.currentVersion })} + : t("desc", { name, version: conflict.currentVersion })} diff --git a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx index a51aa9059..3799c145c 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx @@ -586,6 +586,7 @@ const ContentFormFields = ({ {conflict && data ? ( setConflict(null)} onReload={onReload} opened={data} From c11f8a7d6501d4a63ab3aa2dedd246cc4c21e523 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Fri, 21 Aug 2026 15:22:14 +0200 Subject: [PATCH 4/6] fix(content-engine): Show why an upload was refused, not "please try again" `uploadContentFile` read the error 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 failure outside the route's own body arrived as text, failed the parse, and was replaced by a sentence that says nothing: "File exceeds the maximum size of 5242880 bytes" -> please try again "Unsupported file type: application/pdf" -> please try again "Invalid or corrupt image file" -> please try again "Storage provider not found" -> please try again The last one is the worst: nothing is wrong with the file, and the editor retries a misconfiguration for ever. Three changes: - The route answers JSON `{ code, message }` for every refusal it owns - unknown field, forbidden - and re-shapes the `HTTPException`s escaping `StorageModel` into the same envelope, so a corrupt file and a missing adapter both arrive as something the browser can read and tell apart. - The client reads the body in three layers: JSON, then plain text, then the status. Plain text was the layer that was missing. `413` gets its own sentence because a body the platform rejects never reaches the route, so no `maxBytes` check ran and the limit to raise is not the field's. - The uploader restates the three rules it can say better - too large, wrong type, wrong extension - in the reader's own language from the field's own limits, and shows everything else verbatim. `rawApiFetch` also stops discarding the body of a 500: it read `statusText ?? errorText`, which never fell through, so the only part that said what went wrong was thrown away on every 500. Co-Authored-By: Claude Opus 5 (1M context) --- .../docs/dev/content-engine/fields.mdx | 35 +++ .../src/components/form/fields/file.tsx | 60 ++++- .../vitnode/src/content/admin/upload.test.ts | 241 ++++++++++++++++++ packages/vitnode/src/content/admin/upload.ts | 169 +++++++++--- packages/vitnode/src/content/const.ts | 14 + packages/vitnode/src/content/files.ts | 33 ++- packages/vitnode/src/content/index.ts | 1 + .../content/server/file-upload-route.test.ts | 133 +++++++++- packages/vitnode/src/content/server/routes.ts | 80 ++++-- packages/vitnode/src/lib/fetcher/raw.ts | 6 +- 10 files changed, 710 insertions(+), 62 deletions(-) create mode 100644 packages/vitnode/src/content/admin/upload.test.ts diff --git a/apps/docs/content/docs/dev/content-engine/fields.mdx b/apps/docs/content/docs/dev/content-engine/fields.mdx index 50ff68edf..337abbf5e 100644 --- a/apps/docs/content/docs/dev/content-engine/fields.mdx +++ b/apps/docs/content/docs/dev/content-engine/fields.mdx @@ -307,6 +307,41 @@ The content mutation that follows is ordinary JSON: No base64, no binary through a Server Action, and the form value is the same size whether the image is 4 KB or 4 MB. +#### What a refused upload says + +Every refusal comes back as JSON with a machine-readable `code` and a sentence +written for the person who picked the file: + +```json +{ + "code": "CONTENT_FILE_TOO_LARGE", + "message": "This file is 9 MB. The maximum is 5 MB." +} +``` + +| Code | Means | +| :--- | :--- | +| `CONTENT_FILE_TOO_LARGE` | Over the field's `maxBytes`. | +| `CONTENT_FILE_MIME_TYPE_NOT_ALLOWED` | The declared media type is not in `allowedMimeTypes`. | +| `CONTENT_FILE_EXTENSION_NOT_ALLOWED` | The file name's extension is not in `allowedExtensions`. | +| `CONTENT_FILE_INVALID` | The bytes could not be read - a truncated or corrupt image. | +| `CONTENT_FILE_STORAGE_UNAVAILABLE` | No storage adapter configured, or the image pipeline failed to load. **A configuration fault, not a bad file.** | +| `CONTENT_FILE_FIELD_UNKNOWN` | The URL named a field this content type has not got, or one that is not a `file`. | +| `CONTENT_FILE_FORBIDDEN` | The role may view this content type but not write it. | + +The uploader restates the first three in the reader's own language, using the +field's own limits. Everything else is shown **verbatim**, and deliberately: +"Storage provider not found" is what an admin needs to read, and replacing it +with "please try again" is how somebody retries a misconfiguration for ten +minutes. + + + If your hosting platform or proxy rejects the request body before it arrives, + no `maxBytes` check runs and there is no JSON to read - the uploader says the + file was refused as too large *before* it reached the server, so the limit to + raise is the platform's rather than the field's. + + #### Uploading is not the same as assigning A successful upload proves the file was valid **for the field it was uploaded diff --git a/packages/vitnode/src/components/form/fields/file.tsx b/packages/vitnode/src/components/form/fields/file.tsx index 70181ff8a..d6f31a1a3 100644 --- a/packages/vitnode/src/components/form/fields/file.tsx +++ b/packages/vitnode/src/components/form/fields/file.tsx @@ -12,6 +12,8 @@ import { import { useTranslations } from "next-intl"; import React from "react"; +import type { FileRejectionReason } from "@/lib/file-constraints"; + import { Attachment, AttachmentAction, @@ -82,6 +84,21 @@ export interface AutoFormFileProps extends ItemAutoFormComponentProps { 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/"); @@ -145,8 +162,47 @@ export const AutoFormFile = ({ }, }); - const errorMessage = - rejected ?? (upload.error instanceof Error ? upload.error.message : null); + /** + * 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; 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 index f03b1da92..f1fe20cec 100644 --- a/packages/vitnode/src/content/admin/upload.ts +++ b/packages/vitnode/src/content/admin/upload.ts @@ -1,8 +1,9 @@ +import type { FileRejectionReason } from "../../lib/file-constraints"; import type { ContentFileDescriptor } from "../files"; import type { ContentFormSpec } from "./spec"; import { rawApiFetch } from "../../lib/fetcher/raw"; -import { zodContentFileDescriptor } from "../files"; +import { contentFileRejectionReason, zodContentFileDescriptor } from "../files"; /** * The address of a content type's generated upload route. @@ -24,19 +25,128 @@ export const contentUploadPath = ( 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 }: ContentUploadRejection) { + 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. * @@ -48,9 +158,8 @@ export class ContentUploadError extends Error { * `maxBytes`. The content mutation that follows is ordinary JSON carrying the * identifier this returns. * - * A 4xx with a JSON body becomes a {@link ContentUploadError} whose message is - * the one the server wrote - it names the field's own limits, so it is worth - * showing verbatim. + * 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, @@ -64,32 +173,34 @@ export const uploadContentFile = async ({ const formData = new FormData(); formData.append("file", file); - const response = await rawApiFetch({ - ...contentUploadPath(spec, field), - formData, - method: "post", - options: { credentials: "include" }, - pluginId: spec.pluginId, - prefixPath: "/admin", - }); - - if (!response.ok) { - const rejection: unknown = await response.json().catch(() => null); - const parsed = - rejection !== null && - typeof rejection === "object" && - typeof (rejection as { message?: unknown }).message === "string" - ? (rejection as ContentUploadRejection) - : null; - - throw new ContentUploadError( - parsed ?? { - code: `HTTP_${response.status}`, - message: "The upload failed. Please try again.", - }, - ); + 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 09a44ef32..841e8e01e 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -163,9 +163,23 @@ export const CONTENT_FILE_FOLDER = "content"; */ 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", } as const; /** Appended to the base table name to get the generated translation table. */ diff --git a/packages/vitnode/src/content/files.ts b/packages/vitnode/src/content/files.ts index 7a0f7f4c1..c9aca606c 100644 --- a/packages/vitnode/src/content/files.ts +++ b/packages/vitnode/src/content/files.ts @@ -1,6 +1,10 @@ import { z } from "zod"; -import type { FileCandidate, FileConstraints } from "../lib/file-constraints"; +import type { + FileCandidate, + FileConstraints, + FileRejectionReason, +} from "../lib/file-constraints"; import type { ContentFileField } from "./types"; import { @@ -220,6 +224,33 @@ export const contentFileAccept = fileAcceptAttribute; */ 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, diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index 9986fab13..918440a9c 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -218,6 +218,7 @@ export { contentFileAccept, contentFileConstraints, contentFileFormatLabels, + contentFileRejectionReason, normalizeContentFileExtension, normalizeContentFileExtensions, normalizeContentFileMimeType, diff --git a/packages/vitnode/src/content/server/file-upload-route.test.ts b/packages/vitnode/src/content/server/file-upload-route.test.ts index 820933d73..6d236484b 100644 --- a/packages/vitnode/src/content/server/file-upload-route.test.ts +++ b/packages/vitnode/src/content/server/file-upload-route.test.ts @@ -5,6 +5,7 @@ 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 { @@ -52,26 +53,30 @@ const PLUGIN_ID = "@vitnode/example"; * 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 }) => - 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 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(); @@ -293,6 +298,110 @@ describe("the generated upload route", () => { 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("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; diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts index 501f28776..98b04009d 100644 --- a/packages/vitnode/src/content/server/routes.ts +++ b/packages/vitnode/src/content/server/routes.ts @@ -3,6 +3,7 @@ import type { Context } from "hono"; import { z } from "@hono/zod-openapi"; import { HTTPException } from "hono/http-exception"; +import type { StorageFileUploadResult } from "../../api/models/storage"; import type { AnyContentTypeDefinition, ContentFilterInput, @@ -27,6 +28,7 @@ import { } from "../conflicts"; import { CONTENT_ACTOR_TYPES, + CONTENT_FILE_CODES, CONTENT_FILE_FOLDER, CONTENT_LOCALE_MAX_LENGTH, CONTENT_OPTIONS_LIMIT, @@ -599,16 +601,32 @@ export const buildContentRoutes = < zodFileRejection, "Not a file field, or the file failed the field's own rules", ), - 403: { description: "Not allowed to write this content type" }, + 403: jsonResponse( + zodFileRejection, + "Not allowed to write this content type", + ), + 500: jsonResponse( + zodFileRejection, + "The install cannot store files - no adapter, or the image pipeline failed", + ), }, }, handler: async c => { const field = c.req.param("field"); const fieldValue = fileFields[field]; + // Every rejection below is a JSON `{ code, message }`, never a bare + // `HTTPException`. Hono renders an exception's message as *plain text*, and + // the browser cannot tell that apart from an HTML error page - so the + // uploader falls back to "the upload failed", which is the one thing an + // editor cannot act on. A code and a sentence can both be acted on. if (!fieldValue) { - throw new HTTPException(400, { - message: `"${field}" is not a file field on this content type.`, - }); + return c.json( + { + code: CONTENT_FILE_CODES.unknownField, + message: `"${field}" is not a file field on this content type.`, + }, + 400, + ); } // Either write permission is enough - see the doc comment. @@ -624,7 +642,13 @@ export const buildContentRoutes = < ), ); if (!allowed.some(Boolean)) { - throw new HTTPException(403, { message: "Forbidden" }); + return c.json( + { + code: CONTENT_FILE_CODES.forbidden, + message: `You do not have permission to upload a ${name}.`, + }, + 403, + ); } // Re-parsed rather than read through `c.req.valid("form")`, which cannot @@ -643,18 +667,40 @@ export const buildContentRoutes = < }); if (rejected) return c.json(rejected, 400); - const stored = await c.get("storage").upload({ - file, - folder: CONTENT_FILE_FOLDER, - // Handed to the adapter too, as defence in depth: a direct - // `storage.upload` from somewhere else should not be able to exceed what - // this field declares. - ...(constraints.allowedMimeTypes - ? { allowedMimeTypes: [...constraints.allowedMimeTypes] } - : {}), - maxBytes: constraints.maxBytes, - metadata: { contentTypeId: definition.id, field }, - }); + let stored: StorageFileUploadResult; + try { + stored = await c.get("storage").upload({ + file, + folder: CONTENT_FILE_FOLDER, + // Handed to the adapter too, as defence in depth: a direct + // `storage.upload` from somewhere else should not be able to exceed + // what this field declares. + ...(constraints.allowedMimeTypes + ? { allowedMimeTypes: [...constraints.allowedMimeTypes] } + : {}), + maxBytes: constraints.maxBytes, + metadata: { contentTypeId: definition.id, field }, + }); + } catch (error) { + // `StorageModel` speaks in `HTTPException`s, whose messages are exactly + // what the person needs to read: "Storage provider not found", + // "Invalid or corrupt image file", "Image optimization library (sharp) + // failed to load". Re-shaped rather than rethrown so they arrive as JSON + // instead of as text the uploader has to guess at - a misconfigured + // install must say so, not say "please try again" for ever. + if (!(error instanceof HTTPException)) throw error; + + return c.json( + { + code: + error.status === 400 + ? CONTENT_FILE_CODES.invalid + : CONTENT_FILE_CODES.storage, + message: error.message, + }, + error.status === 400 ? 400 : 500, + ); + } const descriptor = contentFileDescriptorFromUpload(stored); diff --git a/packages/vitnode/src/lib/fetcher/raw.ts b/packages/vitnode/src/lib/fetcher/raw.ts index a93ab678e..cfa2d2dfb 100644 --- a/packages/vitnode/src/lib/fetcher/raw.ts +++ b/packages/vitnode/src/lib/fetcher/raw.ts @@ -114,7 +114,11 @@ export const rawApiFetch = async ({ if (response.status === 500) { const errorText = await response.text(); throw new Error( - `${response.status} - ${url.toString()}\n${response.statusText ?? errorText}`, + // The body first, `statusText` only as a fallback. It used to be + // `statusText ?? errorText`, which never fell through - `statusText` is + // essentially always a non-empty string - so the one part that says *what* + // went wrong was discarded on every 500. + `${response.status} - ${url.toString()}\n${errorText.trim() === "" ? response.statusText : errorText}`, ); } From 4b88d7ad1484aaeb260c2341af9561fa9e6e53ac Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Fri, 21 Aug 2026 21:44:20 +0200 Subject: [PATCH 5/6] fix: upload large format via content system --- apps/docs/next.config.ts | 1 - packages/vitnode/config/next.config.ts | 1 + .../src/api/models/storage-image.test.ts | 155 ++++++++++++- packages/vitnode/src/api/models/storage.ts | 219 ++++++++++++++++-- packages/vitnode/src/content/const.ts | 11 + .../content/server/file-upload-route.test.ts | 19 +- packages/vitnode/src/content/server/routes.ts | 37 +-- 7 files changed, 401 insertions(+), 42 deletions(-) diff --git a/apps/docs/next.config.ts b/apps/docs/next.config.ts index e16d6c775..84bfa9278 100644 --- a/apps/docs/next.config.ts +++ b/apps/docs/next.config.ts @@ -31,7 +31,6 @@ const docsIndexRedirects = [ })); const nextConfig: NextConfig = { - reactCompiler: true, redirects: async () => docsIndexRedirects, }; 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 5c7189412..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( @@ -147,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.ts b/packages/vitnode/src/api/models/storage.ts index c8b3d6613..278694ae1 100644 --- a/packages/vitnode/src/api/models/storage.ts +++ b/packages/vitnode/src/api/models/storage.ts @@ -10,6 +10,7 @@ import { generateStorageFileName, replaceFileExtension, } from "@/lib/api/upload"; +import { formatBytes } from "@/lib/format-bytes"; const DEFAULT_IMAGE_QUALITY = 85; @@ -101,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; @@ -120,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; @@ -133,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.", }); } @@ -255,12 +420,12 @@ export class StorageModel { 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(", ")}.`, }); } @@ -317,6 +482,9 @@ export class StorageModel { ...(processed.dimensions ? { dimensions: processed.dimensions } : {}), + ...(processed.skippedConversion + ? { skippedConversion: processed.skippedConversion } + : {}), }, }) .returning({ id: core_files.id }); @@ -328,7 +496,8 @@ export class StorageModel { if (!created) { await provider.delete(result.key).catch(() => undefined); throw new HTTPException(500, { - message: "The uploaded file could not be recorded", + message: + "The file was stored but could not be recorded in the database, so it cannot be referenced. Nothing was kept - try again.", }); } diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts index 841e8e01e..850ee4c99 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -180,6 +180,17 @@ export const CONTENT_FILE_CODES = { 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. */ diff --git a/packages/vitnode/src/content/server/file-upload-route.test.ts b/packages/vitnode/src/content/server/file-upload-route.test.ts index 6d236484b..a9e817764 100644 --- a/packages/vitnode/src/content/server/file-upload-route.test.ts +++ b/packages/vitnode/src/content/server/file-upload-route.test.ts @@ -8,6 +8,7 @@ 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, @@ -273,7 +274,8 @@ describe("the generated upload route", () => { 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-encoded"); + expect(body.message).toContain("re-encodes"); + expect(body.message).toContain("original format"); expect(deleteFile).toHaveBeenCalledWith(42); }); @@ -349,6 +351,21 @@ describe("the generated upload route", () => { 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. diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts index 98b04009d..ae720ff9a 100644 --- a/packages/vitnode/src/content/server/routes.ts +++ b/packages/vitnode/src/content/server/routes.ts @@ -19,6 +19,7 @@ import { zodPaginationPageInfo, zodPaginationQuery, } from "../../api/lib/with-pagination"; +import { StorageImageUnprocessableError } from "../../api/models/storage"; import { contentTypeName } from "../admin/labels"; import { zodContentConflict, @@ -683,21 +684,27 @@ export const buildContentRoutes = < }); } catch (error) { // `StorageModel` speaks in `HTTPException`s, whose messages are exactly - // what the person needs to read: "Storage provider not found", - // "Invalid or corrupt image file", "Image optimization library (sharp) - // failed to load". Re-shaped rather than rethrown so they arrive as JSON - // instead of as text the uploader has to guess at - a misconfigured - // install must say so, not say "please try again" for ever. + // what the person needs to read: which limit the image exceeded, what + // libvips said about the bytes, which adapter is missing from the config. + // Re-shaped rather than rethrown so they arrive as JSON instead of as + // text the uploader has to guess at - a misconfigured install must say + // so, not say "please try again" for ever. + // + // `StorageImageUnprocessableError` is picked out because it is the one + // 400 that is *not* a bad file: the image read fine and only the + // conversion refused it, so calling it `invalid` would have somebody + // re-exporting a PNG that was never broken. if (!(error instanceof HTTPException)) throw error; + const code = + error instanceof StorageImageUnprocessableError + ? CONTENT_FILE_CODES.unprocessable + : error.status === 400 + ? CONTENT_FILE_CODES.invalid + : CONTENT_FILE_CODES.storage; + return c.json( - { - code: - error.status === 400 - ? CONTENT_FILE_CODES.invalid - : CONTENT_FILE_CODES.storage, - message: error.message, - }, + { code, message: error.message }, error.status === 400 ? 400 : 500, ); } @@ -707,7 +714,9 @@ export const buildContentRoutes = < // The same rules again, against what was actually **stored**. An install // with `storage.image.webp` re-encodes a PNG to WebP, so the stored name // ends `.webp` - and a field that allows only `.png` has to say so now, - // loudly, rather than accepting the upload and refusing the save. The file + // loudly, rather than accepting the upload and refusing the save. It cuts + // the other way too: an image over WebP's 16383px per side is stored in the + // format it arrived in, so a `.webp`-only field refuses that one. The file // is removed because this request created it and nothing else refers to it. const stale = validateContentFile(constraints, descriptor); if (stale) { @@ -716,7 +725,7 @@ export const buildContentRoutes = < return c.json( { code: stale.code, - message: `${stale.message} The stored file is "${descriptor.name}" (${descriptor.mimeType ?? "unknown type"}) - image processing may have re-encoded it, in which case this field's allowlist has to include the converted format.`, + message: `${stale.message} The stored file is "${descriptor.name}" (${descriptor.mimeType ?? "unknown type"}). Image processing re-encodes uploads to WebP, and keeps an image too large for WebP in its original format - so this field's allowlist has to accept both.`, }, 400, ); From 46657b1e2631e7760c6cd9e79a0c486fd2b4ee83 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sat, 22 Aug 2026 10:18:02 +0200 Subject: [PATCH 6/6] perf: Improve errors file send via content system --- apps/api/.env.example | 2 +- apps/api/src/index.ts | 2 +- apps/docs/content/docs/dev/websocket.mdx | 2 +- .../core/staff/admins/edit/[id]/page.tsx | 10 +- .../core/staff/moderators/edit/[id]/page.tsx | 10 +- .../copy-of-vitnode-app/api-bun/src/index.ts | 2 +- .../copy-of-vitnode-app/api/src/index.ts | 14 +- .../monorepo/apps/web/.env.example | 2 +- .../src/create/create-vitnode.ts | 4 +- packages/vitnode/src/components/ui/editor.tsx | 40 +- packages/vitnode/src/content/files.ts | 20 + .../server/file-reference-http-errors.test.ts | 420 ++++++++++++++++++ packages/vitnode/src/content/server/files.ts | 19 +- .../vitnode/src/content/server/http-errors.ts | 28 ++ .../content/server/localized-admin-routes.ts | 22 +- packages/vitnode/src/content/server/routes.ts | 27 +- .../content/server/translation-http-errors.ts | 15 + .../core/staff/admins/edit/[id]/page.tsx | 10 +- .../core/staff/moderators/edit/[id]/page.tsx | 10 +- 19 files changed, 616 insertions(+), 43 deletions(-) create mode 100644 packages/vitnode/src/content/server/file-reference-http-errors.test.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index e3ec0f8ee..829e542a3 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -4,7 +4,7 @@ REDIS_URL=redis://localhost:6379 NEXT_PUBLIC_WEB_URL=http://localhost:3000 # Public origin of this API. Used to build absolute upload URLs for the Local # storage adapter, so it must match where the server is reachable. -NEXT_PUBLIC_API_URL=http://localhost:8080 +NEXT_PUBLIC_API_URL=http://localhost:8000 # === CRON Secret for Internal API Calls === CRON_SECRET=your-secure-cron-secret-key diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index b3742411d..5d7ad7916 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -53,7 +53,7 @@ app.get("/ws", upgradeWebSocket(handleVitNodeWebSocket())); serve( { fetch: app.fetch, - port: 8080, + port: 8000, websocket: { server: wss, }, diff --git a/apps/docs/content/docs/dev/websocket.mdx b/apps/docs/content/docs/dev/websocket.mdx index 5a7ea4019..fe14733aa 100644 --- a/apps/docs/content/docs/dev/websocket.mdx +++ b/apps/docs/content/docs/dev/websocket.mdx @@ -81,7 +81,7 @@ const wss = new WebSocketServer({ noServer: true }); serve({ fetch: app.fetch, - port: 8080, + port: 8000, websocket: { server: wss }, // [!code ++] }); ``` diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/staff/admins/edit/[id]/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/staff/admins/edit/[id]/page.tsx index b9eaf5147..f7526b1eb 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/staff/admins/edit/[id]/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/staff/admins/edit/[id]/page.tsx @@ -4,18 +4,22 @@ import { I18nProvider } from "@vitnode/core/components/i18n-provider"; import { Loader } from "@vitnode/core/components/ui/loader"; import { EditStaffPermissionsView } from "@vitnode/core/views/admin/views/core/staff/edit/edit-staff-permissions-view"; -export default async function Page({ +const EditStaffPermissions = async ({ params, }: { params: Promise<{ id: string }>; -}) { +}) => { const { id } = await params; + return ; +}; + +export default function Page({ params }: { params: Promise<{ id: string }> }) { return (
      }> - +
      diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/staff/moderators/edit/[id]/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/staff/moderators/edit/[id]/page.tsx index 85289e248..dbab0720e 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/staff/moderators/edit/[id]/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/staff/moderators/edit/[id]/page.tsx @@ -4,18 +4,22 @@ import { I18nProvider } from "@vitnode/core/components/i18n-provider"; import { Loader } from "@vitnode/core/components/ui/loader"; import { EditStaffPermissionsView } from "@vitnode/core/views/admin/views/core/staff/edit/edit-staff-permissions-view"; -export default async function Page({ +const EditStaffPermissions = async ({ params, }: { params: Promise<{ id: string }>; -}) { +}) => { const { id } = await params; + return ; +}; + +export default function Page({ params }: { params: Promise<{ id: string }> }) { return (
      }> - +
      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/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/files.ts b/packages/vitnode/src/content/files.ts index c9aca606c..674bd01bf 100644 --- a/packages/vitnode/src/content/files.ts +++ b/packages/vitnode/src/content/files.ts @@ -64,6 +64,26 @@ export interface ContentFileRejection { 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. * 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/files.ts b/packages/vitnode/src/content/server/files.ts index 867082baf..5da5551ea 100644 --- a/packages/vitnode/src/content/server/files.ts +++ b/packages/vitnode/src/content/server/files.ts @@ -282,10 +282,10 @@ export const assertContentFileReferences = async ( /** * A file identifier a content write may not store. * - * A `ContentInputError`, so the generated routes already answer 400 with the - * message - which names the field and the reason and nothing internal. `code` is - * carried so a caller that wants to point at the field can, exactly as - * `ContentAdvancedInputError` does for a missing relation target. + * A `ContentInputError`, so a route that knows nothing about files still answers + * 400. `code` and `field` are carried so one that does can say which rule refused + * which input, exactly as `ContentAdvancedInputError` does for a missing relation + * target - and `rethrowAsHttpError` answers with all three. */ export class ContentFileReferenceError extends ContentInputError { constructor({ @@ -303,10 +303,21 @@ export class ContentFileReferenceError extends ContentInputError { this.name = "ContentFileReferenceError"; this.code = code; + this.detail = message; this.field = field; } readonly code: ContentFileRejection["code"]; + /** + * The sentence as it was written, for the response body. + * + * `Error.message` is not it: `ContentEngineError` prefixes every message with + * `[Content Engine] : ` so a misconfigured plugin is obvious in + * a log, and that prefix is exactly the internal detail a form must not show + * an editor. Kept beside it rather than by stripping it back off, which would + * be string surgery on a format that exists for the log's benefit. + */ + readonly detail: string; readonly field: string; } diff --git a/packages/vitnode/src/content/server/http-errors.ts b/packages/vitnode/src/content/server/http-errors.ts index 2b2443c5b..0a10eb1b8 100644 --- a/packages/vitnode/src/content/server/http-errors.ts +++ b/packages/vitnode/src/content/server/http-errors.ts @@ -21,6 +21,7 @@ import { ContentScheduleError, ContentVersionConflict, } from "../errors"; +import { ContentFileReferenceError } from "./files"; const { foreignKeyViolation: FOREIGN_KEY_VIOLATION, @@ -64,6 +65,21 @@ export const contentUnprocessable = ( body: ContentUnprocessable, ): HTTPException => jsonError(422, body); +/** + * A structured 400, for a file identifier a field may not hold. + * + * The only 400 in this module that carries a body, and it does so because the + * two parts beside the sentence are the actionable ones: `code` says which rule + * refused the file, and `field` says which input to put the message under. A + * save carries every field at once, so a client with only prose to go on can + * report a refusal but not where. + */ +export const contentFileRejected = (body: { + code: string; + field: string; + message: string; +}): HTTPException => jsonError(400, body); + /** * A structured 400, for a schedule the rules refuse. * @@ -148,6 +164,18 @@ export const rethrowAsHttpError = ( throw new HTTPException(400, { message: "Invalid input data." }); } + // Before the generic `ContentInputError` branch below, which is what this used + // to fall through to: that keeps the sentence and drops `code` and `field`, + // the two parts a form can act on. A `ContentFileReferenceError` is the same + // 400 either way - it just says which field and which rule. + if (error instanceof ContentFileReferenceError) { + throw contentFileRejected({ + code: error.code, + field: error.field, + message: error.detail, + }); + } + // Written for the client on purpose - "provide the slug explicitly" is // useless if it never leaves the server. if (error instanceof ContentInputError) { diff --git a/packages/vitnode/src/content/server/localized-admin-routes.ts b/packages/vitnode/src/content/server/localized-admin-routes.ts index 72ac1f58c..10b15e040 100644 --- a/packages/vitnode/src/content/server/localized-admin-routes.ts +++ b/packages/vitnode/src/content/server/localized-admin-routes.ts @@ -23,9 +23,11 @@ import { zodContentTranslationConflict, } from "../conflicts"; import { CONTENT_LOCALE_MAX_LENGTH, CONTENT_PERMISSIONS } from "../const"; +import { zodContentFileReferenceRejection } from "../files"; import { resolveContentActor } from "./actor"; import { contentEditorialEffects } from "./editorial-effects"; import { emitContentEvent } from "./emit"; +import { contentFileFields } from "./files"; import { withHttpErrors } from "./http-errors"; import { contentSearchAdvancedValues, syncContentSearch } from "./search-sync"; import { contentTranslationEffects } from "./translation-effects"; @@ -88,6 +90,22 @@ export const buildContentLocalizedAdminRoutes = < description, }); + /** + * The 400 a composite save answers when a file identifier is refused. + * + * Composite writes go through the same `withHttpErrors` as the plain ones, so + * the body is identical - and a file field is always shared, which is why one + * shape covers a route that writes the base row and every translation at once. + * Declared only when there is a file field to refuse. + */ + const writeRejection = (description: string) => + Object.keys(contentFileFields(definition)).length > 0 + ? jsonResponse( + zodContentFileReferenceRejection, + `${description}, or a file this field may not hold`, + ) + : { description }; + // Every arm a composite save can be refused with, in one union: the base row's // (version, unique), the translations' (version, exists, disabled language, // localized unique) and delivery's reserved address. A client has to be able to @@ -328,7 +346,7 @@ export const buildContentLocalizedAdminRoutes = < request: { body: jsonBody(createBody) }, responses: { 201: jsonResponse(schemas.selectObject, `${name} created successfully`), - 400: { description: "Invalid input data" }, + 400: writeRejection("Invalid input data"), 409: conflict, }, }, @@ -416,7 +434,7 @@ export const buildContentLocalizedAdminRoutes = < request: { params: schemas.params, body: jsonBody(updateBody) }, responses: { 200: jsonResponse(schemas.selectObject, `${name} updated successfully`), - 400: { description: "Invalid or empty payload" }, + 400: writeRejection("Invalid or empty payload"), 404: { description: `${name} not found` }, 409: conflict, }, diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts index ae720ff9a..91380c6af 100644 --- a/packages/vitnode/src/content/server/routes.ts +++ b/packages/vitnode/src/content/server/routes.ts @@ -42,6 +42,7 @@ import { contentFileConstraints, validateContentFile, zodContentFileDescriptor, + zodContentFileReferenceRejection, } from "../files"; import { partitionContentFields } from "../localization"; import { orderableColumns } from "../registry"; @@ -266,6 +267,26 @@ export const buildContentRoutes = < const invalidIdentifier = { description: "Invalid identifier" }; const editorial = definition.editorial.enabled; + /** + * The 400 a write answers when a file identifier is refused. + * + * Declared only for a content type that has a file field, the same way + * `uniqueConflict` is declared only for an editorial one: a content type with + * no file field cannot produce this body, and saying it might would describe a + * response that can never arrive. + * + * The status is shared with plain-text 400s - an unparseable payload is still + * `Invalid input data.` - so a client reads the body as this shape only once it + * has one. That is the same contract the upload route publishes. + */ + const writeRejection = (description: string) => + hasFileFields + ? jsonResponse( + zodContentFileReferenceRejection, + `${description}, or a file this field may not hold`, + ) + : { description }; + // An editorial content type answers both conflict kinds with a JSON body, so // a client can tell "someone saved first" from "that value is taken" and act // on the difference. Everything else keeps the plain-text 409 it has always @@ -780,7 +801,7 @@ export const buildContentRoutes = < request: { body: jsonBody(schemas.create) }, responses: { 201: jsonResponse(schemas.selectObject, `${name} created successfully`), - 400: { description: "Invalid input data" }, + 400: writeRejection("Invalid input data"), 409: uniqueConflict, }, }, @@ -860,7 +881,7 @@ export const buildContentRoutes = < }, responses: { 200: jsonResponse(schemas.selectObject, `${name} updated successfully`), - 400: { description: "Invalid or empty payload" }, + 400: writeRejection("Invalid or empty payload"), 404: { description: `${name} not found` }, 409: uniqueConflict, }, @@ -903,7 +924,7 @@ export const buildContentRoutes = < request: { params: schemas.params, body: jsonBody(schemas.update) }, responses: { 200: jsonResponse(schemas.selectObject, `${name} updated successfully`), - 400: { description: "Invalid or empty payload" }, + 400: writeRejection("Invalid or empty payload"), 404: { description: `${name} not found` }, 409: uniqueConflict, }, diff --git a/packages/vitnode/src/content/server/translation-http-errors.ts b/packages/vitnode/src/content/server/translation-http-errors.ts index 5bd4f58f0..09e302c88 100644 --- a/packages/vitnode/src/content/server/translation-http-errors.ts +++ b/packages/vitnode/src/content/server/translation-http-errors.ts @@ -18,8 +18,10 @@ import { ContentTranslationItemMissing, ContentTranslationVersionConflict, } from "../errors"; +import { ContentFileReferenceError } from "./files"; import { contentDeliveryConflict, + contentFileRejected, contentUnprocessable, rethrowAsHttpError, } from "./http-errors"; @@ -147,6 +149,19 @@ export const withTranslationHttpErrors = async ( }); } + // Ahead of the generic `ContentInputError` branch for the same reason as in + // the shared mapper: the code and the field are what let a form point at the + // input that was refused. A file field is always shared, so this is reached + // by a composite write rather than by a translation-only one - and it has to + // answer identically whichever mapper saw it. + if (error instanceof ContentFileReferenceError) { + throw contentFileRejected({ + code: error.code, + field: error.field, + message: error.detail, + }); + } + // Written for the client on purpose, like the base service's: "send the slug // explicitly" is useless if it never leaves the server. if (error instanceof ContentInputError) { diff --git a/packages/vitnode/src/routes/admin/core/staff/admins/edit/[id]/page.tsx b/packages/vitnode/src/routes/admin/core/staff/admins/edit/[id]/page.tsx index 8398da931..4eede2283 100644 --- a/packages/vitnode/src/routes/admin/core/staff/admins/edit/[id]/page.tsx +++ b/packages/vitnode/src/routes/admin/core/staff/admins/edit/[id]/page.tsx @@ -4,18 +4,22 @@ import { I18nProvider } from "@/components/i18n-provider"; import { Loader } from "@/components/ui/loader"; import { EditStaffPermissionsView } from "@/views/admin/views/core/staff/edit/edit-staff-permissions-view"; -export default async function Page({ +const EditStaffPermissions = async ({ params, }: { params: Promise<{ id: string }>; -}) { +}) => { const { id } = await params; + return ; +}; + +export default function Page({ params }: { params: Promise<{ id: string }> }) { return (
      }> - +
      diff --git a/packages/vitnode/src/routes/admin/core/staff/moderators/edit/[id]/page.tsx b/packages/vitnode/src/routes/admin/core/staff/moderators/edit/[id]/page.tsx index 4aec66910..eee50e89f 100644 --- a/packages/vitnode/src/routes/admin/core/staff/moderators/edit/[id]/page.tsx +++ b/packages/vitnode/src/routes/admin/core/staff/moderators/edit/[id]/page.tsx @@ -4,18 +4,22 @@ import { I18nProvider } from "@/components/i18n-provider"; import { Loader } from "@/components/ui/loader"; import { EditStaffPermissionsView } from "@/views/admin/views/core/staff/edit/edit-staff-permissions-view"; -export default async function Page({ +const EditStaffPermissions = async ({ params, }: { params: Promise<{ id: string }>; -}) { +}) => { const { id } = await params; + return ; +}; + +export default function Page({ params }: { params: Promise<{ id: string }> }) { return (
      }> - +