diff --git a/CLAUDE.md b/CLAUDE.md index a51069ef9..748b84d53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -940,6 +940,100 @@ instead. A `num` placeholder that is not a number drops its whole snippet for th a prop that had to be kept out of a browser could not be used by a provider in the first place. This is why `api/analytics.ts` is the one module-prop surface with no `maskSensitiveProps` on the way out. +### Comments + +Two things wearing one name, and `models/comments.ts` is the seam between them. **Only one provider +is in use per site** — two comment widgets on a page are two separate discussions of it, and neither +of them is the discussion. That is what makes this screen different from Analytics, where several +providers may be on at once. + +**A third-party provider is two YAML files**, exactly as an analytics provider is: a +`definition.yml` (what it is, what it needs configured, the same `props` shape read through +`parseModuleProps`) and a `code.yml` with the markup it contributes. Nine ship — Artalk, Comentario, +Discourse, Disqus, Giscus, Hyvor Talk, Isso, Remark42, Waline. There is no `comments.ts` beside them +and nothing to load: the discussion lives in somebody else's service. A directory that cannot be read +is skipped with a warning rather than emptying the list, as under `modules/analytics/`. + +**The built-in provider is this wiki**, and deliberately has no module directory: its comments are +rows in the `comments` table, served by `api/comments.ts` and drawn on a Talk tab beside the article. +Its settings are declared as `BUILTIN_DEFINITION` in the model so that the admin screen renders one +kind of form for every provider rather than two. + +**Where the markup goes is the one thing that is not like Analytics.** An analytics tag is served in +the document; a comment widget belongs at the bottom of the *article*, and moving between wiki pages +is a router transition and not a document load — a snippet baked into the shell would initialise once +and then show the first page's discussion for ever. So the rendered snippet rides along on the site +payload (`comments.publicConfigFor`, narrow on purpose — the stored configuration holds an Akismet +key) and `PageCommentsEmbed.vue` mounts it per page. `code.yml` has three slots: `head` (added once +per document and awaited), `main` (the container), `body` (the init script, run after both). Scripts +are re-created as real elements — one that arrived through `innerHTML` never runs — and go INSIDE the +container, which giscus and Isso depend on. + +**Placeholders are split between the two sides.** `{{js:prop}}` / `{{attr:prop}}` / `{{num:prop}}` / +`{{bool:prop}}` are resolved on the server as they are for analytics; `{{js:page.url}}` and the rest +of the `page.*` family are left in the string for `helpers/commentsEmbed.js` to fill in per page, +escaping by the same rules. A provider that is selected but missing a required prop serves nothing at +all rather than a widget pointed at no account. + +**The built-in provider's permissions are PAGE rules**, not the group-wide list, so none of its routes +declares `config.permissions` — every one resolves the page and asks `mayOnPage`. `read:comments` to +see a discussion, `write:comments` to post and to edit or delete your own, `manage:comments` to edit or +delete anybody's. The two are not interchangeable and neither implies the other. + +- **Guests can take part**, where a rule grants them `write:comments` — that is how a public wiki opens + a discussion. A name and an email are required of them; the email is stored and never served, and is + what the spam check is given. A guest cannot edit or delete, because there is no session that + identifies them as the author and "their own" has nothing to mean. +- **Two things stand between a comment and the table.** The site's **posting cooldown** (`30s` by + default, `0` for none) is counted per account and per address for a guest, through the same + postgres-backed counter the login limit uses, so instances behind a load balancer agree about it; + `manage:comments` on the page is exempt, since answering five threads in a row is what moderating + looks like. And an optional **Akismet key**, which is the one `sensitive` prop here: masked at the + API boundary like every other module secret. Akismet **fails open** — a timeout or a revoked key + lets the comment through and logs it, because a wiki that silently stops accepting comments is worse + than one that lets a spam comment past. A comment it calls spam is refused outright; there is no + moderation queue yet, which is what the `meta` column is room for. +- **Replies are one level deep, enforced in the model**: a `parentId` naming a comment that is itself a + reply is rewritten to that reply's own parent, so answering the third message in a thread puts the + answer at the bottom of the thread. Deleting a comment takes its replies with it, by the foreign + key's own cascade — half a conversation is not worth keeping. +- **Markdown is rendered in the browser, at display time**, by `frontend/src/renderers/comment.js` — + a second, much smaller renderer than the page pipeline. `html: false` is the whole security + boundary: markdown-it escapes every `<` it is given, so nothing stored is ever HTML and no + sanitizer's older rules can be served back. No headings, no images, no tables; every link leaves + with `rel="nofollow ugc noopener"`. Rendering at display rather than at write is also what lets a + mention re-resolve instead of freezing whatever a handle pointed at on the day it was written. +- **A mention is `@handle`**, and `users.handle` is a column with a unique index on `lower(handle)` — + `@ana` means one person or it means nothing. It is null until somebody picks one, and a user without + one is simply not mentionable; nothing is derived from a display name on anybody's behalf. It is + edited under **Profile → Info** and is NOT gated on `allowProfileEditing`, because no identity + provider owns a wiki mention handle. The comments endpoint resolves the handles of a whole page in + one query and the renderer links only those, so a mention never points at whoever took the handle + later. +- **The Talk tab is for the built-in provider alone.** `Article` / `Talk` above the content, as on + Wikipedia, with a count badge that comes with the page (`commentsCount` on the page payload) rather + than with the comments — it has to be there before the tab is opened. Every other provider draws + itself under the article instead. Both respect the page's own `allowComments`, which is the switch + in its properties dialog. + +**Configuration lives in the site's config blob** under `comments` — `provider` plus a `providers` map +keyed by module — for the same reasons the analytics configuration does. The settings of the providers +that are not in use are kept, so trying another one and coming back finds a form still filled in. + +**Three settings, and they answer different questions.** `features.comments` (**General → Features**, +on by default) is whether the site has comments at all; `comments.provider` is which one handles them; +and a page's own `allowComments` (its properties dialog) is whether this page takes them. Enabling and +disabling is General's job alone — **the Comments screen only picks which provider**, which is why it +offers a radio per provider and no way to choose none. A site starts on the built-in provider, so a +wiki with comments switched on has somewhere for them to go without anybody choosing first. + +The master switch is checked by `comments.isAllowed`, which `publicConfigFor` and `usesBuiltIn` both go +through — and deliberately NOT by `selectedProvider`, which the admin screen reads to show what is +selected: a screen reporting "no provider in use" because the master switch is off would then save that +back as the truth. It says so in a banner instead. `selectedProvider` still answers empty for a stored +key whose module has been dropped from the installation, which is the one case the screen cannot +produce and has to describe. + ### Audit log Every action a **person** takes is one row in `auditLog` — `userId`, `clientIP`, `ts`, `kind` diff --git a/backend/api/bootstrap.ts b/backend/api/bootstrap.ts index 52d44295e..d031a9f80 100644 --- a/backend/api/bootstrap.ts +++ b/backend/api/bootstrap.ts @@ -75,7 +75,10 @@ async function routes(app: FastifyInstance) { ...site.config, id: site.id, hostname: site.hostname, - isEnabled: site.isEnabled + isEnabled: site.isEnabled, + // -> Never the stored `comments` block, which holds an Akismet key: what a browser is told + // is the selected provider and the markup to mount, and nothing else + comments: WIKI.models.comments.publicConfigFor(site.id) }, flags: WIKI.models.flags.getFlags(), auth: WIKI.models.authentication.getConfig(), diff --git a/backend/api/comments.ts b/backend/api/comments.ts new file mode 100644 index 000000000..d0b79aab6 --- /dev/null +++ b/backend/api/comments.ts @@ -0,0 +1,641 @@ +import { audit } from '../helpers/audit.ts' +import { maskSensitiveProps } from '../helpers/common.ts' +import { mayOnPage } from './pages.ts' +import { COMMENT_MAX_LENGTH, COMMENT_MIN_LENGTH } from '../models/comments.ts' +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' +import type { CommentsProviderInput } from '../models/comments.ts' + +const siteIdParam = { + type: 'object', + properties: { + siteId: { + type: 'string', + format: 'uuid' + } + }, + required: ['siteId'] +} + +const pageIdParam = { + type: 'object', + properties: { + siteId: { type: 'string', format: 'uuid' }, + pageId: { type: 'string', format: 'uuid' } + }, + required: ['siteId', 'pageId'] +} + +const commentIdParam = { + type: 'object', + properties: { + siteId: { type: 'string', format: 'uuid' }, + commentId: { type: 'string', format: 'uuid' } + }, + required: ['siteId', 'commentId'] +} + +/** + * A roughly-shaped email address, for the one a guest has to leave. + * + * Deliberately not a proof that the address exists — nothing here sends to it. It is what Akismet is + * given and what a future moderation screen would show, and the check is here so that a required + * field cannot be satisfied with a space. + */ +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +/** + * Comments API Routes + * + * Two halves, and they answer to different permissions. + * + * **The configuration half** — `GET`/`PUT /sites/:siteId/comments` — is the admin area's Comments + * screen: which provider this site uses and how it is configured. `manage:sites`, like every other + * per-site configuration screen. + * + * **The comments themselves** are the built-in provider, and carry NO route-level `permissions`: what + * governs them is `read:comments`, `write:comments` and `manage:comments`, which are PAGE rule + * permissions and cannot be enforced by a hook that only reads the group-wide list. Every one of these + * routes resolves the page first and asks `mayOnPage` about it — see `helpers/pageRules.ts` for how a + * rule is chosen. + */ +async function routes(app: FastifyInstance) { + /** + * GET SITE COMMENTS CONFIGURATION + */ + app.get<{ Params: { siteId: string } }>( + '/sites/:siteId/comments', + { + config: { + permissions: ['manage:sites'] + }, + schema: { + summary: 'Get the comments configuration of a site', + description: + 'The provider this site uses, plus one entry per provider that could be selected — the wiki’s own first, then every module installed in `modules/comments` — each with the values this site has configured for it.\n\nSensitive props are masked: the built-in provider’s Akismet key comes back as a fixed placeholder, and sending that placeholder back keeps the stored key.', + tags: ['Comments'], + params: siteIdParam, + response: { + 200: { + description: 'Comments configuration of the site', + type: 'object', + properties: { + provider: { + type: 'string', + description: + 'Key of the selected provider, or an empty string when this site has picked none. This is what is stored rather than what is in force: `isAllowed` is the other half.' + }, + isAllowed: { + type: 'boolean', + description: + 'Whether the site allows comments at all — the switch under General → Features. False makes the selection below have no effect, which is worth saying on the screen that edits it.' + }, + providers: { + type: 'array', + items: { $ref: 'CommentsProvider#' } + } + } + } + } + } + }, + async (req, reply) => { + const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId }) + if (!site) { + return reply.notFound('Site does not exist.') + } + const providers = WIKI.models.comments.getSiteProviders(req.params.siteId) + return { + // -> What is STORED. The screen edits the selection whether or not the site-wide switch is + // on, and says so through `isAllowed` rather than by reporting nothing selected. + provider: WIKI.models.comments.selectedProvider(req.params.siteId), + isAllowed: WIKI.models.comments.isAllowed(req.params.siteId), + // -> Masking is the last thing that happens on the way out, here and nowhere earlier: the + // model hands out the real values because that is what the spam check reads its key from + providers: providers.map((provider) => ({ + ...provider, + config: maskSensitiveProps(provider.props, provider.config) + })) + } + } + ) + + /** + * UPDATE SITE COMMENTS CONFIGURATION + */ + app.put<{ + Params: { siteId: string } + Body: { provider?: string; providers?: CommentsProviderInput[] } + }>( + '/sites/:siteId/comments', + { + config: { + permissions: ['manage:sites'] + }, + schema: { + summary: 'Update the comments configuration of a site', + description: + 'Selects the provider and writes whatever configuration came with it. The providers not mentioned keep what they had, so trying another one and coming back finds a form that is still filled in.\n\nEverything is validated before any of it is written, so a rejected request changes nothing. A saved change applies to the next page view, on every instance.', + tags: ['Comments'], + params: siteIdParam, + body: { + type: 'object', + properties: { + provider: { + type: 'string', + maxLength: 255, + description: + 'Key of the provider to use, or an empty string to turn comments off for this site.' + }, + providers: { + type: 'array', + items: { $ref: 'CommentsProviderInput#' } + } + } + }, + response: { + 200: { + description: 'Comments configuration updated successfully', + type: 'object', + properties: { + ok: { type: 'boolean' }, + message: { type: 'string' } + } + } + } + } + }, + async (req, reply) => { + const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId }) + if (!site) { + return reply.notFound('Site does not exist.') + } + if ( + req.body.provider !== undefined && + req.body.provider.length > 0 && + !WIKI.models.comments.getDefinition(req.body.provider) + ) { + return reply.badRequest(`There is no comments provider called "${req.body.provider}".`) + } + // -> Validated as a whole first: the screen saves every provider at once, and a partially + // applied configuration is worse than a refused one + for (const patch of req.body.providers ?? []) { + const invalid = WIKI.models.comments.validateProvider(patch) + if (invalid) { + return reply.badRequest(invalid) + } + } + + await WIKI.models.comments.updateSiteConfig(req.params.siteId, req.body) + + /* + Which provider was selected and which fields were touched — never the values. An Akismet key + is exactly the kind of thing `meta` must not carry, and the rest of it names an account at a + third party rather than anything about this wiki. + */ + await audit(req, 'admin', 'updateComments', { + siteId: req.params.siteId, + provider: req.body.provider, + changedProviders: (req.body.providers ?? []).map((patch) => ({ + key: patch.key, + changedFields: Object.keys(patch.config ?? {}) + })) + }) + + return { ok: true, message: 'Comments configuration updated successfully.' } + } + ) + + /** + * LIST COMMENTS OF A PAGE + */ + /* + No route-level `permissions`: `read:comments` is granted by a group's page RULES, which the hook + in `index.ts` cannot see. Checked against this page below instead. + */ + app.get<{ Params: { siteId: string; pageId: string } }>( + '/sites/:siteId/pages/:pageId/comments', + { + schema: { + summary: 'List the comments of a page', + description: + 'Every comment on the page, oldest first and flat — the one level of nesting is assembled by the client from `parentId`, which keeps a reply beside the comment it answers however old that comment is.\n\nOnly for a site using the built-in provider; a site whose discussions live at a third party answers 404 here. Needs `read:comments` on the page, which the guests group may hold.\n\n`mentions` resolves the handles written in these comments, so that a mention is drawn as a link to the right person without a lookup per `@`.', + tags: ['Comments'], + params: pageIdParam, + response: { + 200: { + description: 'The comments on this page', + type: 'object', + properties: { + comments: { + type: 'array', + items: { $ref: 'Comment#' } + }, + mentions: { + type: 'array', + items: { $ref: 'MentionTarget#' } + }, + total: { + type: 'integer', + description: + 'How many comments the page has. The same as the length of `comments` unless the page has more than the list caps at, which is the one case where the count beside the Talk tab must not be taken from the list.' + } + } + } + } + } + }, + async (req, reply) => { + const page = await requireBuiltInPage(req, reply) + if (!page) { + return reply + } + if (!mayOnPage(req, 'read:comments', page)) { + return reply.forbidden('You are not allowed to read the comments of this page.') + } + const comments = await WIKI.models.comments.listForPage(page.id) + const [mentions, total] = await Promise.all([ + WIKI.models.comments.resolveMentions(comments.map((c) => c.content)), + WIKI.models.comments.countForPage(page.id) + ]) + return { comments, mentions, total } + } + ) + + /** + * POST A COMMENT + */ + // -> No route-level permissions: `write:comments` is a page rule. See the note above. + app.post<{ + Params: { siteId: string; pageId: string } + Body: { content: string; parentId?: string; authorName?: string; authorEmail?: string } + }>( + '/sites/:siteId/pages/:pageId/comments', + { + schema: { + summary: 'Post a comment on a page', + description: + 'Needs `write:comments` on the page. A rule may grant it to the guests group, in which case a name and an email address are required of whoever is posting — the email is stored but never served, and is what the spam check is given.\n\nThe site’s posting cooldown applies to everybody who is not a moderator, counted per account and per address for a guest; going over it answers 429 with `Retry-After`. With an Akismet key configured, a comment Akismet calls spam is refused.', + tags: ['Comments'], + params: pageIdParam, + body: { + type: 'object', + required: ['content'], + properties: { + content: { + type: 'string', + minLength: COMMENT_MIN_LENGTH, + maxLength: COMMENT_MAX_LENGTH, + description: 'Markdown source. Raw HTML in it is escaped rather than rendered.' + }, + parentId: { + type: 'string', + format: 'uuid', + description: + 'The comment being answered. Replying to a reply attaches the answer to that reply’s own parent — the thread is one level deep.' + }, + authorName: { + type: 'string', + minLength: 1, + maxLength: 255, + description: + 'Required of a guest, ignored for a signed-in author (the account has one).' + }, + authorEmail: { + type: 'string', + maxLength: 255, + description: 'Required of a guest. Stored, never served.' + } + } + }, + response: { + 201: { + description: 'The comment as it was stored', + $ref: 'Comment#' + } + } + } + }, + async (req, reply) => { + const page = await requireBuiltInPage(req, reply) + if (!page) { + return reply + } + if (!page.allowComments) { + return reply.forbidden('Comments are turned off for this page.') + } + if (!mayOnPage(req, 'write:comments', page)) { + return reply.forbidden('You are not allowed to comment on this page.') + } + + const user = req.session?.authenticated ? req.session.user : null + let authorName = user?.name ?? '' + let authorEmail = user?.email ?? '' + if (!user) { + /* + A guest. Both fields are required here rather than left to the schema, because they are + required only of a guest: a signed-in author has a name and an address on their account, and + a form that asked them for both again would be asking them to type something the wiki + already knows and would then have two answers for. + */ + authorName = (req.body.authorName ?? '').trim() + authorEmail = (req.body.authorEmail ?? '').trim() + if (authorName.length < 1) { + return reply.badRequest('A name is required to comment as a guest.') + } + if (!EMAIL_PATTERN.test(authorEmail)) { + return reply.badRequest('A valid email address is required to comment as a guest.') + } + } + + const cooldown = await consumeCooldown(req, page) + if (cooldown > 0) { + reply.header('Retry-After', String(cooldown)) + return reply.tooManyRequests( + `You are posting too quickly. Try again in ${cooldown} second(s).` + ) + } + + const origin = `${req.protocol}://${req.hostname}` + const isSpam = await WIKI.models.comments.isSpam(req.params.siteId, { + content: req.body.content, + authorName, + authorEmail, + authorIP: req.ip, + userAgent: req.headers['user-agent'] ?? '', + referrer: req.headers.referer ?? '', + permalink: `${origin}/${page.path}`, + isGuest: !user + }) + if (isSpam) { + /* + Refused rather than held: there is no moderation queue yet, and a comment nobody can see + and nobody is told about is worse than one that was turned away with a reason. `meta` on + the row is where a queue would go when there is one. + */ + return reply.badRequest('This comment was flagged as spam and was not posted.') + } + + let comment + try { + comment = await WIKI.models.comments.create({ + pageId: page.id, + parentId: req.body.parentId ?? null, + content: req.body.content, + authorId: user?.id ?? null, + authorName, + authorEmail: user ? '' : authorEmail, + authorIP: req.ip + }) + } catch (err: any) { + return reply.badRequest(err.message) + } + + await audit(req, 'comment', 'createComment', { + commentId: comment.id, + pageId: page.id, + path: page.path, + locale: page.locale, + isReply: Boolean(comment.parentId), + isGuest: comment.isGuest + }) + + reply.code(201) + return comment + } + ) + + /** + * EDIT A COMMENT + */ + // -> No route-level permissions: the two that matter here are page rules. See the note above. + app.put<{ Params: { siteId: string; commentId: string }; Body: { content: string } }>( + '/sites/:siteId/comments/:commentId', + { + schema: { + summary: 'Edit a comment', + description: + 'Whoever holds `manage:comments` on the page may edit any comment on it; everybody else may edit their own, and only while they still hold `write:comments` there.\n\nA guest cannot edit at all: there is no session that identifies them as the author, so `their own` has nothing to mean.', + tags: ['Comments'], + params: commentIdParam, + body: { + type: 'object', + required: ['content'], + properties: { + content: { + type: 'string', + minLength: COMMENT_MIN_LENGTH, + maxLength: COMMENT_MAX_LENGTH + } + } + }, + response: { + 200: { + description: 'The comment as it now stands', + $ref: 'Comment#' + } + } + } + }, + async (req, reply) => { + const comment = await requireWritableComment(req, reply) + if (!comment) { + return reply + } + const updated = await WIKI.models.comments.update(comment.id, req.body.content) + if (!updated) { + return reply.notFound('This comment does not exist.') + } + await audit(req, 'comment', 'updateComment', { + commentId: comment.id, + pageId: comment.pageId, + path: comment.path, + isOwn: comment.authorId === req.session?.user?.id + }) + return updated + } + ) + + /** + * DELETE A COMMENT + */ + // -> No route-level permissions: the two that matter here are page rules. See the note above. + app.delete<{ Params: { siteId: string; commentId: string } }>( + '/sites/:siteId/comments/:commentId', + { + schema: { + summary: 'Delete a comment', + description: + 'Same rule as editing: `manage:comments` on the page deletes any comment, `write:comments` deletes your own.\n\nThe replies underneath go with it. A reply exists to answer something, and left behind it is half of a conversation nobody can read.', + tags: ['Comments'], + params: commentIdParam, + response: { + 200: { + description: 'Comment deleted successfully', + type: 'object', + properties: { + ok: { type: 'boolean' }, + deleted: { + type: 'integer', + description: 'How many comments went, the replies underneath included.' + } + } + } + } + } + }, + async (req, reply) => { + const comment = await requireWritableComment(req, reply) + if (!comment) { + return reply + } + const deleted = await WIKI.models.comments.remove(comment.id) + await audit(req, 'comment', 'deleteComment', { + commentId: comment.id, + pageId: comment.pageId, + path: comment.path, + deleted, + isOwn: comment.authorId === req.session?.user?.id + }) + return { ok: true, deleted } + } + ) + + /** + * SEARCH MENTIONABLE USERS + */ + app.get<{ Params: { siteId: string }; Querystring: { q?: string } }>( + '/sites/:siteId/comments/mentions', + { + schema: { + summary: 'Find users to mention in a comment', + description: + 'What the `@` in a comment box completes against: users who have set a handle, matched on the handle or the display name.\n\nNeeds a signed-in session, and nothing else — a handle and a display name are what every comment already shows, but answering this to anybody at all would make it a way to enumerate the wiki’s users. A guest who knows a handle can still type it; it resolves when the comment is drawn.', + tags: ['Comments'], + params: siteIdParam, + querystring: { + type: 'object', + properties: { + q: { + type: 'string', + maxLength: 64, + description: 'What has been typed after the `@`. Empty lists the first few handles.' + } + } + }, + response: { + 200: { + description: 'Users that can be mentioned', + type: 'array', + items: { $ref: 'MentionTarget#' } + } + } + } + }, + async (req, reply) => { + if (!req.session?.authenticated) { + return reply.unauthorized('You must be signed in to look up users to mention.') + } + return WIKI.models.comments.searchHandles(req.query.q ?? '') + } + ) +} + +/** + * The page a request is about, once it is established that this site's comments are the wiki's own. + * + * Both questions answer 404 rather than anything more specific. A site using Disqus has no comments + * here to have an opinion about, and a page id that is not on this site is not this caller's to be + * told about. + * + * @returns The page, or null once it has sent the reply itself + */ +async function requireBuiltInPage( + req: FastifyRequest<{ Params: { siteId: string; pageId: string } }>, + reply: FastifyReply +) { + if (!WIKI.models.comments.usesBuiltIn(req.params.siteId)) { + reply.notFound('This site does not use the built-in comments provider.') + return null + } + const page = await WIKI.models.comments.pageRef(req.params.siteId, req.params.pageId) + if (!page) { + reply.notFound('This page does not exist.') + return null + } + return page +} + +/** + * The comment a request is about, once it is established that the caller may change it. + * + * `manage:comments` on the page is the moderator's answer and covers anything on it. Otherwise it has + * to be the caller's own comment AND they have to still hold `write:comments` there — a rule that was + * taken away takes the editing of what was written under it with it. + * + * @returns The comment, or null once it has sent the reply itself + */ +async function requireWritableComment( + req: FastifyRequest<{ Params: { siteId: string; commentId: string } }>, + reply: FastifyReply +) { + if (!WIKI.models.comments.usesBuiltIn(req.params.siteId)) { + reply.notFound('This site does not use the built-in comments provider.') + return null + } + const comment = await WIKI.models.comments.getWithPage(req.params.commentId, req.params.siteId) + if (!comment) { + reply.notFound('This comment does not exist.') + return null + } + const page = { path: comment.path, locale: comment.locale, tags: comment.tags ?? [] } + if (mayOnPage(req, 'manage:comments', page)) { + return comment + } + const userId = req.session?.authenticated ? req.session.user?.id : null + if (!userId || comment.authorId !== userId || !mayOnPage(req, 'write:comments', page)) { + reply.forbidden('You are not allowed to modify this comment.') + return null + } + return comment +} + +/** + * Count this post against the site's cooldown, and say how long is left of it. + * + * Per account, and per address for a guest — an office behind one address shares a counter only where + * the wiki has no better way of telling two people apart, which is exactly the case the cooldown is + * for. The counter is the same postgres-backed one the login limit uses, so two instances behind a + * load balancer agree about it. + * + * Moderators are exempt, along with `manage:system` as everywhere: `manage:comments` on this page is + * the permission to clean up after other people, and answering five threads in a row is what that + * looks like. + * + * @returns Seconds the caller must wait, or 0 when the post may go ahead + */ +async function consumeCooldown( + req: FastifyRequest<{ Params: { siteId: string; pageId: string } }>, + page: { path: string; locale: string; tags: string[] } +): Promise { + const seconds = WIKI.models.comments.cooldownFor(req.params.siteId) + if (seconds < 1 || mayOnPage(req, 'manage:comments', page)) { + return 0 + } + // -> The address is the fallback for a session with no user on it as well as for a guest: a key + // ending in `undefined` would be one counter shared by everybody it happened to + const who = (req.session?.authenticated ? req.session.user?.id : null) ?? `ip:${req.ip}` + const verdict = await WIKI.models.rateLimits.consume(`comment:${req.params.siteId}:${who}`, { + /* + One post per window, and a ban as long as the window. Two attempts inside the cooldown are one + post and one refusal, and the refusal does not push the ban further out — a banned key stops + counting, so the wait is measured from the last post that was actually accepted plus whatever + the client spent retrying. + */ + max: 1, + windowSeconds: seconds, + banSeconds: seconds + }) + return verdict.allowed ? 0 : verdict.retryAfter +} + +export default routes diff --git a/backend/api/index.ts b/backend/api/index.ts index 2ce091e2c..dcbb0dca6 100644 --- a/backend/api/index.ts +++ b/backend/api/index.ts @@ -12,6 +12,7 @@ async function routes(app: FastifyInstance) { await import('./schemas/audit.ts').then((m) => m.registerSchemas(app)) await import('./schemas/authentication.ts').then((m) => m.registerSchemas(app)) await import('./schemas/block.ts').then((m) => m.registerSchemas(app)) + await import('./schemas/comments.ts').then((m) => m.registerSchemas(app)) await import('./schemas/extension.ts').then((m) => m.registerSchemas(app)) await import('./schemas/flags.ts').then((m) => m.registerSchemas(app)) await import('./schemas/group.ts').then((m) => m.registerSchemas(app)) @@ -37,6 +38,7 @@ async function routes(app: FastifyInstance) { app.register(import('./authentication.ts')) app.register(import('./blocks.ts')) app.register(import('./bootstrap.ts'), { prefix: '/bootstrap' }) + app.register(import('./comments.ts')) app.register(import('./groups.ts'), { prefix: '/groups' }) app.register(import('./hooks.ts'), { prefix: '/hooks' }) app.register(import('./icons.ts'), { prefix: '/icons' }) diff --git a/backend/api/pages.ts b/backend/api/pages.ts index 065be678a..bc1a6b2fa 100644 --- a/backend/api/pages.ts +++ b/backend/api/pages.ts @@ -693,7 +693,7 @@ async function routes(app: FastifyInstance) { is what makes a page view one request instead of four. */ const actorId = actor?.id ?? null - const [approvalState, isWatching] = await Promise.all([ + const [approvalState, isWatching, commentsCount] = await Promise.all([ WIKI.models.approvals.pageViewerState(req, req.params.siteId, { id: page.id, path: page.path, @@ -701,10 +701,19 @@ async function routes(app: FastifyInstance) { allowContributions: page.allowContributions }), // -> One indexed lookup on (pageId, userId), and none at all for a reader with no account - WIKI.models.pageWatching.isWatching(page.id, actorId) + WIKI.models.pageWatching.isWatching(page.id, actorId), + /* + The badge on the Talk tab, which has to be there before the tab is opened — so it comes with + the page rather than with the comments. One indexed count, and not even that for a site + whose discussions live at a third party or that has comments turned off. + */ + WIKI.models.comments.usesBuiltIn(req.params.siteId) + ? WIKI.models.comments.countForPage(page.id) + : 0 ]) return { ...page, + commentsCount, viewer: { permissions: pagePermissionsFor(req, page), ...approvalState, diff --git a/backend/api/schemas/comments.ts b/backend/api/schemas/comments.ts new file mode 100644 index 000000000..2540f6767 --- /dev/null +++ b/backend/api/schemas/comments.ts @@ -0,0 +1,139 @@ +import type { FastifyInstance } from 'fastify' + +export async function registerSchemas(app: FastifyInstance): Promise { + /** + * COMMENTS PROVIDER - A comments module as configured for a site + */ + app.addSchema({ + $id: 'CommentsProvider', + type: 'object', + properties: { + key: { + type: 'string', + description: + 'Directory name under `modules/comments`, or `default` for the provider the wiki implements itself.' + }, + title: { + type: 'string' + }, + description: { + type: 'string' + }, + website: { + type: 'string', + description: "The provider's own site." + }, + icon: { + type: 'string' + }, + isBuiltIn: { + type: 'boolean', + description: + 'Whether this is the wiki’s own provider. It stores comments here, in the `comments` table, and draws them on a Talk tab beside the article rather than under it.' + }, + isSelected: { + type: 'boolean', + description: + 'Whether this is the provider the site uses. At most one provider is: two comment widgets on a page are two separate discussions of it, and neither is the discussion.' + }, + requires: { + type: 'array', + items: { type: 'string' }, + description: + 'The config keys that must hold a value before the provider can be used. A selected provider missing one of these contributes nothing rather than a widget pointed at no account.' + }, + props: { + type: 'object', + additionalProperties: true, + description: + 'The configuration fields the module declares, as the admin area renders them. Read-only: what a module needs configured is a property of the module, not of the site.' + }, + config: { + type: 'object', + additionalProperties: true, + description: + "The stored value of each prop, completed from the module's defaults. A prop marked sensitive — the built-in provider's Akismet key — is replaced by a fixed mask, and sending that mask back means “leave it as it is”." + } + } + }) + + /** + * COMMENTS PROVIDER INPUT - What a client may change about one provider + */ + app.addSchema({ + $id: 'CommentsProviderInput', + type: 'object', + properties: { + key: { + type: 'string' + }, + config: { + type: 'object', + additionalProperties: true, + description: + 'Values for the props the module declares. Unknown keys are dropped, read-only props are ignored, and a sensitive prop sent back as the mask keeps the value already stored.' + } + }, + required: ['key'] + }) + + /** + * COMMENT - One comment on one page, from the built-in provider + * + * Neither the email a guest typed nor the address it was posted from is here. Both are stored, for + * the spam check and for whatever moderation grows out of it, and neither is anybody's to read from + * an API. + */ + app.addSchema({ + $id: 'Comment', + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + parentId: { + type: 'string', + format: 'uuid', + nullable: true, + description: + 'The comment this one answers, or null for one that starts a thread. Replies are one level deep: answering a reply attaches the answer to that reply’s own parent.' + }, + content: { + type: 'string', + description: + 'Markdown source, as it was typed. There is no stored HTML — a comment is rendered in the reader’s browser with raw HTML disabled.' + }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { + type: 'string', + format: 'date-time', + description: 'Later than `createdAt` for a comment that has been edited.' + }, + authorId: { + type: 'string', + format: 'uuid', + nullable: true, + description: 'Null for a guest, and null again once the account behind it is deleted.' + }, + authorName: { type: 'string' }, + authorHasAvatar: { type: 'boolean' }, + authorHandle: { + type: 'string', + nullable: true, + description: 'The handle this author is mentioned by, or null if they have not set one.' + }, + isGuest: { type: 'boolean' } + } + }) + + /** + * MENTION TARGET - A handle that resolved to somebody + */ + app.addSchema({ + $id: 'MentionTarget', + type: 'object', + properties: { + handle: { type: 'string' }, + id: { type: 'string', format: 'uuid' }, + name: { type: 'string' } + } + }) +} diff --git a/backend/api/schemas/page.ts b/backend/api/schemas/page.ts index cef515176..f30ef58f4 100644 --- a/backend/api/schemas/page.ts +++ b/backend/api/schemas/page.ts @@ -214,6 +214,11 @@ export async function registerSchemas(app: FastifyInstance): Promise { allowComments: { type: 'boolean' }, allowContributions: { type: 'boolean' }, allowRatings: { type: 'boolean' }, + commentsCount: { + type: 'integer', + description: + 'How many comments this page has, which is what the Talk tab’s badge counts. Always 0 unless the site uses the built-in comments provider. Present when a page is fetched on its own.' + }, showSidebar: { type: 'boolean' }, showTags: { type: 'boolean' }, showToc: { type: 'boolean' }, diff --git a/backend/api/schemas/site.ts b/backend/api/schemas/site.ts index ecc9d63c5..31afecabe 100644 --- a/backend/api/schemas/site.ts +++ b/backend/api/schemas/site.ts @@ -94,7 +94,9 @@ export async function registerSchemas(app: FastifyInstance): Promise { enum: ['off', 'stars', 'thumbs'] }, comments: { - type: 'boolean' + type: 'boolean', + description: + 'Whether this site has comments at all. Which provider handles them is `comments.provider`; this turns every one of them off without losing that choice, and a page can still opt out on its own with `allowComments`.' }, reasonForChange: { type: 'string', @@ -105,6 +107,42 @@ export async function registerSchemas(app: FastifyInstance): Promise { } } }, + comments: { + type: 'object', + description: + 'What a browser is told about this site’s comments, and all it is told: which provider is selected, and — for a third-party one — the markup to mount under the article. Built by `models/comments.ts`; the stored configuration behind it is not serialized anywhere, because the built-in provider’s holds an Akismet key.', + properties: { + provider: { + type: 'string', + description: + 'Key of the selected provider, or an empty string when this site has comments turned off — which is also the answer for a provider that is selected but not finished being configured.' + }, + isBuiltIn: { + type: 'boolean', + description: + 'Whether the provider is the wiki’s own. Only for that one is the Talk tab drawn beside the article; every other provider is mounted under it.' + }, + code: { + type: 'object', + description: + 'The third-party markup, with everything but the page placeholders already substituted. Empty for the built-in provider.', + properties: { + head: { type: 'string' }, + main: { type: 'string' }, + body: { type: 'string' } + } + }, + cooldownSeconds: { + type: 'integer', + description: + 'How long the composer makes a reader wait between two comments. Built-in only, and 0 when there is no cooldown.' + }, + maxLength: { + type: 'integer', + description: 'The longest a comment may be, in characters of markdown source.' + } + } + }, uploads: { type: 'object', properties: { diff --git a/backend/api/schemas/user.ts b/backend/api/schemas/user.ts index 9a28880bb..1c19ac965 100644 --- a/backend/api/schemas/user.ts +++ b/backend/api/schemas/user.ts @@ -133,6 +133,11 @@ export async function registerSchemas(app: FastifyInstance): Promise { hasAvatar: { type: 'boolean' }, + handle: { + type: 'string', + description: + 'The name this user is mentioned by in a comment, without the `@`. An empty string for somebody who has not set one, who is therefore not mentionable — nothing is derived from a display name on anybody’s behalf.' + }, location: { type: 'string' }, @@ -183,6 +188,11 @@ export async function registerSchemas(app: FastifyInstance): Promise { hasAvatar: { type: 'boolean' }, + handle: { + type: 'string', + description: + 'The handle this user is mentioned by, without the `@`, or an empty string if they have not set one. Public because it is written into every comment that mentions them.' + }, location: { type: 'string' }, @@ -221,6 +231,13 @@ export async function registerSchemas(app: FastifyInstance): Promise { minLength: 1, maxLength: 255 }, + handle: { + type: 'string', + maxLength: 32, + pattern: '^$|^[A-Za-z0-9_-]{3,32}$', + description: + 'The name to be mentioned by in comments, without the `@`. Unique across the wiki, case-insensitively; an empty string takes it off. 409 if somebody else already has it.' + }, location: { type: 'string', maxLength: 255 diff --git a/backend/api/sites.ts b/backend/api/sites.ts index 3637dc4a5..9b9c97d93 100644 --- a/backend/api/sites.ts +++ b/backend/api/sites.ts @@ -74,7 +74,9 @@ async function routes(app: FastifyInstance) { ...s.config, id: s.id, hostname: s.hostname, - isEnabled: s.isEnabled + isEnabled: s.isEnabled, + // -> See the note in `api/bootstrap.ts`: the stored block is not the one that is served + comments: WIKI.models.comments.publicConfigFor(s.id) })) } ) @@ -143,7 +145,8 @@ async function routes(app: FastifyInstance) { ...site.config, id: site.id, hostname: site.hostname, - isEnabled: site.isEnabled + isEnabled: site.isEnabled, + comments: WIKI.models.comments.publicConfigFor(site.id) } } else { return reply.notFound('Site does not exist.') diff --git a/backend/api/users.ts b/backend/api/users.ts index b84e1e6a4..5f23373c0 100644 --- a/backend/api/users.ts +++ b/backend/api/users.ts @@ -90,6 +90,13 @@ const IDENTITY_PROFILE_FIELDS = ['name', 'location', 'jobTitle', 'pronouns'] as * a directory must not take somebody's accessibility settings away with it. */ const PERSONAL_PROFILE_FIELDS = [ + /* + The handle is here rather than among the identity fields on purpose. No identity provider owns a + wiki mention handle — there is nothing in a directory for it to be kept in step with — so a wiki + that turned profile editing off to keep names authoritative would otherwise have taken away the + one field that lets anybody be mentioned in a comment. + */ + 'handle', 'timezone', 'dateFormat', 'timeFormat', diff --git a/backend/db/migrations/20260913073518_comments/migration.sql b/backend/db/migrations/20260913073518_comments/migration.sql new file mode 100644 index 000000000..321a7c808 --- /dev/null +++ b/backend/db/migrations/20260913073518_comments/migration.sql @@ -0,0 +1,22 @@ +CREATE TABLE "comments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "pageId" uuid NOT NULL, + "parentId" uuid, + "content" text NOT NULL, + "authorId" uuid, + "authorName" varchar(255) NOT NULL, + "authorEmail" varchar(255) DEFAULT '' NOT NULL, + "authorIP" varchar(255) DEFAULT '' NOT NULL, + "meta" jsonb DEFAULT '{}' NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "handle" varchar(64);--> statement-breakpoint +CREATE INDEX "comments_page_created_idx" ON "comments" ("pageId","createdAt");--> statement-breakpoint +CREATE INDEX "comments_parentId_idx" ON "comments" ("parentId");--> statement-breakpoint +CREATE INDEX "comments_authorId_idx" ON "comments" ("authorId");--> statement-breakpoint +CREATE UNIQUE INDEX "users_handle_idx" ON "users" (lower("handle"));--> statement-breakpoint +ALTER TABLE "comments" ADD CONSTRAINT "comments_pageId_pages_id_fkey" FOREIGN KEY ("pageId") REFERENCES "pages"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "comments" ADD CONSTRAINT "comments_parentId_comments_id_fkey" FOREIGN KEY ("parentId") REFERENCES "comments"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "comments" ADD CONSTRAINT "comments_authorId_users_id_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id") ON DELETE SET NULL; \ No newline at end of file diff --git a/backend/db/migrations/20260913073518_comments/snapshot.json b/backend/db/migrations/20260913073518_comments/snapshot.json new file mode 100644 index 000000000..c3e746730 --- /dev/null +++ b/backend/db/migrations/20260913073518_comments/snapshot.json @@ -0,0 +1,6411 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "e68aaee1-b63d-418b-b1b8-1eb1a82f52a4", + "prevIds": [ + "3425541e-bdcc-4010-955b-dda671165ac7" + ], + "ddl": [ + { + "values": [ + "document", + "image", + "other" + ], + "name": "assetKind", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "pending", + "success", + "error" + ], + "name": "hookState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "active", + "completed", + "failed", + "interrupted" + ], + "name": "jobHistoryState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "draft", + "published", + "scheduled" + ], + "name": "pagePublishState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "inherit", + "override", + "overrideExact", + "hide", + "hideExact" + ], + "name": "treeNavigationMode", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "folder", + "page", + "asset" + ], + "name": "treeType", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "apiKeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "approvalRules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "assets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "auditLog", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "authentication", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "blocks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "comments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "groups", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "hooks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "iconSets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "icons", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobHistory", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobLock", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobSchedule", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "locales", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageEditSubmissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageHistory", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageRenderQueue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageWatching", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "rateLimits", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "siteAssets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sites", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "storage", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tree", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "userAvatars", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "userGroups", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "userKeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "users", + "entityType": "tables", + "schema": "public" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "varchar(8)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "keyShort", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "groups", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "expiration", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isRevoked", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'START'", + "generated": null, + "identity": null, + "name": "match", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "submitterGroups", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "reviewerGroups", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileName", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileExt", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isSystem", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "assetKind", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'other'", + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'application/octet-stream'", + "generated": null, + "identity": null, + "name": "mimeType", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileSize", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "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": "auditLog" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "ts", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "clientIP", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "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": "comments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorName", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "authorEmail", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "authorIP", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "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": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isInstalled", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "hash", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "customCode", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "customName", + "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": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "patch", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseHash", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "guestName", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "guestEmail", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'updated'", + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "changedFields", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "versionDate", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowScripts", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowStyles", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "requestedById", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hash", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "alias", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "pagePublishState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "publishState", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishStartDate", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishEndDate", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "relations", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "localeGroupId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "render", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "searchContent", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ts", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "toc", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "editor", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentType", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isBrowsable", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isSearchable", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": { + "as": "\"pages\".\"publishState\" != 'draft' AND \"pages\".\"isSearchable\"", + "type": "stored" + }, + "identity": null, + "name": "isSearchableComputed", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "ratingScore", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "ratingCount", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "scripts", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "historyData", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "creatorId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ownerId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "hits", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "windowStartedAt", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bannedUntil", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "settings" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "siteAssets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "siteAssets" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "siteAssets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hostname", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "contentTypes", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "assetDelivery", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tag", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "usageCount", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "ltree", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "folderPath", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileName", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hash", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "treeType", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tree", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "treeNavigationMode", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'inherit'", + "generated": null, + "identity": null, + "name": "navigationMode", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "navigationId", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "userAvatars" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "userAvatars" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "userGroups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupId", + "entityType": "columns", + "schema": "public", + "table": "userGroups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "validUntil", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "handle", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "auth", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "passkeys", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "prefs", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hasAvatar", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isActive", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isSystem", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isVerified", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastLoginAt", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "approvalRules_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "approvalRules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "assets_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "assets" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_ts_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "kind", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_kind_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_action_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "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": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "comments_page_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "comments_parentId_idx", + "entityType": "indexes", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "comments_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "comments" + }, + { + "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": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "navigation_siteId_locale_key", + "entityType": "indexes", + "schema": "public", + "table": "navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_pageId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"authorId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_page_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "versionDate", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageHistory_pageId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "path", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "versionDate", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageHistory_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageHistory_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageRenderQueue_createdAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageWatching_user_site_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageWatching_page_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "creatorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_creatorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ownerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_ownerId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ts", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "pages_ts_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tags", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "pages_tags_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isSearchableComputed", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_isSearchableComputed_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "localeGroupId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_localeGroupId_locale_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "rateLimits_updatedAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "rateLimits" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "sessions_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "module", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "storage_composite_idx", + "entityType": "indexes", + "schema": "public", + "table": "storage" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tags_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "tag", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tags_composite_idx", + "entityType": "indexes", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "folderPath", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_folderpath_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "folderPath", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gist", + "concurrently": false, + "name": "tree_folderpath_gist_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileName", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_fileName_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_hash_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tree", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_locale_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "navigationMode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_navigationMode_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "navigationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_navigationId_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tags", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "tree_tags_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userGroups_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "groupId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userGroups_groupId_idx", + "entityType": "indexes", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userGroups_composite_idx", + "entityType": "indexes", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userKeys_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "userKeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastLoginAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "users_lastLoginAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lower(\"handle\")", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "users_handle_idx", + "entityType": "indexes", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "approvalRules_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "approvalRules" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "assets_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "assets" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "assets_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "assets" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "auditLog_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "auditLog" + }, + { + "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": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "comments_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "comments", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "comments_parentId_comments_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "comments_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": false, + "columns": [ + "prefix" + ], + "schemaTo": "public", + "tableTo": "iconSets", + "columnsTo": [ + "prefix" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "icons_prefix_iconSets_prefix_fkey", + "entityType": "fks", + "schema": "public", + "table": "icons" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "navigation_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "navigation" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageEditSubmissions_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageEditSubmissions_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageEditSubmissions_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "pageHistory_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageHistory_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageRenderQueue_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageRenderQueue_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": false, + "columns": [ + "requestedById" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "pageRenderQueue_requestedById_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageWatching_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageWatching_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageWatching_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "creatorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_creatorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "ownerId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_ownerId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "sessions_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "siteAssets_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "siteAssets" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "storage_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "storage" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "tags_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "tree_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "userGroups_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": false, + "columns": [ + "groupId" + ], + "schemaTo": "public", + "tableTo": "groups", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "userGroups_groupId_groups_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "userKeys_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "userKeys" + }, + { + "columns": [ + "prefix", + "name" + ], + "nameExplicit": false, + "name": "icons_pkey", + "entityType": "pks", + "schema": "public", + "table": "icons" + }, + { + "columns": [ + "siteId", + "kind" + ], + "nameExplicit": false, + "name": "siteAssets_pkey", + "entityType": "pks", + "schema": "public", + "table": "siteAssets" + }, + { + "columns": [ + "userId", + "groupId" + ], + "nameExplicit": false, + "name": "userGroups_pkey", + "entityType": "pks", + "schema": "public", + "table": "userGroups" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "apiKeys_pkey", + "schema": "public", + "table": "apiKeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "approvalRules_pkey", + "schema": "public", + "table": "approvalRules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "assets_pkey", + "schema": "public", + "table": "assets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "auditLog_pkey", + "schema": "public", + "table": "auditLog", + "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": "comments_pkey", + "schema": "public", + "table": "comments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "groups_pkey", + "schema": "public", + "table": "groups", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "hooks_pkey", + "schema": "public", + "table": "hooks", + "entityType": "pks" + }, + { + "columns": [ + "prefix" + ], + "nameExplicit": false, + "name": "iconSets_pkey", + "schema": "public", + "table": "iconSets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jobHistory_pkey", + "schema": "public", + "table": "jobHistory", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "jobLock_pkey", + "schema": "public", + "table": "jobLock", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jobSchedule_pkey", + "schema": "public", + "table": "jobSchedule", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jobs_pkey", + "schema": "public", + "table": "jobs", + "entityType": "pks" + }, + { + "columns": [ + "code" + ], + "nameExplicit": false, + "name": "locales_pkey", + "schema": "public", + "table": "locales", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "navigation_pkey", + "schema": "public", + "table": "navigation", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageEditSubmissions_pkey", + "schema": "public", + "table": "pageEditSubmissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageHistory_pkey", + "schema": "public", + "table": "pageHistory", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageRenderQueue_pkey", + "schema": "public", + "table": "pageRenderQueue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageWatching_pkey", + "schema": "public", + "table": "pageWatching", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pages_pkey", + "schema": "public", + "table": "pages", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "rateLimits_pkey", + "schema": "public", + "table": "rateLimits", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "sessions_pkey", + "schema": "public", + "table": "sessions", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "settings_pkey", + "schema": "public", + "table": "settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "sites_pkey", + "schema": "public", + "table": "sites", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "storage_pkey", + "schema": "public", + "table": "storage", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tags_pkey", + "schema": "public", + "table": "tags", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tree_pkey", + "schema": "public", + "table": "tree", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "userAvatars_pkey", + "schema": "public", + "table": "userAvatars", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "userKeys_pkey", + "schema": "public", + "table": "userKeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "users_pkey", + "schema": "public", + "table": "users", + "entityType": "pks" + }, + { + "nameExplicit": false, + "columns": [ + "customCode" + ], + "nullsNotDistinct": false, + "name": "locales_customCode_key", + "schema": "public", + "table": "locales", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "nullsNotDistinct": false, + "name": "pageRenderQueue_pageId_key", + "schema": "public", + "table": "pageRenderQueue", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "hostname" + ], + "nullsNotDistinct": false, + "name": "sites_hostname_key", + "schema": "public", + "table": "sites", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "users_email_key", + "schema": "public", + "table": "users", + "entityType": "uniques" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/backend/db/schema.ts b/backend/db/schema.ts index 6f6999001..28985f39f 100644 --- a/backend/db/schema.ts +++ b/backend/db/schema.ts @@ -16,6 +16,7 @@ import { uuid, varchar } from 'drizzle-orm/pg-core' +import type { AnyPgColumn } from 'drizzle-orm/pg-core' // == CUSTOM TYPES ===================== @@ -209,6 +210,74 @@ export const blocks = pgTable( (table) => [index('blocks_siteId_idx').on(table.siteId)] ) +// COMMENTS ---------------------------- +/** + * One comment on one page, for the BUILT-IN comments provider. + * + * The other providers are a snippet of markup and an account somewhere else, so nothing about them + * reaches this table — it exists for the provider that is this wiki. See `models/comments.ts`. + * + * Replies are one level deep and that is enforced in the model: a reply names the comment it answers + * in `parentId`, and a reply to a reply is attached to that reply's own parent rather than nesting + * further. The foreign key is self-referential and cascades, so deleting a comment takes the replies + * under it — which is the whole of what a thread is here. + * + * `content` is markdown source and there is no stored render. It is turned into HTML in the reader's + * browser (`frontend/src/renderers/comment.js`) with raw HTML disabled, the same way a page's + * markdown becomes HTML in the browser — which also means a mention re-resolves every time it is + * drawn rather than freezing whatever a handle pointed at on the day it was written. + */ +export const comments = pgTable( + 'comments', + { + id: uuid().primaryKey().defaultRandom(), + pageId: uuid() + .notNull() + .references(() => pages.id, { onDelete: 'cascade' }), + /** + * The comment this one answers, or null for one that starts a thread. + * + * The annotation breaks the circular inference a self-reference would otherwise cause + * (TS7022/TS7024), the same way the generated column on `pages` does. + */ + parentId: uuid().references((): AnyPgColumn => comments.id, { onDelete: 'cascade' }), + /** Markdown source as it was typed. Never HTML — see the note above. */ + content: text().notNull(), + /** + * The account that wrote it, or null for a guest — and also null once that account is deleted, + * which is why the name below is kept alongside rather than only joined for. + */ + authorId: uuid().references(() => users.id, { onDelete: 'set null' }), + /** + * Who it says wrote it. What a guest typed into the form, and for a signed-in author a copy of + * their display name as it stood — used only when the account behind `authorId` is gone, since a + * rename should show through everywhere else. + */ + authorName: varchar({ length: 255 }).notNull(), + /** + * A guest's email address. Required of a guest, empty for a signed-in author (the account has + * one), and never sent to a client: it is here for the spam check and for whatever moderation + * grows out of it. + */ + authorEmail: varchar({ length: 255 }).notNull().default(''), + /** The address it was posted from, kept for the same reasons as the audit log's. Never served. */ + authorIP: varchar({ length: 255 }).notNull().default(''), + /** + * Room for what a comment may grow: votes, a pin, a moderation state. Nothing reads it yet, and + * nothing should write a key into it without deciding what an absent one means. + */ + meta: jsonb().notNull().default({}), + createdAt: timestamp().notNull().defaultNow(), + updatedAt: timestamp().notNull().defaultNow() + }, + (table) => [ + // -> The talk view's own query: every comment on a page, oldest first + index('comments_page_created_idx').on(table.pageId, table.createdAt), + index('comments_parentId_idx').on(table.parentId), + index('comments_authorId_idx').on(table.authorId) + ] +) + // GROUPS ------------------------------ export const groups = pgTable('groups', { id: uuid().primaryKey().defaultRandom(), @@ -886,6 +955,15 @@ export const users = pgTable( id: uuid().primaryKey().defaultRandom(), email: varchar({ length: 255 }).notNull().unique(), name: varchar({ length: 255 }).notNull(), + /** + * The name this user is mentioned by in a comment, without the `@`. + * + * Null until they pick one, and a user without one is simply not mentionable — nothing is + * derived from their name on their behalf. Unique case-insensitively: `@Ana` and `@ana` have to + * be the same person for a mention to mean anything, so the index below is on the folded form + * while the column keeps the capitalization that was typed. + */ + handle: varchar({ length: 64 }), auth: jsonb().notNull().default({}), meta: jsonb().notNull().default({}), passkeys: jsonb().notNull().default({}), @@ -898,7 +976,12 @@ export const users = pgTable( createdAt: timestamp().notNull().defaultNow(), updatedAt: timestamp().notNull().defaultNow() }, - (table) => [index('users_lastLoginAt_idx').on(table.lastLoginAt)] + (table) => [ + index('users_lastLoginAt_idx').on(table.lastLoginAt), + // -> Folded, so that two handles differing only in case cannot both exist. Nulls are distinct to + // postgres, which is what lets any number of users have no handle at all. + uniqueIndex('users_handle_idx').on(sql`lower(${table.handle})`) + ] ) // == RELATION TABLES ================== diff --git a/backend/index.ts b/backend/index.ts index f6e56921c..f87ec9e6d 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -208,6 +208,7 @@ async function postBoot() { // -> No per-site rows to create: what a site has turned on lives in its own config blob, which the // sites cache above already holds await WIKI.models.analytics.refreshFromDisk() + await WIKI.models.comments.refreshFromDisk() // -> Optional third-party tooling: report what is available, since features silently degrade // without it diff --git a/backend/locales/en.json b/backend/locales/en.json index 1f14d2ee8..418bf7c25 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -136,6 +136,7 @@ "admin.audit.actions.createApiKey": "Created an API key", "admin.audit.actions.createApprovalRule": "Created an approval rule", "admin.audit.actions.createAuthStrategy": "Added an authentication strategy", + "admin.audit.actions.createComment": "Posted a comment", "admin.audit.actions.createFolder": "Created a folder", "admin.audit.actions.createGroup": "Created a group", "admin.audit.actions.createHook": "Created a webhook", @@ -147,6 +148,7 @@ "admin.audit.actions.deleteAuthStrategy": "Deleted an authentication strategy", "admin.audit.actions.deleteAvatar": "Removed their avatar", "admin.audit.actions.deleteBlock": "Deleted a custom block", + "admin.audit.actions.deleteComment": "Deleted a comment", "admin.audit.actions.deleteFolder": "Deleted a folder", "admin.audit.actions.deleteGroup": "Deleted a group", "admin.audit.actions.deleteHook": "Deleted a webhook", @@ -207,6 +209,8 @@ "admin.audit.actions.updateAuthStrategy": "Updated an authentication strategy", "admin.audit.actions.updateAvatar": "Changed their avatar", "admin.audit.actions.updateBlock": "Changed the blocks of a site", + "admin.audit.actions.updateComment": "Edited a comment", + "admin.audit.actions.updateComments": "Changed the comments configuration", "admin.audit.actions.updateEditorSettings": "Changed their editor settings", "admin.audit.actions.updateFlags": "Changed the system flags", "admin.audit.actions.updateFolder": "Renamed a folder", @@ -247,6 +251,7 @@ "admin.audit.kinds.admin": "Admin", "admin.audit.kinds.asset": "File", "admin.audit.kinds.auth": "Sign-in", + "admin.audit.kinds.comment": "Comment", "admin.audit.kinds.page": "Page", "admin.audit.kinds.profile": "Profile", "admin.audit.loadActionsFailed": "Could not load the list of actions.", @@ -351,11 +356,24 @@ "admin.blocks.saveSuccess": "Blocks state saved successfully.", "admin.blocks.subtitle": "Manage dynamic components available for use inside pages.", "admin.blocks.title": "Content Blocks", + "admin.comments.active": "In use", + "admin.comments.builtInInfo": "Comments are stored in this wiki and shown on a Talk tab beside the article. Who may read, write and moderate them is decided by the read, write and manage comments page rules.", + "admin.comments.disabledWarn": "Comments are switched off for this site under General → Features, so none of this has any effect until they are switched back on.", + "admin.comments.inactive": "Not in use", + "admin.comments.incomplete": "Incomplete", + "admin.comments.loadFailed": "Could not load the comments configuration.", + "admin.comments.missingFields": "Fill in {fields} — until then this provider shows nothing at all.", + "admin.comments.noneWarn": "No provider is in use, so pages on this site have no comments.", "admin.comments.provider": "Provider", - "admin.comments.providerConfig": "Provider Configuration", - "admin.comments.providerNoConfig": "This provider has no configuration options you can modify.", - "admin.comments.subtitle": "Add discussions to your wiki pages", + "admin.comments.providerConfiguration": "Provider Configuration", + "admin.comments.providerNoConfiguration": "This provider has nothing to configure.", + "admin.comments.saveFailed": "Could not save the comments configuration.", + "admin.comments.saveSuccess": "Comments configuration saved successfully.", + "admin.comments.subtitle": "Choose how readers discuss the pages of this site", + "admin.comments.thirdPartyInfo": "Comments are handled by this provider and shown under the article. The page rules of this wiki do not apply to them — the provider decides who may take part.", "admin.comments.title": "Comments", + "admin.comments.useProvider": "Use {provider} for comments", + "admin.comments.website": "Visit Website", "admin.contribute.title": "Donate", "admin.dashboard.activeWorkers": "Active Workers", "admin.dashboard.contributeHelp": "We need your help!", @@ -1648,28 +1666,50 @@ "common.clipboard.uuidFailure": "Failed to copy UUID to clipboard.", "common.clipboard.uuidSuccess": "Copied UUID to clipboard successfully.", "common.comments.beFirst": "Be the first to comment.", + "common.comments.charsLeft": "{count} left", + "common.comments.closed": "Comments are turned off for this page.", "common.comments.contentMissingError": "Comment is empty or too short!", - "common.comments.deleteConfirmTitle": "Confirm Delete", + "common.comments.deleteConfirmTitle": "Delete Comment", + "common.comments.deleteFailed": "Could not delete the comment.", "common.comments.deletePermanentWarn": "This action cannot be undone!", - "common.comments.deleteSuccess": "Comment was deleted successfully.", - "common.comments.deleteWarn": "Are you sure you want to permanently delete this comment?", + "common.comments.deleteSuccess": "Comment deleted.", + "common.comments.deleteWarn": "Delete this comment? Any replies to it go with it, and neither can be brought back.", + "common.comments.edited": "edited", "common.comments.fieldContent": "Comment Content", "common.comments.fieldEmail": "Your Email Address", + "common.comments.fieldEmailHint": "Never shown to anybody. It is used to check the comment for spam.", "common.comments.fieldName": "Your Name", - "common.comments.loading": "Loading comments...", + "common.comments.guest": "Guest", + "common.comments.loadFailed": "Could not load the comments.", + "common.comments.loading": "Loading comments…", "common.comments.markdownFormat": "Markdown Format", + "common.comments.markdownHint": "Basic markdown, and {'@'}handle to mention somebody.", + "common.comments.mentionNoMatch": "No one by that handle.", "common.comments.modified": "modified {reldate}", "common.comments.newComment": "New Comment", - "common.comments.newPlaceholder": "Write a new comment...", + "common.comments.newPlaceholder": "Write a comment…", "common.comments.none": "No comments yet.", + "common.comments.notAllowed": "You are not allowed to comment on this page.", "common.comments.postComment": "Post Comment", - "common.comments.postSuccess": "New comment posted successfully.", + "common.comments.postFailed": "Could not post the comment.", + "common.comments.postReply": "Post Reply", + "common.comments.postSuccess": "Comment posted.", "common.comments.postingAs": "Posting as {name}", + "common.comments.preview": "Preview", + "common.comments.previewEmpty": "Nothing to preview yet.", + "common.comments.reply": "Reply", + "common.comments.replyPlaceholder": "Write a reply…", + "common.comments.replyingTo": "Replying to {name}", "common.comments.sdTitle": "Talk", - "common.comments.title": "Comments", - "common.comments.updateComment": "Update Comment", - "common.comments.updateSuccess": "Comment was updated successfully.", + "common.comments.signInToComment": "Sign in to join the discussion.", + "common.comments.tabArticle": "Article", + "common.comments.tabTalk": "Talk", + "common.comments.title": "Discussion", + "common.comments.updateComment": "Save Changes", + "common.comments.updateFailed": "Could not update the comment.", + "common.comments.updateSuccess": "Comment updated.", "common.comments.viewDiscussion": "View Discussion", + "common.comments.write": "Write", "common.datetime": "{date} at {time}", "common.duration.days": "Day(s)", "common.duration.every": "Every", @@ -2534,6 +2574,8 @@ "profile.groupsInfo": "You're currently part of the following groups:", "profile.groupsLoadingFailed": "Failed to load groups.", "profile.groupsNone": "You're not part of any group.", + "profile.handle": "Handle", + "profile.handleHint": "The unique {'@'}name people mention you by in comments. 3 to 32 letters, digits, hyphens or underscores. Leave it empty to not be mentionable.", "profile.infoLoadingFailed": "Failed to load your profile.", "profile.jobTitle": "Job Title", "profile.jobTitleHint": "Your position in your organization; shown on your profile page.", diff --git a/backend/models/auditLog.ts b/backend/models/auditLog.ts index 03c114c9f..dbc696552 100644 --- a/backend/models/auditLog.ts +++ b/backend/models/auditLog.ts @@ -9,7 +9,7 @@ import { sanitizeMeta } from '../helpers/audit.ts' * migration. The admin area's filter is built from this list, and `admin.audit.kinds.` is the * translation of each. */ -export const AUDIT_KINDS = ['page', 'asset', 'auth', 'profile', 'admin'] as const +export const AUDIT_KINDS = ['page', 'asset', 'comment', 'auth', 'profile', 'admin'] as const export type AuditKind = (typeof AUDIT_KINDS)[number] /** @@ -45,6 +45,7 @@ export const AUDIT_ACTIONS = { 'deleteFolder' ], asset: ['uploadAsset', 'updateAsset', 'deleteAsset'], + comment: ['createComment', 'updateComment', 'deleteComment'], auth: [ 'login', 'logout', @@ -108,6 +109,7 @@ export const AUDIT_ACTIONS = { 'deleteSiteImage', 'updateStorage', 'updateAnalytics', + 'updateComments', 'runStorageAction', 'updateFlags', 'updateSecurity', diff --git a/backend/models/comments.ts b/backend/models/comments.ts new file mode 100644 index 000000000..1b10e4f0f --- /dev/null +++ b/backend/models/comments.ts @@ -0,0 +1,981 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { load } from 'js-yaml' +import { and, asc, count, eq, inArray, sql } from 'drizzle-orm' +import { + comments as commentsTable, + pages as pagesTable, + users as usersTable +} from '../db/schema.ts' +import { + durationToSeconds, + htmlEscape, + isSensitiveMask, + parseModuleProps +} from '../helpers/common.ts' +import type { ModuleProp } from '../helpers/common.ts' + +/** + * The key of the provider that IS this wiki, as opposed to the ones that are somebody else's service. + * + * It has no directory under `modules/comments` and never will: what the other providers declare in + * two YAML files, this one implements in a table, a set of routes and a view. Its definition is the + * constant below, so that the admin screen can render its settings through exactly the same form as + * everything else rather than growing a branch for it. + */ +export const BUILTIN_PROVIDER = 'default' + +/** + * The three places a provider's markup goes, and the order they are used in. + * + * `head` is loaded once per document — a stylesheet, an SDK — `main` is the container the widget + * draws itself into, and `body` is the script that starts it, run after the container exists. Unlike + * an analytics tag none of this is served in the HTML: a comment widget belongs at the bottom of the + * article, and moving between wiki pages is a router transition rather than a document load, so a + * snippet baked into the shell would initialise once and then show the first page's discussion for + * ever. `frontend/src/components/PageCommentsEmbed.vue` is what mounts these, per page. + */ +const SLOTS = ['head', 'main', 'body'] as const + +type Slot = (typeof SLOTS)[number] + +/** + * A placeholder in a provider's code template: `{{:}}`. + * + * The context says how the value is written into the snippet rather than what the value is, because + * the same value goes into different places and escapes differently in each — the same contract + * `models/analytics.ts` uses, and the same four contexts. + * + * A name of the form `page.` is NOT resolved here. Those are the placeholders whose value is + * different for every page (`page.url`, `page.id`, `page.path`, `page.title`, `page.locale`), and + * they are left in the rendered string for the browser to fill in as the reader moves from page to + * page — see `renderPlaceholder` in `frontend/src/helpers/commentsEmbed.js`, which reads this same + * pattern and escapes by the same rules. + */ +const PLACEHOLDER = /\{\{(js|attr|num|bool):([A-Za-z0-9_.]+)\}\}/g + +/** The prefix that marks a placeholder as the browser's to resolve. See `PLACEHOLDER`. */ +const PAGE_PREFIX = 'page.' + +/** What a character becomes inside a JavaScript string literal. As `models/analytics.ts`, verbatim. */ +const JS_ESCAPES: Record = { + '\\': '\\\\', + "'": "\\'", + '"': '\\"', + '`': '\\`', + '\n': '\\n', + '\r': '\\r', + '\t': '\\t', + '<': '\\u003C', + '>': '\\u003E', + '&': '\\u0026', + '\u2028': '\\u2028', + '\u2029': '\\u2029' +} + +const JS_ESCAPE_PATTERN = /[\\'"`\n\r\t<>&\u2028\u2029]/g + +/** + * The longest a single comment may be, in characters of markdown source. + * + * Not a setting: this is a comment box, and the number is here to keep a page of discussion from + * becoming a page of content. It is enforced by the route schema and repeated to the client so that + * the composer can count down to it rather than discovering it on submit. + */ +export const COMMENT_MAX_LENGTH = 8000 + +/** The shortest a comment may be, so that an empty box and a stray keystroke are both refused. */ +export const COMMENT_MIN_LENGTH = 2 + +/** How long a client waits between posts when nothing is configured, in seconds. */ +const DEFAULT_POST_COOLDOWN = 30 + +/** What a handle may be made of. Mentions are matched against exactly this. */ +export const HANDLE_PATTERN = /^[A-Za-z0-9_-]{3,32}$/ + +/** + * A mention as it is written in a comment: `@handle`. + * + * The lookbehind is what keeps an email address and a path from being read as one — `a@b.com` and + * `docs/@handle` mention nobody. A handle that matches no user is left as the text that was typed, + * here and in the renderer, so a mention never silently becomes a link to the wrong person. + */ +const MENTION_PATTERN = /(? + /** + * The props that must hold a value before this provider can be used at all. + * + * A comment widget pointed at no account renders an error where the discussion should be, so a + * selected provider missing one of these contributes nothing and the admin screen names the empty + * field instead. + */ + requires: string[] + /** The markup each slot contributes, before any value is substituted into it. */ + code: Record + /** Whether this is the provider implemented by the wiki itself. See `BUILTIN_PROVIDER`. */ + isBuiltIn: boolean +} + +/** One provider as a site has it configured, which is what the admin area edits. */ +export interface CommentsProvider { + key: string + title: string + description: string + website: string + icon: string + isBuiltIn: boolean + /** Whether this is the one provider the site is using. At most one provider is. */ + isSelected: boolean + requires: string[] + props: Record + config: Record +} + +/** What a client may change about one provider. */ +export interface CommentsProviderInput { + key: string + config?: Record +} + +/** + * What a browser is told about this site's comments, and all it is told. + * + * Carried on the site payload rather than fetched, because every page view needs it and the site + * configuration is already in memory on every instance — the same reasoning as the analytics tags. + * Deliberately narrow: the stored configuration of the built-in provider holds an Akismet key, and + * nothing that a `Site` response serializes may go anywhere near it. + */ +export interface CommentsPublicConfig { + /** The selected provider's key, or an empty string when this site has comments turned off. */ + provider: string + /** True when `provider` is the wiki's own. The talk view is drawn only for this one. */ + isBuiltIn: boolean + /** The third-party markup, with everything but the page placeholders already substituted. */ + code: Record + /** Seconds a client must wait between posts. Built-in only; 0 when there is no cooldown. */ + cooldownSeconds: number + /** The cap the composer counts down to. See `COMMENT_MAX_LENGTH`. */ + maxLength: number +} + +/** One comment as the API answers with it. Neither the email nor the address is ever in here. */ +export interface CommentEntry { + id: string + parentId: string | null + content: string + createdAt: Date + updatedAt: Date + /** Null for a guest, and for an author whose account has since been deleted. */ + authorId: string | null + authorName: string + /** Whether an avatar can be fetched for `authorId`. False whenever there is no account. */ + authorHasAvatar: boolean + /** The author's handle, so a reply can address them without the reader looking it up. */ + authorHandle: string | null + /** Whether the comment was written by somebody with no account. */ + isGuest: boolean +} + +/** A handle that resolved to somebody, as the renderer needs it to draw the mention as a link. */ +export interface MentionTarget { + handle: string + id: string + name: string +} + +/** What a comment is created with. */ +export interface CommentInput { + pageId: string + parentId?: string | null + content: string + authorId: string | null + authorName: string + authorEmail: string + authorIP: string +} + +/** The definition of the provider the wiki implements itself. See `BUILTIN_PROVIDER`. */ +const BUILTIN_DEFINITION = { + title: 'Built-in Comments', + description: + 'Discussions that belong to this wiki: no third-party service, no second account for a reader to create, and nothing leaving the instance. Markdown, one level of replies, and @mentions of anybody who has set a handle.', + /* + Empty on purpose, which is what keeps the "Visit Website" button off this provider's panel. Every + other provider is a service with a site to go and read about; this one is the wiki the + administrator is already looking at. + */ + website: '', + icon: '/_assets/icons/ultraviolet-comments2.svg', + requires: [] as string[], + props: { + postCooldown: { + type: 'String', + title: 'Posting Cooldown', + default: '30s', + hint: 'How long somebody must wait between two comments, counted per account and per address for a guest. Set to 0 for no cooldown.', + icon: 'timer', + order: 1 + }, + akismetApiKey: { + type: 'String', + title: 'Akismet API Key', + default: '', + sensitive: true, + hint: 'Optional. With a key, every comment is checked against Akismet before it is stored and a comment it calls spam is refused. Left empty, nothing is sent anywhere.', + icon: 'key', + order: 2 + } + } +} + +/** + * The built-in provider as a definition, built once. + * + * Once rather than per access because `getDefinition` is on the path of `buildConfig`, which the + * public site payload goes through on every bootstrap — and re-parsing a constant's props and + * re-sorting them for each of those is work with a known answer. + */ +const BUILTIN: CommentsDefinition = { + key: BUILTIN_PROVIDER, + ...BUILTIN_DEFINITION, + props: sortProps(parseModuleProps(BUILTIN_DEFINITION.props)), + code: { head: '', main: '', body: '' }, + isBuiltIn: true +} + +/** A site with comments turned off, which is every site until somebody picks a provider. */ +const NO_PUBLIC_CONFIG: CommentsPublicConfig = { + provider: '', + isBuiltIn: false, + code: { head: '', main: '', body: '' }, + cooldownSeconds: 0, + maxLength: COMMENT_MAX_LENGTH +} + +/** + * Comments model + * + * Two things wearing one name, and the whole of this file is the seam between them. + * + * **A provider is one module from `modules/comments//`**, two YAML files exactly as an analytics + * provider is: a `definition.yml` saying what it is and what it needs configured, and a `code.yml` + * holding the markup it contributes. Nothing about such a provider reaches this server at read time — + * the discussion lives in somebody else's service and the wiki's only job is to put the right snippet + * at the bottom of the right page. + * + * **The built-in provider is this wiki**, and has no module directory: comments are rows in + * `comments`, served by `api/comments.ts`, drawn on a Talk tab beside the article. Its settings are + * declared in `BUILTIN_DEFINITION` above so that the admin screen renders one kind of form for every + * provider rather than two. + * + * **Only one provider is selected at a time**, which is what makes this different from analytics: two + * analytics tags count the same visit twice and that is a mistake worth warning about, but two comment + * widgets are two separate discussions of the same page, and neither of them is the discussion. The + * configuration of the providers that are NOT selected is kept all the same, so that trying one and + * going back does not mean typing the first one's settings in again. + * + * **Configuration lives in the site's config blob**, under `comments`, for the same reasons the + * analytics configuration does: every page view needs it, `WIKI.sites` already holds the site + * configurations in memory on every instance, and `sites.updateSite` already reloads them across the + * cluster. What a browser is given of it is `publicConfigFor` and nothing else — the built-in + * provider's stored configuration holds an Akismet key. + */ +class Comments { + /** Definitions read from disk, refreshed by `refreshFromDisk()`. The built-in one is not among them. */ + moduleDefinitions: CommentsDefinition[] = [] + + /** + * Load the comments module definitions from disk. + * + * One directory per provider, each with both files. A directory missing either is skipped with a + * warning rather than emptying the list, as in `models/analytics.ts`: a provider that cannot be + * read is one provider nobody can select, where an empty list would take down the discussions of + * every site that had already selected one. + */ + async refreshFromDisk(): Promise { + const modulesPath = path.join(WIKI.SERVERPATH, 'modules/comments') + const definitions: CommentsDefinition[] = [] + try { + for (const dir of await fs.readdir(modulesPath)) { + try { + const parsed = load( + await fs.readFile(path.join(modulesPath, dir, 'definition.yml'), 'utf8') + ) as Record + const code = load( + await fs.readFile(path.join(modulesPath, dir, 'code.yml'), 'utf8') + ) as Record + definitions.push({ + key: dir, + title: parsed.title ?? dir, + description: parsed.description ?? '', + website: parsed.website ?? '', + icon: parsed.icon ?? '', + props: sortProps(parseModuleProps(parsed.props ?? {})), + requires: parsed.requires ?? [], + code: { + head: typeof code?.head === 'string' ? code.head.trim() : '', + main: typeof code?.main === 'string' ? code.main.trim() : '', + body: typeof code?.body === 'string' ? code.body.trim() : '' + }, + isBuiltIn: false + }) + } catch (err: any) { + WIKI.logger.warn(`Skipping comments module ${dir}: ${err.message}`) + } + } + this.moduleDefinitions = definitions.sort((a, b) => a.title.localeCompare(b.title)) + WIKI.logger.info(`Found ${this.moduleDefinitions.length} comments modules [ OK ]`) + } catch (err: any) { + this.moduleDefinitions = [] + WIKI.logger.error( + `Could not read the comments module definitions at ${modulesPath} [ FAILED ]` + ) + WIKI.logger.error(err.message) + } + } + + /** + * Every provider that can be selected, the wiki's own first. + * + * First rather than sorted in with the rest because it is the one that needs nothing set up, and + * because it is what an administrator opening this screen is most likely to be looking for. + */ + get definitions(): CommentsDefinition[] { + return [BUILTIN, ...this.moduleDefinitions] + } + + /** A single definition, or null when nothing declares that key. */ + getDefinition(key: string): CommentsDefinition | null { + return this.definitions.find((d) => d.key === key) ?? null + } + + /** What a site has stored under `comments`. Empty for a site that has never saved this screen. */ + storedConfig(siteId: string): { provider?: string; providers?: Record } { + return WIKI.sites[siteId]?.config?.comments ?? {} + } + + /** + * The key of the provider this site uses, or an empty string when it uses none. + * + * A key that no longer names anything on disk reads as none: a module removed from an installation + * must not leave the site serving the snippet of a provider that is no longer there. + */ + selectedProvider(siteId: string | undefined): string { + if (!siteId) { + return '' + } + const key = this.storedConfig(siteId).provider ?? '' + return key && this.getDefinition(key) ? key : '' + } + + /** + * Whether this site has comments at all — the switch under **General → Features**. + * + * Separate from which provider is selected, and checked separately: the provider is a choice an + * administrator made and must survive being turned off, which is the whole point of having a + * switch rather than expecting them to clear the selection. Absent reads as on, since a site + * configuration saved before this key existed has no opinion about it. + * + * Deliberately NOT folded into `selectedProvider`, which the admin screen reads to show what is + * selected: a screen that reported "no provider in use" because the master switch is off would + * then save that back as the truth. + */ + isAllowed(siteId: string | undefined): boolean { + return siteId ? WIKI.sites[siteId]?.config?.features?.comments !== false : false + } + + /** Whether this site's comments are the wiki's own, which is what the talk view is drawn for. */ + usesBuiltIn(siteId: string | undefined): boolean { + return this.isAllowed(siteId) && this.selectedProvider(siteId) === BUILTIN_PROVIDER + } + + /** + * Every provider installed, with what this site has configured for it merged in. + * + * Driven by the definitions rather than by what is stored, so a provider nobody has touched is + * listed with its defaults and one dropped from disk simply stops appearing — its stored values + * stay in the site config, ignored, until the screen is next saved. + */ + getSiteProviders(siteId: string): CommentsProvider[] { + const stored = this.storedConfig(siteId) + const selected = this.selectedProvider(siteId) + return this.definitions.map((definition) => ({ + key: definition.key, + title: definition.title, + description: definition.description, + website: definition.website, + icon: definition.icon, + isBuiltIn: definition.isBuiltIn, + isSelected: definition.key === selected, + requires: definition.requires, + props: definition.props, + config: this.buildConfig(definition.key, {}, stored.providers?.[definition.key]?.config ?? {}) + })) + } + + /** + * Merge incoming config values onto the ones already stored, keeping only what the module declares. + * + * Unknown keys are dropped rather than refused, so a provider that loses a prop does not make the + * screen unsaveable. Read-only props are never taken from the client, and a sensitive prop sent + * back as the mask means "leave it alone" — which is the whole reason the mask exists. + */ + 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 + const keep = + prop.readOnly || incoming[key] === undefined || isSensitiveMask(prop, incoming[key]) + config[key] = keep ? current : incoming[key] + } + return config + } + + /** + * Check an incoming provider patch against what the module declares. + * + * The props are a runtime declaration read from a YAML file, so no JSON Schema can cover them. + * + * @returns The reason it is invalid, or null when it is fine + */ + validateProvider(patch: CommentsProviderInput): string | null { + const definition = this.getDefinition(patch.key) + if (!definition) { + return `There is no comments provider called "${patch.key}".` + } + for (const [key, value] of Object.entries(patch.config ?? {})) { + const prop = definition.props[key] + 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.` + } + } + } + if (patch.key === BUILTIN_PROVIDER) { + const cooldown = `${patch.config?.postCooldown ?? ''}`.trim() + if (cooldown.length > 0 && cooldown !== '0' && durationToSeconds(cooldown, 0) < 1) { + return 'The posting cooldown must be a duration such as 30s, 2m or 1h — or 0 for none.' + } + } + return null + } + + /** + * Which of a provider's required props are empty, in declaration order. + * + * The same question the admin area asks of the form in front of it, so that "Giscus is selected but + * has no repository" is something an administrator reads on the screen rather than discovering from + * a widget that draws an error where the discussion should be. + */ + missingRequired(definition: CommentsDefinition, config: Record): string[] { + return definition.requires.filter((key) => { + const value = config[key] + return value === undefined || value === null || `${value}`.trim().length < 1 + }) + } + + /** + * Write the selected provider and whatever configuration came with it. + * + * One write for the lot, through `sites.updateSite`, which is what reloads the cached configuration + * on every instance — without which a provider switched over would not take effect until a restart. + * The providers a client did not mention keep what they had, which is what lets an administrator + * try another one and come back to a form that is still filled in. + */ + async updateSiteConfig( + siteId: string, + input: { provider?: string; providers?: CommentsProviderInput[] } + ): Promise { + const stored = this.storedConfig(siteId) + const providers: Record }> = {} + for (const [key, value] of Object.entries(stored.providers ?? {})) { + providers[key] = { config: (value as any)?.config ?? {} } + } + for (const patch of input.providers ?? []) { + providers[patch.key] = { + config: this.buildConfig(patch.key, patch.config ?? {}, providers[patch.key]?.config ?? {}) + } + } + const provider = input.provider !== undefined ? input.provider : (stored.provider ?? '') + await WIKI.models.sites.updateSite(siteId, { config: { comments: { provider, providers } } }) + } + + /** + * The stored configuration of one provider, completed from its defaults. + * + * This is the real thing, secrets and all — the mask is applied at the API boundary and nowhere + * earlier, exactly as it is for storage targets and authentication strategies. + */ + configFor(siteId: string | undefined, key: string): Record { + if (!siteId) { + return {} + } + return this.buildConfig(key, {}, this.storedConfig(siteId).providers?.[key]?.config ?? {}) + } + + /** + * What a browser is told about this site's comments. See `CommentsPublicConfig`. + * + * Built per call rather than cached: it is a handful of string substitutions over a configuration + * already in memory, and the answer has to change the moment the admin screen is saved. + */ + publicConfigFor(siteId: string | undefined): CommentsPublicConfig { + const key = this.isAllowed(siteId) ? this.selectedProvider(siteId) : '' + if (!key) { + return NO_PUBLIC_CONFIG + } + const definition = this.getDefinition(key)! + const config = this.configFor(siteId, key) + if (this.missingRequired(definition, config).length > 0) { + // -> Selected but not finished. Nothing is drawn rather than a widget pointed at no account. + return NO_PUBLIC_CONFIG + } + if (definition.isBuiltIn) { + return { + provider: key, + isBuiltIn: true, + code: { head: '', main: '', body: '' }, + cooldownSeconds: this.cooldownFor(siteId), + maxLength: COMMENT_MAX_LENGTH + } + } + const code: Record = { head: '', main: '', body: '' } + for (const slot of SLOTS) { + code[slot] = renderTemplate(definition.code[slot], config) ?? '' + } + return { + provider: key, + isBuiltIn: false, + code, + cooldownSeconds: 0, + maxLength: COMMENT_MAX_LENGTH + } + } + + /** + * How long this site makes a client wait between two comments, in seconds. + * + * `0` is no cooldown at all, and so is a value that will not parse — the setting is a duration an + * administrator typed, and a limit nobody can explain is worse than none. + */ + cooldownFor(siteId: string | undefined): number { + const raw = `${this.configFor(siteId, BUILTIN_PROVIDER).postCooldown ?? ''}`.trim() + if (raw === '0' || raw.length < 1) { + return 0 + } + return durationToSeconds(raw, DEFAULT_POST_COOLDOWN) + } + + // == BUILT-IN PROVIDER =============== + // + // Everything below is the wiki's own comments. None of it is reachable for a site that has selected + // one of the module providers: the routes check `usesBuiltIn` before anything else, because a + // comment stored here for a site whose discussions live at Disqus is a comment nobody will ever see. + + /** + * Every comment on a page, oldest first, with its author. + * + * One query with a left join rather than a fetch per author: a talk page is a list, and the author + * of each row is part of what a list of comments IS. The join is left because `authorId` is null + * for a guest and null again once an account is deleted, and in both cases the name stored on the + * row is what stands in. + * + * Ordering is flat and by time; the one level of nesting is assembled by the view from `parentId`, + * which keeps a reply beside the comment it answers however old that comment is. + */ + async listForPage(pageId: string, limit = 500): Promise { + const rows = await WIKI.db + .select({ + id: commentsTable.id, + parentId: commentsTable.parentId, + content: commentsTable.content, + createdAt: commentsTable.createdAt, + updatedAt: commentsTable.updatedAt, + authorId: commentsTable.authorId, + storedName: commentsTable.authorName, + userName: usersTable.name, + userHandle: usersTable.handle, + userHasAvatar: usersTable.hasAvatar + }) + .from(commentsTable) + .leftJoin(usersTable, eq(usersTable.id, commentsTable.authorId)) + .where(eq(commentsTable.pageId, pageId)) + .orderBy(asc(commentsTable.createdAt)) + .limit(limit) + return rows.map((row) => ({ + id: row.id, + parentId: row.parentId, + content: row.content, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + authorId: row.authorId, + // -> The live name where there is still an account behind it, so that a rename shows through + // everywhere; the copy taken at the time is what is left when there is not + authorName: row.userName ?? row.storedName, + authorHasAvatar: row.userHasAvatar ?? false, + authorHandle: row.userHandle ?? null, + isGuest: row.authorId === null + })) + } + + /** + * The page a comment is about, as everything that guards one needs it. + * + * Its path, locale and tags because that is what a page rule is matched against, and + * `allowComments` because a page can be closed to discussion from its own properties dialog + * whatever the site has configured. Deliberately not `pages.getPage` — that assembles a page for + * reading, and this is four columns and a scoping check. + * + * @returns The reference, or null when no such page exists on this site + */ + async pageRef(siteId: string, pageId: string) { + const [row] = await WIKI.db + .select({ + id: pagesTable.id, + path: pagesTable.path, + locale: pagesTable.locale, + title: pagesTable.title, + tags: pagesTable.tags, + allowComments: sql`coalesce((${pagesTable.config} ->> 'allowComments')::boolean, true)` + }) + .from(pagesTable) + .where(and(eq(pagesTable.id, pageId), eq(pagesTable.siteId, siteId))) + return row ?? null + } + + /** How many comments a page has. What the Talk tab's badge counts. */ + async countForPage(pageId: string): Promise { + const [row] = await WIKI.db + .select({ total: count() }) + .from(commentsTable) + .where(eq(commentsTable.pageId, pageId)) + return Number(row?.total ?? 0) + } + + /** One comment with the page it is on, which is what every permission check on it needs. */ + async getWithPage(commentId: string, siteId: string) { + const [row] = await WIKI.db + .select({ + id: commentsTable.id, + parentId: commentsTable.parentId, + content: commentsTable.content, + authorId: commentsTable.authorId, + pageId: commentsTable.pageId, + path: pagesTable.path, + locale: pagesTable.locale, + tags: pagesTable.tags, + allowComments: sql`coalesce((${pagesTable.config} ->> 'allowComments')::boolean, true)` + }) + .from(commentsTable) + .innerJoin(pagesTable, eq(pagesTable.id, commentsTable.pageId)) + .where(and(eq(commentsTable.id, commentId), eq(pagesTable.siteId, siteId))) + return row ?? null + } + + /** + * Store a comment. + * + * Replies are one level deep, and this is where that is true: a `parentId` naming a comment that is + * itself a reply is rewritten to that reply's own parent, so answering the third message in a thread + * puts the answer at the bottom of the thread rather than starting a fourth level of indentation. + * A `parentId` on another page is refused outright — that is not a thread, it is a mistake. + */ + async create(input: CommentInput): Promise { + let parentId: string | null = null + if (input.parentId) { + const [parent] = await WIKI.db + .select({ id: commentsTable.id, parentId: commentsTable.parentId }) + .from(commentsTable) + .where(and(eq(commentsTable.id, input.parentId), eq(commentsTable.pageId, input.pageId))) + if (!parent) { + throw new Error('The comment being replied to is not on this page.') + } + parentId = parent.parentId ?? parent.id + } + const [row] = await WIKI.db + .insert(commentsTable) + .values({ + pageId: input.pageId, + parentId, + content: input.content, + authorId: input.authorId, + authorName: input.authorName, + authorEmail: input.authorEmail, + authorIP: input.authorIP + }) + .returning() + return this.describe(row!) + } + + /** Replace the text of a comment. Who may is decided by the route; this only writes. */ + async update(commentId: string, content: string): Promise { + const [row] = await WIKI.db + .update(commentsTable) + .set({ content, updatedAt: new Date() }) + .where(eq(commentsTable.id, commentId)) + .returning() + return row ? this.describe(row) : null + } + + /** + * Delete a comment, and with it any replies underneath. + * + * The replies go by the foreign key's own cascade rather than by a second statement: a reply exists + * to answer something, and left behind it would be half of a conversation nobody can read. + * + * @returns How many rows went, replies included + */ + async remove(commentId: string): Promise { + const replies = await WIKI.db + .select({ total: count() }) + .from(commentsTable) + .where(eq(commentsTable.parentId, commentId)) + const result = await WIKI.db.delete(commentsTable).where(eq(commentsTable.id, commentId)) + return (result.rowCount ?? 0) > 0 ? 1 + Number(replies[0]?.total ?? 0) : 0 + } + + /** + * The users that the handles written in these comments point at. + * + * Resolved per response rather than per comment, and as one query: a talk page is a list of comments + * that mention each other, and asking the database once per `@` would be one query per mention. What + * comes back is only the handles that exist — the renderer leaves the rest as the text that was + * typed, which is what keeps a mention from ever linking to the wrong person. + */ + async resolveMentions(contents: string[]): Promise { + const handles = new Set() + for (const content of contents) { + for (const match of content.matchAll(MENTION_PATTERN)) { + handles.add(match[1]!.toLowerCase()) + } + } + if (handles.size < 1) { + return [] + } + const rows = await WIKI.db + .select({ id: usersTable.id, name: usersTable.name, handle: usersTable.handle }) + .from(usersTable) + .where( + and( + eq(usersTable.isActive, true), + eq(usersTable.isSystem, false), + inArray(sql`lower(${usersTable.handle})`, [...handles]) + ) + ) + return rows.map((row) => ({ id: row.id, name: row.name, handle: row.handle! })) + } + + /** + * Users whose handle or name starts with what has been typed after an `@`. + * + * Only users who have set a handle, because a handle is what a mention is written with — there is + * nothing to insert for anybody else. Ordered by handle so that the list is stable as it narrows. + */ + async searchHandles(query: string, limit = 8): Promise { + // -> `%` and `_` are wildcards to LIKE and ordinary characters to somebody typing a name, so + // they are escaped rather than passed through: `@%` is a search for a handle containing a + // percent sign, not a request for every user on the wiki + const term = query + .trim() + .toLowerCase() + .replace(/[\\%_]/g, '\\$&') + const rows = await WIKI.db + .select({ id: usersTable.id, name: usersTable.name, handle: usersTable.handle }) + .from(usersTable) + .where( + and( + eq(usersTable.isActive, true), + eq(usersTable.isSystem, false), + sql`${usersTable.handle} is not null`, + term.length > 0 + ? sql`(lower(${usersTable.handle}) like ${term + '%'} or lower(${usersTable.name}) like ${'%' + term + '%'})` + : sql`true` + ) + ) + .orderBy(asc(sql`lower(${usersTable.handle})`)) + .limit(limit) + return rows.map((row) => ({ id: row.id, name: row.name, handle: row.handle! })) + } + + /** + * Ask Akismet whether a comment is spam. + * + * Only when a key is configured; with none, nothing is sent anywhere, which is the default and is + * what a wiki that never opened its comments to the public wants. + * + * **It fails open.** A network blip, a revoked key or a timeout answers "not spam" and logs it, + * because the alternative is a wiki that silently stops accepting comments for a reason nobody can + * see from the inside. A key that is wrong is a configuration problem to be found on the admin + * screen, not a reason to lose a reader's paragraph. + * + * @returns Whether the comment should be refused + */ + async isSpam( + siteId: string, + comment: { + content: string + authorName: string + authorEmail: string + authorIP: string + userAgent: string + referrer: string + permalink: string + isGuest: boolean + } + ): Promise { + const key = `${this.configFor(siteId, BUILTIN_PROVIDER).akismetApiKey ?? ''}`.trim() + if (key.length < 1) { + return false + } + const site = WIKI.sites[siteId] + const blog = site?.hostname ? `https://${site.hostname}` : comment.permalink + const body = new URLSearchParams({ + blog, + user_ip: comment.authorIP, + user_agent: comment.userAgent, + referrer: comment.referrer, + permalink: comment.permalink, + comment_type: 'comment', + comment_author: comment.authorName, + comment_author_email: comment.authorEmail, + comment_content: comment.content, + // -> Akismet weighs a signed-in commenter differently from an anonymous one, and this is the + // only place that distinction is worth passing on + ...(comment.isGuest ? {} : { user_role: 'subscriber' }) + }) + try { + const resp = await fetch( + `https://${encodeURIComponent(key)}.rest.akismet.com/1.1/comment-check`, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + signal: AbortSignal.timeout(AKISMET_TIMEOUT) + } + ) + const text = (await resp.text()).trim() + if (text !== 'true' && text !== 'false') { + // -> Akismet says what is wrong in a header rather than in the body, and an invalid key comes + // back as `invalid` with the reason there + WIKI.logger.warn( + `Akismet answered "${text}" (${resp.headers.get('x-akismet-debug-help') ?? 'no detail'}); the comment was let through.` + ) + return false + } + return text === 'true' + } catch (err: any) { + WIKI.logger.warn( + `Akismet could not be reached (${err.message}); the comment was let through.` + ) + return false + } + } + + /** One stored row as the API answers with it, for a write that already knows its author. */ + private describe(row: typeof commentsTable.$inferSelect): CommentEntry { + return { + id: row.id, + parentId: row.parentId, + content: row.content, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + authorId: row.authorId, + authorName: row.authorName, + authorHasAvatar: false, + authorHandle: null, + isGuest: row.authorId === null + } + } +} + +/** Props in the order the module meant them to be shown in, applied once so every consumer agrees. */ +function sortProps(props: Record): Record { + return Object.fromEntries(Object.entries(props).sort(([, a], [, b]) => a.order - b.order)) +} + +/** A value as it is written into a JavaScript string literal. See `JS_ESCAPES`. */ +function jsEscape(value: string): string { + return value.replace(JS_ESCAPE_PATTERN, (char) => JS_ESCAPES[char]!) +} + +/** + * Substitute a provider's configured values into one of its templates. + * + * Page placeholders are left exactly as they were written, for the browser to resolve per page — see + * `PLACEHOLDER`. + * + * @returns The markup, or null where a placeholder could not be resolved to something that would + * parse: a `num` slot is a bare numeric literal, and a value that is not a number would be a syntax + * error taking the whole snippet with it. + */ +function renderTemplate(template: string, config: Record): string | null { + if (!template) { + return '' + } + let usable = true + const rendered = template.replace(PLACEHOLDER, (match, context: string, key: string) => { + if (key.startsWith(PAGE_PREFIX)) { + return match + } + const value = config[key] + switch (context) { + case 'num': { + const num = Number(value) + if (!Number.isFinite(num)) { + usable = false + return '0' + } + return `${num}` + } + case 'bool': + return value === true ? 'true' : 'false' + case 'attr': + return htmlEscape(`${value ?? ''}`) + default: + return jsEscape(`${value ?? ''}`) + } + }) + return usable ? rendered : null +} + +export const comments = new Comments() diff --git a/backend/models/groups.ts b/backend/models/groups.ts index 866da9a3f..b472739db 100644 --- a/backend/models/groups.ts +++ b/backend/models/groups.ts @@ -250,9 +250,16 @@ class Groups { permissions: ['read:pages', 'read:assets', 'read:comments'], rules: [ { + /* + `write:comments` is granted here while the group-wide list above leaves it out, and the + two lists are answering different questions: the rule is what `checkAccess` reads for a + page permission, and the list above is checked by the route hook, which only understands + global permissions. Without it in the rule, a wiki that turns comments on has a + discussion nobody but an administrator can join. + */ id: uuid(), name: 'Default Rule', - roles: ['read:pages', 'read:assets', 'read:comments'], + roles: ['read:pages', 'read:assets', 'read:comments', 'write:comments'], match: 'START', mode: 'ALLOW', path: '', @@ -270,7 +277,13 @@ class Groups { { id: uuid(), name: 'Default Rule', - roles: ['read:pages', 'read:assets', 'read:comments'], + /* + Named in a rule that DENIES them, which is a fresh install being private rather than a + statement about comments: an operator opening the wiki up flips this one rule to ALLOW, + and what they get is the set the guests group is allowed to hold (`GUEST_ROLES`) rather + than a public wiki whose readers still cannot say anything. + */ + roles: ['read:pages', 'read:assets', 'read:comments', 'write:comments'], match: 'START', mode: 'DENY', path: '', @@ -325,6 +338,9 @@ class Groups { async createGroup(name: string): Promise { const startingPermissions = ['read:pages', 'read:assets', 'read:comments'] + // -> The rule grants one more than the group-wide list does: see the note on the Users group in + // `init()` for why the two differ + const startingRoles = [...startingPermissions, 'write:comments'] const result = await WIKI.db .insert(groupsTable) .values({ @@ -336,7 +352,7 @@ class Groups { { id: uuid(), name: 'Default Rule', - roles: startingPermissions, + roles: startingRoles, match: 'START', mode: 'ALLOW', path: '', diff --git a/backend/models/index.ts b/backend/models/index.ts index 54b5dcbf6..78f8e9613 100644 --- a/backend/models/index.ts +++ b/backend/models/index.ts @@ -5,6 +5,7 @@ import { assets } from './assets.ts' import { auditLog } from './auditLog.ts' import { authentication } from './authentication.ts' import { blocks } from './blocks.ts' +import { comments } from './comments.ts' import { extensions } from './extensions.ts' import { flags } from './flags.ts' import { groups } from './groups.ts' @@ -39,6 +40,7 @@ export default { auditLog, authentication, blocks, + comments, extensions, flags, groups, diff --git a/backend/models/sites.ts b/backend/models/sites.ts index e838e8af0..cf5876520 100644 --- a/backend/models/sites.ts +++ b/backend/models/sites.ts @@ -129,10 +129,23 @@ class Sites { collaborativeEditing: true, ratings: false, ratingsMode: 'off', - comments: false, + // -> On, because what decides whether a site has comments is whether a provider has + // been picked. This is the switch that turns them all off without losing that + // choice, which is only useful to somebody who has already made it. + comments: true, reasonForChange: 'optional', search: true }, + /* + The wiki's own provider, so that a site with comments turned on has somewhere for them + to go without an administrator having to choose first. Every alternative is somebody + else's service with an account to open; this one needs nothing set up. Whether there + are comments at all is `features.comments` above -- see `models/comments.ts`. + */ + comments: { + provider: 'default', + providers: {} + }, logoUrl: '', logoText: true, sitemap: true, @@ -399,10 +412,14 @@ class Sites { collaborativeEditing: true, ratings: false, ratingsMode: 'off', - comments: false, + comments: true, reasonForChange: 'optional', search: true }, + comments: { + provider: 'default', + providers: {} + }, logoText: true, sitemap: true, robots: { diff --git a/backend/models/users.ts b/backend/models/users.ts index 9df2aaf9c..229559ceb 100644 --- a/backend/models/users.ts +++ b/backend/models/users.ts @@ -15,6 +15,8 @@ import { nanoid } from 'nanoid' import { flatten, uniq } from 'es-toolkit/array' import { detectImageMime, resizeImageToSquareJpeg } from '../helpers/images.ts' import { buildTotpUri, generateTotpSecret, verifyTotpCode } from '../helpers/totp.ts' +import { CustomError } from '../helpers/common.ts' +import { HANDLE_PATTERN } from './comments.ts' import type { AuthStrategy, ProviderProfile } from './authentication.ts' import type { SystemIds } from './types.ts' @@ -87,6 +89,8 @@ export interface UserProfileAuthMethod { export interface UserPatch { name?: string email?: string + /** The mention handle, or null to take it off. See the column in `db/schema.ts`. */ + handle?: string | null isActive?: boolean isVerified?: boolean meta?: Record @@ -102,6 +106,8 @@ export interface UserProfile { name: string email: string hasAvatar: boolean + /** The name this user is mentioned by in a comment, without the `@`. Empty when they have none. */ + handle: string location: string jobTitle: string pronouns: string @@ -125,6 +131,7 @@ export interface PublicUserProfile { id: string name: string hasAvatar: boolean + handle: string location: string jobTitle: string pronouns: string @@ -135,6 +142,7 @@ export interface PublicUserProfile { /** The fields a user may change on its own profile. Notably not the email, nor any admin flag. */ export interface UserProfilePatch { name?: string + handle?: string location?: string jobTitle?: string pronouns?: string @@ -577,6 +585,9 @@ class Users { name: user.name, email: user.email, hasAvatar: user.hasAvatar, + // -> A column of its own rather than a `meta` key, because it has to be unique across the + // wiki: `@ana` means one person or it means nothing + handle: user.handle ?? '', location: meta.location ?? '', jobTitle: meta.jobTitle ?? '', pronouns: meta.pronouns ?? '', @@ -611,6 +622,8 @@ class Users { id: user.id, name: user.name, hasAvatar: user.hasAvatar, + // -> Public on purpose: it is written into every comment that mentions them + handle: user.handle ?? '', location: meta.location ?? '', jobTitle: meta.jobTitle ?? '', pronouns: meta.pronouns ?? '', @@ -692,7 +705,29 @@ class Users { if (patch.name !== undefined) { values.name = patch.name } - await this.updateUser(id, values) + if (patch.handle !== undefined) { + const handle = patch.handle.trim() + if (handle.length > 0 && !HANDLE_PATTERN.test(handle)) { + throw new CustomError( + 'userHandleInvalid', + 'A handle is 3 to 32 characters of letters, digits, hyphens and underscores.' + ) + } + // -> Empty is how a handle is taken off, and null rather than '' is what the unique index + // needs: postgres counts nulls as distinct, so any number of users may have none + values.handle = handle.length > 0 ? handle : null + } + + try { + await this.updateUser(id, values) + } catch (err: any) { + // -> 23505 is the unique index on `lower(handle)`. It is the one failure here a user can fix, + // and the only one worth turning into a sentence rather than a 500. + if (err.code === '23505') { + throw new CustomError('userHandleTaken', 'That handle is already taken.', 409) + } + throw err + } return this.getProfile(id) } diff --git a/backend/modules/comments/artalk/code.yml b/backend/modules/comments/artalk/code.yml new file mode 100644 index 000000000..fea013a31 --- /dev/null +++ b/backend/modules/comments/artalk/code.yml @@ -0,0 +1,16 @@ +head: | + + +main: | +
+body: | + diff --git a/backend/modules/comments/artalk/definition.yml b/backend/modules/comments/artalk/definition.yml new file mode 100644 index 000000000..e333651fc --- /dev/null +++ b/backend/modules/comments/artalk/definition.yml @@ -0,0 +1,27 @@ +title: Artalk +description: A light, self-hosted comment system with its own moderation dashboard, notifications and captcha. One Artalk instance can serve several sites. +website: https://artalk.js.org +icon: '/_assets/icons/ultraviolet-artalk.svg' +requires: ['server'] +props: + server: + type: String + title: Server URL + default: '' + hint: 'Publicly reachable URL of your Artalk instance, with the scheme and without a trailing slash, e.g. https://artalk.example.com' + icon: dns + order: 1 + siteName: + type: String + title: Site Name + default: '' + hint: The site as it is named in the Artalk dashboard. Leave empty to use its default site. + icon: rename + order: 2 + darkMode: + type: Boolean + title: Follow Dark Mode + default: true + hint: Let Artalk follow the reader's colour scheme instead of always drawing itself light. + icon: 3d-touch + order: 3 diff --git a/backend/modules/comments/comentario/code.yml b/backend/modules/comments/comentario/code.yml new file mode 100644 index 000000000..bf11d46b3 --- /dev/null +++ b/backend/modules/comments/comentario/code.yml @@ -0,0 +1,4 @@ +head: | + +main: | + diff --git a/backend/modules/comments/comentario/definition.yml b/backend/modules/comments/comentario/definition.yml new file mode 100644 index 000000000..e98f9ed01 --- /dev/null +++ b/backend/modules/comments/comentario/definition.yml @@ -0,0 +1,20 @@ +title: Comentario +description: A privacy-friendly, self-hosted comment engine, and the maintained successor to Commento. No tracking, optional anonymous comments, and moderation built in. +website: https://comentario.app +icon: '/_assets/icons/ultraviolet-comentario.svg' +requires: ['instanceUrl'] +props: + instanceUrl: + type: String + title: Instance URL + default: '' + hint: 'URL of your Comentario instance, with the scheme and without a trailing slash, e.g. https://comentario.example.com' + icon: dns + order: 1 + autoInit: + type: Boolean + title: Auto Initialize + default: true + hint: Let Comentario set itself up as soon as its script loads. Turn this off only if you are driving it yourself from the theme's custom code. + icon: apply + order: 2 diff --git a/backend/modules/comments/discourse/code.yml b/backend/modules/comments/discourse/code.yml new file mode 100644 index 000000000..3b5614947 --- /dev/null +++ b/backend/modules/comments/discourse/code.yml @@ -0,0 +1,14 @@ +main: | +
+body: | + diff --git a/backend/modules/comments/discourse/definition.yml b/backend/modules/comments/discourse/definition.yml new file mode 100644 index 000000000..72bb86cbe --- /dev/null +++ b/backend/modules/comments/discourse/definition.yml @@ -0,0 +1,20 @@ +title: Discourse +description: Turn a Discourse forum into the comments of your wiki. Each page gets a topic in the category you choose, and the discussion carries on in the forum itself. +website: https://www.discourse.org +icon: '/_assets/icons/ultraviolet-discourse.svg' +requires: ['discourseUrl'] +props: + discourseUrl: + type: String + title: Forum URL + default: '' + hint: 'URL of your Discourse forum, with the scheme and a trailing slash, e.g. https://forum.example.com/ . The wiki''s hostname must be listed under its Embedding settings.' + icon: discussion-forum + order: 1 + discourseUserName: + type: String + title: Posting Username + default: '' + hint: Discourse account new topics are created as. Leave empty to use the one set as the embeddable host's default. + icon: contact + order: 2 diff --git a/backend/modules/comments/disqus/code.yml b/backend/modules/comments/disqus/code.yml new file mode 100644 index 000000000..23a891d30 --- /dev/null +++ b/backend/modules/comments/disqus/code.yml @@ -0,0 +1,20 @@ +main: | +
+body: | + diff --git a/backend/modules/comments/disqus/definition.yml b/backend/modules/comments/disqus/definition.yml new file mode 100644 index 000000000..53f8ee519 --- /dev/null +++ b/backend/modules/comments/disqus/definition.yml @@ -0,0 +1,13 @@ +title: Disqus +description: The largest hosted commenting service, with a shared identity across every site that uses it. Free with advertising; paid plans remove it. +website: https://disqus.com +icon: '/_assets/icons/ultraviolet-disqus.svg' +requires: ['shortname'] +props: + shortname: + type: String + title: Shortname + default: '' + hint: The unique identifier Disqus gave your site, as it appears in its admin under Settings → General. + icon: rename + order: 1 diff --git a/backend/modules/comments/giscus/code.yml b/backend/modules/comments/giscus/code.yml new file mode 100644 index 000000000..f5772d43e --- /dev/null +++ b/backend/modules/comments/giscus/code.yml @@ -0,0 +1,17 @@ +body: | + diff --git a/backend/modules/comments/giscus/definition.yml b/backend/modules/comments/giscus/definition.yml new file mode 100644 index 000000000..35049969e --- /dev/null +++ b/backend/modules/comments/giscus/definition.yml @@ -0,0 +1,71 @@ +title: Giscus +description: Comments backed by GitHub Discussions, in the repository of your choice. Readers comment with their GitHub account, and every discussion stays in a repository you own. +website: https://giscus.app +icon: '/_assets/icons/ultraviolet-giscus.svg' +requires: ['repo', 'repoId', 'categoryId'] +props: + repo: + type: String + title: Repository + default: '' + hint: 'Owner and name of the repository discussions are stored in, e.g. requarks/wiki. It must be public, with the giscus app installed and Discussions turned on.' + icon: github + order: 1 + repoId: + type: String + title: Repository ID + default: '' + hint: The repository identifier giscus.app generates for you, starting with R_. + icon: rename + order: 2 + category: + type: String + title: Discussion Category + default: 'Announcements' + hint: Name of the category new discussions are created in. + icon: list + order: 3 + categoryId: + type: String + title: Category ID + default: '' + hint: The category identifier giscus.app generates for you, starting with DIC_. + icon: rename + order: 4 + mapping: + type: String + title: Page Mapping + default: 'pathname' + enum: + - 'pathname|Page path' + - 'url|Full page URL' + - 'title|Page title' + - 'og:title|Open Graph title' + hint: What ties a wiki page to its discussion. The page path is the stable choice; a page moved to another path starts a new discussion under any of them. + icon: link + order: 5 + theme: + type: String + title: Theme + default: 'preferred_color_scheme' + enum: + - 'preferred_color_scheme|Follow the reader' + - 'light|Light' + - 'dark|Dark' + - 'transparent_dark|Transparent dark' + icon: 3d-touch + order: 6 + reactionsEnabled: + type: Boolean + title: Reactions + default: true + hint: Show the reaction buttons for the discussion itself above the comments. + icon: apply + order: 7 + lang: + type: String + title: Language + default: 'en' + hint: Two-letter code giscus draws its own interface in. + icon: geography + order: 8 diff --git a/backend/modules/comments/hyvortalk/code.yml b/backend/modules/comments/hyvortalk/code.yml new file mode 100644 index 000000000..d0d6c58ed --- /dev/null +++ b/backend/modules/comments/hyvortalk/code.yml @@ -0,0 +1,4 @@ +head: | + +main: | + diff --git a/backend/modules/comments/hyvortalk/definition.yml b/backend/modules/comments/hyvortalk/definition.yml new file mode 100644 index 000000000..67d1c59c4 --- /dev/null +++ b/backend/modules/comments/hyvortalk/definition.yml @@ -0,0 +1,23 @@ +title: Hyvor Talk +description: A hosted, privacy-first commenting platform with no ads and no tracking. Paid, with moderation, notifications and single sign-on included. +website: https://talk.hyvor.com +icon: '/_assets/icons/ultraviolet-hyvortalk.svg' +requires: ['websiteId'] +props: + websiteId: + type: Number + title: Website ID + default: 0 + hint: The numeric identifier of your website in the Hyvor Talk console. + icon: rename + order: 1 + colorScheme: + type: String + title: Colour Scheme + default: 'os' + enum: + - 'os|Follow the reader' + - 'light|Light' + - 'dark|Dark' + icon: 3d-touch + order: 2 diff --git a/backend/modules/comments/isso/code.yml b/backend/modules/comments/isso/code.yml new file mode 100644 index 000000000..8f48bbd24 --- /dev/null +++ b/backend/modules/comments/isso/code.yml @@ -0,0 +1,6 @@ +main: | +
+body: | + diff --git a/backend/modules/comments/isso/definition.yml b/backend/modules/comments/isso/definition.yml new file mode 100644 index 000000000..787ebb3f5 --- /dev/null +++ b/backend/modules/comments/isso/definition.yml @@ -0,0 +1,20 @@ +title: Isso +description: A tiny self-hosted comment server written in Python, storing everything in one SQLite file. Comments are anonymous by default and can be edited for a while after posting. +website: https://isso-comments.de +icon: '/_assets/icons/ultraviolet-isso.svg' +requires: ['server'] +props: + server: + type: String + title: Server URL + default: '' + hint: 'Publicly reachable URL of your Isso server, with the scheme and without a trailing slash, e.g. https://isso.example.com' + icon: dns + order: 1 + requireAuthor: + type: Boolean + title: Require a Name + default: false + hint: Ask for a name before a comment can be posted. This has to match the server's own configuration to take effect. + icon: contact + order: 2 diff --git a/backend/modules/comments/remark42/code.yml b/backend/modules/comments/remark42/code.yml new file mode 100644 index 000000000..5d850b0ba --- /dev/null +++ b/backend/modules/comments/remark42/code.yml @@ -0,0 +1,24 @@ +main: | +
+body: | + diff --git a/backend/modules/comments/remark42/definition.yml b/backend/modules/comments/remark42/definition.yml new file mode 100644 index 000000000..6a6588276 --- /dev/null +++ b/backend/modules/comments/remark42/definition.yml @@ -0,0 +1,36 @@ +title: Remark42 +description: A small, self-hosted comment engine in Go. Anonymous or social sign-in, threaded replies, votes and an admin interface, with no database to run beside it. +website: https://remark42.com +icon: '/_assets/icons/ultraviolet-remark42.svg' +requires: ['host', 'siteId'] +props: + host: + type: String + title: Server URL + default: '' + hint: 'Publicly reachable URL of your Remark42 server, with the scheme and without a trailing slash, e.g. https://remark42.example.com' + icon: dns + order: 1 + siteId: + type: String + title: Site ID + default: 'remark' + hint: The site identifier Remark42 was started with (its SITE environment variable). + icon: rename + order: 2 + theme: + type: String + title: Theme + default: 'light' + enum: + - 'light|Light' + - 'dark|Dark' + icon: 3d-touch + order: 3 + maxShownComments: + type: Number + title: Comments Shown + default: 15 + hint: How many comments are drawn before the reader has to ask for more. + icon: list + order: 4 diff --git a/backend/modules/comments/waline/code.yml b/backend/modules/comments/waline/code.yml new file mode 100644 index 000000000..0fdc33a9f --- /dev/null +++ b/backend/modules/comments/waline/code.yml @@ -0,0 +1,15 @@ +head: | + +main: | +
+body: | + diff --git a/backend/modules/comments/waline/definition.yml b/backend/modules/comments/waline/definition.yml new file mode 100644 index 000000000..b32b18660 --- /dev/null +++ b/backend/modules/comments/waline/definition.yml @@ -0,0 +1,41 @@ +title: Waline +description: A self-hosted comment system that runs on a serverless function and a database you already have. Markdown, reactions, anonymous comments and a light client. +website: https://waline.js.org +icon: '/_assets/icons/ultraviolet-waline.svg' +requires: ['serverURL'] +props: + serverURL: + type: String + title: Server URL + default: '' + hint: 'URL of your Waline server, with the scheme and without a trailing slash, e.g. https://waline.example.com' + icon: dns + order: 1 + clientUrl: + type: String + title: Client Script URL + default: 'https://unpkg.com/@waline/client@v3/dist/waline.js' + hint: Where the Waline browser client is loaded from. Change it to pin a version, or to serve it from your own host. + icon: link + order: 2 + styleUrl: + type: String + title: Client Stylesheet URL + default: 'https://unpkg.com/@waline/client@v3/dist/waline.css' + hint: Where the Waline stylesheet is loaded from. It has to match the client version above. + icon: link + order: 3 + lang: + type: String + title: Language + default: 'en' + hint: Locale code Waline draws its own interface in, e.g. en, fr, zh-CN. + icon: geography + order: 4 + reaction: + type: Boolean + title: Reactions + default: false + hint: Show the reaction buttons above the comment box. + icon: apply + order: 5 diff --git a/frontend/public/_assets/icons/ultraviolet-artalk.svg b/frontend/public/_assets/icons/ultraviolet-artalk.svg new file mode 100644 index 000000000..e235322b5 --- /dev/null +++ b/frontend/public/_assets/icons/ultraviolet-artalk.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/_assets/icons/ultraviolet-comentario.svg b/frontend/public/_assets/icons/ultraviolet-comentario.svg new file mode 100644 index 000000000..24daf0218 --- /dev/null +++ b/frontend/public/_assets/icons/ultraviolet-comentario.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/_assets/icons/ultraviolet-comments2.svg b/frontend/public/_assets/icons/ultraviolet-comments2.svg new file mode 100644 index 000000000..04a474d78 --- /dev/null +++ b/frontend/public/_assets/icons/ultraviolet-comments2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/_assets/icons/ultraviolet-discourse.svg b/frontend/public/_assets/icons/ultraviolet-discourse.svg new file mode 100644 index 000000000..216f335fb --- /dev/null +++ b/frontend/public/_assets/icons/ultraviolet-discourse.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/_assets/icons/ultraviolet-disqus.svg b/frontend/public/_assets/icons/ultraviolet-disqus.svg new file mode 100644 index 000000000..ff6e88d0a --- /dev/null +++ b/frontend/public/_assets/icons/ultraviolet-disqus.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/_assets/icons/ultraviolet-giscus.svg b/frontend/public/_assets/icons/ultraviolet-giscus.svg new file mode 100644 index 000000000..05feecf2b --- /dev/null +++ b/frontend/public/_assets/icons/ultraviolet-giscus.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/_assets/icons/ultraviolet-hyvortalk.svg b/frontend/public/_assets/icons/ultraviolet-hyvortalk.svg new file mode 100644 index 000000000..d2a8893fb --- /dev/null +++ b/frontend/public/_assets/icons/ultraviolet-hyvortalk.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/_assets/icons/ultraviolet-isso.svg b/frontend/public/_assets/icons/ultraviolet-isso.svg new file mode 100644 index 000000000..939d164b1 --- /dev/null +++ b/frontend/public/_assets/icons/ultraviolet-isso.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/_assets/icons/ultraviolet-remark42.svg b/frontend/public/_assets/icons/ultraviolet-remark42.svg new file mode 100644 index 000000000..16071e74b --- /dev/null +++ b/frontend/public/_assets/icons/ultraviolet-remark42.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/_assets/icons/ultraviolet-waline.svg b/frontend/public/_assets/icons/ultraviolet-waline.svg new file mode 100644 index 000000000..98368338b --- /dev/null +++ b/frontend/public/_assets/icons/ultraviolet-waline.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/icons.generated.js b/frontend/src/assets/icons.generated.js index 45df58055..d1e739544 100644 --- a/frontend/src/assets/icons.generated.js +++ b/frontend/src/assets/icons.generated.js @@ -5,7 +5,7 @@ never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or removing an icon; `check-icons.mjs` fails the build if this drifts. - 282 icons. + 281 icons. */ export const BUNDLED_ICONS = { "la:angle-right": {"body":"","width":32,"height":32}, @@ -41,6 +41,7 @@ export const BUNDLED_ICONS = { "la:code": {"body":"","width":32,"height":32}, "la:code-branch": {"body":"","width":32,"height":32}, "la:cog": {"body":"","width":32,"height":32}, + "la:comment": {"body":"","width":32,"height":32}, "la:comments": {"body":"","width":32,"height":32}, "la:copy": {"body":"","width":32,"height":32}, "la:crop": {"body":"","width":32,"height":32}, @@ -112,6 +113,7 @@ export const BUNDLED_ICONS = { "la:question-circle": {"body":"","width":32,"height":32}, "la:redo": {"body":"","width":32,"height":32}, "la:redo-alt": {"body":"","width":32,"height":32}, + "la:reply": {"body":"","width":32,"height":32}, "la:ruler-vertical": {"body":"","width":32,"height":32}, "la:search": {"body":"","width":32,"height":32}, "la:search-minus": {"body":"","width":32,"height":32}, @@ -184,7 +186,6 @@ export const BUNDLED_ICONS = { "mdi:code-json": {"body":"","width":24,"height":24}, "mdi:code-tags": {"body":"","width":24,"height":24}, "mdi:cog": {"body":"","width":24,"height":24}, - "mdi:cog-box": {"body":"","width":24,"height":24}, "mdi:crop-square": {"body":"","width":24,"height":24}, "mdi:database-refresh": {"body":"","width":24,"height":24}, "mdi:dice-5": {"body":"","width":24,"height":24}, @@ -249,7 +250,6 @@ export const BUNDLED_ICONS = { "mdi:link-variant": {"body":"","width":24,"height":24}, "mdi:link-variant-plus": {"body":"","width":24,"height":24}, "mdi:logout": {"body":"","width":24,"height":24}, - "mdi:magnify": {"body":"","width":24,"height":24}, "mdi:marker": {"body":"","width":24,"height":24}, "mdi:marker-cancel": {"body":"","width":24,"height":24}, "mdi:menu-down": {"body":"","width":24,"height":24}, @@ -281,7 +281,6 @@ export const BUNDLED_ICONS = { "mdi:table-row-plus-before": {"body":"","width":24,"height":24}, "mdi:table-row-remove": {"body":"","width":24,"height":24}, "mdi:table-split-cell": {"body":"","width":24,"height":24}, - "mdi:tag": {"body":"","width":24,"height":24}, "mdi:text-box-outline": {"body":"","width":24,"height":24}, "mdi:tooltip-plus-outline": {"body":"","width":24,"height":24}, "mdi:toy-brick-plus": {"body":"","width":24,"height":24}, diff --git a/frontend/src/components/PageComment.vue b/frontend/src/components/PageComment.vue new file mode 100644 index 000000000..c896f9055 --- /dev/null +++ b/frontend/src/components/PageComment.vue @@ -0,0 +1,312 @@ + + + + + diff --git a/frontend/src/components/PageCommentEditor.vue b/frontend/src/components/PageCommentEditor.vue new file mode 100644 index 000000000..7d1d3ec9e --- /dev/null +++ b/frontend/src/components/PageCommentEditor.vue @@ -0,0 +1,421 @@ + + + + + diff --git a/frontend/src/components/PageCommentsEmbed.vue b/frontend/src/components/PageCommentsEmbed.vue new file mode 100644 index 000000000..b8169c757 --- /dev/null +++ b/frontend/src/components/PageCommentsEmbed.vue @@ -0,0 +1,89 @@ + + + diff --git a/frontend/src/components/PagePropertiesDialog.vue b/frontend/src/components/PagePropertiesDialog.vue index c8377d0c5..6d5e95bd0 100644 --- a/frontend/src/components/PagePropertiesDialog.vue +++ b/frontend/src/components/PagePropertiesDialog.vue @@ -262,7 +262,7 @@
{{ t('editor.props.social') }}
-
+
+
+
+ +
{{ t('common.comments.title') }}
+ + +
+ +
+ {{ t('common.comments.loading') }} +
+ +
+ + + + + diff --git a/frontend/src/components/PageViewTabs.vue b/frontend/src/components/PageViewTabs.vue new file mode 100644 index 000000000..e0be309d4 --- /dev/null +++ b/frontend/src/components/PageViewTabs.vue @@ -0,0 +1,355 @@ + + + + + diff --git a/frontend/src/components/shared/WInput.vue b/frontend/src/components/shared/WInput.vue index d63853198..f50f61295 100644 --- a/frontend/src/components/shared/WInput.vue +++ b/frontend/src/components/shared/WInput.vue @@ -599,6 +599,14 @@ registerWithForm?.({ validate }) defineExpose({ validate, focus: () => inputEl.value?.focus(), + /** + * The underlying `` or `