From 8e6a35de98a45472f9f881282add757dd2eae6ee Mon Sep 17 00:00:00 2001 From: NGPixel Date: Sun, 9 Aug 2026 18:20:31 -0400 Subject: [PATCH] feat: page redirection + various fixes --- CLAUDE.md | 9 +- backend/api/assets.ts | 2 +- backend/api/pages.ts | 7 +- backend/api/schemas/page.ts | 9 +- backend/api/schemas/site.ts | 16 +- backend/api/sites.ts | 155 +- backend/controllers/site.ts | 94 +- .../20260809182854_main/migration.sql | 8 + .../20260809182854_main/snapshot.json | 5749 +++++++++++++++++ backend/db/schema.ts | 17 + backend/helpers/common.ts | 48 + backend/helpers/images.ts | 76 + backend/index.ts | 49 +- backend/locales/en.json | 48 +- backend/models/assets.ts | 172 + backend/models/hooks.ts | 1 + backend/models/pages.ts | 122 +- backend/models/sites.ts | 110 +- backend/models/tree.ts | 94 +- frontend/src/App.vue | 16 + frontend/src/assets/icons.generated.js | 3 +- frontend/src/components/EditorRedirect.vue | 281 + frontend/src/components/FileManager.vue | 7 +- .../src/components/FolderCreateDialog.vue | 3 + .../src/components/FolderRenameDialog.vue | 3 + frontend/src/components/LinkPickerDialog.vue | 6 + frontend/src/components/NavEditOverlay.vue | 48 +- frontend/src/components/PageActionsCol.vue | 72 +- frontend/src/components/PageHeader.vue | 62 +- frontend/src/components/PageNewMenu.vue | 17 +- .../src/components/PagePropertiesDialog.vue | 49 +- frontend/src/components/PageRedirect.vue | 263 + .../src/components/PageRelationDialog.vue | 44 +- frontend/src/components/TreeBrowserDialog.vue | 4 + .../components/UploadPendingAssetsDialog.vue | 6 +- frontend/src/helpers/pagePaths.js | 40 + frontend/src/helpers/pageRedirect.js | 65 + frontend/src/helpers/siteImages.js | 69 + frontend/src/pages/AdminGeneral.vue | 288 +- frontend/src/pages/AdminLogin.vue | 123 +- frontend/src/pages/Index.vue | 35 +- frontend/src/pages/Login.vue | 2 +- frontend/src/stores/page.js | 35 +- frontend/src/stores/site.js | 7 + 44 files changed, 7962 insertions(+), 372 deletions(-) create mode 100644 backend/db/migrations/20260809182854_main/migration.sql create mode 100644 backend/db/migrations/20260809182854_main/snapshot.json create mode 100644 frontend/src/components/EditorRedirect.vue create mode 100644 frontend/src/components/PageRedirect.vue create mode 100644 frontend/src/helpers/pagePaths.js create mode 100644 frontend/src/helpers/pageRedirect.js create mode 100644 frontend/src/helpers/siteImages.js diff --git a/CLAUDE.md b/CLAUDE.md index dcd560fc8..3630273f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -419,15 +419,16 @@ An earlier iteration of 3.x used GraphQL/Apollo. **All of it is deprecated** — server left in `backend/`, and `APOLLO_CLIENT` is not defined as a global, so any call still going through it throws. `blocks/block-index/` also still imports a `tree.graphql`. -Four files under `frontend/src/` make live `APOLLO_CLIENT` calls, and each needs a REST endpoint +Three files under `frontend/src/` make live `APOLLO_CLIENT` calls, and each needs a REST endpoint that does not exist yet, so the feature behind it is currently broken: | File | Feature | | ---- | ------- | | `components/AuthLoginPanel.vue` | self-registration (the `register()` call only — passkey login and 2FA are REST now) | -| `pages/AdminGeneral.vue`, `pages/AdminNavigation.vue`, `pages/AdminUtilities.vue` | assorted admin actions | +| `pages/AdminNavigation.vue`, `pages/AdminUtilities.vue` | assorted admin actions | When touching such a file, port it to the REST API (`API_CLIENT` + the matching `backend/api/` route) rather than extending the GraphQL code. If the REST endpoint doesn't exist yet, add it under -`backend/api/` following the schema + permissions conventions above — `users/profile/editor-settings` -is a recent example of doing exactly that. +`backend/api/` following the schema + permissions conventions above — `sites/:siteId/images/:kind`, +which replaced the logo and favicon upload mutations in `AdminGeneral.vue`, is a recent example of +doing exactly that. diff --git a/backend/api/assets.ts b/backend/api/assets.ts index f9974a557..0fe546458 100644 --- a/backend/api/assets.ts +++ b/backend/api/assets.ts @@ -70,7 +70,7 @@ async function routes(app: FastifyInstance) { */ schema: { summary: 'Upload an asset', - description: `The body is the file itself, not a multipart form — send the bytes with their \`Content-Type\`. At most ${Math.round((WIKI.config.security?.uploadMaxFileSize ?? 10485760) / 1024 / 1024)} MB. The file name is sanitized, so the stored name in the response may differ from the one sent; the type served back later comes from that name's extension rather than from the request. Images get a thumbnail when the Sharp extension is installed.`, + description: `The body is the file itself, not a multipart form — send the bytes with their \`Content-Type\`. At most ${Math.round((WIKI.config.security?.uploadMaxFileSize ?? 10485760) / 1024 / 1024)} MB. The file name is sanitized, so the stored name in the response may differ from the one sent; the type served back later comes from that name's extension rather than from the request. Images get a thumbnail when the Sharp extension is installed.\n\nA file already at that name in that folder is settled by the site's upload conflict behavior: \`overwrite\` (the default) replaces it in place and answers with its existing ID, \`reject\` answers 409, and \`new\` stores the arrival as the next free \`name-1.ext\`. So the name and ID in the response are what to link to — never the ones that were sent. A page or a folder holding the name is answered 409 whichever behavior is set.`, tags: ['Assets'], consumes: ['*/*'], params: { diff --git a/backend/api/pages.ts b/backend/api/pages.ts index 0ceb3127d..b94ce71f7 100644 --- a/backend/api/pages.ts +++ b/backend/api/pages.ts @@ -2,7 +2,7 @@ import { validate as uuidValidate } from 'uuid' import type { FastifyInstance, FastifyRequest } from 'fastify' import type { PageActor, PageInput } from '../models/pages.ts' import { SEARCH_ORDER_BY, type SearchOrderBy } from '../models/search.ts' -import { generatePathHash } from '../helpers/common.ts' +import { generatePathHash, normalizePagePath } from '../helpers/common.ts' import { limitAuthAttempts, limitRenders } from '../helpers/rateLimit.ts' /** Comma-separated query lists, which is how the browser sends a multi-valued filter here. */ @@ -386,8 +386,9 @@ async function routes(app: FastifyInstance) { }, async (req, reply) => { const actor = actorFrom(req) - // -> The stored path: no wrapping slashes, lowercase, and the site root is the `home` page - const path = req.query.path.trim().replace(/^\/+/, '').replace(/\/+$/, '').toLowerCase() + // -> The stored form of whatever the including page wrote, since that is what it is looked up + // by. The site root is the `home` page. + const path = normalizePagePath(req.query.path) const page = await WIKI.models.pages.getPage({ siteId: req.params.siteId, hash: generatePathHash(path || 'home'), diff --git a/backend/api/schemas/page.ts b/backend/api/schemas/page.ts index 29eeb5dc8..490c124ba 100644 --- a/backend/api/schemas/page.ts +++ b/backend/api/schemas/page.ts @@ -56,11 +56,13 @@ export async function registerSchemas(app: FastifyInstance): Promise { editor: { type: 'string', maxLength: 255, - description: 'Which editor authored the content, e.g. `markdown`.' + description: + 'Which editor authored the content, e.g. `markdown`. `redirect` is a page with no body at all: it sends its reader elsewhere, is never searchable, and its content is the JSON below rather than a document.' }, content: { type: 'string', - description: 'The source, in whatever the editor writes.' + description: + 'The source, in whatever the editor writes. For a `redirect` page, `{ "kind": "page" | "url", "target": string, "showInterstitial": boolean }` — a page target is a rooted path within this wiki, a URL target a complete http(s) address.' }, render: { type: 'string', @@ -179,7 +181,8 @@ export async function registerSchemas(app: FastifyInstance): Promise { render: { type: 'string' }, content: { type: 'string', - description: 'Only present when the request asked for it.' + description: + 'Only present when the request asked for it — except on a redirection, whose content is where it sends its reader rather than a body, and comes back either way.' }, allowComments: { type: 'boolean' }, allowContributions: { type: 'boolean' }, diff --git a/backend/api/schemas/site.ts b/backend/api/schemas/site.ts index e0b47c0f6..c873aad63 100644 --- a/backend/api/schemas/site.ts +++ b/backend/api/schemas/site.ts @@ -40,9 +40,6 @@ export async function registerSchemas(app: FastifyInstance): Promise { type: 'string' } }, - pageCasing: { - type: 'boolean' - }, discoverable: { type: 'boolean' }, @@ -98,10 +95,9 @@ export async function registerSchemas(app: FastifyInstance): Promise { properties: { conflictBehavior: { type: 'string', + description: + 'What an upload does about a file already at the name it wants: replace it in place, refuse the upload, or store the arrival as the next free `name-1.ext`.', enum: ['overwrite', 'reject', 'new'] - }, - normalizeFilename: { - type: 'boolean' } } }, @@ -196,19 +192,15 @@ export async function registerSchemas(app: FastifyInstance): Promise { }, assets: { type: 'object', + description: + 'Which images have been uploaded for this site. The images themselves are served from `/_site//`, which falls back to the built-in default wherever the flag is false.', properties: { logo: { type: 'boolean' }, - logoExt: { - type: 'string' - }, favicon: { type: 'boolean' }, - faviconExt: { - type: 'string' - }, loginBg: { type: 'boolean' } diff --git a/backend/api/sites.ts b/backend/api/sites.ts index 642b74b03..4e50afa3c 100644 --- a/backend/api/sites.ts +++ b/backend/api/sites.ts @@ -1,7 +1,13 @@ import { validate as uuidValidate } from 'uuid' import { CustomError } from '../helpers/common.ts' +import { detectImageMime, detectSvg, imageMimeTypes, svgMimeType } from '../helpers/images.ts' +import { siteAssetKinds } from '../models/sites.ts' +import type { SiteAssetKind } from '../models/sites.ts' import type { FastifyInstance } from 'fastify' +/** How large one of a site's own images may be uploaded, before it is re-encoded. */ +const imageUploadLimit = 10 * 1024 * 1024 + /** * Site properties stored in the `config` JSONB column rather than as their own table column. * Anything listed here is merged into the existing config on update. @@ -13,7 +19,6 @@ const SITE_CONFIG_KEYS = [ 'contentLicense', 'footerExtra', 'pageExtensions', - 'pageCasing', 'logoText', 'sitemap', 'discoverable', @@ -32,6 +37,17 @@ const SITE_CONFIG_KEYS = [ * Sites API Routes */ async function routes(app: FastifyInstance) { + // -> An image upload is the raw file rather than a multipart form: one file, no fields, and no + // dependency to add. Registered inside this plugin, so every other route keeps rejecting an + // image body outright. + app.addContentTypeParser( + [...imageMimeTypes, svgMimeType], + { parseAs: 'buffer', bodyLimit: imageUploadLimit }, + (req, body, done) => { + done(null, body) + } + ) + app.get( '/', { @@ -247,7 +263,6 @@ async function routes(app: FastifyInstance) { contentLicense?: string footerExtra?: string pageExtensions?: string[] - pageCasing?: boolean logoText?: boolean sitemap?: boolean discoverable?: boolean @@ -321,9 +336,6 @@ async function routes(app: FastifyInstance) { pattern: '^[a-z0-9]+$' } }, - pageCasing: { - type: 'boolean' - }, logoText: { type: 'boolean' }, @@ -475,6 +487,139 @@ async function routes(app: FastifyInstance) { } ) + /** + * UPLOAD SITE IMAGE + */ + app.put<{ Params: { siteId: string; kind: SiteAssetKind } }>( + '/:siteId/images/:kind', + { + config: { + permissions: ['manage:sites'] + }, + schema: { + summary: "Replace one of a site's images", + description: `The body is the raw image, not a multipart form — send the file itself with its \`Content-Type\`. At most ${imageUploadLimit / 1024 / 1024} MB, and it must really be one of the accepted formats: the bytes are checked, not the declared type.\n\nA raster upload is re-encoded to the size and format the image is served at — 512x512 WebP for a logo, 180x180 PNG for a favicon, 1920x1080 WebP for a login background — when the Sharp extension is installed, and stored as uploaded when it is not. An SVG is always stored as uploaded.\n\nServed afterwards from \`/_site//\`, which falls back to the built-in default until something is uploaded.`, + tags: ['Sites'], + consumes: [...imageMimeTypes, svgMimeType], + params: { + type: 'object', + properties: { + siteId: { + type: 'string', + format: 'uuid' + }, + kind: { + type: 'string', + description: 'Which of the site images to replace.', + enum: [...siteAssetKinds] + } + }, + required: ['siteId', 'kind'] + }, + response: { + 200: { + description: 'Image uploaded successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + } + } + } + } + } + }, + async (req, reply) => { + const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId }) + if (!site) { + return reply.notFound('Site does not exist.') + } + + const data = req.body + if (!Buffer.isBuffer(data) || data.length < 1) { + throw new CustomError('siteImageEmpty', 'No image was sent.') + } + // -> The declared content type got the request this far; what the bytes actually are is what + // decides, since they are what gets stored and served back + if (!detectImageMime(data) && !detectSvg(data)) { + throw new CustomError( + 'siteImageInvalidImage', + 'Not an SVG, PNG, JPEG, WebP or GIF image, whatever the request said it was.' + ) + } + + await WIKI.models.sites.setAsset(req.params.siteId, req.params.kind, data) + + return { + ok: true, + message: 'Image uploaded successfully.' + } + } + ) + + /** + * CLEAR SITE IMAGE + */ + app.delete<{ Params: { siteId: string; kind: SiteAssetKind } }>( + '/:siteId/images/:kind', + { + config: { + permissions: ['manage:sites'] + }, + schema: { + summary: "Remove one of a site's images", + description: + 'Leaves the built-in default to be served in its place again. Succeeds even if there was no image to remove.', + tags: ['Sites'], + params: { + type: 'object', + properties: { + siteId: { + type: 'string', + format: 'uuid' + }, + kind: { + type: 'string', + description: 'Which of the site images to remove.', + enum: [...siteAssetKinds] + } + }, + required: ['siteId', 'kind'] + }, + response: { + 200: { + description: 'Image cleared successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + } + } + } + } + } + }, + async (req, reply) => { + const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId }) + if (!site) { + return reply.notFound('Site does not exist.') + } + + await WIKI.models.sites.clearAsset(req.params.siteId, req.params.kind) + + return { + ok: true, + message: 'Image cleared successfully.' + } + } + ) + /** * DELETE SITE */ diff --git a/backend/controllers/site.ts b/backend/controllers/site.ts index d2911488f..d980ff510 100644 --- a/backend/controllers/site.ts +++ b/backend/controllers/site.ts @@ -1,14 +1,41 @@ import { validate as uuidValidate } from 'uuid' import { replyWithFile } from '../helpers/common.ts' +import { svgMimeType } from '../helpers/images.ts' +import crypto from 'node:crypto' import path from 'node:path' +import type { SiteAssetKind } from '../models/sites.ts' import type { FastifyInstance } from 'fastify' +/** + * What is served for each of a site's images while nobody has uploaded one. The keys are the names + * the images are addressed by, which are the asset kinds themselves. + */ +const SITE_ASSET_FALLBACKS: Record = { + logo: 'assets/_assets/logo-wikijs.svg', + favicon: 'assets/_assets/logo-wikijs.svg', + loginBg: 'assets/_assets/bg/login.jpg' +} + +/** + * An uploaded site image changes whenever an administrator replaces it, and the URL never carries a + * version — so it is always revalidated, and the ETag turns that into an empty 304 rather than a + * re-download. + */ +const SITE_ASSET_CACHE = 'public, no-cache' + +/** + * An SVG is a document, not an image file: opened directly rather than through an ``, a browser + * will run whatever scripts are in it, in this origin. Uploading one takes `manage:sites`, which + * already allows injecting markup into every page of the site — but that is a reason to keep the + * blast radius of a stolen admin session small, not to ignore it. Nothing legitimate in a logo needs + * more than the markup itself, so the response allows nothing else. + */ +const SVG_CSP = "default-src 'none'; style-src 'unsafe-inline'; sandbox" + /** * _site Routes */ async function routes(app: FastifyInstance) { - const siteAssetsPath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, 'assets') - app.get<{ Params: { siteId: string; resource: string } }>( '/:siteId/:resource', async (req, reply) => { @@ -23,41 +50,36 @@ async function routes(app: FastifyInstance) { if (!site) { return reply.notFound('Site not found') } - switch (req.params.resource) { - case 'logo': { - if (site.config.assets.logo) { - // TODO: Fetch from db if not in disk cache - return replyWithFile( - reply, - path.join(siteAssetsPath, `logo-${site.id}.${site.config.assets.logoExt}`) - ) - } else { - return replyWithFile(reply, path.join(WIKI.ROOTPATH, 'assets/_assets/logo-wikijs.svg')) - } - } - case 'favicon': { - if (site.config.assets.favicon) { - // TODO: Fetch from db if not in disk cache - return replyWithFile( - reply, - path.join(siteAssetsPath, `favicon-${site.id}.${site.config.assets.faviconExt}`) - ) - } else { - return replyWithFile(reply, path.join(WIKI.ROOTPATH, 'assets/_assets/logo-wikijs.svg')) - } - } - case 'loginbg': { - if (site.config.assets.loginBg) { - // TODO: Fetch from db if not in disk cache - return replyWithFile(reply, path.join(siteAssetsPath, `loginbg-${site.id}.jpg`)) - } else { - return replyWithFile(reply, path.join(WIKI.ROOTPATH, 'assets/_assets/bg/login.jpg')) - } - } - default: { - return reply.badRequest('Invalid Site Resource') - } + + const kind = req.params.resource as SiteAssetKind + const fallback = SITE_ASSET_FALLBACKS[kind] + if (!fallback) { + return reply.badRequest('Invalid Site Resource') + } + + // -> The flag lives in the cached site config, so a site that has uploaded nothing — which is + // every site until an administrator says otherwise — never touches the database here + const asset = site.config.assets?.[kind] + ? await WIKI.models.sites.getAsset(site.id, kind) + : null + if (!asset) { + return replyWithFile(reply, path.join(WIKI.ROOTPATH, fallback)) } + + const etag = `"${crypto.createHash('sha1').update(asset.data).digest('hex')}"` + reply.header('ETag', etag) + reply.header('Cache-Control', SITE_ASSET_CACHE) + // -> The bytes were uploaded, so the browser must take the type at its word rather than looking + // for something more interesting in them + reply.header('X-Content-Type-Options', 'nosniff') + if (asset.mime === svgMimeType) { + reply.header('Content-Security-Policy', SVG_CSP) + } + if (req.headers['if-none-match'] === etag) { + return reply.code(304).send() + } + + return reply.type(asset.mime).send(asset.data) } ) } diff --git a/backend/db/migrations/20260809182854_main/migration.sql b/backend/db/migrations/20260809182854_main/migration.sql new file mode 100644 index 000000000..07e96206b --- /dev/null +++ b/backend/db/migrations/20260809182854_main/migration.sql @@ -0,0 +1,8 @@ +CREATE TABLE "siteAssets" ( + "siteId" uuid, + "kind" varchar(255), + "data" bytea NOT NULL, + CONSTRAINT "siteAssets_pkey" PRIMARY KEY("siteId","kind") +); +--> statement-breakpoint +ALTER TABLE "siteAssets" ADD CONSTRAINT "siteAssets_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id"); \ No newline at end of file diff --git a/backend/db/migrations/20260809182854_main/snapshot.json b/backend/db/migrations/20260809182854_main/snapshot.json new file mode 100644 index 000000000..4cd47bc9d --- /dev/null +++ b/backend/db/migrations/20260809182854_main/snapshot.json @@ -0,0 +1,5749 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "e9b7974b-7b2a-4f98-a4eb-d8b9fd6db541", + "prevIds": [ + "383dbfe0-4388-4913-9e70-74fa6651e483" + ], + "ddl": [ + { + "values": [ + "document", + "image", + "other" + ], + "name": "assetKind", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "pending", + "success", + "error" + ], + "name": "hookState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "active", + "completed", + "failed", + "interrupted" + ], + "name": "jobHistoryState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "draft", + "published", + "scheduled" + ], + "name": "pagePublishState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "inherit", + "override", + "overrideExact", + "hide", + "hideExact" + ], + "name": "treeNavigationMode", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "folder", + "page", + "asset" + ], + "name": "treeType", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "apiKeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "approvalRules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "assets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "authentication", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "blocks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "groups", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "hooks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "iconSets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "icons", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobHistory", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobLock", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobSchedule", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "locales", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageEditSubmissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageHistory", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageRenderQueue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageWatching", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "rateLimits", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "siteAssets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sites", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "storage", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tree", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "userAvatars", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "userGroups", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "userKeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "users", + "entityType": "tables", + "schema": "public" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "varchar(8)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "keyShort", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "groups", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "expiration", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isRevoked", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'START'", + "generated": null, + "identity": null, + "name": "match", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "submitterGroups", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "reviewerGroups", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileName", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileExt", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isSystem", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "assetKind", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'other'", + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'application/octet-stream'", + "generated": null, + "identity": null, + "name": "mimeType", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileSize", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "storageInfo", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "displayName", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "registration", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "allowedEmailRegex", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "autoEnrollGroups", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "block", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isCustom", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rules", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "redirectOnLogin", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "redirectOnFirstLogin", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "redirectOnLogout", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isSystem", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "events", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "includeMetadata", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "includeContent", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "acceptUntrusted", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authHeader", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "hookState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastErrorMessage", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "info", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refreshedAt", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "16", + "generated": null, + "identity": null, + "name": "width", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "16", + "generated": null, + "identity": null, + "name": "height", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "left", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "top", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "rotate", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hFlip", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "vFlip", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "task", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "jobHistoryState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "useWorker", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "wasScheduled", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastErrorMessage", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "executedBy", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "jobLock" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastCheckedBy", + "entityType": "columns", + "schema": "public", + "table": "jobLock" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastCheckedAt", + "entityType": "columns", + "schema": "public", + "table": "jobLock" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "task", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cron", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'system'", + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "task", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "useWorker", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "retries", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "waitUntil", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isScheduled", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdBy", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nativeName", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(8)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(3)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "region", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(4)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "script", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isRTL", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "strings", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "completeness", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "items", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "patch", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseHash", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "guestName", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "guestEmail", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'updated'", + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "changedFields", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "versionDate", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowScripts", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowStyles", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "requestedById", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hash", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "alias", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "pagePublishState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "publishState", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishStartDate", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishEndDate", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "relations", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "render", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "searchContent", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ts", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "toc", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "editor", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentType", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isBrowsable", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isSearchable", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": { + "as": "\"pages\".\"publishState\" != 'draft' AND \"pages\".\"isSearchable\"", + "type": "stored" + }, + "identity": null, + "name": "isSearchableComputed", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "ratingScore", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "ratingCount", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "scripts", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "historyData", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "creatorId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ownerId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "hits", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "windowStartedAt", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bannedUntil", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "settings" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "siteAssets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "siteAssets" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "siteAssets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hostname", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "contentTypes", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "assetDelivery", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "versioning", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tag", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "usageCount", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "ltree", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "folderPath", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileName", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hash", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "treeType", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tree", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "treeNavigationMode", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'inherit'", + "generated": null, + "identity": null, + "name": "navigationMode", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "navigationId", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "userAvatars" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "userAvatars" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "userGroups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupId", + "entityType": "columns", + "schema": "public", + "table": "userGroups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "validUntil", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "auth", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "passkeys", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "prefs", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hasAvatar", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isActive", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isSystem", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isVerified", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastLoginAt", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "approvalRules_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "approvalRules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "assets_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "assets" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blocks_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "blocks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "locales_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "locales" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "navigation_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_pageId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"authorId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_page_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "versionDate", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageHistory_pageId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "path", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "versionDate", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageHistory_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageHistory_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageRenderQueue_createdAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageWatching_user_site_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageWatching_page_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "creatorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_creatorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ownerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_ownerId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ts", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "pages_ts_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tags", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "pages_tags_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isSearchableComputed", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_isSearchableComputed_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "rateLimits_updatedAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "rateLimits" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "sessions_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "module", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "storage_composite_idx", + "entityType": "indexes", + "schema": "public", + "table": "storage" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tags_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "tag", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tags_composite_idx", + "entityType": "indexes", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "folderPath", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_folderpath_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "folderPath", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gist", + "concurrently": false, + "name": "tree_folderpath_gist_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileName", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_fileName_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_hash_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tree", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_locale_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "navigationMode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_navigationMode_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "navigationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_navigationId_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tags", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "tree_tags_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userGroups_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "groupId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userGroups_groupId_idx", + "entityType": "indexes", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userGroups_composite_idx", + "entityType": "indexes", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userKeys_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "userKeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastLoginAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "users_lastLoginAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "approvalRules_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "approvalRules" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "assets_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "assets" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "assets_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "assets" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "blocks_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blocks" + }, + { + "nameExplicit": false, + "columns": [ + "prefix" + ], + "schemaTo": "public", + "tableTo": "iconSets", + "columnsTo": [ + "prefix" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "icons_prefix_iconSets_prefix_fkey", + "entityType": "fks", + "schema": "public", + "table": "icons" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "navigation_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "navigation" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageEditSubmissions_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageEditSubmissions_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageEditSubmissions_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "pageHistory_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageHistory_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageRenderQueue_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageRenderQueue_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": false, + "columns": [ + "requestedById" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "pageRenderQueue_requestedById_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageWatching_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageWatching_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageWatching_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "creatorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_creatorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "ownerId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_ownerId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "sessions_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "siteAssets_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "siteAssets" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "storage_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "storage" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "tags_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "tree_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "userGroups_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": false, + "columns": [ + "groupId" + ], + "schemaTo": "public", + "tableTo": "groups", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "userGroups_groupId_groups_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "userKeys_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "userKeys" + }, + { + "columns": [ + "prefix", + "name" + ], + "nameExplicit": false, + "name": "icons_pkey", + "entityType": "pks", + "schema": "public", + "table": "icons" + }, + { + "columns": [ + "siteId", + "kind" + ], + "nameExplicit": false, + "name": "siteAssets_pkey", + "entityType": "pks", + "schema": "public", + "table": "siteAssets" + }, + { + "columns": [ + "userId", + "groupId" + ], + "nameExplicit": false, + "name": "userGroups_pkey", + "entityType": "pks", + "schema": "public", + "table": "userGroups" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "apiKeys_pkey", + "schema": "public", + "table": "apiKeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "approvalRules_pkey", + "schema": "public", + "table": "approvalRules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "assets_pkey", + "schema": "public", + "table": "assets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "authentication_pkey", + "schema": "public", + "table": "authentication", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blocks_pkey", + "schema": "public", + "table": "blocks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "groups_pkey", + "schema": "public", + "table": "groups", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "hooks_pkey", + "schema": "public", + "table": "hooks", + "entityType": "pks" + }, + { + "columns": [ + "prefix" + ], + "nameExplicit": false, + "name": "iconSets_pkey", + "schema": "public", + "table": "iconSets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jobHistory_pkey", + "schema": "public", + "table": "jobHistory", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "jobLock_pkey", + "schema": "public", + "table": "jobLock", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jobSchedule_pkey", + "schema": "public", + "table": "jobSchedule", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jobs_pkey", + "schema": "public", + "table": "jobs", + "entityType": "pks" + }, + { + "columns": [ + "code" + ], + "nameExplicit": false, + "name": "locales_pkey", + "schema": "public", + "table": "locales", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "navigation_pkey", + "schema": "public", + "table": "navigation", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageEditSubmissions_pkey", + "schema": "public", + "table": "pageEditSubmissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageHistory_pkey", + "schema": "public", + "table": "pageHistory", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageRenderQueue_pkey", + "schema": "public", + "table": "pageRenderQueue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageWatching_pkey", + "schema": "public", + "table": "pageWatching", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pages_pkey", + "schema": "public", + "table": "pages", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "rateLimits_pkey", + "schema": "public", + "table": "rateLimits", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "sessions_pkey", + "schema": "public", + "table": "sessions", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "settings_pkey", + "schema": "public", + "table": "settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "sites_pkey", + "schema": "public", + "table": "sites", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "storage_pkey", + "schema": "public", + "table": "storage", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tags_pkey", + "schema": "public", + "table": "tags", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tree_pkey", + "schema": "public", + "table": "tree", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "userAvatars_pkey", + "schema": "public", + "table": "userAvatars", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "userKeys_pkey", + "schema": "public", + "table": "userKeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "users_pkey", + "schema": "public", + "table": "users", + "entityType": "pks" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "nullsNotDistinct": false, + "name": "pageRenderQueue_pageId_key", + "schema": "public", + "table": "pageRenderQueue", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "hostname" + ], + "nullsNotDistinct": false, + "name": "sites_hostname_key", + "schema": "public", + "table": "sites", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "users_email_key", + "schema": "public", + "table": "users", + "entityType": "uniques" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/backend/db/schema.ts b/backend/db/schema.ts index eaffba6fc..3e59f7bc6 100644 --- a/backend/db/schema.ts +++ b/backend/db/schema.ts @@ -627,6 +627,23 @@ export const sites = pgTable('sites', { createdAt: timestamp().notNull().defaultNow() }) +// -> The images an administrator uploads for a site — its logo, favicon and login background — one row +// per kind. Held in the database rather than under `dataPath`, which is a cache: an instance that +// comes back with an empty data directory must still look like itself. Whether a kind has been +// uploaded at all is mirrored in the site's `config.assets`, so serving a site that has uploaded +// nothing costs no query here. +export const siteAssets = pgTable( + 'siteAssets', + { + siteId: uuid() + .notNull() + .references(() => sites.id), + kind: varchar({ length: 255 }).notNull(), + data: bytea().notNull() + }, + (table) => [primaryKey({ columns: [table.siteId, table.kind] })] +) + // STORAGE ----------------------------- export const storage = pgTable( 'storage', diff --git a/backend/helpers/common.ts b/backend/helpers/common.ts index 721af688a..46068c1c4 100644 --- a/backend/helpers/common.ts +++ b/backend/helpers/common.ts @@ -82,6 +82,54 @@ export function encodeTreePath(str?: string | null): string { return str?.toLowerCase()?.replaceAll('/', '.') || '' } +/** + * Reduce a page path to the single form it is stored, addressed and looked up under. + * + * A path is a URL, and a URL that differs only in casing or in how a space was encoded is the same + * page as far as anyone reading the wiki is concerned — so there is one spelling, and everything + * that takes a path from a human or from page content passes it through here first. Wrapping slashes + * go, runs of whitespace become a single hyphen, and what is left is lowercased. + * + * What it does not do is decide whether the result is *allowed*: the characters a path may contain + * are the page model's rule to enforce, on the normalized form. + */ +export function normalizePagePath(input?: string | null): string { + return (input ?? '') + .trim() + .replace(/^\/+/, '') + .replace(/\/+$/, '') + .replaceAll(/\s+/g, '-') + .toLowerCase() +} + +/** + * Drop a site's page extension from the end of a URL path. + * + * A wiki's pages are addressed without one — `/foo/bar`, not `/foo/bar.md` — but the file the page + * was written as keeps turning up in links: an export, a repository mirror, a migration from a system + * that served files. So a site lists the extensions its content is written in, and a path ending in + * one of them means the page underneath it. + * + * Only the last segment is considered, and only when there is a name in front of the dot: `/.md` and + * `/docs.md/thing` address nothing. + * + * @param extensions Lowercase, without the dot, as the site config stores them + * @returns The path without the extension, or null if it does not end in one of them + */ +export function stripPageExtension(urlPath: string, extensions?: string[] | null): string | null { + if (!extensions || extensions.length < 1) { + return null + } + const dot = urlPath.lastIndexOf('.') + if (dot < 1 || urlPath[dot - 1] === '/' || urlPath.lastIndexOf('/') > dot) { + return null + } + if (!extensions.includes(urlPath.slice(dot + 1).toLowerCase())) { + return null + } + return urlPath.slice(0, dot) +} + /** * Generate SHA-1 Hash of a string * diff --git a/backend/helpers/images.ts b/backend/helpers/images.ts index 4fdd259e1..85e745e3b 100644 --- a/backend/helpers/images.ts +++ b/backend/helpers/images.ts @@ -10,6 +10,25 @@ export const imageMimeTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/g export type ImageMimeType = (typeof imageMimeTypes)[number] +/** + * SVG, which is markup rather than an image format and so is handled apart from the raster ones + * everywhere: it is recognized by reading it, it cannot be resized or re-encoded, and serving one + * back means serving a document a browser will happily execute scripts from. + */ +export const svgMimeType = 'image/svg+xml' + +/** + * Recognize SVG markup. + * + * There is no magic number to match: an SVG may open with a byte order mark, an XML declaration, a + * doctype or comments before the root element ever appears. So the start of the file is read as text + * and the root element looked for — enough to tell an SVG from a file claiming to be one, which is + * all this decides. + */ +export function detectSvg(data: Buffer): boolean { + return /]/i.test(data.subarray(0, 1024).toString('utf8')) +} + /** * Recognize an image from its leading bytes. * @@ -77,6 +96,63 @@ export async function resizeImageToSquareJpeg(data: Buffer, size: number): Promi } } +/** How an uploaded image is brought down to the size and format it will be served at. */ +export type ImageNormalization = { + width: number + height: number + /** + * `cover` crops to the target aspect ratio, for an image whose frame is fixed — a favicon, a + * background. `inside` fits within the box instead, for one whose own proportions matter, such as + * a logo that may be any shape. + */ + fit: 'cover' | 'inside' + /** `webp` for anything displayed by the app itself; `png` where the widest support is worth the + * bytes, as it is for a favicon. Both keep transparency, which a logo usually depends on. */ + format: 'webp' | 'png' +} + +/** + * Re-encode an image to the given size and format, using the Sharp extension. + * + * Never enlarges: upscaling a small upload would cost bytes to look worse. So the result is at most + * the requested size, and an image already smaller than the box is only re-encoded. + * + * @returns The re-encoded image, or null if Sharp is not usable on this system + */ +export async function normalizeImage( + data: Buffer, + { width, height, fit, format }: ImageNormalization +): Promise { + const definition = WIKI.models.extensions.getDefinition('sharp') + if (!definition || !(await WIKI.models.extensions.isInstalled(definition))) { + return null + } + const specifier = 'sharp' + // -> Loading Sharp and running it are kept apart, as they are for a thumbnail: the upload may simply + // be an image Sharp cannot read, which must not be recorded as Sharp itself being broken + let sharp: any + try { + ;({ default: sharp } = await import(specifier)) + } catch (err: any) { + WIKI.models.extensions.noteLoadFailure(specifier) + WIKI.logger.warn(`Could not load Sharp to re-encode an image: ${err.message}`) + return null + } + try { + const resized = sharp(data).resize(width, height, { + fit, + position: 'centre', + withoutEnlargement: true + }) + return await ( + format === 'png' ? resized.png({ compressionLevel: 9 }) : resized.webp({ quality: 80 }) + ).toBuffer() + } catch (err: any) { + WIKI.logger.warn(`Could not re-encode an uploaded image: ${err.message}`) + return null + } +} + /** * Shrink an image to a WebP thumbnail, using the Sharp extension. * diff --git a/backend/index.ts b/backend/index.ts index 8c527fec6..71e65548d 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -34,10 +34,30 @@ import configSvc from './core/config.ts' import dbManager from './core/db.ts' import logger from './core/logger.ts' import scheduler from './core/scheduler.ts' +import { stripPageExtension } from './helpers/common.ts' import { corsOrigin, parseCspDirectives } from './helpers/security.ts' const nanoid = customAlphabet('1234567890abcdef', 10) +/** + * Files a browser or a crawler asks for at the root by convention, rather than because the wiki has a + * page there. Kept out of the page URL rules below — `txt` is a page extension on a default site, and + * answering `/robots.txt` with a redirect to `/robots` would be answering the wrong question. + */ +const RESERVED_ROOT_FILES = new Set(['favicon.ico', 'robots.txt', 'sitemap.xml']) + +/** + * Whether a URL addresses the page tree rather than the server itself. + * + * Everything the server mounts sits under a leading-underscore segment — `/_api`, `/_assets`, + * `/_files`, and the rest registered in `initHTTPServer` — which is what makes the distinction a + * prefix test rather than a list to keep in step with the routes. + */ +function isPageUrl(urlPath: string): boolean { + const firstSegment = urlPath.split('/')[1] ?? '' + return !firstSegment.startsWith('_') && !RESERVED_ROOT_FILES.has(firstSegment.toLowerCase()) +} + if (!semver.satisfies(process.version, '>=26')) { console.error('ERROR: Node.js 26.x or later required!') process.exit(1) @@ -545,11 +565,34 @@ async function initHTTPServer() { app.addHook('onRequest', (req, reply, done) => { const [urlPath, urlQuery] = req.raw.url!.split('?') - if (urlPath!.length > 1 && urlPath!.endsWith('/')) { - const newPath = urlPath!.slice(0, -1) - reply.redirect(urlQuery ? `${newPath}?${urlQuery}` : newPath, 301) + const withQuery = (newPath: string) => (urlQuery ? `${newPath}?${urlQuery}` : newPath) + + const trimmed = urlPath!.length > 1 && urlPath!.endsWith('/') ? urlPath!.slice(0, -1) : urlPath! + + if (isPageUrl(trimmed)) { + // -> Straight off the site caches rather than through the model: this runs on every request, and + // both lookups are the ones `getSiteByHostname` would do, minus its optional reload + const siteId = WIKI.sitesMappings[req.hostname] || WIKI.sitesMappings['*'] + const withoutExtension = stripPageExtension( + trimmed, + WIKI.sites[siteId]?.config?.pageExtensions + ) + if (withoutExtension) { + // -> Answers a trailing slash as well, rather than sending the client back for a second + // round trip to be told about the extension. + // + // Not a 301: which extensions resolve this way is a setting, and a browser that cached a + // permanent redirect would go on applying it after an administrator had changed it + reply.redirect(withQuery(withoutExtension), 302) + return + } + } + + if (trimmed !== urlPath) { + reply.redirect(withQuery(trimmed), 301) return } + done() }) diff --git a/backend/locales/en.json b/backend/locales/en.json index acae4e4af..92e43fbdb 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -315,21 +315,26 @@ "admin.general.displaySiteTitle": "Display Site Title", "admin.general.displaySiteTitleHint": "Should the site title be displayed next to the logo? If your logo isn't square and contain your brand name, turn this option off.", "admin.general.favicon": "Favicon", - "admin.general.faviconHint": "Favicon image file, in SVG, PNG, JPG, WEBP or GIF format. Must be a square image.", + "admin.general.faviconClearFailed": "Failed to clear the site favicon.", + "admin.general.faviconClearSuccess": "Site favicon cleared successfully.", + "admin.general.faviconHint": "Favicon image file, in SVG, PNG, JPG, WEBP or GIF format. A square image works best, as it is cropped to a square.", + "admin.general.faviconUploadFailed": "Failed to upload the site favicon.", "admin.general.faviconUploadSuccess": "Site Favicon uploaded successfully.", "admin.general.features": "Features", "admin.general.footerCopyright": "Footer / Copyright", "admin.general.footerExtra": "Additional Footer Text", "admin.general.footerExtraHint": "Optionally add more content to the footer, such as additional copyright terms or mandatory regulatory info.", "admin.general.general": "General", + "admin.general.imageUploadInvalidType": "Only SVG, PNG, JPG, WEBP and GIF images can be used.", "admin.general.logo": "Logo", + "admin.general.logoClearFailed": "Failed to clear the site logo.", + "admin.general.logoClearSuccess": "Site logo cleared successfully.", "admin.general.logoUpl": "Site Logo", "admin.general.logoUplHint": "Logo image file, in SVG, PNG, JPG, WEBP or GIF format.", + "admin.general.logoUploadFailed": "Failed to upload the site logo.", "admin.general.logoUploadSuccess": "Site logo uploaded successfully.", - "admin.general.pageCasing": "Case Sensitive Paths", - "admin.general.pageCasingHint": "Treat paths with different casing as distinct pages.", "admin.general.pageExtensions": "Page Extensions", - "admin.general.pageExtensionsHint": "A comma-separated list of URL extensions that will be treated as pages. For example, adding md will treat /foobar.md the same as /foobar.", + "admin.general.pageExtensionsHint": "A comma-separated list of URL extensions that address a page. For example, adding md redirects /foobar.md to /foobar.", "admin.general.ratingsOff": "Off", "admin.general.ratingsStars": "Stars", "admin.general.ratingsThumbs": "Thumbs", @@ -361,13 +366,11 @@ "admin.general.title": "General", "admin.general.uploadClear": "Clear", "admin.general.uploadConflictBehavior": "Upload Conflict Behavior", - "admin.general.uploadConflictBehaviorHint": "How should uploads for a file that already exists be handled?", - "admin.general.uploadConflictBehaviorNew": "Append Time to Filename", + "admin.general.uploadConflictBehaviorHint": "How should uploads for a file that already exists be handled? Overwriting replaces the file where it is, so pages already using it show the new version.", + "admin.general.uploadConflictBehaviorNew": "Keep Both (Append Number)", "admin.general.uploadConflictBehaviorOverwrite": "Overwrite", "admin.general.uploadConflictBehaviorReject": "Reject", "admin.general.uploadLogo": "Upload Logo", - "admin.general.uploadNormalizeFilename": "Normalize Filenames", - "admin.general.uploadNormalizeFilenameHint": "Automatically transform filenames to a standard URL-friendly format.", "admin.general.uploadSizeHint": "An image of {size} pixels is recommended for best results.", "admin.general.uploadTypesHint": "{typeList} or {lastType} files only", "admin.general.uploads": "Uploads", @@ -522,9 +525,12 @@ "admin.locale.title": "Locale", "admin.logging.title": "Logging", "admin.login.background": "Background Image", - "admin.login.backgroundHint": "Specify an image to use as the login background. PNG and JPG are supported, 1920x1080 recommended. Leave empty for default.", + "admin.login.backgroundHint": "Specify an image to use as the login background. SVG, PNG, JPG, WEBP and GIF are supported, 1920x1080 recommended. Clear it to use the default.", + "admin.login.bgClearFailed": "Failed to clear the login background image.", + "admin.login.bgClearSuccess": "Login background image cleared successfully.", + "admin.login.bgUploadFailed": "Failed to upload the login background image.", + "admin.login.bgUploadInvalidType": "Only SVG, PNG, JPG, WEBP and GIF images can be used as a login background.", "admin.login.bgUploadSuccess": "Login background image uploaded successfully.", - "admin.login.bgUploadUnavailable": "Uploading a background image is not implemented yet.", "admin.login.bypassScreen": "Bypass Login Screen", "admin.login.bypassScreenHint": "Should the user be redirected automatically to the first authentication provider. Has no effect if the first provider is a username/password provider type.", "admin.login.bypassUnauthorized": "Bypass Unauthorized Screen", @@ -1612,6 +1618,14 @@ "common.password.poor": "Poor", "common.password.strong": "Strong", "common.password.weak": "Weak", + "common.redirect.broken": "This redirection has no target.", + "common.redirect.brokenHint": "Edit this page to choose where it should send readers.", + "common.redirect.chain": "These redirections lead in a circle.", + "common.redirect.follow": "Follow Redirection", + "common.redirect.goNow": "Go Now", + "common.redirect.held": "This page redirects elsewhere.", + "common.redirect.loop": "This redirection points at itself.", + "common.redirect.redirectingTo": "Redirecting to {target}...", "common.sidebar.browse": "Browse", "common.sidebar.currentDirectory": "Current Directory", "common.sidebar.mainMenu": "Main Menu", @@ -1853,6 +1867,19 @@ "editor.reasonForChange.reasonMissing": "A reason is missing.", "editor.reasonForChange.required": "You must provide a reason for this change. Enter a small description of what changed.", "editor.reasonForChange.title": "Reason For Change", + "editor.redirect.choose": "Choose...", + "editor.redirect.noTargetSelected": "No target selected yet.", + "editor.redirect.pageTitle": "Redirect Title", + "editor.redirect.pageTitleHint": "The name this page appears under in navigation and in the file manager.", + "editor.redirect.pickerTitle": "Select Redirection Target", + "editor.redirect.showInterstitial": "Show Interstitial", + "editor.redirect.showInterstitialHint": "Show a short notice saying where the reader is going before taking them there. Off sends them straight on.", + "editor.redirect.summaryDirect": "Readers arriving at this page are sent to {target} right away.", + "editor.redirect.summaryIncomplete": "This redirection has no target yet, and cannot be saved until it does.", + "editor.redirect.summaryInterstitial": "Readers arriving at this page are told they are being sent to {target}, then taken there a few seconds later.", + "editor.redirect.target": "Target", + "editor.redirect.targetHint": "Where readers arriving at this page are sent — a page of this wiki, or any URL.", + "editor.redirect.title": "Redirection", "editor.renderFailed": "The preview could not be rendered. The last successful render is kept.", "editor.renderPreview": "Render Preview", "editor.save.createSuccess": "Page created successfully.", @@ -1994,6 +2021,7 @@ "fileman.pptxFileType": "Microsoft Powerpoint Presentation", "fileman.psdFileType": "Adobe Photoshop Document", "fileman.rarFileType": "RAR Archive", + "fileman.redirectPageType": "Redirection", "fileman.renameAssetInvalid": "Asset name is invalid.", "fileman.renameAssetSuccess": "Asset renamed successfully", "fileman.renameFolderInvalidData": "One or more fields are invalid.", diff --git a/backend/models/assets.ts b/backend/models/assets.ts index be1bae9fa..8391ef3b9 100644 --- a/backend/models/assets.ts +++ b/backend/models/assets.ts @@ -44,6 +44,24 @@ export const INLINE_EXTS = new Set(['png', 'apng', 'jpg', 'jpeg', 'gif', 'bmp', /** What an asset is, for the sake of grouping and filtering. Mirrors the `assetKind` schema enum. */ export type AssetKind = 'document' | 'image' | 'other' +/** + * What an upload does about a file already sitting at the name it wants, per the site's + * `uploads.conflictBehavior` setting. + * + * - `overwrite` replaces the file where it is: same ID, same path, so every page pointing at it now + * shows the new contents. This is the default, and the one that makes re-uploading a corrected file + * do what the uploader meant. + * - `reject` refuses the upload and says what is in the way, for a wiki where a file's contents are + * expected to be stable once published. + * - `new` keeps both, the arrival taking the next free `name-1.ext`. + * + * Whichever is chosen, only an *asset* can be replaced: a page or a folder already holding the name + * is reported rather than written over. + */ +export type UploadConflictBehavior = 'overwrite' | 'reject' | 'new' + +const UPLOAD_CONFLICT_BEHAVIORS = new Set(['overwrite', 'reject', 'new']) + /** Extensions that count as a document rather than "other". */ const DOCUMENT_EXTS = new Set([ 'csv', @@ -93,6 +111,9 @@ export interface AssetAtPath extends Asset { * Any directory part is dropped — the folder comes from the request, never from the name — and what * is left is lowercased down to the characters that survive a URL untouched, which is the same bar * folder path names are held to. + * + * Applied to every upload, with nothing to turn it off: a stored name is a URL, and a path is looked + * up lowercased, so a name that skipped this would be one the site could not serve back. */ export function sanitizeFileName(input: string): string { const base = path.basename(input.trim().replaceAll('\\', '/')) @@ -165,9 +186,24 @@ class Assets { /** Whether a sweep is running, so that a burst of writes queues no more than one. */ sweeping = false + /** + * What this site does about an upload landing on a name that is taken. + * + * Read per upload rather than held anywhere, so that changing it in the admin area applies to the + * next file rather than to the next restart. Anything unrecognized is treated as the default. + */ + conflictBehaviorFor(siteId: string): UploadConflictBehavior { + const configured = WIKI.sites[siteId]?.config?.uploads?.conflictBehavior + return UPLOAD_CONFLICT_BEHAVIORS.has(configured) ? configured : 'overwrite' + } + /** * Store an uploaded file. * + * A file already at this name is settled per the site's conflict behavior — see + * `UploadConflictBehavior`. An overwrite returns the existing asset's ID, so a caller that means to + * link to what it just uploaded must read the returned name and ID rather than assume its own. + * * @param folderId UUID of the folder to upload into. The site root when absent. * @param fileName What to call it. Sanitized, so what comes back may differ from what went in. * @param data The file itself. @@ -204,6 +240,50 @@ class Assets { ? await makeImageThumbnail(data, THUMBNAIL_SIZE.width, THUMBNAIL_SIZE.height) : null + // -> What is already at this name, if anything, and what the site says to do about it. Asked + // before any row is touched, since two of the three answers write nothing new at all. + const behavior = this.conflictBehaviorFor(siteId) + const occupant = + behavior === 'new' + ? null + : await WIKI.models.tree.getEntryAt({ + siteId, + locale, + parentId: folderId, + fileName: safeName + }) + if (occupant) { + if (occupant.type !== 'asset') { + // -> Neither replacing nor renaming is what an administrator asked for here: a page or a + // folder owns this name, and only its owner can give it up + throw new CustomError( + 'assetNameTakenByEntry', + `A ${occupant.type} with this name already exists here.`, + 409 + ) + } + if (behavior === 'reject') { + throw new CustomError( + 'assetAlreadyExists', + 'A file with this name already exists here.', + 409 + ) + } + return this.replace({ + id: occupant.id, + siteId, + folderPath: decodeTreePath(occupant.folderPath ?? '') ?? '', + fileName: occupant.fileName, + title: occupant.title, + fileExt, + kind, + mimeType: resolvedMime, + data, + preview, + authorId + }) + } + // -> The tree row goes in first: it owns the name, and it is what settles a collision with // something already in the folder before any bytes are written. What comes back is the name // that was actually free, which is not always the one asked for. @@ -264,6 +344,98 @@ class Assets { } } + /** + * Replace an existing asset's contents in place, for an upload that landed on it under the + * `overwrite` conflict behavior. + * + * The asset keeps its ID, its name and its place in the tree, so every page and every link already + * pointing at the file goes on working and now resolves to the new bytes. What changes is what the + * file *is* — its contents, size, type and thumbnail — plus who put them there. + * + * The name it keeps is the stored one, which is why the extension and type are the incoming file's: + * the two only differ when a browser sent `Photo.PNG` for what is stored as `photo.png`, and the + * sanitized name is what both agree on. + */ + private async replace({ + id, + siteId, + folderPath, + fileName, + title, + fileExt, + kind, + mimeType, + data, + preview, + authorId + }: { + id: string + siteId: string + folderPath: string + fileName: string + title: string + fileExt: string + kind: AssetKind + mimeType: string + data: Buffer + preview: Buffer | null + authorId: string + }): Promise { + await WIKI.db + .update(assetsTable) + .set({ + fileExt, + kind, + mimeType, + fileSize: data.length, + data, + preview, + authorId, + updatedAt: sql`now()` + }) + .where(eq(assetsTable.id, id)) + // -> The tree carries its own copy of these, and it is what a folder listing reads + await WIKI.db + .update(treeTable) + .set({ meta: { fileSize: data.length, fileExt, mimeType }, updatedAt: sql`now()` }) + .where(eq(treeTable.id, id)) + + // -> The path resolves to the same asset as before, but to different metadata: the ETag is the + // modification time, so a reader holding the old file has to be told to fetch it again. The + // cached bytes are keyed by that same time and are unreachable from here on, but are dropped + // rather than left for the sweep, since the file they hold is gone for good. + this.forgetPath(siteId, folderPath, fileName) + await this.dropCachedContent([id]) + + WIKI.models.hooks.emit('asset:edit', { + id, + fileName, + folderPath, + siteId, + authorId, + metadata: { fileSize: data.length, mimeType, kind } + }) + + const updated = await this.getAsset(siteId, id) + // -> Only if the row vanished between the update and the read, which means someone deleted the + // file mid-upload. Answering with what was written beats failing a request that did land. + return ( + updated ?? { + id, + fileName, + fileExt, + kind, + mimeType, + fileSize: data.length, + folderPath, + title, + hasPreview: Boolean(preview), + createdAt: new Date(), + updatedAt: new Date() + } + ) + } + /** * An asset's metadata, without its bytes. Null if there is no such asset on this site. */ diff --git a/backend/models/hooks.ts b/backend/models/hooks.ts index aff53bcbe..1a6e1a080 100644 --- a/backend/models/hooks.ts +++ b/backend/models/hooks.ts @@ -40,6 +40,7 @@ export const EMITTED_EVENTS: HookEvent[] = [ 'page:rename', 'page:delete', 'asset:upload', + 'asset:edit', 'asset:rename', 'asset:delete', 'user:join', diff --git a/backend/models/pages.ts b/backend/models/pages.ts index a48b36ec7..d03def58b 100644 --- a/backend/models/pages.ts +++ b/backend/models/pages.ts @@ -1,6 +1,11 @@ import { and, eq, inArray, ne, sql } from 'drizzle-orm' import { pages as pagesTable, tree as treeTable, users as usersTable } from '../db/schema.ts' -import { CustomError, generatePathHash, timingSafeCompare } from '../helpers/common.ts' +import { + CustomError, + generatePathHash, + normalizePagePath, + timingSafeCompare +} from '../helpers/common.ts' import type { RenderPermissions, TocNode } from './rendering.ts' import type { DeletedEntry } from './tree.ts' @@ -8,9 +13,20 @@ import type { DeletedEntry } from './tree.ts' const EDITOR_CONTENT_TYPES: Record = { markdown: 'markdown', asciidoc: 'asciidoc', - wysiwyg: 'html' + wysiwyg: 'html', + redirect: 'redirect' } +/** + * The editor whose pages send their reader somewhere else. + * + * A redirection is an ordinary page — it has a path, a title, an icon and a place in the tree, and is + * browsable like any other — with nothing to read: no body, no render, and therefore nothing for the + * search index to hold. What an author fills in is where it points, and that is what its content + * column carries. See `normalizeRedirectContent`. + */ +const REDIRECT_EDITOR = 'redirect' + /** A page path is what ends up in a URL, so it is held to what reads and routes cleanly. */ const rePagePath = /^[a-zA-Z0-9-_/]*$/ const reAlias = /^[a-zA-Z0-9-_]*$/ @@ -55,6 +71,10 @@ export interface Page { tags: string[] toc: TocNode[] render: string + /** + * The source. Present when the request asked for it, and always for a redirection — see `toPage`, + * and `RedirectContent` for what a redirection's holds. + */ content?: string allowComments: boolean allowContributions: boolean @@ -122,10 +142,13 @@ function hasPermission(actor: PageActor, permission: string): boolean { } /** - * Strip a path down to the form that gets stored: no wrapping slashes, lowercase. + * Normalize a path to the form that gets stored, and refuse it if what is left is not addressable. + * + * Casing and spaces are corrected rather than rejected — `My Page` is a path someone meant, and it + * means `my-page`. Anything else outside the allowed characters is not something to guess at. */ function normalizePath(input: string): string { - const path = (input ?? '').trim().replace(/^\/+/, '').replace(/\/+$/, '').toLowerCase() + const path = normalizePagePath(input) if (!rePagePath.test(path)) { throw new CustomError( 'pageInvalidPath', @@ -135,6 +158,66 @@ function normalizePath(input: string): string { return path } +/** + * Where a redirection points, as its content column holds it. + * + * `kind` is stored rather than sniffed off the target, because it is the question the author actually + * answered: a page of this wiki, or somewhere else. The two are not reliably told apart afterwards — + * `/help` is a page here and a perfectly good relative URL elsewhere — and the editor has to open on + * the choice that was made rather than on a guess about it. + */ +export interface RedirectContent { + kind: 'page' | 'url' + /** A rooted path within this wiki, or an absolute `http(s)` URL. */ + target: string + /** Whether the reader is told where they are going before being taken there. */ + showInterstitial: boolean +} + +/** + * Read a redirection's target back out of what the editor sent, and refuse anything that would not + * send a reader anywhere. + * + * Re-serialized rather than stored as it arrived, so that the column holds one canonical spelling: a + * save that changes nothing then reports no change, and the history rows say what they mean. + * + * A URL target is held to `http`/`https` deliberately. This value ends up in a `location` assignment, + * so any other scheme is either useless (`mailto:` in a redirect that nobody chose to follow) or an + * invitation (`javascript:`) — and a redirection is followed without the reader clicking anything. + */ +function normalizeRedirectContent(content: string | undefined): string { + let parsed: any + try { + parsed = JSON.parse(content ?? '') + } catch { + throw new CustomError('pageRedirectInvalid', 'A redirection needs a target.') + } + const kind = parsed?.kind === 'url' ? 'url' : 'page' + const target = typeof parsed?.target === 'string' ? parsed.target.trim() : '' + if (target.length < 1) { + throw new CustomError('pageRedirectMissingTarget', 'A redirection needs a target.') + } + if (kind === 'url') { + if (!/^https?:\/\/\S/i.test(target)) { + throw new CustomError( + 'pageRedirectInvalidUrl', + 'A redirection to a URL must be a complete http:// or https:// address.' + ) + } + } else if (!target.startsWith('/') || target.startsWith('//')) { + throw new CustomError( + 'pageRedirectInvalidPath', + 'A redirection to a page of this wiki must be a path starting with a slash.' + ) + } + const redirect: RedirectContent = { + kind, + target, + showInterstitial: parsed?.showInterstitial === true + } + return JSON.stringify(redirect) +} + /** * Pages model * @@ -156,6 +239,11 @@ class Pages { * looking at the lock screen is told what page they are being asked for a password to. * @param withPassword Include the page's own password. Only for a requester who may edit the page, * which is the one that has to be able to read it back and save it again. + * @param withContent Include the source. A redirection's comes back either way: its content is not + * a body somebody wrote, it is where the page sends its reader — which every + * reader is about to be shown by being taken there. Withholding it would leave + * the page view unable to do the one thing the page is for, and the page view + * does not ask for content. */ private toPage( row: any, @@ -189,7 +277,9 @@ class Pages { tags: row.tags ?? [], toc: locked ? [] : (row.toc ?? []), render: locked ? '' : (row.render ?? ''), - ...(withContent && !locked ? { content: row.content ?? '' } : {}), + ...((withContent || row.editor === REDIRECT_EDITOR) && !locked + ? { content: row.content ?? '' } + : {}), allowComments: config.allowComments ?? true, allowContributions: config.allowContributions ?? true, allowRatings: config.allowRatings ?? true, @@ -375,10 +465,14 @@ class Pages { if (title.length < 1) { throw new CustomError('pageTitleMissing', 'A page needs a title.') } - if (!input.content || input.content.trim().length < 1) { + const editor = input.editor || 'markdown' + const isRedirect = editor === REDIRECT_EDITOR + // -> A redirection has no body to be empty: what it holds instead is where it points, and that has + // its own rules about being filled in + const content = isRedirect ? normalizeRedirectContent(input.content) : input.content + if (!isRedirect && (!content || content.trim().length < 1)) { throw new CustomError('pageEmptyContent', 'A page cannot be empty.') } - const editor = input.editor || 'markdown' const hash = generatePathHash(path) const duplicate = await WIKI.db @@ -411,14 +505,16 @@ class Pages { creatorId: actor.id, ownerId: actor.id, config: this.buildConfig(input, siteId), - content: input.content, + content, contentType: EDITOR_CONTENT_TYPES[editor] ?? 'text', description: input.description ?? '', editor, hash, icon: input.icon ?? '', isBrowsable: input.isBrowsable ?? true, - isSearchable: input.isSearchable ?? true, + // -> A redirection has nothing to find: a result for it would be a result whose page is a + // doorway to the page the reader actually wanted, which is the one search should offer + isSearchable: isRedirect ? false : (input.isSearchable ?? true), locale, password: input.password || null, path, @@ -498,6 +594,9 @@ class Pages { const values: Record = { updatedAt: sql`now()` } let treeTitle: string | null = null + // -> Which editor authored a page is not something a save may change, so the row is the authority + // on whether this is a redirection + const isRedirect = existing.editor === REDIRECT_EDITOR if (patch.title !== undefined) { const title = patch.title.trim() @@ -517,7 +616,7 @@ class Pages { values.alias = await this.validateAlias(siteId, patch.alias, id) } if (patch.content !== undefined) { - values.content = patch.content + values.content = isRedirect ? normalizeRedirectContent(patch.content) : patch.content } if (patch.publishState !== undefined) { if ( @@ -542,7 +641,8 @@ class Pages { values.isBrowsable = patch.isBrowsable } if (patch.isSearchable !== undefined) { - values.isSearchable = patch.isSearchable + // -> Never for a redirection; see the same call in `createPage` + values.isSearchable = isRedirect ? false : patch.isSearchable } if (patch.password !== undefined) { values.password = patch.password || null diff --git a/backend/models/sites.ts b/backend/models/sites.ts index 3a65ed042..7266e5045 100644 --- a/backend/models/sites.ts +++ b/backend/models/sites.ts @@ -2,12 +2,39 @@ import { mergeWith, toMerged } from 'es-toolkit/object' import { keyBy } from 'es-toolkit/array' import { blocks as blocksTable, + siteAssets as siteAssetsTable, sites as sitesTable, storage as storageTable } from '../db/schema.ts' -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' +import { detectImageMime, detectSvg, normalizeImage, svgMimeType } from '../helpers/images.ts' +import type { ImageNormalization } from '../helpers/images.ts' import type { SystemIds } from './types.ts' +/** + * The images a site can have uploaded for it. Each name is also the flag in the site's + * `config.assets` saying whether there is one — which is what the cached site config is asked before + * the bytes are ever looked up — and the name the image is addressed by, both to upload it and to + * serve it. + */ +export const siteAssetKinds = ['logo', 'favicon', 'loginBg'] as const + +export type SiteAssetKind = (typeof siteAssetKinds)[number] + +/** + * The size and format each image is stored at, i.e. what a browser is eventually handed. Every one + * is far smaller than what an administrator is likely to upload: these are a header logo, a tab icon + * and a login backdrop, not artwork to be kept at its original resolution. + */ +const SITE_ASSET_NORMALIZATION: Record = { + // -> A logo is whatever shape its owner made it, so it is fitted rather than cropped + logo: { width: 512, height: 512, fit: 'inside', format: 'webp' }, + // -> PNG rather than WebP: a favicon is read by whatever the browser's tab strip, bookmark list and + // home screen are made of, some of it much older than the page itself + favicon: { width: 180, height: 180, fit: 'cover', format: 'png' }, + loginBg: { width: 1920, height: 1080, fit: 'cover', format: 'webp' } +} + /** * Sites model */ @@ -73,7 +100,6 @@ class Sites { contentLicense: '', footerExtra: '', pageExtensions: ['md', 'html', 'txt'], - pageCasing: true, discoverable: false, defaults: { tocDepth: { @@ -116,9 +142,7 @@ class Sites { }, assets: { logo: false, - logoExt: 'svg', favicon: false, - faviconExt: 'svg', loginBg: false }, theme: { @@ -163,8 +187,7 @@ class Sites { } }, uploads: { - conflictBehavior: 'overwrite', - normalizeFilename: true + conflictBehavior: 'overwrite' } }, config @@ -232,12 +255,75 @@ class Sites { return true } + /** + * The bytes of an image uploaded for a site, if there is one. + * + * What was stored depends on what the upload could be normalized to — Sharp is an optional + * extension, and an SVG is never re-encoded at all — so the type is read back off the bytes rather + * than assumed. + */ + async getAsset( + siteId: string, + kind: SiteAssetKind + ): Promise<{ data: Buffer; mime: string } | null> { + const rows = await WIKI.db + .select({ data: siteAssetsTable.data }) + .from(siteAssetsTable) + .where(and(eq(siteAssetsTable.siteId, siteId), eq(siteAssetsTable.kind, kind))) + .limit(1) + const data = rows[0]?.data + if (!data) { + return null + } + const mime = + detectImageMime(data) ?? (detectSvg(data) ? svgMimeType : 'application/octet-stream') + return { data, mime } + } + + /** + * Replace one of a site's images. + * + * A raster upload is brought down to the size and format it will be served at, per + * `SITE_ASSET_NORMALIZATION` — there is no reason to hand every visitor the multi-megabyte + * original of an image displayed 34 pixels tall. That needs the Sharp extension, so without it the + * uploaded bytes are stored as they came in, which is what the admin area's "requires Sharp" + * indicator is warning about. An SVG is stored as it came in either way: it is markup, it already + * scales to any size, and rasterizing it would throw away the only reason to use one. + * + * @param data The uploaded image, already known to be one of the supported formats + */ + async setAsset(siteId: string, kind: SiteAssetKind, data: Buffer): Promise { + const normalized = detectSvg(data) + ? data + : ((await normalizeImage(data, SITE_ASSET_NORMALIZATION[kind])) ?? data) + await WIKI.db + .insert(siteAssetsTable) + .values({ siteId, kind, data: normalized }) + .onConflictDoUpdate({ + target: [siteAssetsTable.siteId, siteAssetsTable.kind], + set: { data: normalized } + }) + // -> Serving reads this flag off the cached site config before it looks for any bytes + await WIKI.models.sites.updateSite(siteId, { config: { assets: { [kind]: true } } }) + } + + /** + * Remove one of a site's images, leaving the built-in default to be served again. + */ + async clearAsset(siteId: string, kind: SiteAssetKind): Promise { + await WIKI.db + .delete(siteAssetsTable) + .where(and(eq(siteAssetsTable.siteId, siteId), eq(siteAssetsTable.kind, kind))) + await WIKI.models.sites.updateSite(siteId, { config: { assets: { [kind]: false } } }) + } + async deleteSite(id: string): Promise { - // -> Block and storage rows are registration metadata derived from disk, and their FK has no - // cascade, so they would otherwise block the delete. Content tables (pages, assets, ...) - // deliberately still do — see the conflict handling in the route. + // -> Block, storage and uploaded image rows belong to the site rather than to its content, and + // their FK has no cascade, so they would otherwise block the delete. Content tables (pages, + // assets, ...) deliberately still do — see the conflict handling in the route. await WIKI.db.delete(blocksTable).where(eq(blocksTable.siteId, id)) await WIKI.db.delete(storageTable).where(eq(storageTable.siteId, id)) + await WIKI.db.delete(siteAssetsTable).where(eq(siteAssetsTable.siteId, id)) const deletedResult = await WIKI.db.delete(sitesTable).where(eq(sitesTable.id, id)) if ((deletedResult.rowCount ?? 0) < 1) { @@ -266,7 +352,6 @@ class Sites { contentLicense: '', footerExtra: '', pageExtensions: ['md', 'html', 'txt'], - pageCasing: true, discoverable: false, defaults: { tocDepth: { @@ -307,9 +392,7 @@ class Sites { }, assets: { logo: false, - logoExt: 'svg', favicon: false, - faviconExt: 'svg', loginBg: false }, editors: { @@ -354,8 +437,7 @@ class Sites { contentFont: 'roboto' }, uploads: { - conflictBehavior: 'overwrite', - normalizeFilename: true + conflictBehavior: 'overwrite' } } }) diff --git a/backend/models/tree.ts b/backend/models/tree.ts index baf8db1c9..69805b290 100644 --- a/backend/models/tree.ts +++ b/backend/models/tree.ts @@ -6,7 +6,8 @@ import { decodeTreePath, encodeTreePath, generateHash, - generatePathHash + generatePathHash, + normalizePagePath } from '../helpers/common.ts' /** What a tree entry can be. Mirrors the `treeType` enum in the schema. */ @@ -575,6 +576,56 @@ class Tree { return (results[0] as TreeRow) ?? null } + /** + * Whatever already sits at a name inside a folder, or null if the name is free. + * + * The question an upload has to ask before it writes anything, since what is there decides whether + * the file replaces it, is refused, or takes the next free name. A folder that does not exist holds + * nothing, so an unresolvable destination answers null rather than raising: the caller is about to + * create it. + * + * @param parentId UUID of the folder to look in. Takes precedence over `parentPath`; the site root + * when both are absent. + */ + async getEntryAt({ + siteId, + locale, + parentId, + parentPath, + fileName + }: { + siteId: string + locale: string + parentId?: string | null + parentPath?: string | null + fileName: string + }): Promise { + let path = '' + if (parentId || parentPath) { + let folder: TreeRow + try { + folder = await this.getFolder({ id: parentId, path: parentPath, locale, siteId }) + } catch { + return null + } + path = childPathOf(folder) + } + + const results = await WIKI.db + .select() + .from(treeTable) + .where( + and( + eq(treeTable.siteId, siteId), + eq(treeTable.locale, locale), + eq(treeTable.folderPath, path), + eq(treeTable.fileName, fileName) + ) + ) + .limit(1) + return (results[0] as TreeRow) ?? null + } + /** * Resolve a folder, either by ID or by path. * @@ -637,7 +688,8 @@ class Tree { * * @param parentId UUID of the folder to create it in. Takes precedence over `parentPath`. * @param parentPath Slash-separated path of the folder to create it in. The root when both are absent. - * @param pathName The folder's own path segment, lowercase and URL friendly. + * @param pathName The folder's own path segment. Normalized the way a page path is, so what the + * folder ends up called may differ from what was asked for. */ async createFolder({ parentId, @@ -654,7 +706,10 @@ class Tree { locale: string siteId: string }): Promise { - if (!rePathName.test(pathName)) { + // -> A folder name is a segment of every page path under it, so it is normalized the same way a + // page path is before it is held to what a segment may contain + const name = normalizePagePath(pathName) + if (!rePathName.test(name)) { throw new CustomError( 'treeInvalidPath', 'A folder path name may only contain lowercase alphanumeric and hyphen characters.' @@ -685,7 +740,7 @@ class Tree { eq(treeTable.siteId, siteId), eq(treeTable.locale, effectiveLocale), eq(treeTable.folderPath, path), - eq(treeTable.fileName, pathName), + eq(treeTable.fileName, name), eq(treeTable.type, 'folder') ) ) @@ -753,12 +808,12 @@ class Tree { } } - const fullPath = path ? `${decodeTreePath(path)}/${pathName}` : pathName + const fullPath = path ? `${decodeTreePath(path)}/${name}` : name const inserted = await WIKI.db .insert(treeTable) .values({ folderPath: path, - fileName: pathName, + fileName: name, type: 'folder', title, hash: generateHash(fullPath), @@ -777,8 +832,8 @@ class Tree { /** * Rename a folder, moving everything under it along with it. * - * @param pathName The new path segment. Unchanged from the current one when only the title differs, - * which leaves every descendant's path untouched. + * @param pathName The new path segment, normalized as on the way in. Unchanged from the current + * one when only the title differs, which leaves every descendant's path untouched. */ async renameFolder({ folderId, @@ -793,7 +848,10 @@ class Tree { if (!folder) { throw new CustomError('treeInvalidFolder', 'This folder does not exist.', 404) } - if (!rePathName.test(pathName)) { + // -> Normalized as it is on the way in, since this renames the segment every page path under the + // folder is built from + const name = normalizePagePath(pathName) + if (!rePathName.test(name)) { throw new CustomError( 'treeInvalidPath', 'A folder path name may only contain lowercase alphanumeric and hyphen characters.' @@ -803,7 +861,7 @@ class Tree { throw new CustomError('treeInvalidTitle', 'The folder title contains invalid characters.') } - if (pathName === folder.fileName) { + if (name === folder.fileName) { const updated = await WIKI.db .update(treeTable) .set({ title, updatedAt: sql`now()` }) @@ -821,7 +879,7 @@ class Tree { eq(treeTable.siteId, folder.siteId), eq(treeTable.locale, folder.locale), eq(treeTable.folderPath, folder.folderPath ?? ''), - eq(treeTable.fileName, pathName), + eq(treeTable.fileName, name), eq(treeTable.type, 'folder') ) ) @@ -835,7 +893,7 @@ class Tree { } const oldPath = childPathOf(folder) - const newPath = folder.folderPath ? `${folder.folderPath}.${pathName}` : pathName + const newPath = folder.folderPath ? `${folder.folderPath}.${name}` : name WIKI.logger.debug(`Renaming folder ${folder.id} from ${oldPath} to ${newPath}...`) @@ -854,12 +912,10 @@ class Tree { and(eq(treeTable.siteId, folder.siteId), sql`${treeTable.folderPath} <@ ${oldPath}::ltree`) ) - const fullPath = folder.folderPath - ? `${decodeTreePath(folder.folderPath)}/${pathName}` - : pathName + const fullPath = folder.folderPath ? `${decodeTreePath(folder.folderPath)}/${name}` : name const updated = await WIKI.db .update(treeTable) - .set({ fileName: pathName, title, hash: generateHash(fullPath), updatedAt: sql`now()` }) + .set({ fileName: name, title, hash: generateHash(fullPath), updatedAt: sql`now()` }) .where(eq(treeTable.id, folder.id)) .returning() @@ -1058,8 +1114,10 @@ class Tree { siteId, tags, meta, - // -> Uploading a file already in the folder takes the next free `name-1.ext`, rather than - // failing on something the uploader did not choose and cannot see + // -> Whatever the site's upload conflict behavior is, a name that is taken by the time the row + // is written takes the next free `name-1.ext`: the assets model settled the collisions it + // could see, and a file that appeared since must not fail on something the uploader did not + // choose and cannot see onConflict: 'suffix' }) } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index fe8a97671..f57b0e08b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -13,6 +13,7 @@ import { useRouter, useRoute } from 'vue-router' import { useI18n } from 'vue-i18n' import { setCssVar } from '@/helpers/cssVars' +import { stripPageExtension } from '@/helpers/pagePaths' import { useDark } from '@/composables/dark' import { notify } from '@/composables/notify' @@ -229,6 +230,21 @@ router.beforeEach(async (to, from) => { await loadBootstrap() } + /* + -> Page extensions + A path ending in one of the extensions the site's content is written in addresses the page + underneath it, so `/foo/bar.md` is `/foo/bar`. The server redirects a request that reaches it, but + a link inside a page is followed by the router alone -- which is what this is for. Below the + bootstrap above, since that is where the site's extensions come from. A `/_` route is the app + itself rather than a page, and is left alone as it is by the server. + */ + const withoutExtension = to.path.startsWith('/_') + ? null + : stripPageExtension(to.path, siteStore.pageExtensions) + if (withoutExtension) { + return { path: withoutExtension, query: to.query, hash: to.hash, replace: true } + } + // -> Locale if ( !commonStore.desiredLocale || diff --git a/frontend/src/assets/icons.generated.js b/frontend/src/assets/icons.generated.js index c66aa38c3..818bd318a 100644 --- a/frontend/src/assets/icons.generated.js +++ b/frontend/src/assets/icons.generated.js @@ -5,7 +5,7 @@ never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or removing an icon; `check-icons.mjs` fails the build if this drifts. - 263 icons. + 264 icons. */ export const BUNDLED_ICONS = { "la:angle-double-right": {"body":"","width":32,"height":32}, @@ -47,6 +47,7 @@ export const BUNDLED_ICONS = { "la:css3-alt": {"body":"","width":32,"height":32}, "la:dharmachakra": {"body":"","width":32,"height":32}, "la:dice-d6": {"body":"","width":32,"height":32}, + "la:directions": {"body":"","width":32,"height":32}, "la:download": {"body":"","width":32,"height":32}, "la:edit": {"body":"","width":32,"height":32}, "la:ellipsis-h": {"body":"","width":32,"height":32}, diff --git a/frontend/src/components/EditorRedirect.vue b/frontend/src/components/EditorRedirect.vue new file mode 100644 index 000000000..a081576d2 --- /dev/null +++ b/frontend/src/components/EditorRedirect.vue @@ -0,0 +1,281 @@ + + + + + diff --git a/frontend/src/components/FileManager.vue b/frontend/src/components/FileManager.vue index 10a9b3d7b..e1068609f 100644 --- a/frontend/src/components/FileManager.vue +++ b/frontend/src/components/FileManager.vue @@ -317,7 +317,12 @@ {{ t(`common.actions.edit`) }} - + + diff --git a/frontend/src/components/FolderCreateDialog.vue b/frontend/src/components/FolderCreateDialog.vue index bac40cd52..ed9bbcef0 100644 --- a/frontend/src/components/FolderCreateDialog.vue +++ b/frontend/src/components/FolderCreateDialog.vue @@ -70,6 +70,7 @@ import slugify from 'slugify' import { useSiteStore } from '@/stores/site' import { apiErrorMessage } from '@/helpers/apiError' +import { normalizePagePath } from '@/helpers/pagePaths' // PROPS @@ -141,6 +142,8 @@ watch( async function create() { state.loading++ try { + // -> The name is a segment of every page path under the folder, and is corrected the way one is + state.path = normalizePagePath(state.path) const isFormValid = await newFolderForm.value.validate(true) if (!isFormValid) { throw new Error(t('fileman.createFolderInvalidData')) diff --git a/frontend/src/components/FolderRenameDialog.vue b/frontend/src/components/FolderRenameDialog.vue index 9ed0c147a..26ace0e10 100644 --- a/frontend/src/components/FolderRenameDialog.vue +++ b/frontend/src/components/FolderRenameDialog.vue @@ -70,6 +70,7 @@ import slugify from 'slugify' import { useSiteStore } from '@/stores/site' import { apiErrorMessage } from '@/helpers/apiError' +import { normalizePagePath } from '@/helpers/pagePaths' // PROPS @@ -143,6 +144,8 @@ watch( async function rename() { state.loading++ try { + // -> The name is a segment of every page path under the folder, and is corrected the way one is + state.path = normalizePagePath(state.path) const isFormValid = await renameFolderForm.value.validate(true) if (!isFormValid) { throw new Error(t('fileman.renameFolderInvalidData')) diff --git a/frontend/src/components/LinkPickerDialog.vue b/frontend/src/components/LinkPickerDialog.vue index 9b0f261dd..6ce6984fb 100644 --- a/frontend/src/components/LinkPickerDialog.vue +++ b/frontend/src/components/LinkPickerDialog.vue @@ -340,6 +340,12 @@ function selectItem(item) { function submit() { onDialogOK({ href: href.value, + /* + Which tab answered, so a caller that stores the two kinds differently does not have to work it + out from the string afterwards. It cannot be worked out reliably: `/help` is a page of this wiki + and a perfectly good relative URL elsewhere. This is the choice somebody made. + */ + kind: state.currentTab, // -> Only ever true for a URL: a page of this wiki opens in the tab the reader is already in openInNewTab: state.currentTab === 'url' && props.newTabOption && state.openInNewTab, title: state.currentTab === 'page' ? state.pageTitle : '' diff --git a/frontend/src/components/NavEditOverlay.vue b/frontend/src/components/NavEditOverlay.vue index a713572a3..f935c829a 100644 --- a/frontend/src/components/NavEditOverlay.vue +++ b/frontend/src/components/NavEditOverlay.vue @@ -299,7 +299,26 @@ v-model="state.current.target" dense hide-bottom-space - :aria-label="t(`navEdit.target`)" /> + :aria-label="t(`navEdit.target`)"> + + @@ -442,8 +461,9 @@ diff --git a/frontend/src/components/PagePropertiesDialog.vue b/frontend/src/components/PagePropertiesDialog.vue index 3a6ff155e..ac43174c1 100644 --- a/frontend/src/components/PagePropertiesDialog.vue +++ b/frontend/src/components/PagePropertiesDialog.vue @@ -35,9 +35,7 @@ :bar-style="siteStore.scrollStyle.bar" style="height: calc(100% - 50px)"> -
- {{ t('editor.props.info') }} -
+
{{ t('editor.props.info') }}
-
- {{ t('editor.props.publishState') }} -
+
{{ t('editor.props.publishState') }}
-
- {{ t('editor.props.relations') }} -
+
{{ t('editor.props.relations') }}
-
- {{ t('editor.props.scripts') }} -
+
{{ t('editor.props.scripts') }}
-
- {{ t('editor.props.sidebar') }} -
+
{{ t('editor.props.sidebar') }}
-
- {{ t('editor.props.social') }} -
+
{{ t('editor.props.social') }}
-
- {{ t('editor.props.tags') }} -
+
{{ t('editor.props.tags') }}
-
- {{ t('editor.props.visibility') }} -
+
{{ t('editor.props.visibility') }}
{ border-bottom-left-radius: inherit; border-bottom-right-radius: inherit; } + + /* + The section headings, in the treatment the profile pages use. + + `.w-section-header` carries its own 16px inset and expects to sit in a column that has none -- + inside a `w-card-section` it would be indented twice, and its wash would stop short of the panel + on both sides. So the section's padding is cancelled around it: the band then spans the panel and + its text lines up with the fields beneath it, exactly as on a profile page. The top padding is + given back so the heading sits where the section's own padding had it. + + The tinted `alt-card` sections keep their stripe: the heading is inside the section, so the wash + is drawn over whichever surface that section has. + */ + .w-section-header { + margin: -16px -16px 10px; + padding-top: 16px; + } } diff --git a/frontend/src/components/PageRedirect.vue b/frontend/src/components/PageRedirect.vue new file mode 100644 index 000000000..b98d46c8f --- /dev/null +++ b/frontend/src/components/PageRedirect.vue @@ -0,0 +1,263 @@ + + + diff --git a/frontend/src/components/PageRelationDialog.vue b/frontend/src/components/PageRelationDialog.vue index 275646af2..8a10928ee 100644 --- a/frontend/src/components/PageRelationDialog.vue +++ b/frontend/src/components/PageRelationDialog.vue @@ -7,10 +7,9 @@ -
{{ t('editor.pageRel.position') }}
+
{{ t('editor.pageRel.position') }}
-
{{ t('editor.pageRel.button') }}
+
{{ t('editor.pageRel.button') }}
+ {{ t('iconPicker.open') }} -
{{ t('editor.pageRel.target') }}
+
{{ t('editor.pageRel.target') }}
-
{{ t('editor.pageRel.preview') }}
+
{{ t('editor.pageRel.preview') }}
{ }) }) + + diff --git a/frontend/src/components/TreeBrowserDialog.vue b/frontend/src/components/TreeBrowserDialog.vue index 83ca7bd0e..dc0f02903 100644 --- a/frontend/src/components/TreeBrowserDialog.vue +++ b/frontend/src/components/TreeBrowserDialog.vue @@ -155,6 +155,7 @@ import Tree from '@/components/TreeNav.vue' import { useSiteStore } from '@/stores/site' import { apiErrorMessage } from '@/helpers/apiError' +import { normalizePagePath } from '@/helpers/pagePaths' // PROPS @@ -287,6 +288,9 @@ async function save() { }) return } + // -> A path is a URL: casing and spaces are corrected rather than refused, the way the server does + // it, and the field is left showing what will actually be saved + state.path = normalizePagePath(state.path) if (!/^[a-z0-9-]+$/.test(state.path)) { notify({ type: 'negative', diff --git a/frontend/src/components/UploadPendingAssetsDialog.vue b/frontend/src/components/UploadPendingAssetsDialog.vue index 374483f33..7e254ab3e 100644 --- a/frontend/src/components/UploadPendingAssetsDialog.vue +++ b/frontend/src/components/UploadPendingAssetsDialog.vue @@ -87,8 +87,10 @@ onMounted(async () => { if (resp?.ok === false) { throw new Error(resp.message || 'An unexpected error occured.') } - // -> The stored name is not always the one asked for: a file already in the folder gets the - // next free `name-1.ext`, and the content has to point at what was actually stored + // -> The stored name is not always the one asked for: what happens to a file already in the + // folder is the site's upload conflict behavior to decide — it may be replaced, or the + // arrival may take the next free `name-1.ext` — so the content has to point at what the + // server says it stored const storedPath = assetPath(resp?.asset?.folderPath, resp?.asset?.fileName) pageStore.content = pageStore.content.replaceAll(item.blobUrl, storedPath) replacements.push({ from: item.blobUrl, to: storedPath }) diff --git a/frontend/src/helpers/pagePaths.js b/frontend/src/helpers/pagePaths.js new file mode 100644 index 000000000..b3eafdc01 --- /dev/null +++ b/frontend/src/helpers/pagePaths.js @@ -0,0 +1,40 @@ +/** + * The one spelling a page path has. + * + * Mirrors `normalizePagePath` in the backend's `helpers/common.ts`, so that a path typed into a + * dialog is corrected in front of the person typing it rather than silently changed by the server + * after they hit save. Whether what comes out is *allowed* is still each field's own rule — this only + * settles casing and spaces. + */ +export function normalizePagePath(input) { + return (input ?? '') + .trim() + .replace(/^\/+/, '') + .replace(/\/+$/, '') + .replaceAll(/\s+/g, '-') + .toLowerCase() +} + +/** + * Drop a site's page extension from the end of a URL path. + * + * The server redirects these too, but a link inside page content is followed by the router without + * ever asking it — so `/foo/bar.md` written into a page has to resolve to `/foo/bar` here as well. + * Mirrors `stripPageExtension` in the backend's `helpers/common.ts`. + * + * @param extensions Lowercase and without the dot, as `siteStore.pageExtensions` holds them + * @returns The path without the extension, or null if it does not end in one of them + */ +export function stripPageExtension(urlPath, extensions) { + if (!extensions?.length) { + return null + } + const dot = urlPath.lastIndexOf('.') + if (dot < 1 || urlPath[dot - 1] === '/' || urlPath.lastIndexOf('/') > dot) { + return null + } + if (!extensions.includes(urlPath.slice(dot + 1).toLowerCase())) { + return null + } + return urlPath.slice(0, dot) +} diff --git a/frontend/src/helpers/pageRedirect.js b/frontend/src/helpers/pageRedirect.js new file mode 100644 index 000000000..2995fe1ec --- /dev/null +++ b/frontend/src/helpers/pageRedirect.js @@ -0,0 +1,65 @@ +/** + * What a redirection page holds instead of a body. + * + * A redirection is an ordinary page authored with the `redirect` editor: it has a path, a title and a + * place in the tree, and nothing to read. Where it points is its content, as JSON — see + * `normalizeRedirectContent` in the backend's `models/pages.ts`, which is the authority on the shape + * and refuses a save that does not match it. This file is the same reading, in front of the author: + * the editor round-trips through it, and the page view follows what it returns. + */ + +/** + * How long the interstitial is shown before the reader is taken on, in milliseconds. + * + * Long enough to read one line and see where they are going, short enough that nobody waits on it. + */ +export const REDIRECT_INTERSTITIAL_MS = 2500 + +/** An empty redirection, which is what a page being created starts as. */ +export function emptyRedirect() { + return { kind: 'page', target: '', showInterstitial: false } +} + +/** + * Read a stored redirection. Never throws: content that is missing or unparseable comes back as an + * empty redirection, which the editor opens on and the page view reports as having nowhere to go. + */ +export function parseRedirect(content) { + let parsed = null + try { + parsed = JSON.parse(content ?? '') + } catch { + // -> An empty redirection is the answer; see above + } + return { + kind: parsed?.kind === 'url' ? 'url' : 'page', + target: typeof parsed?.target === 'string' ? parsed.target.trim() : '', + showInterstitial: parsed?.showInterstitial === true + } +} + +/** The canonical spelling of a redirection, which is what gets saved. */ +export function serializeRedirect({ kind, target, showInterstitial } = {}) { + return JSON.stringify({ + kind: kind === 'url' ? 'url' : 'page', + target: (target ?? '').trim(), + showInterstitial: showInterstitial === true + }) +} + +/** + * Whether a redirection can actually be followed. + * + * The same two rules the server enforces: a page target is a rooted path within this wiki, and a URL + * target is a complete `http(s)` address — anything else is either not a destination or, for + * `javascript:`, a link nobody chose to follow. + */ +export function isFollowable({ kind, target } = {}) { + const value = (target ?? '').trim() + if (value.length < 1) { + return false + } + return kind === 'url' + ? /^https?:\/\/\S/i.test(value) + : value.startsWith('/') && !value.startsWith('//') +} diff --git a/frontend/src/helpers/siteImages.js b/frontend/src/helpers/siteImages.js new file mode 100644 index 000000000..5ad5b3630 --- /dev/null +++ b/frontend/src/helpers/siteImages.js @@ -0,0 +1,69 @@ +/** + * The images a site has of its own — its logo, its favicon and the backdrop of its login page. + * + * Uploading one is the same exchange whichever it is, and the accepted formats have to agree with + * what the endpoint checks, so both live here rather than in each admin view that offers an upload. + */ + +/** What the endpoint accepts, mirroring the formats it recognizes from the bytes themselves. */ +export const SITE_IMAGE_TYPES = [ + 'image/svg+xml', + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif' +] + +/** + * Ask for an image file. + * + * @returns The chosen file, or null if the picker was dismissed + */ +export function pickSiteImage() { + return new Promise((resolve) => { + const input = document.createElement('input') + input.type = 'file' + input.accept = SITE_IMAGE_TYPES.join(',') + input.onchange = (ev) => resolve(ev.target.files?.[0] ?? null) + // -> Dismissing the picker fires no `change` event, so the promise would otherwise never settle + input.oncancel = () => resolve(null) + input.click() + }) +} + +/** + * Whether a chosen file is one the endpoint will take. The picker's filter is a suggestion the user + * can override, and the server checks the bytes anyway; asking here beats a 415 with nothing to + * explain it. + */ +export function isAcceptedSiteImage(file) { + return SITE_IMAGE_TYPES.includes(file.type) +} + +/** + * Replace one of a site's images. + * + * @param kind One of `logo`, `favicon` or `loginBg` + */ +export async function uploadSiteImage(siteId, kind, file) { + // -> The image is the request body itself: the endpoint takes the raw file, not a form + const resp = await API_CLIENT.put(`sites/${siteId}/images/${kind}`, { + body: file, + headers: { + 'content-type': file.type + } + }).json() + if (!resp?.ok) { + throw new Error(resp?.message || 'An unexpected error occured.') + } +} + +/** + * Remove one of a site's images, leaving the built-in default in its place. + */ +export async function clearSiteImage(siteId, kind) { + const resp = await API_CLIENT.delete(`sites/${siteId}/images/${kind}`).json() + if (!resp?.ok) { + throw new Error(resp?.message || 'An unexpected error occured.') + } +} diff --git a/frontend/src/pages/AdminGeneral.vue b/frontend/src/pages/AdminGeneral.vue index d33abb7c3..5cddedce9 100644 --- a/frontend/src/pages/AdminGeneral.vue +++ b/frontend/src/pages/AdminGeneral.vue @@ -310,13 +310,22 @@ {{ t(`admin.general.logoUplHint`) }} - +
+ + +
@@ -365,13 +374,22 @@ {{ t(`admin.general.faviconHint`) }} - +
+ + +
@@ -441,21 +459,6 @@ :aria-label="t(`admin.general.uploadConflictBehavior`)" /> - - - - - {{ t(`admin.general.uploadNormalizeFilename`) }} - {{ - t(`admin.general.uploadNormalizeFilenameHint`) - }} - - - - - @@ -476,19 +479,6 @@ :aria-label="t(`admin.general.pageExtensions`)" /> - - - - - {{ t(`admin.general.pageCasing`) }} - {{ t(`admin.general.pageCasingHint`) }} - - - - - @@ -548,6 +538,13 @@ import { loading } from '@/composables/loading' import { useAdminStore } from '@/stores/admin' import { useSiteStore } from '@/stores/site' +import { + clearSiteImage, + isAcceptedSiteImage, + pickSiteImage, + uploadSiteImage +} from '@/helpers/siteImages' + import { toMerged } from 'es-toolkit/object' // STORES @@ -580,7 +577,6 @@ function defaultConfig() { contentLicense: '', footerExtra: '', pageExtensions: '', - pageCasing: false, logoText: false, ratings: { index: false, @@ -614,6 +610,10 @@ function defaultConfig() { const state = reactive({ loading: 0, assetTimestamp: new Date().toISOString(), + // -> Whether this site has a logo / favicon of its own, i.e. whether there is anything to clear. + // The previews always render: without one they show the default that is served instead. + hasLogo: false, + hasFavicon: false, config: defaultConfig() }) @@ -668,6 +668,8 @@ async function load() { ...resp, pageExtensions: resp.pageExtensions.join(',') }) + state.hasLogo = resp?.assets?.logo ?? false + state.hasFavicon = resp?.assets?.favicon ?? false loading.hide() state.loading-- } @@ -694,12 +696,10 @@ async function save() { contentLicense: state.config.contentLicense ?? '', footerExtra: state.config.footerExtra ?? '', pageExtensions: parsePageExtensions(state.config.pageExtensions), - pageCasing: state.config.pageCasing ?? false, logoText: state.config.logoText ?? false, sitemap: state.config.sitemap ?? false, uploads: { - conflictBehavior: state.config.uploads?.conflictBehavior ?? 'overwrite', - normalizeFilename: state.config.uploads?.normalizeFilename ?? false + conflictBehavior: state.config.uploads?.conflictBehavior ?? 'overwrite' }, robots: { index: state.config.robots?.index ?? false, @@ -746,115 +746,107 @@ async function save() { } async function uploadLogo() { - const input = document.createElement('input') - input.type = 'file' - - input.onchange = async (e) => { - state.loading++ - try { - const resp = await APOLLO_CLIENT.mutate({ - context: { - uploadMode: true - }, - mutation: ` - mutation uploadLogo ( - $id: UUID! - $image: Upload! - ) { - uploadSiteLogo ( - id: $id - image: $image - ) { - operation { - succeeded - message - } - } - } - `, - variables: { - id: adminStore.currentSiteId, - image: e.target.files[0] - } - }) - if (resp?.data?.uploadSiteLogo?.operation?.succeeded) { - notify({ - type: 'positive', - message: t('admin.general.logoUploadSuccess') - }) - state.assetTimestamp = new Date().toISOString() - } else { - throw new Error( - resp?.data?.uploadSiteLogo?.operation?.message || 'An unexpected error occured.' - ) - } - } catch (err) { - notify({ - type: 'negative', - message: 'Failed to upload site logo.', - caption: err.message - }) - } - state.loading-- + const file = await pickSiteImage() + if (!file) { + return + } + if (!isAcceptedSiteImage(file)) { + notify({ + type: 'negative', + message: t('admin.general.logoUploadFailed'), + caption: t('admin.general.imageUploadInvalidType') + }) + return + } + state.loading++ + try { + await uploadSiteImage(adminStore.currentSiteId, 'logo', file) + notify({ + type: 'positive', + message: t('admin.general.logoUploadSuccess') + }) + state.hasLogo = true + state.assetTimestamp = new Date().toISOString() + } catch (err) { + notify({ + type: 'negative', + message: t('admin.general.logoUploadFailed'), + caption: err.message + }) } + state.loading-- +} - input.click() +async function clearLogo() { + state.loading++ + try { + await clearSiteImage(adminStore.currentSiteId, 'logo') + notify({ + type: 'positive', + message: t('admin.general.logoClearSuccess') + }) + state.hasLogo = false + state.assetTimestamp = new Date().toISOString() + } catch (err) { + notify({ + type: 'negative', + message: t('admin.general.logoClearFailed'), + caption: err.message + }) + } + state.loading-- } async function uploadFavicon() { - const input = document.createElement('input') - input.type = 'file' - - input.onchange = async (e) => { - state.loading++ - try { - const resp = await APOLLO_CLIENT.mutate({ - context: { - uploadMode: true - }, - mutation: ` - mutation uploadFavicon ( - $id: UUID! - $image: Upload! - ) { - uploadSiteFavicon ( - id: $id - image: $image - ) { - operation { - succeeded - message - } - } - } - `, - variables: { - id: adminStore.currentSiteId, - image: e.target.files[0] - } - }) - if (resp?.data?.uploadSiteFavicon?.operation?.succeeded) { - notify({ - type: 'positive', - message: t('admin.general.faviconUploadSuccess') - }) - state.assetTimestamp = new Date().toISOString() - } else { - throw new Error( - resp?.data?.uploadSiteFavicon?.operation?.message || 'An unexpected error occured.' - ) - } - } catch (err) { - notify({ - type: 'negative', - message: 'Failed to upload site favicon.', - caption: err.message - }) - } - state.loading-- + const file = await pickSiteImage() + if (!file) { + return } + if (!isAcceptedSiteImage(file)) { + notify({ + type: 'negative', + message: t('admin.general.faviconUploadFailed'), + caption: t('admin.general.imageUploadInvalidType') + }) + return + } + state.loading++ + try { + await uploadSiteImage(adminStore.currentSiteId, 'favicon', file) + notify({ + type: 'positive', + message: t('admin.general.faviconUploadSuccess') + }) + state.hasFavicon = true + state.assetTimestamp = new Date().toISOString() + } catch (err) { + notify({ + type: 'negative', + message: t('admin.general.faviconUploadFailed'), + caption: err.message + }) + } + state.loading-- +} - input.click() +async function clearFavicon() { + state.loading++ + try { + await clearSiteImage(adminStore.currentSiteId, 'favicon') + notify({ + type: 'positive', + message: t('admin.general.faviconClearSuccess') + }) + state.hasFavicon = false + state.assetTimestamp = new Date().toISOString() + } catch (err) { + notify({ + type: 'negative', + message: t('admin.general.faviconClearFailed'), + caption: err.message + }) + } + state.loading-- } // MOUNTED diff --git a/frontend/src/pages/AdminLogin.vue b/frontend/src/pages/AdminLogin.vue index 551c808b4..ec4a5f976 100644 --- a/frontend/src/pages/AdminLogin.vue +++ b/frontend/src/pages/AdminLogin.vue @@ -2,7 +2,9 @@