diff --git a/CLAUDE.md b/CLAUDE.md index 996d4dc96..f6d67d8fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,9 @@ scheduler → event emitters), `initHTTPServer()` (Fastify plugins, auth, routes - `api/schemas/` — shared JSON Schemas registered via `app.addSchema()` and referenced from route schemas as `{ $ref: 'Site#' }`. Register new shared schemas in `api/index.ts` *before* the routes. - `controllers/` — non-API HTTP routes. `site.ts` serves per-site resources (logo, favicon, login - background) under `/_site`. + background) under `/_site`; `icons.ts` serves icons under `/_icons`, implementing the part of the + Iconify API protocol the frontend speaks (`/_icons/.json?icons=a,b` and + `/_icons//.svg`). Public and cached hard — see [Icons](#icons). - `core/` — long-lived singletons: `config.ts` (yml + db-backed settings), `db.ts` (pg pool, Drizzle instance, migrations, LISTEN/NOTIFY pubsub), `logger.ts`, `scheduler.ts` (poolifier thread pool + postgres-backed job queue). @@ -53,7 +55,9 @@ scheduler → event emitters), `initHTTPServer()` (Fastify plugins, auth, routes `SystemIds` passed to each model's `init()` during first-run seeding. - `modules/` — pluggable extensions, discovered from disk. Each module is a directory with a `definition.yml` (key, title, props/config schema) plus its implementation — e.g. - `modules/authentication/local/`. + `modules/authentication/local/`. `modules/storage/*` is definition-only so far: the admin area + stores a configuration per site and module, but no `storage.ts` exists yet and nothing reads or + writes content through a target — pages and assets go straight to the database. - `tasks/simple/` — jobs run in-process by the scheduler; each exports `task()`. File name is kebab-case, the task key is its camelCase form. - `tasks/workers/` — CPU-bound jobs run in a worker thread via `worker.ts`, which boots a minimal @@ -73,8 +77,9 @@ Quasar app on plain Vite (not Quasar CLI). `src/main.js` wires it up manually: r - `src/boot/` — one-time app initializers: `api.js` (creates the `ky` client with JWT refresh, exposed as the `API_CLIENT` global), `components.js` (global components), `eventbus.js` (`EVENT_BUS` global, - mitt), `externals.js`, `i18n.js`, `monaco.js`, `temporal.js` (conditionally polyfills `Temporal`, - awaited before anything else in `main.js`). + mitt), `externals.js`, `i18n.js`, `iconify.js` (points Iconify at this instance's `/_icons`), + `monaco.js`, `temporal.js` (conditionally polyfills `Temporal`, awaited before anything else in + `main.js`). - `src/router/` — `index.js` (router factory) and `routes.js` (the full route table; page components are lazily imported). - `src/layouts/` — `MainLayout`, `AdminLayout`, `AuthLayout`, `ProfileLayout`. @@ -90,8 +95,8 @@ Quasar app on plain Vite (not Quasar CLI). `src/main.js` wires it up manually: r Path alias `@` → `frontend/src` (defined in `vite.config.js`; `jsconfig.json` mirrors it for the IDE). -Dev server runs on **3001** and proxies `/_api`, `/_blocks`, `/_site`, `/_thumb`, `/_user` to the -backend on **3000**, so the backend must be running too. +Dev server runs on **3001** and proxies `/_api`, `/_blocks`, `/_icons`, `/_site`, `/_thumb`, `/_user` +to the backend on **3000**, so the backend must be running too. ### `blocks/` @@ -165,12 +170,14 @@ literal and assert it to `WikiGlobal`, since each populates the object progressi `backend/types/fastify.d.ts` augments Fastify: session fields (`authenticated`, `user`, `permissions`) and the per-route `config.permissions` used by the `preHandler` permission hook. -**Three dynamic paths are extension-sensitive** and invisible to the type checker — they must be +**Four dynamic paths are extension-sensitive** and invisible to the type checker — they must be updated by hand if the files they point at are ever renamed: - `core/scheduler.ts` → `path.join(WIKI.SERVERPATH, 'worker.ts')` (the poolifier pool entry) - `worker.ts` → `import('./tasks/workers/${kebabCase(job.task)}.ts')` - `models/authentication.ts` → `import('../modules/authentication/${stg.module}/authentication.ts')` +- `models/storage.ts` → `import('../modules/storage/${key}/storage.ts')`, plus the `storage.ts` + presence check in `hasImplementation()` that gates it `scheduler.ts` reads `tasks/simple/` filenames with `/\.[jt]s$/`, so task files are extension-agnostic. @@ -282,9 +289,31 @@ These apply to **every workspace**, `frontend/` included — not just the backen [Utilities and dates](#utilities-and-dates); the `lodash-es` and `luxon` still present in older files are on their way out. +### Icons + +Icons come from **Iconify** and are referenced the way Iconify references them — `:`, +e.g. `mdi:account-edit`. That string is all that content, navigation items and page relations ever +store; no SVG is ever written into content. + +- **Admin** (`AdminIcons.vue` → `/_api/icons`) manages which sets exist: adding a set stores its + metadata only, and enabling/disabling one controls whether its icons can be searched and filled in. +- **`models/icons.ts`** resolves a reference through four tiers — memory, disk + (`/cache/icons//.json`), the `icons` db table, then the Iconify API. **Only + the db is permanent**; the disk cache is derived and starts empty on a fresh instance, so never treat + it as storage. The upstream API is consulted only for an icon nobody has used yet, is capped per + minute (public routes can trigger a fill), and is skipped entirely when `offline` is set. +- **Serving** is `controllers/icons.ts` under `/_icons`, cached for a year and immutable. Rendering a + page never resolves an icon server-side. +- **Frontend**: render every user-supplied icon reference with ``, which draws an + Iconify reference through the `iconify-icon` element and hands anything else (`las la-cog`, + `mdi-check`, `img:…`) to `q-icon`. Passing such a reference to a Quasar `icon` prop does *not* work — + use the component's icon slot instead. +- Picking an icon calls `POST /_api/icons/materialize`, which is what guarantees the wiki can serve it + afterwards without the Iconify API. + ### GraphQL is being removed -An earlier iteration of 3.x used GraphQL/Apollo. 59 files under `frontend/src/` still reference +An earlier iteration of 3.x used GraphQL/Apollo. 29 files under `frontend/src/` still reference `APOLLO_CLIENT` (mostly in commented-out queries), and `blocks/block-index/` still imports a `tree.graphql`. **All of it is deprecated** — there is no GraphQL server left in `backend/`, and `APOLLO_CLIENT` is no longer defined as a global. diff --git a/backend/api/icons.ts b/backend/api/icons.ts new file mode 100644 index 000000000..c9d277fb6 --- /dev/null +++ b/backend/api/icons.ts @@ -0,0 +1,579 @@ +import type { FastifyInstance } from 'fastify' + +/** + * Permissions for looking icons up and storing them. + * + * Anyone who can put an icon somewhere — a page, a navigation item, a page relation — needs to be able + * to search for one and have it stored, which is what makes it servable from this instance afterwards. + */ +const PICKER_PERMISSIONS = ['write:pages', 'manage:pages', 'manage:sites', 'manage:system'] + +/** + * Icons API Routes + * + * Administration of the icon sets, plus the search and materialize calls the icon picker makes. The + * icons themselves are served outside `/_api`, under `/_icons` — see `controllers/icons.ts`. + */ +async function routes(app: FastifyInstance) { + /** + * LIST ADDED ICON SETS + */ + app.get( + '/sets', + { + config: { + permissions: PICKER_PERMISSIONS + }, + schema: { + summary: 'List the icon sets added to this wiki', + description: + 'Alphabetical. `iconCount` is how many icons of the set are stored in the database, which is what this instance can serve on its own — the disk cache is derived from those rows and may be empty.', + tags: ['Icons'], + response: { + 200: { + description: 'List of icon sets', + type: 'array', + items: { $ref: 'IconSet#' } + } + } + } + }, + async () => { + return WIKI.models.icons.getSets() + } + ) + + /** + * ADD ICON SET + */ + app.post<{ Body: { prefix: string } }>( + '/sets', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Add an icon set', + description: + 'The set must exist upstream, and its name and metadata are taken from there — so this call needs outbound access to the Iconify API. Nothing is downloaded beyond the metadata: icons are stored the first time something references them.', + tags: ['Icons'], + body: { + type: 'object', + required: ['prefix'], + properties: { + prefix: { + type: 'string', + maxLength: 64, + description: 'Iconify prefix of the set, e.g. `tabler`.' + } + } + }, + response: { + 200: { + description: 'Icon set added successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + }, + set: { $ref: 'IconSet#' } + } + } + } + } + }, + async (req, reply) => { + try { + const set = await WIKI.models.icons.addSet(req.body.prefix.toLowerCase()) + return { + ok: true, + message: `The ${set.name} icon set has been added.`, + set + } + } catch (err: any) { + WIKI.logger.warn(err.message) + return reply.badRequest(err.message) + } + } + ) + + /** + * ENABLE / DISABLE ICON SET + */ + app.put<{ Params: { prefix: string }; Body: { isEnabled: boolean } }>( + '/sets/:prefix', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Enable or disable an icon set', + description: + 'A disabled set disappears from the picker and stops taking on new icons. Icons already stored for it keep being served, since content referencing them is already published.', + tags: ['Icons'], + params: { + type: 'object', + properties: { + prefix: { + type: 'string', + maxLength: 64 + } + }, + required: ['prefix'] + }, + body: { + type: 'object', + required: ['isEnabled'], + properties: { + isEnabled: { + type: 'boolean' + } + } + }, + response: { + 200: { + description: 'Icon set updated successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + } + } + } + } + } + }, + async (req, reply) => { + const prefix = req.params.prefix.toLowerCase() + if (!(await WIKI.models.icons.getSet(prefix))) { + return reply.notFound('Icon set has not been added.') + } + await WIKI.models.icons.setSetState(prefix, req.body.isEnabled) + return { + ok: true, + message: `The ${prefix} icon set has been ${req.body.isEnabled ? 'enabled' : 'disabled'}.` + } + } + ) + + /** + * DELETE ICON SET + */ + app.delete<{ Params: { prefix: string } }>( + '/sets/:prefix', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Delete an icon set', + description: + 'Deletes the set and every icon stored for it, and drops its disk cache. Content still referencing those icons stops rendering them — disable the set instead to keep serving what is already in use.', + tags: ['Icons'], + params: { + type: 'object', + properties: { + prefix: { + type: 'string', + maxLength: 64 + } + }, + required: ['prefix'] + }, + response: { + 200: { + description: 'Icon set deleted successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + }, + deletedIcons: { + type: 'integer' + } + } + } + } + } + }, + async (req, reply) => { + const prefix = req.params.prefix.toLowerCase() + if (!(await WIKI.models.icons.getSet(prefix))) { + return reply.notFound('Icon set has not been added.') + } + const deletedIcons = await WIKI.models.icons.deleteSet(prefix) + return { + ok: true, + message: `The ${prefix} icon set has been deleted.`, + deletedIcons + } + } + ) + + /** + * LIST ICON SETS AVAILABLE UPSTREAM + */ + app.get( + '/available-sets', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'List the icon sets offered by the Iconify API', + description: + 'The catalog an administrator picks from, marking the sets already added. Fetched from upstream and memoized for an hour, so it needs outbound access.', + tags: ['Icons'], + response: { + 200: { + description: 'List of available icon sets', + type: 'array', + items: { $ref: 'AvailableIconSet#' } + } + } + } + }, + async (_req, reply) => { + try { + return await WIKI.models.icons.getAvailableSets() + } catch (err: any) { + WIKI.logger.warn(err.message) + return reply.badGateway(`Could not reach the Iconify API: ${err.message}`) + } + } + ) + + /** + * REFRESH ICON SET METADATA + */ + app.post( + '/sets/refresh', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Refresh the metadata of every added icon set', + description: + 'Re-reads names, totals and licenses from upstream. Stored icons are untouched.', + tags: ['Icons'], + response: { + 200: { + description: 'Icon sets refreshed successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + }, + refreshed: { + type: 'integer' + } + } + } + } + } + }, + async (_req, reply) => { + try { + const refreshed = await WIKI.models.icons.refreshSets() + return { + ok: true, + message: `Refreshed ${refreshed} icon sets.`, + refreshed + } + } catch (err: any) { + WIKI.logger.warn(err.message) + return reply.badGateway(`Could not reach the Iconify API: ${err.message}`) + } + } + ) + + /** + * SEARCH ICONS + */ + app.get<{ Querystring: { query: string; prefixes?: string; limit?: number } }>( + '/search', + { + config: { + permissions: PICKER_PERMISSIONS + }, + schema: { + summary: 'Search icons across the enabled icon sets', + description: + 'Searched upstream, then narrowed to the sets enabled here — so results are always icons that can actually be used. Returns references shaped `prefix:name`, which is what content stores.', + tags: ['Icons'], + querystring: { + type: 'object', + required: ['query'], + properties: { + query: { + type: 'string', + minLength: 2, + maxLength: 128 + }, + prefixes: { + type: 'string', + description: + 'Comma-separated set prefixes to search in. Defaults to every enabled set.' + }, + limit: { + type: 'integer', + minimum: 32, + maximum: 999, + default: 96 + } + } + }, + response: { + 200: { + description: 'Matching icon references', + type: 'object', + properties: { + icons: { + type: 'array', + items: { + type: 'string' + } + } + } + } + } + } + }, + async (req, reply) => { + try { + const icons = await WIKI.models.icons.searchIcons({ + query: req.query.query, + prefixes: req.query.prefixes?.split(',').filter(Boolean), + limit: req.query.limit + }) + return { icons } + } catch (err: any) { + WIKI.logger.warn(err.message) + return reply.badGateway(`Could not reach the Iconify API: ${err.message}`) + } + } + ) + + /** + * LIST THE ICONS OF ONE SET + */ + app.get<{ Params: { prefix: string } }>( + '/sets/:prefix/icons', + { + config: { + permissions: PICKER_PERMISSIONS + }, + schema: { + summary: 'List every icon name in an enabled set', + description: + 'For browsing a set with no search term. Deprecated icons are left out. Fetched from upstream and memoized for an hour.', + tags: ['Icons'], + params: { + type: 'object', + properties: { + prefix: { + type: 'string', + maxLength: 64 + } + }, + required: ['prefix'] + }, + response: { + 200: { + description: 'Icon names, without the set prefix', + type: 'object', + properties: { + prefix: { + type: 'string' + }, + icons: { + type: 'array', + items: { + type: 'string' + } + } + } + } + } + } + }, + async (req, reply) => { + const prefix = req.params.prefix.toLowerCase() + try { + return { prefix, icons: await WIKI.models.icons.listSetIcons(prefix) } + } catch (err: any) { + WIKI.logger.warn(err.message) + return reply.badRequest(err.message) + } + } + ) + + /** + * MATERIALIZE ICONS + */ + app.post<{ Body: { icons: string[] } }>( + '/materialize', + { + config: { + permissions: PICKER_PERMISSIONS + }, + schema: { + summary: 'Store icons so this instance can serve them', + description: + 'Called when an icon is chosen, while the author is online: it fetches the icon from upstream and writes it to the database, after which the wiki serves it forever without the Iconify API. Icons already stored are a no-op.', + tags: ['Icons'], + body: { + type: 'object', + required: ['icons'], + properties: { + icons: { + type: 'array', + minItems: 1, + maxItems: 128, + items: { + type: 'string', + maxLength: 320, + description: 'An icon reference shaped `prefix:name`.' + } + } + } + }, + response: { + 200: { + description: 'Icons materialized', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + }, + failed: { + type: 'array', + items: { + type: 'string' + }, + description: + 'References that could not be stored: malformed, from a set that is not enabled, or unknown upstream.' + } + } + } + } + } + }, + async (req) => { + const failed = await WIKI.models.icons.materializeIcons(req.body.icons) + return { + ok: failed.length < 1, + message: + failed.length < 1 + ? 'Icons are stored and ready to be served.' + : `${failed.length} of ${req.body.icons.length} icons could not be stored.`, + failed + } + } + ) + + /** + * ICON CACHE STATE + */ + app.get( + '/cache', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Report what this instance holds and has cached', + description: + '`iconCount` is permanent (database), the rest is this instance’s cache and can be discarded at any time.', + tags: ['Icons'], + response: { + 200: { + description: 'Icon storage and cache state', + type: 'object', + properties: { + setCount: { + type: 'integer' + }, + enabledSetCount: { + type: 'integer' + }, + iconCount: { + type: 'integer' + }, + memoryCount: { + type: 'integer' + }, + diskCount: { + type: 'integer' + }, + diskSize: { + type: 'integer', + description: 'Bytes held by the SVG files in the disk cache.' + } + } + } + } + } + }, + async () => { + return WIKI.models.icons.getStats() + } + ) + + /** + * PURGE ICON CACHE + */ + app.delete( + '/cache', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Purge the icon cache of this instance', + description: + 'Empties the memory and disk caches. Nothing is lost — both are rebuilt from the database as icons are requested again.', + tags: ['Icons'], + response: { + 200: { + description: 'Cache purged successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + } + } + } + } + } + }, + async () => { + await WIKI.models.icons.purgeCache() + return { + ok: true, + message: 'The icon cache has been purged.' + } + } + ) +} + +export default routes diff --git a/backend/api/index.ts b/backend/api/index.ts index db703dd00..6221918f6 100644 --- a/backend/api/index.ts +++ b/backend/api/index.ts @@ -12,10 +12,12 @@ async function routes(app: FastifyInstance) { await import('./schemas/flags.ts').then((m) => m.registerSchemas(app)) await import('./schemas/group.ts').then((m) => m.registerSchemas(app)) await import('./schemas/hook.ts').then((m) => m.registerSchemas(app)) + await import('./schemas/icon.ts').then((m) => m.registerSchemas(app)) await import('./schemas/mail.ts').then((m) => m.registerSchemas(app)) await import('./schemas/scheduler.ts').then((m) => m.registerSchemas(app)) await import('./schemas/security.ts').then((m) => m.registerSchemas(app)) await import('./schemas/site.ts').then((m) => m.registerSchemas(app)) + await import('./schemas/storage.ts').then((m) => m.registerSchemas(app)) await import('./schemas/user.ts').then((m) => m.registerSchemas(app)) // Register routes @@ -24,11 +26,13 @@ async function routes(app: FastifyInstance) { app.register(import('./blocks.ts')) app.register(import('./groups.ts'), { prefix: '/groups' }) app.register(import('./hooks.ts'), { prefix: '/hooks' }) + app.register(import('./icons.ts'), { prefix: '/icons' }) app.register(import('./locales.ts'), { prefix: '/locales' }) app.register(import('./mail.ts'), { prefix: '/mail' }) app.register(import('./pages.ts')) app.register(import('./scheduler.ts'), { prefix: '/scheduler' }) app.register(import('./sites.ts'), { prefix: '/sites' }) + app.register(import('./storage.ts')) app.register(import('./system.ts'), { prefix: '/system' }) app.register(import('./users.ts'), { prefix: '/users' }) } diff --git a/backend/api/schemas/icon.ts b/backend/api/schemas/icon.ts new file mode 100644 index 000000000..20db18622 --- /dev/null +++ b/backend/api/schemas/icon.ts @@ -0,0 +1,87 @@ +import type { FastifyInstance } from 'fastify' + +export async function registerSchemas(app: FastifyInstance): Promise { + /** + * ICON SET - An Iconify icon set added to this wiki + */ + app.addSchema({ + $id: 'IconSet', + type: 'object', + properties: { + prefix: { + type: 'string', + description: 'Iconify prefix, i.e. the part before the colon in `mdi:account-edit`.' + }, + name: { + type: 'string' + }, + isEnabled: { + type: 'boolean', + description: + 'A disabled set is not searchable and takes on no new icons, but the icons already stored for it keep being served so that published content does not break.' + }, + info: { + type: 'object', + additionalProperties: true, + description: + 'Iconify collection metadata (author, license, total, palette, samples, …) as published upstream. Empty until the first metadata refresh, which needs outbound access.' + }, + iconCount: { + type: 'integer', + description: + 'Icons of this set stored in the database, i.e. what this instance can serve without the upstream API.' + }, + refreshedAt: { + type: 'string', + nullable: true + }, + createdAt: { + type: 'string' + } + } + }) + + /** + * AVAILABLE ICON SET - A set offered upstream, whether or not it is added here + */ + app.addSchema({ + $id: 'AvailableIconSet', + type: 'object', + properties: { + prefix: { + type: 'string' + }, + name: { + type: 'string' + }, + total: { + type: 'integer', + description: 'How many icons the set holds upstream.' + }, + author: { + type: 'string' + }, + license: { + type: 'string' + }, + category: { + type: 'string' + }, + palette: { + type: 'boolean', + description: + 'Whether the icons carry their own colors, in which case they cannot be recolored.' + }, + samples: { + type: 'array', + items: { + type: 'string' + }, + description: 'A few icon names from the set, for a preview.' + }, + isAdded: { + type: 'boolean' + } + } + }) +} diff --git a/backend/api/schemas/storage.ts b/backend/api/schemas/storage.ts new file mode 100644 index 000000000..fa750299a --- /dev/null +++ b/backend/api/schemas/storage.ts @@ -0,0 +1,217 @@ +import { CONTENT_TYPES } from '../../models/storage.ts' +import type { FastifyInstance } from 'fastify' + +export async function registerSchemas(app: FastifyInstance): Promise { + /** + * STORAGE TARGET - A storage module as configured for a site + */ + app.addSchema({ + $id: 'StorageTarget', + type: 'object', + properties: { + id: { + type: 'string', + format: 'uuid' + }, + module: { + type: 'string', + description: 'Directory name under `modules/storage`.' + }, + isEnabled: { + type: 'boolean' + }, + title: { + type: 'string' + }, + description: { + type: 'string' + }, + icon: { + type: 'string' + }, + banner: { + type: 'string' + }, + vendor: { + type: 'string' + }, + website: { + type: 'string' + }, + contentTypes: { + type: 'object', + description: 'Which kinds of content this target holds.', + properties: { + activeTypes: { + type: 'array', + items: { + type: 'string', + enum: [...CONTENT_TYPES] + } + }, + largeThreshold: { + type: 'string', + description: 'Size above which an asset counts as a large file, e.g. `5MB`.' + } + } + }, + assetDelivery: { + type: 'object', + description: + 'How assets reach the user. The `is*Supported` flags come from the module and are read-only.', + properties: { + isStreamingSupported: { + type: 'boolean' + }, + isDirectAccessSupported: { + type: 'boolean' + }, + streaming: { + type: 'boolean' + }, + directAccess: { + type: 'boolean' + } + } + }, + versioning: { + type: 'object', + description: + 'Whether past versions are kept. `isForceEnabled` marks a module where versioning is inherent, such as git.', + properties: { + isSupported: { + type: 'boolean' + }, + isForceEnabled: { + type: 'boolean' + }, + enabled: { + type: 'boolean' + } + } + }, + setup: { + type: 'object', + description: + 'Only present for a module that has a setup process and an implementation to run it.', + properties: { + handler: { + type: 'string', + description: 'Which setup flow the admin area should walk through, e.g. `github`.' + }, + state: { + type: 'string', + enum: ['notconfigured', 'pendinginstall', 'configured'] + }, + values: { + type: 'object', + additionalProperties: true, + description: 'Values the setup form starts from.' + } + } + }, + props: { + type: 'object', + additionalProperties: true, + description: + 'The module configuration, declared in its `definition.yml`: each entry carries a `type`, `title`, `hint`, `default` and the display hints the admin area renders a control from. A `readOnly` prop is shown but cannot be changed, and is silently kept at its stored value when written to.' + }, + config: { + type: 'object', + additionalProperties: true, + description: + 'Values for the module props, completed with the module defaults for any prop that has none stored yet.' + }, + actions: { + type: 'array', + description: + 'Operations that can be run on demand. Empty for a module without an implementation, since there would be nothing to run.', + items: { + type: 'object', + properties: { + handler: { + type: 'string' + }, + label: { + type: 'string' + }, + hint: { + type: 'string' + }, + warn: { + type: 'string', + description: 'Present when the action destroys data.' + }, + icon: { + type: 'string' + } + } + } + } + } + }) + + /** + * STORAGE TARGET INPUT - A partial update of one target + */ + app.addSchema({ + $id: 'StorageTargetInput', + type: 'object', + required: ['id'], + properties: { + id: { + type: 'string', + format: 'uuid' + }, + isEnabled: { + type: 'boolean', + description: + 'The database target cannot be disabled, and a target with a pending setup cannot be enabled.' + }, + contentTypes: { + type: 'object', + properties: { + activeTypes: { + type: 'array', + items: { + type: 'string', + enum: [...CONTENT_TYPES] + } + }, + largeThreshold: { + type: 'string', + maxLength: 32 + } + } + }, + assetDelivery: { + type: 'object', + description: 'A delivery mode the module does not support is stored as off.', + properties: { + streaming: { + type: 'boolean' + }, + directAccess: { + type: 'boolean' + } + } + }, + versioning: { + type: 'object', + description: + 'Ignored by a module that does not support versioning or that forces it on — the module decides, not the client.', + properties: { + enabled: { + type: 'boolean' + } + } + }, + config: { + type: 'object', + additionalProperties: true, + description: + 'Values for the module props. Validated against what the module declares: an unknown key is dropped, a wrong type is refused, and a read-only prop keeps its stored value.' + } + } + }) +} diff --git a/backend/api/storage.ts b/backend/api/storage.ts new file mode 100644 index 000000000..c2f1b60e1 --- /dev/null +++ b/backend/api/storage.ts @@ -0,0 +1,380 @@ +import type { FastifyInstance } from 'fastify' +import type { StorageTargetInput } from '../models/storage.ts' + +/** + * Storage API Routes + */ +async function routes(app: FastifyInstance) { + /** + * LIST SITE STORAGE TARGETS + */ + app.get<{ Params: { siteId: string } }>( + '/sites/:siteId/storage/targets', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'List the storage targets of a site', + description: + 'One target per storage module installed in `modules/storage`, whether or not it has ever been enabled. Configuration values include any credentials a module stores, hence the `manage:system` requirement. Note that no module ships an implementation yet: a target holds configuration, and nothing reads or writes content through it.', + tags: ['Storage'], + params: { + type: 'object', + properties: { + siteId: { + type: 'string', + format: 'uuid' + } + }, + required: ['siteId'] + }, + response: { + 200: { + description: 'List of storage targets', + type: 'array', + items: { $ref: 'StorageTarget#' } + } + } + } + }, + async (req, reply) => { + const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId }) + if (!site) { + return reply.notFound('Site does not exist.') + } + return WIKI.models.storage.getSiteTargets(req.params.siteId) + } + ) + + /** + * UPDATE SITE STORAGE TARGETS + */ + app.put<{ Params: { siteId: string }; Body: { targets: StorageTargetInput[] } }>( + '/sites/:siteId/storage/targets', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Update the storage targets of a site', + description: + 'Only the targets listed are affected, and within each of them only the fields provided. Every target is validated before any of them is written, so a rejected request changes nothing.', + tags: ['Storage'], + params: { + type: 'object', + properties: { + siteId: { + type: 'string', + format: 'uuid' + } + }, + required: ['siteId'] + }, + body: { + type: 'object', + required: ['targets'], + properties: { + targets: { + type: 'array', + items: { $ref: 'StorageTargetInput#' } + } + } + }, + response: { + 200: { + description: 'Storage targets updated successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + }, + updated: { + type: 'integer', + description: + 'How many target rows were written. A target already in the requested state still counts.' + } + } + } + } + } + }, + async (req, reply) => { + const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId }) + if (!site) { + return reply.notFound('Site does not exist.') + } + + // -> Validated as a whole first: a partially applied storage configuration is worse than a + // refused one, since the admin area saves every target at once + const current = await WIKI.models.storage.getSiteTargets(req.params.siteId) + const patches = [] + for (const patch of req.body.targets) { + const target = current.find((t) => t.id === patch.id) + if (!target) { + return reply.notFound(`Storage target ${patch.id} does not exist.`) + } + const invalid = WIKI.models.storage.validateTarget(target, patch) + if (invalid) { + return reply.badRequest(invalid) + } + patches.push({ target, patch }) + } + + let updated = 0 + for (const { target, patch } of patches) { + if (await WIKI.models.storage.updateTarget(req.params.siteId, target, patch)) { + updated++ + } + } + + return { + ok: true, + message: 'Storage targets updated successfully.', + updated + } + } + ) + + /** + * EXECUTE STORAGE TARGET ACTION + */ + app.post<{ Params: { siteId: string; targetId: string; action: string } }>( + '/sites/:siteId/storage/targets/:targetId/actions/:action', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Run an action on a storage target', + description: + 'The actions a target offers are listed with it. Only an enabled target can run one, and only a module with an implementation offers any — so every action currently fails, no module having one yet.', + tags: ['Storage'], + params: { + type: 'object', + properties: { + siteId: { + type: 'string', + format: 'uuid' + }, + targetId: { + type: 'string', + format: 'uuid' + }, + action: { + type: 'string', + maxLength: 255 + } + }, + required: ['siteId', 'targetId', 'action'] + }, + response: { + 200: { + description: 'Action completed successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + } + } + } + } + } + }, + async (req, reply) => { + const target = await WIKI.models.storage.getSiteTargetById( + req.params.siteId, + req.params.targetId + ) + if (!target) { + return reply.notFound('Storage target does not exist.') + } + if (!target.isEnabled) { + return reply.conflict('The storage target must be enabled before running an action.') + } + if (!target.actions.some((act) => act.handler === req.params.action)) { + return reply.badRequest(`${target.title} has no "${req.params.action}" action.`) + } + + try { + await WIKI.models.storage.executeAction(target, req.params.action) + } catch (err: any) { + WIKI.logger.warn(err) + return reply.badRequest(err.message) + } + + return { + ok: true, + message: 'Action completed successfully.' + } + } + ) + + /** + * RUN STORAGE TARGET SETUP STEP + */ + app.post<{ + Params: { siteId: string; targetId: string } + Body: Record + }>( + '/sites/:siteId/storage/targets/:targetId/setup', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Advance the setup process of a storage target', + description: + 'For modules that cannot be configured by hand, such as one backed by an app installed on a provider. The body is passed to the module as-is, and what comes back tells the admin area what to do next. Only a module with an implementation has a setup process — none does yet.', + tags: ['Storage'], + params: { + type: 'object', + properties: { + siteId: { + type: 'string', + format: 'uuid' + }, + targetId: { + type: 'string', + format: 'uuid' + } + }, + required: ['siteId', 'targetId'] + }, + body: { + type: 'object', + required: ['step'], + additionalProperties: true, + properties: { + step: { + type: 'string', + maxLength: 255, + description: 'Which step of the process to run, as named by the module.' + } + } + }, + response: { + 200: { + description: 'Setup step completed successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + }, + state: { + type: 'object', + additionalProperties: true, + description: 'What the module wants done next, e.g. `{ nextStep, url }`.' + } + } + } + } + } + }, + async (req, reply) => { + const target = await WIKI.models.storage.getSiteTargetById( + req.params.siteId, + req.params.targetId + ) + if (!target) { + return reply.notFound('Storage target does not exist.') + } + if (!target.setup) { + return reply.badRequest(`${target.title} has no setup process.`) + } + + try { + const state = await WIKI.models.storage.runSetup(target, req.body) + return { + ok: true, + message: 'Setup step completed successfully.', + state + } + } catch (err: any) { + WIKI.logger.warn(err) + return reply.badRequest(err.message) + } + } + ) + + /** + * DESTROY STORAGE TARGET SETUP + */ + app.delete<{ Params: { siteId: string; targetId: string } }>( + '/sites/:siteId/storage/targets/:targetId/setup', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Reset the setup of a storage target', + description: + 'Undoes what the setup process configured, so that it can be started over. What that involves is up to the module.', + tags: ['Storage'], + params: { + type: 'object', + properties: { + siteId: { + type: 'string', + format: 'uuid' + }, + targetId: { + type: 'string', + format: 'uuid' + } + }, + required: ['siteId', 'targetId'] + }, + response: { + 200: { + description: 'Setup reset successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + } + } + } + } + } + }, + async (req, reply) => { + const target = await WIKI.models.storage.getSiteTargetById( + req.params.siteId, + req.params.targetId + ) + if (!target) { + return reply.notFound('Storage target does not exist.') + } + if (!target.setup) { + return reply.badRequest(`${target.title} has no setup process.`) + } + + try { + await WIKI.models.storage.destroySetup(target) + } catch (err: any) { + WIKI.logger.warn(err) + return reply.badRequest(err.message) + } + + return { + ok: true, + message: 'Setup reset successfully.' + } + } + ) +} + +export default routes diff --git a/backend/base.yml b/backend/base.yml index 265253fb7..bc44ece3f 100644 --- a/backend/base.yml +++ b/backend/base.yml @@ -27,6 +27,10 @@ defaults: offline: false dataPath: ./data bodyParserLimit: 5mb + icons: + # Iconify API the wiki fetches icons from the first time they are used. Point this at a + # self-hosted Iconify API to keep icon lookups inside your network. + apiUrl: 'https://api.iconify.design' scheduler: workers: 3 pollingCheck: 5 diff --git a/backend/controllers/icons.ts b/backend/controllers/icons.ts new file mode 100644 index 000000000..96fab1fae --- /dev/null +++ b/backend/controllers/icons.ts @@ -0,0 +1,112 @@ +import { generateHash } from '../helpers/common.ts' +import type { FastifyInstance, FastifyReply } from 'fastify' + +/** Ceiling on how many icons one batch request may ask for. */ +const MAX_ICONS_PER_REQUEST = 128 + +/** An icon never changes under a given name, so the answer can be cached as hard as HTTP allows. */ +const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable' + +/** Long enough that a page's icons are asked for once, short enough to pick up new sets. */ +const BATCH_CACHE = 'public, max-age=604800' + +/** A batch that came back incomplete is worth asking about again soon. */ +const INCOMPLETE_CACHE = 'public, max-age=60' + +/** + * Answer with a body only when the client does not already have it. + * + * Icons are immutable and served for a year, so this only matters for the client that arrives without + * a warm HTTP cache but with a stale one — cheap enough to be worth the few lines. + */ +function sendCacheable( + reply: FastifyReply, + ifNoneMatch: string | undefined, + body: string, + { contentType, cacheControl }: { contentType: string; cacheControl: string } +): FastifyReply { + const etag = `"${generateHash(body)}"` + reply.header('ETag', etag) + reply.header('Cache-Control', cacheControl) + if (ifNoneMatch === etag) { + return reply.code(304).send() + } + return reply.type(contentType).send(body) +} + +/** + * _icons Routes + * + * Implements the part of the Iconify API protocol the frontend uses, so that `iconify-icon` and any + * other Iconify client can be pointed at this wiki instead of a third-party host: content references + * `mdi:account-edit`, the browser asks this route for it, and nothing about which icons a reader looks + * at leaves the instance. + * + * Public on purpose — icons are page furniture, and a reader who can see a page can see its icons. + * The routes only serve what the wiki holds or can fill in for an enabled set, and filling is bounded + * by the model's upstream budget. + */ +async function routes(app: FastifyInstance) { + /** + * BATCH ICON DATA — what `iconify-icon` requests, one call per set per page + */ + app.get<{ Params: { prefix: string }; Querystring: { icons?: string } }>( + '/:prefix.json', + async (req, reply) => { + const prefix = req.params.prefix.toLowerCase() + const names = (req.query.icons ?? '') + .split(',') + .map((name) => name.trim().toLowerCase()) + .filter(Boolean) + .slice(0, MAX_ICONS_PER_REQUEST) + if (names.length < 1) { + return reply.badRequest('No icons requested.') + } + + const set = await WIKI.models.icons.getSet(prefix) + if (!set) { + return reply.notFound('Icon set not found.') + } + + const resolved = await WIKI.models.icons.resolveIcons(prefix, names) + const payload = { + prefix, + icons: resolved.icons, + ...(resolved.notFound.length > 0 && { not_found: resolved.notFound }) + } + + return sendCacheable(reply, req.headers['if-none-match'], JSON.stringify(payload), { + contentType: 'application/json; charset=utf-8', + cacheControl: resolved.notFound.length > 0 ? INCOMPLETE_CACHE : BATCH_CACHE + }) + } + ) + + /** + * SINGLE ICON AS SVG — for `` and CSS, where a URL is all that fits + */ + app.get<{ Params: { prefix: string; name: string } }>( + '/:prefix/:name.svg', + async (req, reply) => { + const svg = await WIKI.models.icons.getIconSvg( + req.params.prefix.toLowerCase(), + req.params.name.toLowerCase() + ) + if (!svg) { + return reply.notFound('Icon not found.') + } + + // -> The markup comes from a third party and is served from our own origin, so it is locked down + // for the case where it is opened as a document rather than drawn as an image + reply.header('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'") + reply.header('X-Content-Type-Options', 'nosniff') + + return sendCacheable(reply, req.headers['if-none-match'], svg, { + contentType: 'image/svg+xml; charset=utf-8', + cacheControl: IMMUTABLE_CACHE + }) + } + ) +} + +export default routes diff --git a/backend/core/config.ts b/backend/core/config.ts index 784cd8fcc..4fd23bd75 100644 --- a/backend/core/config.ts +++ b/backend/core/config.ts @@ -158,6 +158,7 @@ export default { await WIKI.models.authentication.init(ids) await WIKI.models.users.init(ids) await WIKI.models.jobs.init() + await WIKI.models.icons.init() }, /** * Subscribe to HA propagation events diff --git a/backend/db/migrations/20260726155600_main/migration.sql b/backend/db/migrations/20260726155600_main/migration.sql new file mode 100644 index 000000000..57c7bf297 --- /dev/null +++ b/backend/db/migrations/20260726155600_main/migration.sql @@ -0,0 +1,14 @@ +CREATE TABLE "storage" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "module" varchar(255) NOT NULL, + "isEnabled" boolean DEFAULT false NOT NULL, + "contentTypes" jsonb DEFAULT '{}' NOT NULL, + "assetDelivery" jsonb DEFAULT '{}' NOT NULL, + "versioning" jsonb DEFAULT '{}' NOT NULL, + "config" jsonb DEFAULT '{}' NOT NULL, + "state" jsonb DEFAULT '{}' NOT NULL, + "siteId" uuid NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "storage_composite_idx" ON "storage" ("siteId","module");--> statement-breakpoint +ALTER TABLE "storage" ADD CONSTRAINT "storage_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id"); \ No newline at end of file diff --git a/backend/db/migrations/20260726155600_main/snapshot.json b/backend/db/migrations/20260726155600_main/snapshot.json new file mode 100644 index 000000000..e796754dd --- /dev/null +++ b/backend/db/migrations/20260726155600_main/snapshot.json @@ -0,0 +1,4117 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "4bd0ffe1-0347-46e1-98ac-712c21ca46eb", + "prevIds": [ + "2edb30b9-3a30-45f4-a267-0751f92e8505" + ], + "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": "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": "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": "pages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "settings", + "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": "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": "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": "pages" + }, + { + "type": "ltree", + "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": "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": "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": "ltree", + "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": "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": "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": "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": "gist", + "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": [ + "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": [ + "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": [ + "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": "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": [ + "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": "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": [ + "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": "pages_pkey", + "schema": "public", + "table": "pages", + "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": [ + "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/migrations/20260726164139_main/migration.sql b/backend/db/migrations/20260726164139_main/migration.sql new file mode 100644 index 000000000..262d613cb --- /dev/null +++ b/backend/db/migrations/20260726164139_main/migration.sql @@ -0,0 +1,25 @@ +CREATE TABLE "iconSets" ( + "prefix" varchar(64) PRIMARY KEY, + "name" varchar(255) NOT NULL, + "isEnabled" boolean DEFAULT true NOT NULL, + "info" jsonb DEFAULT '{}' NOT NULL, + "refreshedAt" timestamp, + "createdAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "icons" ( + "prefix" varchar(64), + "name" varchar(255), + "body" text NOT NULL, + "width" integer DEFAULT 16 NOT NULL, + "height" integer DEFAULT 16 NOT NULL, + "left" integer DEFAULT 0 NOT NULL, + "top" integer DEFAULT 0 NOT NULL, + "rotate" integer DEFAULT 0 NOT NULL, + "hFlip" boolean DEFAULT false NOT NULL, + "vFlip" boolean DEFAULT false NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "icons_pkey" PRIMARY KEY("prefix","name") +); +--> statement-breakpoint +ALTER TABLE "icons" ADD CONSTRAINT "icons_prefix_iconSets_prefix_fkey" FOREIGN KEY ("prefix") REFERENCES "iconSets"("prefix"); \ No newline at end of file diff --git a/backend/db/migrations/20260726164139_main/snapshot.json b/backend/db/migrations/20260726164139_main/snapshot.json new file mode 100644 index 000000000..a6c4b68d1 --- /dev/null +++ b/backend/db/migrations/20260726164139_main/snapshot.json @@ -0,0 +1,4388 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "8df793cb-b12b-48d1-9a51-6b07f814e6b4", + "prevIds": [ + "4bd0ffe1-0347-46e1-98ac-712c21ca46eb" + ], + "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": "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": "pages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "settings", + "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": "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": "pages" + }, + { + "type": "ltree", + "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": "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": "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": "ltree", + "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": "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": "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": "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": "gist", + "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": [ + "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": [ + "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": "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": [ + "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": "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": "pages_pkey", + "schema": "public", + "table": "pages", + "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": [ + "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 70714da23..75d8d3a45 100644 --- a/backend/db/schema.ts +++ b/backend/db/schema.ts @@ -145,6 +145,46 @@ export const hooks = pgTable('hooks', { updatedAt: timestamp().notNull().defaultNow() }) +// ICONS ------------------------------- +// -> An Iconify icon set the wiki draws icons from, e.g. `mdi`. Adding one makes its icons +// searchable; individual icons are only stored once something references them. +export const iconSets = pgTable('iconSets', { + // -> The Iconify prefix, which is what content references: `:` + prefix: varchar({ length: 64 }).primaryKey(), + name: varchar({ length: 255 }).notNull(), + isEnabled: boolean().notNull().default(true), + // -> Iconify collection metadata (author, license, total, palette, samples, ...) as published by + // the upstream API, refreshed on demand rather than being authored here + info: jsonb().notNull().default({}), + refreshedAt: timestamp(), + createdAt: timestamp().notNull().defaultNow() +}) + +// -> The permanent home of every icon the wiki has ever served. Fetched from the Iconify API on first +// use, then never fetched again: the disk cache is derived from these rows and may be empty. +export const icons = pgTable( + 'icons', + { + prefix: varchar({ length: 64 }) + .notNull() + .references(() => iconSets.prefix), + name: varchar({ length: 255 }).notNull(), + // -> The SVG markup inside the `` element, with `currentColor` left as-is + body: text().notNull(), + // -> Resolved Iconify icon properties: the viewBox is `left top width height`, and the transform + // flags apply on top of it. Aliases are resolved before storing, so a row is self-contained. + width: integer().notNull().default(16), + height: integer().notNull().default(16), + left: integer().notNull().default(0), + top: integer().notNull().default(0), + rotate: integer().notNull().default(0), + hFlip: boolean().notNull().default(false), + vFlip: boolean().notNull().default(false), + createdAt: timestamp().notNull().defaultNow() + }, + (table) => [primaryKey({ columns: [table.prefix, table.name] })] +) + // JOB HISTORY ------------------------- export const jobHistoryStateEnum = pgEnum('jobHistoryState', [ 'active', @@ -328,6 +368,33 @@ export const sites = pgTable('sites', { createdAt: timestamp().notNull().defaultNow() }) +// STORAGE ----------------------------- +export const storage = pgTable( + 'storage', + { + id: uuid().primaryKey().defaultRandom(), + // -> Directory name under `modules/storage`, one row per module per site + module: varchar({ length: 255 }).notNull(), + isEnabled: boolean().notNull().default(false), + // -> `{ activeTypes: string[], largeThreshold: string }` + contentTypes: jsonb().notNull().default({}), + // -> `{ streaming: boolean, directAccess: boolean }` + assetDelivery: jsonb().notNull().default({}), + // -> `{ enabled: boolean }` + versioning: jsonb().notNull().default({}), + // -> Values for the props the module declares in its `definition.yml` + config: jsonb().notNull().default({}), + // -> Where the module stands, as opposed to how it is configured: `{ setup: 'notconfigured' | + // 'pendinginstall' | 'configured' }` for a module that has a setup process to go through. + state: jsonb().notNull().default({}), + siteId: uuid() + .notNull() + .references(() => sites.id) + }, + // -> Covers lookups by site as well, being the leading column + (table) => [uniqueIndex('storage_composite_idx').on(table.siteId, table.module)] +) + // TAGS -------------------------------- export const tags = pgTable( 'tags', diff --git a/backend/index.ts b/backend/index.ts index 2b6a380d0..9f603cd40 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -61,11 +61,7 @@ const WIKI = { configSvc, sites: {}, sitesMappings: {}, - startedAt: Temporal.Now.instant(), - storage: { - defs: [], - modules: [] - } + startedAt: Temporal.Now.instant() } as unknown as WikiGlobal global.WIKI = WIKI @@ -146,11 +142,18 @@ async function postBoot() { await WIKI.models.blocks.refreshFromDisk() await WIKI.models.blocks.syncAllSites() + // -> Same: every site gets a row per installed storage module + await WIKI.models.storage.refreshFromDisk() + await WIKI.models.storage.syncAllSites() + // -> Optional third-party tooling: report what is available, since features silently degrade // without it await WIKI.models.extensions.refreshFromDisk() await WIKI.models.extensions.logState() + // -> The icon cache is derived from the db and starts empty on a fresh instance + await WIKI.models.icons.ensureCacheDir() + await WIKI.dbManager.subscribeToNotifications() await WIKI.scheduler.start() } @@ -546,6 +549,7 @@ async function initHTTPServer() { app.register(import('./api/index.ts'), { prefix: '/_api' }) app.register(import('./controllers/site.ts'), { prefix: '/_site' }) + app.register(import('./controllers/icons.ts'), { prefix: '/_icons' }) // ---------------------------------------- // Error handling diff --git a/backend/locales/en.json b/backend/locales/en.json index d846c3ac6..4d5b460e7 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -403,12 +403,48 @@ "admin.groups.users": "Users", "admin.groups.usersCount": "0 user | 1 user | {count} users", "admin.groups.usersNone": "This group doesn't have any user yet.", - "admin.icons.mandatory": "Used by the system and cannot be disabled.", + "admin.icons.addFailed": "Failed to add the icon set.", + "admin.icons.addSet": "Add Icon Set", + "admin.icons.addSetHint": "Pick an icon set to make its icons available. Only the set description is downloaded — individual icons are fetched and stored the first time they are used.", + "admin.icons.addSuccess": "The {set} icon set has been added.", + "admin.icons.added": "Added", + "admin.icons.deleteFailed": "Failed to delete the icon set.", + "admin.icons.deleteSet": "Delete Icon Set", + "admin.icons.deleteSetConfirm": "Delete the {set} icon set and the {count} icons stored for it? Content still referencing those icons will stop showing them. Disable the set instead to keep serving the icons already in use.", + "admin.icons.deleteSuccess": "The {set} icon set has been deleted.", + "admin.icons.disableSuccess": "The {set} icon set has been disabled.", + "admin.icons.diskCache": "Disk cache", + "admin.icons.diskCacheValue": "{count} icons ({size})", + "admin.icons.enableSuccess": "The {set} icon set has been enabled.", + "admin.icons.filterSets": "Filter icon sets...", + "admin.icons.howItWorks": "How icons are stored", + "admin.icons.howItWorksHint": "Content references an icon by name, e.g. mdi:account-edit. The first time an icon is used it is fetched from Iconify and saved to the database, which is its permanent home. Memory and disk are caches in front of it and can be discarded at any time.", + "admin.icons.isEnabled": "Enabled", + "admin.icons.loadFailed": "Failed to load the icon sets.", + "admin.icons.memoryCache": "Memory cache", + "admin.icons.memoryCacheValue": "{count} icons on this instance", + "admin.icons.noSets": "No icon set has been added yet. Add one to start using icons.", + "admin.icons.paletteWarn": "This set has fixed colors and cannot be recolored.", + "admin.icons.purgeCache": "Purge Cache", + "admin.icons.purgeCacheConfirm": "Empty the memory and disk caches of this instance? Nothing is lost — both are rebuilt from the database as icons are requested again.", + "admin.icons.purgeCacheFailed": "Failed to purge the icon cache.", + "admin.icons.purgeCacheHint": "Empties this instance’s caches. The stored icons are unaffected.", + "admin.icons.purgeCacheSuccess": "The icon cache has been purged.", "admin.icons.reference": "Reference", - "admin.icons.subtitle": "Configure the icon packs available for use", + "admin.icons.referenceHint": "View every icon in this set, with its name, on Iconify.", + "admin.icons.saveFailed": "Failed to save the icon set.", + "admin.icons.setIconCount": "{count} icons stored", + "admin.icons.setTotal": "{total} available", + "admin.icons.sets": "Icon Sets", + "admin.icons.setsHint": "Icons from an enabled set can be searched and used across the wiki.", + "admin.icons.storage": "Storage", + "admin.icons.storageHint": "What this wiki holds, and what this instance has cached.", + "admin.icons.storedIcons": "Stored icons", + "admin.icons.storedIconsValue": "{count} icons in the database", + "admin.icons.subtitle": "Choose which icon sets can be used across the wiki", "admin.icons.title": "Icons", - "admin.icons.warnHint": "Only activate the icon packs you actually use.", - "admin.icons.warnLabel": "Enabling additional icon packs can significantly increase page load times!", + "admin.icons.upstream": "Icon source", + "admin.icons.upstreamHint": "Icons come from the Iconify API. Once stored, they are served by this wiki and never fetched again — readers never talk to Iconify.", "admin.instances.activeConnections": "Active Connections", "admin.instances.activeListeners": "Active Listeners", "admin.instances.firstSeen": "First Seen", @@ -736,7 +772,9 @@ "admin.ssl.title": "SSL", "admin.ssl.writableConfigFileWarning": "Note that your config file must be writable in order to persist ports configuration.", "admin.stats.title": "Statistics", + "admin.storage.actionFailed": "Failed to run {action}.", "admin.storage.actionRun": "Run", + "admin.storage.actionSuccess": "{action} completed successfully.", "admin.storage.actions": "Actions", "admin.storage.actionsInactiveWarn": "You must enable this storage target and apply changes before you can run actions.", "admin.storage.addTarget": "Add Storage Target", @@ -806,6 +844,7 @@ "admin.storage.inactiveTarget": "Inactive", "admin.storage.lastSync": "Last synchronization {time}", "admin.storage.lastSyncAttempt": "Last attempt was {time}", + "admin.storage.loadFailed": "Failed to load storage configuration.", "admin.storage.missingOrigin": "Missing Origin", "admin.storage.noActions": "This storage target has no actions that you can execute.", "admin.storage.noConfigOption": "This storage target has no configuration options you can modify.", @@ -1818,6 +1857,19 @@ "history.restore.confirmText": "Are you sure you want to restore this page content as it was on {date}? This version will be copied on top of the current history. As such, newer versions will still be preserved.", "history.restore.confirmTitle": "Restore page version?", "history.restore.success": "Page version restored succesfully!", + "iconPicker.allSets": "All sets", + "iconPicker.custom": "Custom", + "iconPicker.customHint": "Any other icon reference, such as a webfont name (las la-home) or an image URL (img:/path/to/icon.svg).", + "iconPicker.icons": "Icons", + "iconPicker.materializeFailed": "Could not store {icon} for offline use.", + "iconPicker.noResults": "No icon matches your search.", + "iconPicker.reference": "Icon reference", + "iconPicker.search": "Search icons...", + "iconPicker.searchFailed": "Icon search failed.", + "iconPicker.searchHint": "Type at least 2 characters to search icons.", + "iconPicker.selection": "Selected icon", + "iconPicker.set": "Set", + "iconPicker.setsFailed": "Failed to load the icon sets.", "navEdit.clearItems": "Clear All Items", "navEdit.editMenuItems": "Edit Menu Items", "navEdit.emptyMenuText": "Click the Add button to add your first menu item.", diff --git a/backend/models/icons.ts b/backend/models/icons.ts new file mode 100644 index 000000000..c3cf43e7e --- /dev/null +++ b/backend/models/icons.ts @@ -0,0 +1,815 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { and, count, eq, inArray } from 'drizzle-orm' +import { getIconData, iconToHTML, iconToSVG } from '@iconify/utils' +import { icons as iconsTable, iconSets as iconSetsTable } from '../db/schema.ts' +import type { IconifyIcon, IconifyInfo, IconifyJSON } from '@iconify/types' + +/** An icon set as stored, plus how many of its icons the wiki holds. */ +export interface IconSet { + prefix: string + name: string + isEnabled: boolean + info: IconifyInfo | Record + refreshedAt: Date | null + createdAt: Date + /** Icons of this set stored in the database, i.e. the ones this wiki can serve on its own. */ + iconCount: number +} + +/** An icon set offered by the upstream API but not added here yet. */ +export interface AvailableIconSet { + prefix: string + name: string + total: number + author: string + license: string + category: string + /** Whether the set's icons carry their own colors, i.e. cannot be recolored with `currentColor`. */ + palette: boolean + samples: string[] + isAdded: boolean +} + +/** The result of a resolve, in the shape the Iconify API protocol expects. */ +export interface ResolvedIcons { + icons: Record + notFound: string[] +} + +/** + * Icon sets seeded on a fresh instance, so that the picker is usable before an administrator has + * added anything. The names are the upstream ones and get overwritten by the first metadata refresh. + */ +const DEFAULT_SETS: { prefix: string; name: string }[] = [ + { prefix: 'mdi', name: 'Material Design Icons' }, + { prefix: 'la', name: 'Line Awesome' } +] + +/** Iconify prefixes and icon names are lowercase, dash-separated words. */ +const PREFIX_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ +const NAME_PATTERN = /^[a-z0-9]+(?:[-.][a-z0-9]+)*$/ + +/** How many resolved icons to hold per instance. An icon body is ~1 kB, so this is a few MB. */ +const MEMORY_CACHE_MAX = 2000 + +/** How long the upstream collection list and per-set icon lists stay memoized. */ +const CATALOG_TTL_MS = 60 * 60 * 1000 + +/** + * Ceiling on upstream requests per minute, across every caller. + * + * The public icon route fills the cache on a miss, and it is reachable by anyone who can read a page. + * Without a ceiling, a stream of requests for icons that do not exist would be amplified into a + * stream of requests to the Iconify API. Icons already stored are unaffected — they never go upstream. + */ +const UPSTREAM_BUDGET_PER_MINUTE = 60 + +/** How long a name that upstream does not know stays remembered as missing. */ +const NOT_FOUND_TTL_MS = 60 * 60 * 1000 + +/** + * Reject anything that could execute when an icon is opened directly rather than drawn into a page. + * + * Icon bodies come from a third-party API, and while Iconify publishes shape markup, a compromised or + * misconfigured upstream is exactly the case worth being defensive about. Nothing legitimate in an + * icon body needs a script, an event handler or an external reference. + */ +function isSafeIconBody(body: string): boolean { + return !/]+href\s*=\s*["']?https?:|\son\w+\s*=|javascript:/i.test( + body + ) +} + +/** + * Icons model + * + * Icons are addressed the way Iconify addresses them — `:`, e.g. `mdi:account-edit` — + * and that reference is all content ever stores. Resolving one to markup goes through four tiers: + * + * 1. **memory**, per instance, for the icons a page is actually made of + * 2. **disk**, under `/cache/icons`, one small JSON file per icon + * 3. **the database**, the permanent record: every icon the wiki has ever served lives here, so a new + * instance with an empty disk (or an instance with no outbound network at all) serves everything + * that content references + * 4. **the Iconify API**, consulted only for an icon nobody has used yet, and then persisted + * + * Rendering a page never resolves an icon: the page carries names, the browser asks for the icons it + * needs in one batch, and those answers are cached hard by the browser. Serving them touches the + * database only for an icon that is neither in memory nor on disk — so on a warm instance, never. + */ +class Icons { + /** Resolved icon data, keyed `prefix:name`. Insertion-ordered, so the oldest entry is evictable. */ + memoryCache = new Map() + + /** Names upstream has no icon for, keyed `prefix:name` with the time they were last looked up. */ + notFoundCache = new Map() + + /** Upstream catalog responses, memoized to keep the admin area and the picker snappy. */ + catalogCache = new Map() + + /** Rolling count of upstream requests, for `UPSTREAM_BUDGET_PER_MINUTE`. */ + upstreamBudget = { windowStartedAt: 0, used: 0 } + + get cachePath(): string { + return path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, 'cache/icons') + } + + get apiUrl(): string { + return WIKI.config.icons?.apiUrl || 'https://api.iconify.design' + } + + /** + * Split a `prefix:name` reference, or null when it is not one + */ + parseRef(ref: string): { prefix: string; name: string } | null { + const [prefix, name, ...rest] = `${ref}`.toLowerCase().split(':') + if (!prefix || !name || rest.length > 0) { + return null + } + return this.isValidRef(prefix, name) ? { prefix, name } : null + } + + /** + * Whether a prefix and name are shaped like Iconify identifiers. + * + * Both end up in a file path, so this is what keeps `../` and friends out of the disk cache. + */ + isValidRef(prefix: string, name: string): boolean { + return PREFIX_PATTERN.test(prefix) && NAME_PATTERN.test(name) + } + + // == SETS =========================== + + /** + * Every added icon set, alphabetically, with the number of icons stored for each + */ + async getSets(): Promise { + const sets = await WIKI.db.select().from(iconSetsTable).orderBy(iconSetsTable.name) + const counts = await WIKI.db + .select({ prefix: iconsTable.prefix, total: count() }) + .from(iconsTable) + .groupBy(iconsTable.prefix) + return sets.map((set) => ({ + ...set, + info: (set.info ?? {}) as IconifyInfo, + iconCount: counts.find((c) => c.prefix === set.prefix)?.total ?? 0 + })) as IconSet[] + } + + /** + * A single set, or null when it has not been added + */ + async getSet(prefix: string): Promise { + return (await this.getSets()).find((set) => set.prefix === prefix) ?? null + } + + /** + * The prefixes of the sets icons may currently be drawn from + */ + async getEnabledPrefixes(): Promise { + const sets = await WIKI.db + .select({ prefix: iconSetsTable.prefix }) + .from(iconSetsTable) + .where(eq(iconSetsTable.isEnabled, true)) + return sets.map((s) => s.prefix) + } + + /** + * Add an icon set, taking its name and metadata from upstream. + * + * @returns The set as added + * @throws When the prefix is malformed, already added, or unknown upstream + */ + async addSet(prefix: string): Promise { + if (!PREFIX_PATTERN.test(prefix)) { + return Promise.reject(new Error(`"${prefix}" is not a valid icon set prefix.`)) + } + if (await this.getSet(prefix)) { + return Promise.reject(new Error(`The ${prefix} icon set has already been added.`)) + } + const collections = await this.getCollections() + const info = collections[prefix] + if (!info) { + return Promise.reject(new Error(`There is no "${prefix}" icon set available upstream.`)) + } + + await WIKI.db.insert(iconSetsTable).values({ + prefix, + name: info.name ?? prefix, + isEnabled: true, + info, + refreshedAt: new Date() + }) + WIKI.logger.info(`Added icon set ${prefix} [ OK ]`) + return (await this.getSet(prefix))! + } + + /** + * Enable or disable an icon set. + * + * A disabled set stops being searchable and stops being filled from upstream, but the icons already + * stored for it keep being served: content referencing them is already published, and answering + * those requests with nothing would silently break pages. + * + * @returns Whether the set was updated + */ + async setSetState(prefix: string, isEnabled: boolean): Promise { + const result = await WIKI.db + .update(iconSetsTable) + .set({ isEnabled }) + .where(eq(iconSetsTable.prefix, prefix)) + return (result.rowCount ?? 0) > 0 + } + + /** + * Delete an icon set along with every icon stored for it, and drop its disk cache. + * + * Content referencing those icons will stop rendering them, which is why the admin area asks first. + * + * @returns How many stored icons went with it + */ + async deleteSet(prefix: string): Promise { + const deletedIcons = await WIKI.db.delete(iconsTable).where(eq(iconsTable.prefix, prefix)) + await WIKI.db.delete(iconSetsTable).where(eq(iconSetsTable.prefix, prefix)) + + for (const key of this.memoryCache.keys()) { + if (key.startsWith(`${prefix}:`)) { + this.memoryCache.delete(key) + } + } + await fs.rm(path.join(this.cachePath, prefix), { recursive: true, force: true }) + + WIKI.logger.info(`Deleted icon set ${prefix} [ OK ]`) + return deletedIcons.rowCount ?? 0 + } + + /** + * Re-read the metadata of every added set from upstream. + * + * Only the description of a set changes here — its icons are untouched. + * + * @returns How many sets were refreshed + */ + async refreshSets(): Promise { + const collections = await this.getCollections() + const sets = await WIKI.db.select({ prefix: iconSetsTable.prefix }).from(iconSetsTable) + let refreshed = 0 + for (const set of sets) { + const info = collections[set.prefix] + if (!info) { + // -> A set can be renamed or withdrawn upstream. Keeping the row is the right call: its icons + // are stored here and content still references them. + WIKI.logger.warn(`Icon set ${set.prefix} is no longer offered upstream [ SKIPPED ]`) + continue + } + await WIKI.db + .update(iconSetsTable) + .set({ name: info.name ?? set.prefix, info, refreshedAt: new Date() }) + .where(eq(iconSetsTable.prefix, set.prefix)) + refreshed++ + } + return refreshed + } + + // == UPSTREAM CATALOG =============== + + /** + * Every icon set the upstream API offers, keyed by prefix + */ + async getCollections(): Promise> { + return this.fetchCatalog('collections', '/collections') + } + + /** + * The upstream catalog as the admin area lists it, marking the sets already added + */ + async getAvailableSets(): Promise { + const [collections, added] = await Promise.all([ + this.getCollections(), + WIKI.db.select({ prefix: iconSetsTable.prefix }).from(iconSetsTable) + ]) + const addedPrefixes = added.map((s) => s.prefix) + return Object.entries(collections) + .map(([prefix, info]) => ({ + prefix, + name: info.name ?? prefix, + total: info.total ?? 0, + author: info.author?.name ?? '', + license: info.license?.title ?? '', + category: info.category ?? '', + palette: info.palette === true, + samples: info.samples ?? [], + isAdded: addedPrefixes.includes(prefix) + })) + .sort((a, b) => a.name.localeCompare(b.name)) + } + + /** + * The names of every icon in a set, for browsing it without a search term. + * + * @throws When the set has not been added or is disabled + */ + async listSetIcons(prefix: string): Promise { + const set = await this.getSet(prefix) + if (!set?.isEnabled) { + return Promise.reject(new Error(`The ${prefix} icon set is not available.`)) + } + const collection = await this.fetchCatalog( + `collection:${prefix}`, + `/collection?prefix=${encodeURIComponent(prefix)}` + ) + // -> Icons come either grouped in categories or as a flat `uncategorized` list, and a set can use + // both. Hidden icons are deprecated ones kept for compatibility, so they are left out. + const categorized = Object.values( + (collection.categories ?? {}) as Record + ).flat() + const names = [...categorized, ...((collection.uncategorized ?? []) as string[])] + return [...new Set(names)].sort() + } + + /** + * Search icons upstream, within the sets that are enabled here. + * + * @returns References shaped `prefix:name` + */ + async searchIcons({ + query, + prefixes, + limit = 96 + }: { + query: string + prefixes?: string[] + limit?: number + }): Promise { + const enabled = await this.getEnabledPrefixes() + // -> Searching a disabled set would offer icons that cannot then be stored + const searchIn = prefixes?.length ? prefixes.filter((p) => enabled.includes(p)) : enabled + if (searchIn.length < 1) { + return [] + } + const params = new URLSearchParams({ + query, + limit: `${Math.min(Math.max(limit, 32), 999)}`, + prefixes: searchIn.join(',') + }) + const result = await this.apiFetch(`/search?${params}`) + return (result.icons ?? []) as string[] + } + + /** + * Fetch an upstream catalog response, memoized for `CATALOG_TTL_MS`. + * + * These are large and change rarely, whereas the admin area and the picker ask for them often. + */ + async fetchCatalog(key: string, pathname: string): Promise { + const cached = this.catalogCache.get(key) + if (cached && Date.now() - cached.fetchedAt < CATALOG_TTL_MS) { + return cached.data + } + const data = await this.apiFetch(pathname) + this.catalogCache.set(key, { fetchedAt: Date.now(), data }) + return data + } + + // == RESOLVING ====================== + + /** + * Resolve icons of one set, filling the cache from upstream for any the wiki does not hold yet. + * + * @param allowUpstream Whether a miss may be fetched upstream. False for callers that must not + * cause outbound traffic, e.g. a bulk render. + */ + async resolveIcons( + prefix: string, + names: string[], + { allowUpstream = true }: { allowUpstream?: boolean } = {} + ): Promise { + const wanted = [...new Set(names)].filter((name) => this.isValidRef(prefix, name)) + const icons: Record = {} + const missing: string[] = [] + + // -> Memory first: the icons a page is made of are asked for again and again + for (const name of wanted) { + const cached = this.memoryCache.get(`${prefix}:${name}`) + if (cached) { + icons[name] = cached + } else { + missing.push(name) + } + } + if (missing.length < 1) { + return { icons, notFound: [] } + } + + // -> Then disk, which survives a restart and is what keeps page views off the database + const stillMissingAfterDisk: string[] = [] + for (const name of missing) { + const cached = await this.readDiskCache(prefix, name) + if (cached) { + this.remember(prefix, name, cached) + icons[name] = cached + } else { + stillMissingAfterDisk.push(name) + } + } + if (stillMissingAfterDisk.length < 1) { + return { icons, notFound: [] } + } + + // -> Then the permanent record, in one query for everything still missing + const rows = await WIKI.db + .select() + .from(iconsTable) + .where(and(eq(iconsTable.prefix, prefix), inArray(iconsTable.name, stillMissingAfterDisk))) + for (const row of rows) { + const icon = this.rowToIcon(row) + this.remember(prefix, row.name, icon) + await this.writeDiskCache(prefix, row.name, icon) + icons[row.name] = icon + } + + const stillMissing = stillMissingAfterDisk.filter((name) => !(name in icons)) + if (stillMissing.length < 1) { + return { icons, notFound: [] } + } + if (!allowUpstream) { + return { icons, notFound: stillMissing } + } + + const fetched = await this.fetchIconsUpstream(prefix, stillMissing) + return { + icons: { ...icons, ...fetched.icons }, + notFound: fetched.notFound + } + } + + /** + * Fetch icons from upstream and store them permanently. + * + * Refuses for a set that is not enabled, so that a disabled set cannot grow, and holds to the + * upstream budget so that requests for icons that do not exist cannot be amplified. + */ + async fetchIconsUpstream(prefix: string, names: string[]): Promise { + const set = await this.getSet(prefix) + if (!set?.isEnabled) { + return { icons: {}, notFound: names } + } + + // -> A name upstream has already denied is not worth asking about again + const asking = names.filter((name) => !this.isKnownMissing(prefix, name)) + if (asking.length < 1) { + return { icons: {}, notFound: names } + } + if (!this.claimUpstreamBudget()) { + WIKI.logger.warn( + `Upstream icon request budget exhausted, not fetching ${prefix}:${asking.join(',')} [ SKIPPED ]` + ) + return { icons: {}, notFound: names } + } + + let iconSet: IconifyJSON + try { + iconSet = (await this.apiFetch( + `/${prefix}.json?icons=${asking.map(encodeURIComponent).join(',')}` + )) as IconifyJSON + } catch (err: any) { + WIKI.logger.warn(`Could not fetch icons from ${this.apiUrl} [ FAILED ]`) + WIKI.logger.warn(err.message) + return { icons: {}, notFound: names } + } + + const icons: Record = {} + const notFound: string[] = [] + for (const name of asking) { + // -> Resolves aliases, character references and set-level defaults into one self-contained icon + const data = getIconData(iconSet, name) + if (!data?.body) { + notFound.push(name) + this.notFoundCache.set(`${prefix}:${name}`, Date.now()) + continue + } + if (!isSafeIconBody(data.body)) { + notFound.push(name) + WIKI.logger.warn(`Refused unsafe icon body for ${prefix}:${name} [ FAILED ]`) + continue + } + icons[name] = data + await this.storeIcon(prefix, name, data) + await this.writeDiskCache(prefix, name, data) + this.remember(prefix, name, data) + } + + if (Object.keys(icons).length > 0) { + WIKI.logger.debug(`Stored ${Object.keys(icons).length} new icons for set ${prefix} [ OK ]`) + } + return { icons, notFound: [...notFound, ...names.filter((n) => !asking.includes(n))] } + } + + /** + * Write an icon to the permanent record + */ + async storeIcon(prefix: string, name: string, icon: IconifyIcon): Promise { + const values = { + prefix, + name, + body: icon.body, + width: icon.width ?? 16, + height: icon.height ?? 16, + left: icon.left ?? 0, + top: icon.top ?? 0, + rotate: icon.rotate ?? 0, + hFlip: icon.hFlip ?? false, + vFlip: icon.vFlip ?? false + } + await WIKI.db + .insert(iconsTable) + .values(values) + .onConflictDoUpdate({ + target: [iconsTable.prefix, iconsTable.name], + set: values + }) + } + + /** + * Materialize icons so the wiki can serve them without the upstream API. + * + * Called when an icon is picked, i.e. while the author is online and before anyone else needs it. + * + * @param refs References shaped `prefix:name` + * @returns The references that could not be stored + */ + async materializeIcons(refs: string[]): Promise { + const byPrefix = new Map() + const invalid: string[] = [] + for (const ref of refs) { + const parsed = this.parseRef(ref) + if (!parsed) { + invalid.push(ref) + continue + } + byPrefix.set(parsed.prefix, [...(byPrefix.get(parsed.prefix) ?? []), parsed.name]) + } + + const failed = [...invalid] + for (const [prefix, names] of byPrefix) { + const result = await this.resolveIcons(prefix, names) + failed.push(...result.notFound.map((name) => `${prefix}:${name}`)) + } + return failed + } + + /** + * The SVG for one icon, for the callers that can only carry a URL — an ``, a CSS background. + * + * @returns The SVG markup, or null when there is no such icon + */ + async getIconSvg( + prefix: string, + name: string, + { allowUpstream = true }: { allowUpstream?: boolean } = {} + ): Promise { + const resolved = await this.resolveIcons(prefix, [name], { allowUpstream }) + const icon = resolved.icons[name] + return icon ? this.renderSvg(icon) : null + } + + /** + * Turn resolved icon data into standalone SVG markup. + * + * Sized in pixels rather than the `1em` Iconify defaults to, since this file is also used as a plain + * image — an `` has no font size to scale against. + */ + renderSvg(icon: IconifyIcon): string { + const rendered = iconToSVG(icon, { + width: `${icon.width ?? 16}`, + height: `${icon.height ?? 16}` + }) + return iconToHTML(rendered.body, rendered.attributes) + } + + // == CACHE ========================== + + /** + * Hold an icon in memory, evicting the least recently stored one when full + */ + remember(prefix: string, name: string, icon: IconifyIcon): void { + if (this.memoryCache.size >= MEMORY_CACHE_MAX) { + const oldest = this.memoryCache.keys().next().value + if (oldest) { + this.memoryCache.delete(oldest) + } + } + this.memoryCache.set(`${prefix}:${name}`, icon) + } + + /** + * Whether upstream said recently that it has no such icon + */ + isKnownMissing(prefix: string, name: string): boolean { + const at = this.notFoundCache.get(`${prefix}:${name}`) + if (at === undefined) { + return false + } + if (Date.now() - at > NOT_FOUND_TTL_MS) { + this.notFoundCache.delete(`${prefix}:${name}`) + return false + } + return true + } + + /** + * Take one slot from the per-minute upstream allowance + * + * @returns Whether the request may go ahead + */ + claimUpstreamBudget(): boolean { + const now = Date.now() + if (now - this.upstreamBudget.windowStartedAt > 60_000) { + this.upstreamBudget = { windowStartedAt: now, used: 0 } + } + if (this.upstreamBudget.used >= UPSTREAM_BUDGET_PER_MINUTE) { + return false + } + this.upstreamBudget.used++ + return true + } + + /** + * Where an icon sits in the disk cache. + * + * Icon data rather than rendered SVG, so that one cached file answers both the batch data requests + * the frontend makes and the SVG requests an `` makes — rendering from data is string building. + */ + diskCachePath(prefix: string, name: string): string { + return path.join(this.cachePath, prefix, `${name}.json`) + } + + /** + * Read an icon from the disk cache + * + * @returns The icon, or null when it is not cached or the file is unusable + */ + async readDiskCache(prefix: string, name: string): Promise { + try { + const icon = JSON.parse(await fs.readFile(this.diskCachePath(prefix, name), 'utf8')) + return typeof icon?.body === 'string' ? icon : null + } catch { + // -> Not cached on this instance yet, which is the normal state of a fresh container. A corrupt + // file lands here too and is treated the same way: refill it from the database. + return null + } + } + + /** + * Write an icon to the disk cache, best effort. + * + * A full or read-only disk must not stop an icon from being served, hence the swallowed error: the + * cache is derived data and every request can be answered without it. + * + * The file is written under a temporary name and renamed, so that a concurrent reader either sees + * the previous file or the complete new one, never a half-written one. + */ + async writeDiskCache(prefix: string, name: string, icon: IconifyIcon): Promise { + const filePath = this.diskCachePath(prefix, name) + const tempPath = `${filePath}.${process.pid}.tmp` + try { + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(tempPath, JSON.stringify(icon), 'utf8') + await fs.rename(tempPath, filePath) + } catch (err: any) { + WIKI.logger.warn(`Could not write ${filePath} to the icon cache [ SKIPPED ]`) + WIKI.logger.warn(err.message) + await fs.rm(tempPath, { force: true }).catch(() => {}) + } + } + + /** + * Drop the disk and memory caches. Nothing is lost: both are rebuilt from the database on demand. + */ + async purgeCache(): Promise { + this.memoryCache.clear() + this.notFoundCache.clear() + this.catalogCache.clear() + await fs.rm(this.cachePath, { recursive: true, force: true }) + await fs.mkdir(this.cachePath, { recursive: true }) + WIKI.logger.info('Purged the icon cache [ OK ]') + } + + /** + * What the wiki holds and what it has cached, for the admin area + */ + async getStats(): Promise<{ + setCount: number + enabledSetCount: number + iconCount: number + memoryCount: number + diskCount: number + diskSize: number + }> { + const [sets, iconCount] = await Promise.all([ + WIKI.db.select({ isEnabled: iconSetsTable.isEnabled }).from(iconSetsTable), + WIKI.db.$count(iconsTable) + ]) + const disk = await this.measureDiskCache() + return { + setCount: sets.length, + enabledSetCount: sets.filter((s) => s.isEnabled).length, + iconCount, + memoryCount: this.memoryCache.size, + diskCount: disk.files, + diskSize: disk.bytes + } + } + + /** + * Walk the disk cache. Cheap enough to do on demand: it holds one small file per icon in use. + */ + async measureDiskCache(): Promise<{ files: number; bytes: number }> { + let files = 0 + let bytes = 0 + try { + const entries = await fs.readdir(this.cachePath, { recursive: true, withFileTypes: true }) + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.json')) { + continue + } + files++ + bytes += (await fs.stat(path.join(entry.parentPath, entry.name))).size + } + } catch { + // -> No cache directory yet, which is simply an empty cache + } + return { files, bytes } + } + + // == PLUMBING ======================= + + /** + * Call the upstream Iconify API + * + * @throws When offline mode is on, the request fails, or the response is not JSON + */ + async apiFetch(pathname: string): Promise { + if (WIKI.config.offline) { + return Promise.reject( + new Error('Wiki.js is in offline mode and cannot reach the Iconify API.') + ) + } + const url = `${this.apiUrl}${pathname}` + WIKI.logger.debug(`Fetching ${url}`) + const resp = await fetch(url, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(15_000) + }) + if (!resp.ok) { + return Promise.reject(new Error(`${this.apiUrl} answered ${resp.status} for ${pathname}`)) + } + const data = await resp.json() + // -> The API answers an unknown prefix with the string `404` and a 200 status + if (typeof data !== 'object' || data === null) { + return Promise.reject(new Error(`${this.apiUrl} has nothing for ${pathname}`)) + } + return data + } + + rowToIcon(row: typeof iconsTable.$inferSelect): IconifyIcon { + return { + body: row.body, + width: row.width, + height: row.height, + left: row.left, + top: row.top, + rotate: row.rotate, + hFlip: row.hFlip, + vFlip: row.vFlip + } + } + + /** + * Make sure the cache directory exists, so that the first icon request is not the one to find out + */ + async ensureCacheDir(): Promise { + try { + await fs.mkdir(this.cachePath, { recursive: true }) + } catch (err: any) { + WIKI.logger.warn(`Could not create the icon cache directory ${this.cachePath} [ SKIPPED ]`) + WIKI.logger.warn(err.message) + } + } + + /** + * Seed the icon sets a fresh instance starts with. + * + * Deliberately network-free: the wiki has to install without outbound access, so only the prefix and + * a name go in, and the metadata is filled in by the first refresh. + */ + async init(): Promise { + WIKI.logger.info('Inserting default icon sets...') + await WIKI.db + .insert(iconSetsTable) + .values(DEFAULT_SETS.map((set) => ({ ...set, isEnabled: true }))) + } +} + +export const icons = new Icons() diff --git a/backend/models/index.ts b/backend/models/index.ts index cda3ab1f4..547c71753 100644 --- a/backend/models/index.ts +++ b/backend/models/index.ts @@ -5,6 +5,7 @@ import { extensions } from './extensions.ts' import { flags } from './flags.ts' import { groups } from './groups.ts' import { hooks } from './hooks.ts' +import { icons } from './icons.ts' import { jobs } from './jobs.ts' import { locales } from './locales.ts' import { search } from './search.ts' @@ -12,6 +13,7 @@ import { security } from './security.ts' import { sessions } from './sessions.ts' import { settings } from './settings.ts' import { sites } from './sites.ts' +import { storage } from './storage.ts' import { users } from './users.ts' export default { @@ -22,6 +24,7 @@ export default { flags, groups, hooks, + icons, jobs, locales, search, @@ -29,5 +32,6 @@ export default { sessions, settings, sites, + storage, users } diff --git a/backend/models/sites.ts b/backend/models/sites.ts index 6077df113..3ed0c475b 100644 --- a/backend/models/sites.ts +++ b/backend/models/sites.ts @@ -1,6 +1,10 @@ import { mergeWith, toMerged } from 'es-toolkit/object' import { keyBy } from 'es-toolkit/array' -import { blocks as blocksTable, sites as sitesTable } from '../db/schema.ts' +import { + blocks as blocksTable, + sites as sitesTable, + storage as storageTable +} from '../db/schema.ts' import { eq } from 'drizzle-orm' import type { SystemIds } from './types.ts' @@ -183,31 +187,15 @@ class Sites { // items: [] // }) - // WIKI.logger.debug(`Creating new DB storage for site ${newSite.id}`) - - // await WIKI.db.storage.query().insert({ - // module: 'db', - // siteId: newSite.id, - // isEnabled: true, - // contentTypes: { - // activeTypes: ['pages', 'images', 'documents', 'others', 'large'], - // largeThreshold: '5MB' - // }, - // assetDelivery: { - // streaming: true, - // directAccess: false - // }, - // state: { - // current: 'ok' - // } - // }) - // -> Site lookups by id / hostname are served from cache, which must know about the new site await WIKI.models.sites.reloadCache() // -> Otherwise the new site would have no blocks until the next restart await WIKI.models.blocks.syncSite(newSite.id) + // -> Same for storage: the site needs its database target from the moment it can hold content + await WIKI.models.storage.syncSite(newSite.id) + return newSite } @@ -253,12 +241,11 @@ class Sites { } async deleteSite(id: string): Promise { - // await WIKI.db.storage.query().delete().where('siteId', id) - - // -> Block 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 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. await WIKI.db.delete(blocksTable).where(eq(blocksTable.siteId, id)) + await WIKI.db.delete(storageTable).where(eq(storageTable.siteId, id)) const deletedResult = await WIKI.db.delete(sitesTable).where(eq(sitesTable.id, id)) if ((deletedResult.rowCount ?? 0) < 1) { diff --git a/backend/models/storage.ts b/backend/models/storage.ts new file mode 100644 index 000000000..181999ccd --- /dev/null +++ b/backend/models/storage.ts @@ -0,0 +1,605 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import yaml from 'js-yaml' +import { and, eq, inArray } from 'drizzle-orm' +import { parseModuleProps } from '../helpers/common.ts' +import { sites as sitesTable, storage as storageTable } from '../db/schema.ts' +import type { ModuleProp } from '../helpers/common.ts' + +/** The kinds of content a target can be asked to hold. */ +export const CONTENT_TYPES = ['pages', 'images', 'documents', 'others', 'large'] as const + +/** + * The module every site stores its content in, and the only one that is guaranteed to work: assets + * and pages live in the wiki database. It cannot be disabled, as that would leave content nowhere. + */ +const DB_MODULE = 'db' + +/** An action a module knows how to run on demand, as declared by its `definition.yml`. */ +export interface StorageAction { + /** Key of the handler on the module implementation, i.e. what gets called. */ + handler: string + label: string + hint: string + /** Shown in red, and turned into a confirmation prompt by the admin area. */ + warn?: string + icon: string +} + +/** A storage module, as declared by its `definition.yml`. */ +export interface StorageDefinition { + key: string + title: string + description: string + icon: string + banner: string + vendor: string + website: string + contentTypes: { + defaultTypesEnabled: string[] + defaultLargeThreshold: string + } + assetDelivery: { + isStreamingSupported: boolean + isDirectAccessSupported: boolean + defaultStreamingEnabled: boolean + defaultDirectAccessEnabled: boolean + } + versioning: { + isSupported: boolean + /** Versioning is inherent to the module and cannot be turned off, as in a git history. */ + isForceEnabled: boolean + defaultEnabled: boolean + } + /** Declared by modules that cannot be configured by hand, e.g. an app installed on a provider. */ + setup?: { + handler: string + defaultValues: Record + } + props: Record + actions: StorageAction[] + /** + * Whether a `storage.ts` sits next to the definition. + * + * No module ships one yet, so every target is configuration-only for now: nothing reads or writes + * content through a module. Actions and setup are gated on this, so that the admin area never + * offers to run something that has no implementation behind it. + */ + hasImplementation: boolean +} + +/** A configured target: the module definition, plus how this site has it set up. */ +export interface StorageTarget { + id: string + module: string + isEnabled: boolean + title: string + description: string + icon: string + banner: string + vendor: string + website: string + contentTypes: { + activeTypes: string[] + largeThreshold: string + } + assetDelivery: { + isStreamingSupported: boolean + isDirectAccessSupported: boolean + streaming: boolean + directAccess: boolean + } + versioning: { + isSupported: boolean + isForceEnabled: boolean + enabled: boolean + } + setup?: { + handler: string + state: string + values: Record + } + props: Record + config: Record + actions: StorageAction[] +} + +/** The shape a target is written with. Every field is optional, i.e. it doubles as a patch. */ +export interface StorageTargetInput { + id: string + isEnabled?: boolean + contentTypes?: { + activeTypes?: string[] + largeThreshold?: string + } + assetDelivery?: { + streaming?: boolean + directAccess?: boolean + } + versioning?: { + enabled?: boolean + } + config?: Record +} + +/** What a module implementation is expected to export, once any of them do. */ +export interface StorageModule { + /** Advance a multi-step setup process, returning what the admin area should do next. */ + setup?: (targetId: string, state: Record) => Promise> + /** Undo whatever `setup` configured, so that it can be started over. */ + setupDestroy?: (targetId: string) => Promise + /** Handlers named by the definition's actions. */ + [handler: string]: any +} + +/** + * Storage model + * + * A storage target is one module configured for one site — S3 for assets, git for pages, and so on. + * Each module lives in `modules/storage//definition.yml`, which declares what it supports and + * what it needs configured. Every site gets a row per module (see `syncSite`), so a target always + * has a stable ID whether or not it has ever been enabled. + * + * Nothing dispatches content to targets yet: pages and assets are read and written straight from the + * database, and no module ships an implementation. What this model handles is the configuration those + * modules will read once they exist. + */ +class Storage { + /** Definitions read from disk, refreshed by `refreshFromDisk()`. */ + definitions: StorageDefinition[] = [] + + /** Implementations loaded by `ensureModule()`, keyed by module. */ + modules: Record = {} + + /** + * Load the storage module definitions from disk. + */ + async refreshFromDisk(): Promise { + const storagePath = path.join(WIKI.SERVERPATH, 'modules/storage') + const definitions: StorageDefinition[] = [] + try { + for (const dir of await fs.readdir(storagePath)) { + const raw = await fs.readFile(path.join(storagePath, dir, 'definition.yml'), 'utf8') + const parsed = yaml.load(raw) as Record + // -> The directory name is the key, as it is for every other module type + parsed.key = dir + // -> Props carry a display `order`, applied once here so that every consumer — the admin + // area included — reads them in the order the module meant them to be shown in + parsed.props = Object.fromEntries( + Object.entries(parseModuleProps(parsed.props ?? {})).sort( + ([, a], [, b]) => a.order - b.order + ) + ) + // -> Declared as a map keyed by handler, which is far more readable in YAML than a list of + // objects, but the handler has to travel with the action for it to be callable + parsed.actions = Object.entries(parsed.actions ?? {}).map(([handler, action]) => ({ + handler, + ...(action as Omit) + })) + parsed.versioning = { + isSupported: false, + isForceEnabled: false, + defaultEnabled: false, + ...parsed.versioning + } + parsed.hasImplementation = await this.hasImplementation(dir) + definitions.push(parsed as StorageDefinition) + } + // -> The database target first, then alphabetically: it is the one every site starts with + this.definitions = definitions.sort((a, b) => + a.key === DB_MODULE ? -1 : b.key === DB_MODULE ? 1 : a.title.localeCompare(b.title) + ) + WIKI.logger.info(`Found ${this.definitions.length} storage modules [ OK ]`) + } catch (err: any) { + this.definitions = [] + WIKI.logger.error( + `Could not read the storage module definitions at ${storagePath} [ FAILED ]` + ) + WIKI.logger.error(err.message) + } + } + + /** + * Whether the module has any code to run, as opposed to only a definition + */ + async hasImplementation(key: string): Promise { + try { + await fs.access(path.join(WIKI.SERVERPATH, 'modules/storage', key, 'storage.ts')) + return true + } catch { + return false + } + } + + /** + * A single definition, or null when nothing on disk declares that key + */ + getDefinition(key: string): StorageDefinition | null { + return this.definitions.find((d) => d.key === key) ?? null + } + + /** + * Give a site a row per installed module, and drop rows for modules no longer on disk. + * + * Existing rows are left alone: their settings belong to the site, whereas everything the + * definition declares is read from disk on every request rather than copied into the row. + */ + async syncSite(siteId: string): Promise { + const existing = await WIKI.db + .select({ module: storageTable.module }) + .from(storageTable) + .where(eq(storageTable.siteId, siteId)) + const existingKeys = existing.map((t) => t.module) + const definedKeys = this.definitions.map((d) => d.key) + + for (const definition of this.definitions) { + if (existingKeys.includes(definition.key)) { + continue + } + await WIKI.db.insert(storageTable).values({ + siteId, + module: definition.key, + // -> Content has to land somewhere from the moment a site exists + isEnabled: definition.key === DB_MODULE, + contentTypes: { + activeTypes: definition.contentTypes?.defaultTypesEnabled ?? [], + largeThreshold: definition.contentTypes?.defaultLargeThreshold ?? '5MB' + }, + assetDelivery: { + streaming: definition.assetDelivery?.defaultStreamingEnabled ?? false, + directAccess: definition.assetDelivery?.defaultDirectAccessEnabled ?? false + }, + versioning: { + enabled: definition.versioning.isForceEnabled || definition.versioning.defaultEnabled + }, + config: this.buildConfig(definition.key), + state: definition.setup ? { setup: 'notconfigured' } : {} + }) + } + + // -> A module removed from disk should not linger in the admin list + const orphaned = existingKeys.filter((key) => !definedKeys.includes(key)) + if (orphaned.length > 0) { + await WIKI.db + .delete(storageTable) + .where(and(eq(storageTable.siteId, siteId), inArray(storageTable.module, orphaned))) + } + } + + /** + * Register the installed storage modules for every site. Called at boot, after the sites cache. + */ + async syncAllSites(): Promise { + WIKI.logger.info('Registering storage targets for all sites...') + const sites = await WIKI.db.select({ id: sitesTable.id }).from(sitesTable) + for (const site of sites) { + await WIKI.models.storage.syncSite(site.id) + } + WIKI.logger.info(`Registered storage targets for ${sites.length} sites [ OK ]`) + } + + /** + * The stored target rows, without anything merged in from disk + */ + async getTargets({ + siteId, + enabledOnly = false + }: { siteId?: string; enabledOnly?: boolean } = {}) { + const conditions = [ + siteId ? eq(storageTable.siteId, siteId) : undefined, + enabledOnly ? eq(storageTable.isEnabled, true) : undefined + ].filter(Boolean) + return WIKI.db + .select() + .from(storageTable) + .where(conditions.length > 0 ? and(...conditions) : undefined) + } + + /** + * Every target of a site, in the order the admin area lists them. + * + * Config values are completed from the module's declared defaults, so a prop added to a module + * after a target was configured is returned with its default rather than as a missing key. + */ + async getSiteTargets(siteId: string): Promise { + const rows = await this.getTargets({ siteId }) + const targets: StorageTarget[] = [] + // -> Driven by the definitions rather than by the rows, so that the list is ordered the same way + // and a module dropped on disk without a restart is simply absent instead of half-present + for (const definition of this.definitions) { + const row = rows.find((t) => t.module === definition.key) + if (!row) { + continue + } + const contentTypes = (row.contentTypes ?? {}) as Record + const assetDelivery = (row.assetDelivery ?? {}) as Record + const versioning = (row.versioning ?? {}) as Record + targets.push({ + id: row.id, + module: definition.key, + isEnabled: row.isEnabled, + title: definition.title, + description: definition.description, + icon: definition.icon, + banner: definition.banner, + vendor: definition.vendor, + website: definition.website, + contentTypes: { + activeTypes: contentTypes.activeTypes ?? [], + largeThreshold: contentTypes.largeThreshold ?? '5MB' + }, + assetDelivery: { + isStreamingSupported: definition.assetDelivery?.isStreamingSupported ?? false, + isDirectAccessSupported: definition.assetDelivery?.isDirectAccessSupported ?? false, + streaming: assetDelivery.streaming ?? false, + directAccess: assetDelivery.directAccess ?? false + }, + versioning: { + isSupported: definition.versioning.isSupported, + isForceEnabled: definition.versioning.isForceEnabled, + enabled: versioning.enabled ?? false + }, + // -> Only offered for a module that can actually run its setup process + ...(definition.setup && + definition.hasImplementation && { + setup: { + handler: definition.setup.handler, + state: ((row.state ?? {}) as Record).setup ?? 'notconfigured', + values: this.buildSetupValues(definition, row.config as Record) + } + }), + props: definition.props, + config: this.buildConfig(definition.key, {}, row.config as Record), + // -> Same reasoning as setup: an action with nothing behind it cannot be run + actions: definition.hasImplementation ? definition.actions : [] + }) + } + return targets + } + + /** + * A single target of a site, or null if there is no such target + */ + async getSiteTargetById(siteId: string, id: string): Promise { + return (await this.getSiteTargets(siteId)).find((t) => t.id === id) ?? null + } + + /** + * The values the setup form starts from: whatever the module stored, else its declared defaults. + */ + buildSetupValues( + definition: StorageDefinition, + stored: Record = {} + ): Record { + const values: Record = {} + for (const [key, value] of Object.entries(definition.setup?.defaultValues ?? {})) { + values[key] = stored[key] ?? value + } + return values + } + + /** + * Merge incoming config values onto the ones already stored, keeping only what the module declares. + * + * Read-only props are never taken from the client: they are declarations of something the server + * does not support changing, so the stored value (or the module default) always wins. + */ + buildConfig( + moduleKey: string, + incoming: Record = {}, + existing: Record = {} + ): Record { + const props = this.getDefinition(moduleKey)?.props ?? {} + const config: Record = {} + for (const [key, prop] of Object.entries(props)) { + const current = existing[key] !== undefined ? existing[key] : prop.default + config[key] = prop.readOnly || incoming[key] === undefined ? current : incoming[key] + } + return config + } + + /** + * Check incoming config values against what the module declares. + * + * The props are a runtime declaration read from a YAML file, so no JSON Schema can cover them — + * without this, a boolean prop would happily store the string `"maybe"`. + * + * @returns The reason it is invalid, or null when it is fine + */ + validateConfig(moduleKey: string, incoming: Record = {}): string | null { + const props = this.getDefinition(moduleKey)?.props ?? {} + for (const [key, value] of Object.entries(incoming)) { + const prop = props[key] + // -> Unknown keys are dropped by buildConfig rather than refused: a module losing a prop must + // not make the admin area unable to save + if (!prop || prop.readOnly || value === undefined) { + continue + } + if (prop.enum) { + // -> Enum entries are declared as `value` or `value|label` + const allowed = prop.enum.map((entry) => entry.split('|')[0]) + if (!allowed.includes(`${value}`)) { + return `"${value}" is not a valid value for ${prop.title}.` + } + continue + } + switch (prop.type) { + case 'boolean': + if (typeof value !== 'boolean') { + return `${prop.title} must be true or false.` + } + break + case 'number': + if (typeof value !== 'number' || !Number.isFinite(value)) { + return `${prop.title} must be a number.` + } + break + default: + if (typeof value !== 'string') { + return `${prop.title} must be a string.` + } + } + } + return null + } + + /** + * Check a target patch against what its module supports. + * + * @returns The reason it is invalid, or null when it is fine + */ + validateTarget(target: StorageTarget, patch: StorageTargetInput): string | null { + const definition = this.getDefinition(target.module)! + if (patch.isEnabled === false && target.module === DB_MODULE) { + return 'The database storage target cannot be disabled, as content would have nowhere to live.' + } + if (patch.isEnabled === true && target.setup && target.setup.state !== 'configured') { + return `${definition.title} cannot be enabled until its setup process is completed.` + } + const activeTypes = patch.contentTypes?.activeTypes + if (activeTypes) { + const unknown = activeTypes.find( + (type) => !(CONTENT_TYPES as readonly string[]).includes(type) + ) + if (unknown) { + return `"${unknown}" is not a valid content type.` + } + if (target.module === DB_MODULE && !activeTypes.includes('pages')) { + return 'The database storage target must keep holding pages.' + } + } + const largeThreshold = patch.contentTypes?.largeThreshold + if (largeThreshold !== undefined && !/^\d+(\.\d+)?\s?(B|KB|MB|GB|TB)$/i.test(largeThreshold)) { + return `"${largeThreshold}" is not a valid size threshold. Use a size such as "5MB".` + } + return this.validateConfig(target.module, patch.config) + } + + /** + * Apply a patch to a target. + * + * Capabilities the module does not have are stored as off whatever was asked for, and versioning it + * forces on is stored as on — the admin area disables those controls, but the values are the + * module's to decide, not the client's. + * + * @param target The target as it currently stands, which the caller already has from validating + * @returns Whether the target was written + */ + async updateTarget( + siteId: string, + target: StorageTarget, + patch: StorageTargetInput + ): Promise { + const definition = this.getDefinition(target.module)! + + const values: Partial = {} + if (patch.isEnabled !== undefined) { + values.isEnabled = patch.isEnabled + } + if (patch.contentTypes) { + values.contentTypes = { + activeTypes: patch.contentTypes.activeTypes ?? target.contentTypes.activeTypes, + largeThreshold: patch.contentTypes.largeThreshold ?? target.contentTypes.largeThreshold + } + } + if (patch.assetDelivery) { + values.assetDelivery = { + streaming: + definition.assetDelivery.isStreamingSupported && + (patch.assetDelivery.streaming ?? target.assetDelivery.streaming), + directAccess: + definition.assetDelivery.isDirectAccessSupported && + (patch.assetDelivery.directAccess ?? target.assetDelivery.directAccess) + } + } + if (patch.versioning) { + values.versioning = { + enabled: + definition.versioning.isForceEnabled || + (definition.versioning.isSupported && + (patch.versioning.enabled ?? target.versioning.enabled)) + } + } + if (patch.config !== undefined) { + values.config = this.buildConfig(target.module, patch.config, target.config) + } + if (Object.keys(values).length < 1) { + return false + } + + const result = await WIKI.db + .update(storageTable) + .set(values) + .where(and(eq(storageTable.siteId, siteId), eq(storageTable.id, target.id))) + return (result.rowCount ?? 0) > 0 + } + + /** + * Ensure a module's implementation is loaded + * + * @returns The implementation, or null when the module has none or it failed to load + */ + async ensureModule(key: string): Promise { + if (this.modules[key]) { + return this.modules[key] + } + if (!this.getDefinition(key)?.hasImplementation) { + return null + } + try { + // -> Extension-sensitive dynamic import, invisible to the type checker + this.modules[key] = (await import(`../modules/storage/${key}/storage.ts`)).default + WIKI.logger.debug(`Activated storage module ${key} [ OK ]`) + return this.modules[key] + } catch (err: any) { + WIKI.logger.warn(`Failed to load storage module ${key} [ FAILED ]`) + WIKI.logger.warn(err) + return null + } + } + + /** + * Run one of the actions a module declares. + * + * @throws When the module cannot be loaded or does not implement the handler + */ + async executeAction(target: StorageTarget, handler: string): Promise { + const mod = await this.ensureModule(target.module) + if (!mod) { + throw new Error(`The ${target.title} storage module has no implementation installed.`) + } + if (typeof mod[handler] !== 'function') { + throw new Error(`The ${target.title} storage module does not implement "${handler}".`) + } + await mod[handler](target) + } + + /** + * Advance a module's setup process. + * + * @returns What the admin area should do next, as decided by the module + * @throws When the module cannot be loaded or has no setup process + */ + async runSetup(target: StorageTarget, state: Record): Promise> { + const mod = await this.ensureModule(target.module) + if (!mod?.setup) { + throw new Error(`The ${target.title} storage module has no setup process.`) + } + return mod.setup(target.id, state) + } + + /** + * Undo a module's setup, so that it can be started over. + * + * @throws When the module cannot be loaded or has no setup process + */ + async destroySetup(target: StorageTarget): Promise { + const mod = await this.ensureModule(target.module) + if (!mod?.setupDestroy) { + throw new Error(`The ${target.title} storage module has no setup process.`) + } + await mod.setupDestroy(target.id) + } +} + +export const storage = new Storage() diff --git a/backend/modules/storage/azure/definition.yml b/backend/modules/storage/azure/definition.yml new file mode 100644 index 000000000..928f918e0 --- /dev/null +++ b/backend/modules/storage/azure/definition.yml @@ -0,0 +1,56 @@ +key: azure +title: Azure Blob Storage +icon: '/_assets/icons/ultraviolet-azure.svg' +banner: '/_assets/storage/azure.jpg' +description: Azure Blob Storage is Microsoft's object storage solution for the cloud. Blob storage is optimized for storing massive amounts of unstructured data. +vendor: Microsoft Corporation +website: 'https://azure.microsoft.com' +assetDelivery: + isStreamingSupported: true + isDirectAccessSupported: true + defaultStreamingEnabled: true + defaultDirectAccessEnabled: true +contentTypes: + defaultTypesEnabled: ['images', 'documents', 'others', 'large'] + defaultLargeThreshold: '5MB' +versioning: + isSupported: false + defaultEnabled: false +props: + accountName: + type: String + title: Account Name + default: '' + hint: Your unique account name. + icon: 3d-touch + order: 1 + accountKey: + type: String + title: Account Access Key + default: '' + hint: Either key 1 or key 2. + icon: key + sensitive: true + order: 2 + containerName: + type: String + title: Container Name + default: wiki + hint: Will automatically be created if it doesn't exist yet. + icon: shipping-container + order: 3 + storageTier: + type: String + title: Storage Tier + hint: Represents the access tier on a blob. Use Cool for lower storage costs but at higher retrieval costs. + icon: scan-stock + order: 4 + default: cool + enum: + - hot|Hot + - cool|Cool +actions: + exportAll: + label: Export All DB Assets to Azure + hint: Output all content from the DB to Azure Blog Storage, overwriting any existing data. If you enabled Azure Blog Storage after content was created or you temporarily disabled it, you'll want to execute this action to add the missing content. + icon: this-way-up diff --git a/backend/modules/storage/db/definition.yml b/backend/modules/storage/db/definition.yml new file mode 100644 index 000000000..9dfbe26e0 --- /dev/null +++ b/backend/modules/storage/db/definition.yml @@ -0,0 +1,25 @@ +key: db +title: 'Database' +icon: '/_assets/icons/ultraviolet-database.svg' +banner: '/_assets/storage/database.jpg' +description: 'The local PostgreSQL database can store any assets. It is however not recommended to store large files directly in the database as this can cause performance issues.' +vendor: 'Wiki.js' +website: 'https://js.wiki' +assetDelivery: + isStreamingSupported: true + isDirectAccessSupported: false + defaultStreamingEnabled: true + defaultDirectAccessEnabled: false +contentTypes: + defaultTypesEnabled: ['pages', 'images', 'documents', 'others', 'large'] + defaultLargeThreshold: '5MB' +versioning: + isSupported: true + defaultEnabled: false +props: {} +actions: + purge: + label: Purge All Assets + hint: Delete all asset data from the database (not the metadata). Useful if you moved assets to another storage target and want to reduce the size of the database. + warn: This is a destructive action! Make sure all asset files are properly stored on another storage module! This action cannot be undone! + icon: explosion diff --git a/backend/modules/storage/disk/definition.yml b/backend/modules/storage/disk/definition.yml new file mode 100644 index 000000000..a9aef3627 --- /dev/null +++ b/backend/modules/storage/disk/definition.yml @@ -0,0 +1,45 @@ +key: disk +title: Local File System +icon: '/_assets/icons/ultraviolet-hdd.svg' +banner: '/_assets/storage/disk.jpg' +description: Store files on the local file system or over network attached storage. Note that you must use replicated storage if using high-availability instances. +vendor: Wiki.js +website: 'https://js.wiki' +assetDelivery: + isStreamingSupported: true + isDirectAccessSupported: false + defaultStreamingEnabled: true + defaultDirectAccessEnabled: false +contentTypes: + defaultTypesEnabled: ['images', 'documents', 'others', 'large'] + defaultLargeThreshold: '5MB' +versioning: + isSupported: false + defaultEnabled: false +props: + path: + type: String + title: Path + hint: Absolute path without a trailing slash (e.g. /home/wiki/backup, C:\wiki\backup) + icon: symlink-directory + order: 1 + createDailyBackups: + type: Boolean + default: false + title: Create Daily Backups + hint: A tar.gz archive containing all content will be created daily in subfolder named _daily. Archives are kept for a month. + icon: archive-folder + order: 2 +actions: + dump: + label: Dump all content to disk + hint: Output all content from the DB to the local disk. If you enabled this module after content was created or you temporarily disabled this module, you'll want to execute this action to add the missing files. + icon: downloads + backup: + label: Create Backup + hint: Will create a manual backup archive at this point in time, in a subfolder named _manual, from the contents currently on disk. + icon: archive-folder + importAll: + label: Import Everything + hint: Will import all content currently in the local disk folder. + icon: database-daily-import diff --git a/backend/modules/storage/gcs/definition.yml b/backend/modules/storage/gcs/definition.yml new file mode 100644 index 000000000..5a440a997 --- /dev/null +++ b/backend/modules/storage/gcs/definition.yml @@ -0,0 +1,65 @@ +key: gcs +title: Google Cloud Storage +icon: '/_assets/icons/ultraviolet-google.svg' +banner: '/_assets/storage/gcs.jpg' +description: Google Cloud Storage is an online file storage web service for storing and accessing data on Google Cloud Platform infrastructure. +vendor: Alphabet Inc. +website: 'https://cloud.google.com' +assetDelivery: + isStreamingSupported: true + isDirectAccessSupported: true + defaultStreamingEnabled: true + defaultDirectAccessEnabled: true +contentTypes: + defaultTypesEnabled: ['images', 'documents', 'others', 'large'] + defaultLargeThreshold: '5MB' +versioning: + isSupported: false + defaultEnabled: false +props: + accountName: + type: String + title: Project ID + hint: The project ID from the Google Developer's Console (e.g. grape-spaceship-123). + icon: 3d-touch + default: '' + order: 1 + credentialsJSON: + type: String + title: JSON Credentials + hint: Contents of the JSON credentials file for the service account having Cloud Storage permissions. + icon: key + default: '' + multiline: true + sensitive: true + order: 2 + bucket: + type: String + title: Unique bucket name + hint: The unique bucket name to create (e.g. wiki-johndoe). + icon: open-box + order: 3 + storageTier: + type: String + title: Storage Tier + hint: Select the storage class to use when uploading new assets. + icon: scan-stock + order: 4 + default: STANDARD + enum: + - STANDARD|Standard + - NEARLINE|Nearline + - COLDLINE|Coldline + - ARCHIVE|Archive + apiEndpoint: + type: String + title: API Endpoint + hint: The API endpoint of the service used to make requests. + icon: api + default: storage.google.com + order: 5 +actions: + exportAll: + label: Export All DB Assets to GCS + hint: Output all content from the DB to Google Cloud Storage, overwriting any existing data. If you enabled Google Cloud Storage after content was created or you temporarily disabled it, you'll want to execute this action to add the missing content. + icon: this-way-up diff --git a/backend/modules/storage/git/definition.yml b/backend/modules/storage/git/definition.yml new file mode 100644 index 000000000..1c1fb5eec --- /dev/null +++ b/backend/modules/storage/git/definition.yml @@ -0,0 +1,148 @@ +key: git +title: Local Git +icon: '/_assets/icons/ultraviolet-git.svg' +banner: '/_assets/storage/git.jpg' +description: Git is a version control system for tracking changes in computer files and coordinating work on those files among multiple people. If using GitHub, use the GitHub module instead! +vendor: Software Freedom Conservancy, Inc. +website: 'https://git-scm.com' +assetDelivery: + isStreamingSupported: true + isDirectAccessSupported: false + defaultStreamingEnabled: true + defaultDirectAccessEnabled: false +contentTypes: + defaultTypesEnabled: ['pages', 'images', 'documents', 'others', 'large'] + defaultLargeThreshold: '5MB' +versioning: + isSupported: true + defaultEnabled: true + isForceEnabled: true +# Synchronization (direction and schedule) is not modelled yet — nothing reads a sync declaration, so +# this module currently only holds configuration. +props: + authType: + type: String + default: 'ssh' + title: Authentication Type + hint: Use SSH for maximum security. + icon: security-configuration + enum: + - basic|Basic + - ssh|SSH + enumDisplay: buttons + order: 1 + repoUrl: + type: String + title: Repository URI + hint: Git-compliant URI (e.g. git@server.com:org/repo.git for ssh, https://server.com/org/repo.git for basic) + icon: dns + order: 2 + branch: + type: String + default: 'main' + title: Branch + hint: The branch to use during pull / push + icon: code-fork + order: 3 + sshPrivateKeyMode: + type: String + title: SSH Private Key Mode + hint: The mode to use to load the private key. Fill in the corresponding field below. + icon: grand-master-key + order: 11 + default: inline + enum: + - path|File Path + - inline|Inline Contents + enumDisplay: buttons + if: + - { key: 'authType', eq: 'ssh' } + sshPrivateKeyPath: + type: String + title: SSH Private Key Path + hint: Absolute path to the key. The key must NOT be passphrase-protected. + icon: key + order: 12 + if: + - { key: 'authType', eq: 'ssh' } + - { key: 'sshPrivateKeyMode', eq: 'path' } + sshPrivateKeyContent: + type: String + title: SSH Private Key Contents + hint: Paste the contents of the private key. The key must NOT be passphrase-protected. + icon: key + multiline: true + sensitive: true + order: 13 + if: + - { key: 'authType', eq: 'ssh' } + - { key: 'sshPrivateKeyMode', eq: 'inline' } + verifySSL: + type: Boolean + default: true + title: Verify SSL Certificate + hint: Some hosts requires SSL certificate checking to be disabled. Leave enabled for proper security. + icon: security-ssl + order: 14 + basicUsername: + type: String + title: Username + hint: Basic Authentication Only + icon: test-account + order: 20 + if: + - { key: 'authType', eq: 'basic' } + basicPassword: + type: String + title: Password / PAT + hint: Basic Authentication Only + icon: password + sensitive: true + order: 21 + if: + - { key: 'authType', eq: 'basic' } + defaultEmail: + type: String + title: Default Author Email + default: 'name@company.com' + hint: 'Used as fallback in case the author of the change is not present.' + icon: email + order: 30 + defaultName: + type: String + title: Default Author Name + default: 'John Smith' + hint: 'Used as fallback in case the author of the change is not present.' + icon: customer + order: 31 + localRepoPath: + type: String + title: Local Repository Path + default: './data/repo' + hint: 'Path where the local git repository will be created.' + icon: symlink-directory + order: 32 + gitBinaryPath: + type: String + title: Git Binary Path + default: '' + hint: Optional - Absolute path to the Git binary, when not available in PATH. Leave empty to use the default PATH location (recommended). + icon: run-command + order: 50 +actions: + syncUntracked: + label: Add Untracked Changes + hint: Output all content from the DB to the local Git repository to ensure all untracked content is saved. If you enabled Git after content was created or you temporarily disabled Git, you'll want to execute this action to add the missing untracked changes. + icon: database-daily-export + sync: + label: Force Sync + hint: Will trigger an immediate sync operation, regardless of the current sync schedule. The sync direction is respected. + icon: synchronize + importAll: + label: Import Everything + hint: Will import all content currently in the local Git repository, regardless of the latest commit state. Useful for importing content from the remote repository created before git was enabled. + icon: database-daily-import + purge: + label: Purge Local Repository + hint: If you have unrelated merge histories, clearing the local repository can resolve this issue. This will not affect the remote repository or perform any commit. + icon: trash diff --git a/backend/modules/storage/s3/definition.yml b/backend/modules/storage/s3/definition.yml new file mode 100644 index 000000000..3d4538f72 --- /dev/null +++ b/backend/modules/storage/s3/definition.yml @@ -0,0 +1,159 @@ +key: s3 +title: AWS S3 / Cloudflare R2 / DO Spaces +icon: '/_assets/icons/ultraviolet-amazon-web-services.svg' +banner: '/_assets/storage/s3.jpg' +description: Amazon Simple Storage Service (Amazon S3) is an object storage service offering industry-leading scalability, data availability, security, and performance. +vendor: Amazon.com, Inc. +website: 'https://aws.amazon.com' +assetDelivery: + isStreamingSupported: true + isDirectAccessSupported: true + defaultStreamingEnabled: true + defaultDirectAccessEnabled: true +contentTypes: + defaultTypesEnabled: ['images', 'documents', 'others', 'large'] + defaultLargeThreshold: '5MB' +versioning: + isSupported: false + defaultEnabled: false +props: + mode: + type: String + title: Mode + hint: Select a preset configuration mode or define a custom one. + icon: tune + default: aws + order: 1 + enum: + - aws|AWS S3 + - do|DigitalOcean Spaces + - custom|Custom + awsRegion: + type: String + title: Region + hint: The AWS datacenter region where the bucket will be created. + icon: geography + default: us-east-1 + enum: + - af-south-1|af-south-1 - Africa (Cape Town) + - ap-east-1|ap-east-1 - Asia Pacific (Hong Kong) + - ap-southeast-3|ap-southeast-3 - Asia Pacific (Jakarta) + - ap-south-1|ap-south-1 - Asia Pacific (Mumbai) + - ap-northeast-3|ap-northeast-3 - Asia Pacific (Osaka) + - ap-northeast-2|ap-northeast-2 - Asia Pacific (Seoul) + - ap-southeast-1|ap-southeast-1 - Asia Pacific (Singapore) + - ap-southeast-2|ap-southeast-2 - Asia Pacific (Sydney) + - ap-northeast-1|ap-northeast-1 - Asia Pacific (Tokyo) + - ca-central-1|ca-central-1 - Canada (Central) + - cn-north-1|cn-north-1 - China (Beijing) + - cn-northwest-1|cn-northwest-1 - China (Ningxia) + - eu-central-1|eu-central-1 - Europe (Frankfurt) + - eu-west-1|eu-west-1 - Europe (Ireland) + - eu-west-2|eu-west-2 - Europe (London) + - eu-south-1|eu-south-1 - Europe (Milan) + - eu-west-3|eu-west-3 - Europe (Paris) + - eu-north-1|eu-north-1 - Europe (Stockholm) + - me-south-1|me-south-1 - Middle East (Bahrain) + - sa-east-1|sa-east-1 - South America (São Paulo) + - us-east-1|us-east-1 - US East (N. Virginia) + - us-east-2|us-east-2 - US East (Ohio) + - us-west-1|us-west-1 - US West (N. California) + - us-west-2|us-west-2 - US West (Oregon) + order: 2 + if: + - { key: 'mode', eq: 'aws' } + doRegion: + type: String + title: Region + hint: The DigitalOcean Spaces region + icon: geography + default: nyc3 + enum: + - ams3|Amsterdam + - fra1|Frankfurt + - nyc3|New York + - sfo2|San Francisco 2 + - sfo3|San Francisco 3 + - sgp1|Singapore + order: 2 + if: + - { key: 'mode', eq: 'do' } + endpoint: + type: String + title: Endpoint URI + hint: The full S3-compliant endpoint URI. + icon: dns + default: https://service.region.example.com + order: 2 + if: + - { key: 'mode', eq: 'custom' } + bucket: + type: String + title: Unique bucket name + hint: The unique bucket name to create (e.g. wiki-johndoe). + icon: open-box + order: 3 + accessKeyId: + type: String + title: Access Key ID + hint: The Access Key. + icon: 3d-touch + order: 4 + secretAccessKey: + type: String + title: Secret Access Key + hint: The Secret Access Key for the Access Key ID you created above. + icon: key + sensitive: true + order: 5 + storageTier: + type: String + title: Storage Tier + hint: The storage tier to use when adding files. + icon: scan-stock + order: 6 + default: STANDARD + enum: + - STANDARD|Standard + - STANDARD_IA|Standard Infrequent Access + - INTELLIGENT_TIERING|Intelligent Tiering + - ONEZONE_IA|One Zone Infrequent Access + - REDUCED_REDUNDANCY|Reduced Redundancy + - GLACIER_IR|Glacier Instant Retrieval + - GLACIER|Glacier Flexible Retrieval + - DEEP_ARCHIVE|Glacier Deep Archive + - OUTPOSTS|Outposts + if: + - { key: 'mode', eq: 'aws' } + sslEnabled: + type: Boolean + title: Use SSL + hint: Whether to enable SSL for requests + icon: secure + default: true + order: 10 + if: + - { key: 'mode', eq: 'custom' } + s3ForcePathStyle: + type: Boolean + title: Force Path Style for S3 objects + hint: Whether to force path style URLs for S3 objects. + icon: filtration + default: false + order: 11 + if: + - { key: 'mode', eq: 'custom' } + s3BucketEndpoint: + type: Boolean + title: Single Bucket Endpoint + hint: Whether the provided endpoint addresses an individual bucket. + icon: swipe-right + default: false + order: 12 + if: + - { key: 'mode', eq: 'custom' } +actions: + exportAll: + label: Export All DB Assets to S3 + hint: Output all content from the DB to S3, overwriting any existing data. If you enabled S3 after content was created or you temporarily disabled it, you'll want to execute this action to add the missing content. + icon: this-way-up diff --git a/backend/modules/storage/sftp/definition.yml b/backend/modules/storage/sftp/definition.yml new file mode 100644 index 000000000..c21eda1ba --- /dev/null +++ b/backend/modules/storage/sftp/definition.yml @@ -0,0 +1,94 @@ +key: sftp +title: 'SFTP' +icon: '/_assets/icons/ultraviolet-nas.svg' +banner: '/_assets/storage/ssh.jpg' +description: 'Store files over a remote connection using the SSH File Transfer Protocol.' +vendor: 'Wiki.js' +website: 'https://js.wiki' +assetDelivery: + isStreamingSupported: false + isDirectAccessSupported: false + defaultStreamingEnabled: false + defaultDirectAccessEnabled: false +contentTypes: + defaultTypesEnabled: ['pages', 'images', 'documents', 'others', 'large'] + defaultLargeThreshold: '5MB' +versioning: + isSupported: false + defaultEnabled: false +props: + host: + type: String + title: Host + default: '' + hint: Hostname or IP of the remote SSH server. + icon: dns + order: 1 + port: + type: Number + title: Port + default: 22 + hint: SSH port of the remote server. + icon: ethernet-off + order: 2 + authMode: + type: String + title: Authentication Method + default: 'privateKey' + hint: Whether to use Private Key or Password-based authentication. A private key is highly recommended for best security. + icon: grand-master-key + enum: + - privateKey|Private Key + - password|Password + enumDisplay: buttons + order: 3 + username: + type: String + title: Username + default: '' + hint: Username for authentication. + icon: test-account + order: 4 + privateKey: + type: String + title: Private Key Contents + default: '' + hint: Contents of the private key + icon: key + multiline: true + sensitive: true + order: 5 + if: + - { key: 'authMode', eq: 'privateKey' } + passphrase: + type: String + title: Private Key Passphrase + default: '' + hint: Passphrase if the private key is encrypted, leave empty otherwise + icon: password + sensitive: true + order: 6 + if: + - { key: 'authMode', eq: 'privateKey' } + password: + type: String + title: Password + default: '' + hint: Password for authentication + icon: password + sensitive: true + order: 6 + if: + - { key: 'authMode', eq: 'password' } + basePath: + type: String + title: Base Directory Path + default: '/root/wiki' + hint: Base directory where files will be transferred to. The path must already exists and be writable by the user. + icon: symlink-directory +actions: + exportAll: + label: Export All DB Assets to Remote + hint: Output all content from the DB to the remote SSH server, overwriting any existing data. If you enabled SFTP after content was created or you temporarily disabled it, you'll want to execute this action to add the missing content. + icon: this-way-up + diff --git a/backend/package-lock.json b/backend/package-lock.json index f9a268f86..6cba79cb6 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -22,6 +22,7 @@ "@fastify/swagger-ui": "6.0.0", "@fastify/view": "12.0.0", "@gquittet/graceful-server": "6.0.10", + "@iconify/utils": "3.1.4", "ajv-formats": "3.0.1", "bcryptjs": "3.0.3", "chalk": "5.6.2", @@ -63,6 +64,19 @@ "node": ">=26.0" } }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/@azure-rest/core-client": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@azure-rest/core-client/-/core-client-2.5.1.tgz", @@ -1309,6 +1323,23 @@ "fsevents": "^2.3.3" } }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.4.tgz", + "integrity": "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, "node_modules/@js-joda/core": { "version": "5.7.0", "resolved": "https://registry.npmjs.org/@js-joda/core/-/core-5.7.0.tgz", @@ -3826,6 +3857,16 @@ "dev": true, "license": "ISC" }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4676,6 +4717,12 @@ } } }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "license": "MIT" + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -5462,6 +5509,15 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinypool": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index 9924c5ea5..ffb9c2267 100644 --- a/backend/package.json +++ b/backend/package.json @@ -48,6 +48,7 @@ "@fastify/swagger-ui": "6.0.0", "@fastify/view": "12.0.0", "@gquittet/graceful-server": "6.0.10", + "@iconify/utils": "3.1.4", "ajv-formats": "3.0.1", "bcryptjs": "3.0.3", "chalk": "5.6.2", diff --git a/backend/types/global.d.ts b/backend/types/global.d.ts index f6affb4df..1540bf006 100644 --- a/backend/types/global.d.ts +++ b/backend/types/global.d.ts @@ -40,10 +40,6 @@ declare global { groups: Record strategies: Record } - storage: { - defs: unknown[] - modules: unknown[] - } /** * Merged config.yml + base.yml defaults + the `settings` DB table. Assembled at runtime from diff --git a/config.sample.yml b/config.sample.yml index d55c42e15..6c2f37473 100644 --- a/config.sample.yml +++ b/config.sample.yml @@ -73,6 +73,16 @@ offline: false # Writeable data path used for cache and temporary user uploads. dataPath: ./data +# --------------------------------------------------------------------- +# Icons +# --------------------------------------------------------------------- +# Icons are fetched from the Iconify API the first time they are used, then +# stored in the database and served by this instance. Point this at a +# self-hosted Iconify API to keep icon lookups inside your network. + +icons: + apiUrl: 'https://api.iconify.design' + # --------------------------------------------------------------------- # Body Parser Limit # --------------------------------------------------------------------- diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fc5a39d68..ccd5ba2ac 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,6 +24,7 @@ "filesize-parser": "1.5.1", "fuse.js": "7.4.2", "highlight.js": "11.11.1", + "iconify-icon": "3.0.2", "js-cookie": "3.0.8", "jwt-decode": "4.0.0", "katex": "0.17.0", @@ -1312,6 +1313,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, "node_modules/@inquirer/ansi": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", @@ -7524,6 +7531,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/iconify-icon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/iconify-icon/-/iconify-icon-3.0.2.tgz", + "integrity": "sha512-DYPAumiUeUeT/GHT8x2wrAVKn1FqZJqFH0Y5pBefapWRreV1BBvqBVMb0020YQ2njmbR59r/IathL2d2OrDrxA==", + "license": "MIT", + "dependencies": { + "@iconify/types": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/cyberalien" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", diff --git a/frontend/package.json b/frontend/package.json index 165de6db8..eee68bd38 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "filesize-parser": "1.5.1", "fuse.js": "7.4.2", "highlight.js": "11.11.1", + "iconify-icon": "3.0.2", "js-cookie": "3.0.8", "jwt-decode": "4.0.0", "katex": "0.17.0", diff --git a/frontend/src/boot/components.js b/frontend/src/boot/components.js index 6204e731d..6a55fc2ea 100644 --- a/frontend/src/boot/components.js +++ b/frontend/src/boot/components.js @@ -1,11 +1,13 @@ import BlueprintIcon from '@/components/BlueprintIcon.vue' import StatusLight from '@/components/StatusLight.vue' import LoadingGeneric from '@/components/LoadingGeneric.vue' +import WikiIcon from '@/components/WikiIcon.vue' import VNetworkGraph from 'v-network-graph' export function initializeComponents (app) { app.component('BlueprintIcon', BlueprintIcon) app.component('LoadingGeneric', LoadingGeneric) app.component('StatusLight', StatusLight) + app.component('WikiIcon', WikiIcon) app.use(VNetworkGraph) } diff --git a/frontend/src/boot/iconify.js b/frontend/src/boot/iconify.js new file mode 100644 index 000000000..41bdbdc6b --- /dev/null +++ b/frontend/src/boot/iconify.js @@ -0,0 +1,18 @@ +import { addAPIProvider } from 'iconify-icon' + +/** + * Point Iconify at this wiki instead of the public Iconify API. + * + * The `iconify-icon` element resolves `:` by asking an API for the icon data, batching + * every icon a page needs into one request per set and caching the answers in localStorage. Replacing + * the default provider means that traffic goes to `/_icons` on this instance: icons are served from + * the wiki's own store, nothing about which pages a reader visits leaks to a third party, and the wiki + * keeps working when it has no outbound access at all. + * + * Importing the package for its side effect is what defines the `` custom element. + */ +export function initializeIconify () { + addAPIProvider('', { + resources: [`${window.location.origin}/_icons`] + }) +} diff --git a/frontend/src/components/IconPickerDialog.vue b/frontend/src/components/IconPickerDialog.vue index f7aa21300..ee13e24be 100644 --- a/frontend/src/components/IconPickerDialog.vue +++ b/frontend/src/components/IconPickerDialog.vue @@ -1,6 +1,5 @@ - @@ -224,5 +284,44 @@ onMounted(() => { background-color: $dark-5; } } + + &-results { + position: relative; + height: 220px; + overflow-y: auto; + border-radius: 4px; + + @at-root .body--light & { + background-color: #FFF; + } + @at-root .body--dark & { + background-color: $dark-5; + } + } + + &-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); + gap: 2px; + padding: 4px; + } + + &-cell { + height: 44px; + + &--active { + @at-root .body--light & { + background-color: $blue-1; + } + @at-root .body--dark & { + background-color: $blue-9; + } + } + } + + &-ref { + font-family: monospace; + word-break: break-all; + } } diff --git a/frontend/src/components/NavEditOverlay.vue b/frontend/src/components/NavEditOverlay.vue index 3b16d8ef1..3fb29f20c 100644 --- a/frontend/src/components/NavEditOverlay.vue +++ b/frontend/src/components/NavEditOverlay.vue @@ -72,7 +72,7 @@ q-layout(view='hHh lpR fFf', container) clickable ) q-item-section(side) - q-icon(:name='element.icon', color='white') + wiki-icon(:name='element.icon', color='white') q-item-section.text-wordbreak-all {{ element.label }} q-item-section(side) q-icon.handle(name='mdi-drag-horizontal', size='sm') @@ -242,8 +242,7 @@ q-layout(view='hHh lpR fFf', container) color='primary' ) q-menu(content-class='shadow-7') - .q-pa-lg: em [ TODO: Icon Picker Dialog ] - // icon-picker-dialog(v-model='pageStore.icon') + icon-picker-dialog(v-model='state.current.icon') q-separator.q-my-sm(inset) q-item blueprint-icon(icon='link') diff --git a/frontend/src/components/NavSidebar.vue b/frontend/src/components/NavSidebar.vue index 72f4e3ae7..f41e971e9 100644 --- a/frontend/src/components/NavSidebar.vue +++ b/frontend/src/components/NavSidebar.vue @@ -15,10 +15,14 @@ q-scroll-area.sidebar-nav( ) {{ item.label }} q-expansion-item( v-else-if='item.type === `link` && item.children?.length > 0' - :icon='item.icon' - :label='item.label' dense ) + //- The icon goes through a header slot rather than the `icon` prop, so that an Iconify + //- reference is drawn by wiki-icon like everywhere else + template(#header) + q-item-section(side) + wiki-icon(:name='item.icon', color='white') + q-item-section.text-wordbreak-all.text-white {{ item.label }} q-list( clickable dense @@ -30,14 +34,14 @@ q-scroll-area.sidebar-nav( :key='itemChild.id' ) q-item-section(side) - q-icon(:name='itemChild.icon', color='white') + wiki-icon(:name='itemChild.icon', color='white') q-item-section.text-wordbreak-all.text-white {{ itemChild.label }} q-item( v-else-if='item.type === `link`' :to='item.target' ) q-item-section(side) - q-icon(:name='item.icon', color='white') + wiki-icon(:name='item.icon', color='white') q-item-section.text-wordbreak-all.text-white {{ item.label }} q-separator( v-else-if='item.type === `separator`' diff --git a/frontend/src/components/PageHeader.vue b/frontend/src/components/PageHeader.vue index 4bb373334..ccc4a62cd 100644 --- a/frontend/src/components/PageHeader.vue +++ b/frontend/src/components/PageHeader.vue @@ -6,16 +6,16 @@ v-if='editorStore.isActive' padding='none' size='37px' - :icon='pageStore.icon' color='primary' flat + :aria-label='t(`editor.props.icon`)' ) + wiki-icon(:name='pageStore.icon', size='37px') q-badge(color='grey' floating rounded) q-icon(name='las la-pen', size='xs', padding='xs xs') q-menu(content-class='shadow-7') - .q-pa-lg: em [ TODO: Icon Picker Dialog ] - // icon-picker-dialog(v-model='pageStore.icon') - q-icon.rounded-borders( + icon-picker-dialog(v-model='pageStore.icon') + wiki-icon.rounded-borders( v-else :name='pageStore.icon' size='64px' diff --git a/frontend/src/components/PagePropertiesDialog.vue b/frontend/src/components/PagePropertiesDialog.vue index 9b8ab1beb..063ed88c8 100644 --- a/frontend/src/components/PagePropertiesDialog.vue +++ b/frontend/src/components/PagePropertiesDialog.vue @@ -110,7 +110,7 @@ q-card.page-properties-dialog ) q-item(v-for='rel of pageStore.relations', :key='`rel-id-` + rel.id') q-item-section(side) - q-icon(:name='rel.icon') + wiki-icon(:name='rel.icon') q-item-section q-item-label: strong {{rel.label}} q-item-label(caption) {{rel.caption}} diff --git a/frontend/src/components/PageRelationDialog.vue b/frontend/src/components/PageRelationDialog.vue index a82a573f7..222419b54 100644 --- a/frontend/src/components/PageRelationDialog.vue +++ b/frontend/src/components/PageRelationDialog.vue @@ -52,32 +52,32 @@ q-card.page-relation-dialog(style='width: 500px;') v-if='state.pos === `left`' padding='sm md' outline - :icon='state.icon' no-caps color='primary' ) + wiki-icon(:name='state.icon') .column.text-left.q-pl-md .text-body2: strong {{state.label}} .text-caption {{state.caption}} q-btn.full-width( v-else-if='state.pos === `center`' - :label='state.label' color='primary' flat no-caps - :icon='state.icon' - ) + ) + wiki-icon.q-mr-sm(:name='state.icon') + span {{ state.label }} q-btn( v-else-if='state.pos === `right`' padding='sm md' outline - :icon-right='state.icon' no-caps color='primary' ) .column.text-left.q-pr-md .text-body2: strong {{state.label}} .text-caption {{state.caption}} + wiki-icon(:name='state.icon') q-card-actions.card-actions q-space q-btn.acrylic-btn( diff --git a/frontend/src/components/WikiIcon.vue b/frontend/src/components/WikiIcon.vue new file mode 100644 index 000000000..8f9fd14fb --- /dev/null +++ b/frontend/src/components/WikiIcon.vue @@ -0,0 +1,70 @@ + + + diff --git a/frontend/src/main.js b/frontend/src/main.js index 1fa855d67..125885a45 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -7,6 +7,7 @@ import { initializeComponents } from './boot/components' import { initializeEventBus } from './boot/eventbus' import { initializeExternals } from './boot/externals' import { initializeI18n } from './boot/i18n' +import { initializeIconify } from './boot/iconify' import { initializeTemporal } from './boot/temporal' import quasarIconSet from 'quasar/icon-set/mdi-v7' @@ -34,6 +35,7 @@ app.use(router) initializeApi(store) initializeComponents(app) initializeEventBus() +initializeIconify() initializeExternals(router, store) initializeI18n(app, store) diff --git a/frontend/src/pages/AdminIcons.vue b/frontend/src/pages/AdminIcons.vue index 4cca4f494..99adf2dbe 100644 --- a/frontend/src/pages/AdminIcons.vue +++ b/frontend/src/pages/AdminIcons.vue @@ -28,82 +28,187 @@ q-page.admin-icons q-tooltip {{ t(`common.actions.refresh`) }} q-btn( unelevated - icon='mdi-check' - :label='t(`common.actions.apply`)' - color='secondary' - @click='save' - :disabled='state.loading > 0' + icon='las la-plus' + :label='t(`admin.icons.addSet`)' + color='primary' + @click='openAddSet' ) q-separator(inset) .row.q-pa-md.q-col-gutter-md - .col-12 + .col-12.col-lg + //- ----------------------- + //- Icon Sets + //- ----------------------- q-card q-card-section - q-card.bg-negative.text-white.rounded-borders(flat) - q-card-section.items-center(horizontal) - q-card-section.col-auto.q-pr-none - q-icon(name='las la-exclamation-triangle', size='sm') - q-card-section - span {{ t('admin.icons.warnLabel') }} - .text-caption.text-red-1 {{ t('admin.icons.warnHint') }} + .text-subtitle1 {{ t('admin.icons.sets') }} + .text-body2.text-grey {{ t('admin.icons.setsHint') }} + q-banner.q-mx-md.q-mb-md( + v-if='state.sets.length < 1 && state.loading < 1' + rounded + :class='$q.dark.isActive ? `bg-grey-9 text-white` : `bg-grey-2 text-grey-7`' + ) {{ t('admin.icons.noSets') }} q-list(separator) - q-item(v-for='pack of combinedPacks', :key='pack.key') - blueprint-icon(icon='small-icons', :hueRotate='30') - q-item-section - q-item-label: strong {{pack.label}} - q-item-label(caption, v-if='pack.isMandatory') - em {{t('admin.icons.mandatory')}} - template(v-if='pack.config') - q-item-section( - side - ) - q-btn( - icon='las la-cog' - :label='t(`admin.editors.configuration`)' - :color='$q.dark.isActive ? `blue-grey-3` : `blue-grey-8`' - outline - no-caps - padding='xs md' + q-item(v-for='set of state.sets', :key='set.prefix') + q-item-section(side) + .admin-icons-samples + wiki-icon.admin-icons-sample( + v-for='sample of sampleRefs(set)' + :key='sample' + :name='sample' + size='24px' ) - q-separator.q-ml-md(vertical) - q-item-section( - side - ) - q-btn( + q-icon(v-if='sampleRefs(set).length < 1', name='las la-icons', size='24px', color='grey') + q-item-section + q-item-label + strong {{ set.name }} + q-chip.q-ml-sm(square, dense, size='sm', color='primary', text-color='white') {{ set.prefix }} + q-item-label(caption) {{ setCaption(set) }} + q-item-label.text-deep-orange(caption, v-if='set.info?.palette') {{ t('admin.icons.paletteWarn') }} + q-item-section(side) + q-btn.acrylic-btn( type='a' icon='las la-external-link-square-alt' :label='t(`admin.icons.reference`)' color='indigo' - outline + flat no-caps padding='xs md' - :href='pack.website' + :href='referenceUrl(set)' target='_blank' rel='noreferrer noopener' - ) + ) + q-tooltip {{ t('admin.icons.referenceHint') }} q-separator.q-ml-md(vertical) q-item-section(side) q-toggle.q-pr-sm( - :modelValue='pack.isActive' - @update:model-value='newValue => setPackState(pack.key, newValue)' - :color='pack.isDisabled ? `grey` : `primary`' + :modelValue='set.isEnabled' + @update:model-value='newValue => setSetState(set, newValue)' + color='primary' checked-icon='las la-check' unchecked-icon='las la-times' - :label='t(`admin.sites.isActive`)' - :aria-label='t(`admin.sites.isActive`)' - :disabled='pack.isMandatory' + :label='t(`admin.icons.isEnabled`)' + :aria-label='t(`admin.icons.isEnabled`)' + ) + q-item-section(side) + q-btn.acrylic-btn( + icon='las la-trash' + flat + color='negative' + :aria-label='t(`common.actions.delete`)' + @click='confirmDeleteSet(set)' ) + q-tooltip {{ t(`common.actions.delete`) }} + + .col-12.col-lg-auto + //- ----------------------- + //- Storage / Cache + //- ----------------------- + q-card.rounded-borders(style='width: 350px;') + q-card-section + .text-subtitle1 {{ t('admin.icons.storage') }} + .text-body2.text-grey {{ t('admin.icons.storageHint') }} + q-list.q-pb-sm(dense) + q-item + q-item-section + q-item-label.text-grey {{ t('admin.icons.storedIcons') }} + q-item-label {{ t('admin.icons.storedIconsValue', { count: state.cache.iconCount ?? 0 }) }} + q-separator.q-my-sm(inset) + q-item + q-item-section + q-item-label.text-grey {{ t('admin.icons.diskCache') }} + q-item-label {{ t('admin.icons.diskCacheValue', { count: state.cache.diskCount ?? 0, size: prettyBytes(state.cache.diskSize ?? 0) }) }} + q-separator.q-my-sm(inset) + q-item + q-item-section + q-item-label.text-grey {{ t('admin.icons.memoryCache') }} + q-item-label {{ t('admin.icons.memoryCacheValue', { count: state.cache.memoryCount ?? 0 }) }} + q-separator + q-card-actions.q-px-md + q-btn.acrylic-btn( + flat + no-caps + icon='las la-broom' + color='negative' + :label='t(`admin.icons.purgeCache`)' + @click='purgeCache' + ) + q-tooltip {{ t('admin.icons.purgeCacheHint') }} + + //- ----------------------- + //- How it works + //- ----------------------- + q-card.rounded-borders.q-mt-md(style='width: 350px;') + q-card-section + .text-subtitle1 {{ t('admin.icons.howItWorks') }} + .text-body2.text-grey.q-mt-sm {{ t('admin.icons.howItWorksHint') }} + q-separator.q-mb-sm(inset) + q-item + q-item-section + q-item-label.text-grey {{ t('admin.icons.upstream') }} + q-item-label.text-caption {{ t('admin.icons.upstreamHint') }} + //- ----------------------- + //- Add Set Dialog + //- ----------------------- + q-dialog(v-model='state.addSetDialog') + q-card(style='width: 700px; max-width: 90vw;') + q-card-section.row.items-center.q-pb-none + .text-h6 {{ t('admin.icons.addSet') }} + q-space + q-btn(icon='las la-times', flat, round, dense, v-close-popup) + q-card-section + .text-body2.text-grey {{ t('admin.icons.addSetHint') }} + q-input.q-mt-md( + v-model='state.availableFilter' + outlined + dense + clearable + :label='t(`admin.icons.filterSets`)' + :aria-label='t(`admin.icons.filterSets`)' + ) + template(#prepend) + q-icon(name='las la-search') + q-separator + q-card-section.q-pa-none(style='height: 50vh; overflow-y: auto;') + q-inner-loading(:showing='state.loadingAvailable') + q-spinner-tail(color='primary', size='md') + q-banner.q-ma-md( + v-if='state.availableError' + rounded + class='bg-negative text-white' + ) {{ state.availableError }} + q-list(separator) + q-item( + v-for='set of filteredAvailableSets' + :key='set.prefix' + clickable + :disable='set.isAdded' + @click='addSet(set)' + ) + q-item-section(side) + .admin-icons-samples + wiki-icon.admin-icons-sample( + v-for='sample of set.samples.slice(0, 3)' + :key='sample' + :name='`${set.prefix}:${sample}`' + size='24px' + ) + q-item-section + q-item-label + strong {{ set.name }} + q-chip.q-ml-sm(square, dense, size='sm', color='primary', text-color='white') {{ set.prefix }} + q-item-label(caption) {{ availableCaption(set) }} + q-item-section(side) + q-chip(v-if='set.isAdded', dense, size='sm', color='positive', text-color='white', icon='las la-check') {{ t('admin.icons.added') }} + q-icon(v-else, name='las la-plus-circle', color='primary', size='sm')