From 739cf9630bb6902a96f442e3b70e9be167cd998d Mon Sep 17 00:00:00 2001 From: NGPixel Date: Thu, 24 Sep 2026 10:36:50 -0400 Subject: [PATCH] fix: missing comments events in webhooks + reload site state after import --- backend/api/comments.ts | 56 +++++++++++++++++++ backend/api/hooks.ts | 22 ++------ backend/locales/en.json | 4 +- backend/models/comments.ts | 9 ++- backend/models/hooks.ts | 26 +-------- .../src/components/ImportWikijs2Overlay.vue | 23 ++++++++ frontend/src/components/WebhookEditDialog.vue | 26 +-------- 7 files changed, 97 insertions(+), 69 deletions(-) diff --git a/backend/api/comments.ts b/backend/api/comments.ts index 01669ffe5..f1453c533 100644 --- a/backend/api/comments.ts +++ b/backend/api/comments.ts @@ -399,6 +399,25 @@ async function routes(app: FastifyInstance) { isReply: Boolean(comment.parentId), isGuest: comment.isGuest }) + // -> The email is a guest's alone: an account's + // comment stores none, and `authorId` is what identifies it + await WIKI.models.hooks.emit('comment:new', { + id: comment.id, + parentId: comment.parentId, + pageId: page.id, + path: page.path, + locale: page.locale, + siteId: req.params.siteId, + authorId: comment.authorId, + metadata: { + authorName: comment.authorName, + authorEmail: user ? null : authorEmail, + authorIP: req.ip, + isGuest: comment.isGuest, + pageTitle: page.title + }, + content: comment.content + }) reply.code(201) return comment @@ -452,6 +471,25 @@ async function routes(app: FastifyInstance) { path: comment.path, isOwn: comment.authorId === req.session?.user?.id }) + // -> `actorId` beside `authorId`, since a moderator editing somebody else's comment is exactly + // the case a subscriber is likely to be watching for + await WIKI.models.hooks.emit('comment:edit', { + id: comment.id, + parentId: comment.parentId, + pageId: comment.pageId, + path: comment.path, + locale: comment.locale, + siteId: req.params.siteId, + authorId: comment.authorId, + actorId: req.session?.user?.id ?? null, + metadata: { + authorName: updated.authorName, + authorEmail: comment.authorEmail || null, + authorIP: comment.authorIP || null, + isGuest: updated.isGuest + }, + content: updated.content + }) return updated } ) @@ -497,6 +535,24 @@ async function routes(app: FastifyInstance) { deleted, isOwn: comment.authorId === req.session?.user?.id }) + // -> One event for the comment asked about, not one per reply the cascade took with it: + // `deleted` says how many went + await WIKI.models.hooks.emit('comment:delete', { + id: comment.id, + parentId: comment.parentId, + pageId: comment.pageId, + path: comment.path, + locale: comment.locale, + siteId: req.params.siteId, + authorId: comment.authorId, + actorId: req.session?.user?.id ?? null, + deleted, + metadata: { + authorEmail: comment.authorEmail || null, + authorIP: comment.authorIP || null, + isGuest: comment.authorId === null + } + }) return { ok: true, deleted } } ) diff --git a/backend/api/hooks.ts b/backend/api/hooks.ts index bcda5d3f7..29c708670 100644 --- a/backend/api/hooks.ts +++ b/backend/api/hooks.ts @@ -1,7 +1,7 @@ import { audit } from '../helpers/audit.ts' import { SENSITIVE_MASK } from '../helpers/common.ts' import type { FastifyInstance, FastifyRequest } from 'fastify' -import { EMITTED_EVENTS, HOOK_EVENTS } from '../models/hooks.ts' +import { HOOK_EVENTS } from '../models/hooks.ts' /** Whether this caller may change webhooks, as opposed to only reading them. */ function mayManage(req: FastifyRequest): boolean { @@ -108,31 +108,18 @@ async function routes(app: FastifyInstance) { }, schema: { summary: 'List the events a webhook can subscribe to', - description: - 'Only the `user:*` events are emitted at the moment. Pages, assets and comments are not implemented yet, so a subscription to those is stored but never triggered.', tags: ['Webhooks'], response: { 200: { description: 'List of event keys', type: 'array', - items: { - type: 'object', - properties: { - key: { - type: 'string' - }, - isEmitted: { - type: 'boolean', - description: 'Whether anything in the server currently emits this event.' - } - } - } + items: { type: 'string' } } } } }, async () => { - return HOOK_EVENTS.map((key) => ({ key, isEmitted: EMITTED_EVENTS.includes(key) })) + return HOOK_EVENTS } ) @@ -147,8 +134,7 @@ async function routes(app: FastifyInstance) { }, schema: { summary: 'Get a single webhook', - description: - 'See the listing for how `authHeader` reads.', + description: 'See the listing for how `authHeader` reads.', tags: ['Webhooks'], params: { type: 'object', diff --git a/backend/locales/en.json b/backend/locales/en.json index 75e1ea726..c3cff8fa5 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -165,6 +165,7 @@ "admin.audit.actions.enableTfa": "Turned 2FA on", "admin.audit.actions.exportAuditLog": "Exported the audit log", "admin.audit.actions.fetchLocales": "Fetched the locale list", + "admin.audit.actions.finishImport": "Finished an import", "admin.audit.actions.flushCache": "Flushed the caches", "admin.audit.actions.flushIconCache": "Purged the icon cache", "admin.audit.actions.forcedPasswordChange": "Changed a password when required to at sign-in", @@ -199,11 +200,13 @@ "admin.audit.actions.sendTestEmail": "Sent a test email", "admin.audit.actions.sendWelcomeEmail": "Sent a welcome email", "admin.audit.actions.setFolderColor": "Changed a folder colour", + "admin.audit.actions.startImport": "Started an import", "admin.audit.actions.submitPageEdit": "Suggested an edit", "admin.audit.actions.togglePasswordLogin": "Turned password sign-in on or off", "admin.audit.actions.unassignUserFromGroup": "Removed a user from a group", "admin.audit.actions.unlockPage": "Unlocked a password-protected page", "admin.audit.actions.unwatchPage": "Stopped watching a page", + "admin.audit.actions.updateAnalytics": "Changed the analytics configuration", "admin.audit.actions.updateApiState": "Turned the API on or off", "admin.audit.actions.updateApprovalRule": "Updated an approval rule", "admin.audit.actions.updateAsset": "Renamed or moved a file", @@ -1564,7 +1567,6 @@ "admin.webhooks.eventEditComment": "Edit an existing comment", "admin.webhooks.eventEditPage": "Update an existing page", "admin.webhooks.eventNewComment": "Post a new comment", - "admin.webhooks.eventNotEmitted": "Not emitted yet — this part of the wiki is not implemented.", "admin.webhooks.eventRenameAsset": "Rename / move an asset", "admin.webhooks.eventRenamePage": "Rename / move a page", "admin.webhooks.eventUploadAsset": "Upload a new asset", diff --git a/backend/models/comments.ts b/backend/models/comments.ts index 1b10e4f0f..936e1fcd1 100644 --- a/backend/models/comments.ts +++ b/backend/models/comments.ts @@ -692,7 +692,12 @@ class Comments { return Number(row?.total ?? 0) } - /** One comment with the page it is on, which is what every permission check on it needs. */ + /** + * One comment with the page it is on, which is what every permission check on it needs. + * + * Carries the email and address it was posted with, which are for the server's own use — the spam + * check and webhook payloads — and never for a reply to the client. + */ async getWithPage(commentId: string, siteId: string) { const [row] = await WIKI.db .select({ @@ -700,6 +705,8 @@ class Comments { parentId: commentsTable.parentId, content: commentsTable.content, authorId: commentsTable.authorId, + authorEmail: commentsTable.authorEmail, + authorIP: commentsTable.authorIP, pageId: commentsTable.pageId, path: pagesTable.path, locale: pagesTable.locale, diff --git a/backend/models/hooks.ts b/backend/models/hooks.ts index 1a6e1a080..fcb910463 100644 --- a/backend/models/hooks.ts +++ b/backend/models/hooks.ts @@ -4,10 +4,8 @@ import { hooks as hooksTable } from '../db/schema.ts' import { desc, eq, sql } from 'drizzle-orm' /** - * The events a webhook can subscribe to, as offered by the admin area. - * - * Not all of them have emit points today — comments are not implemented yet, so subscribing to those - * stores a subscription that nothing triggers. + * The events a webhook can subscribe to, as offered by the admin area. Every one of them has an + * `emit()` call somewhere in the server; add the call in the same change that adds a key here. */ export const HOOK_EVENTS = [ 'page:create', @@ -28,26 +26,6 @@ export const HOOK_EVENTS = [ export type HookEvent = (typeof HOOK_EVENTS)[number] -/** - * The events something in the server actually emits today. - * - * Kept as an explicit list rather than inferred from the prefix, since the comment events have no - * emit point yet. Add an event here when you add its `emit()` call. - */ -export const EMITTED_EVENTS: HookEvent[] = [ - 'page:create', - 'page:edit', - 'page:rename', - 'page:delete', - 'asset:upload', - 'asset:edit', - 'asset:rename', - 'asset:delete', - 'user:join', - 'user:login', - 'user:logout' -] - /** A webhook as exposed by the API. */ export interface Hook { id: string diff --git a/frontend/src/components/ImportWikijs2Overlay.vue b/frontend/src/components/ImportWikijs2Overlay.vue index ae0d88c71..7c6810761 100644 --- a/frontend/src/components/ImportWikijs2Overlay.vue +++ b/frontend/src/components/ImportWikijs2Overlay.vue @@ -511,6 +511,29 @@ async function startImport() { addLog('error', err.message) state.phase = 'failed' } + await refreshSite() +} + +/** + * Take in the site configuration the import may have just replaced. + * + * The settings step rewrites the site config on the server, but the app is still running on the one + * it booted with -- a package with comments off left the Talk tab drawn over an endpoint that now + * refuses it, until a full reload. Done after a failure too: settings go first, so an import that + * died halfway has usually already written them. + */ +async function refreshSite() { + if (!selectedContent.value.includes('settings')) { + return + } + try { + await adminStore.fetchSites() + if (state.siteId === siteStore.id) { + await siteStore.loadSite(window.location.hostname) + } + } catch (err) { + addLog('warn', err.message) + } } /** diff --git a/frontend/src/components/WebhookEditDialog.vue b/frontend/src/components/WebhookEditDialog.vue index f58586bbb..6d2f838e3 100644 --- a/frontend/src/components/WebhookEditDialog.vue +++ b/frontend/src/components/WebhookEditDialog.vue @@ -59,7 +59,7 @@ v-model="state.hook.events" :disable="!canManage" outlined - :options="events" + :options="EVENT_DEFINITIONS" multiple map-options emit-value @@ -88,10 +88,6 @@ }} {{ opt.name }} - - {{ - t('admin.webhooks.eventNotEmitted') - }} @@ -265,8 +261,6 @@ const canManage = computed(() => userStore.can('manage:webhooks')) const state = reactive({ isLoading: false, - /** Event keys the server actually emits. Null until fetched, i.e. assume all of them. */ - emittedEvents: null, hook: { name: '', events: [], @@ -347,13 +341,6 @@ const EVENT_DEFINITIONS = computed(() => [ } ]) -const events = computed(() => - EVENT_DEFINITIONS.value.map((evt) => ({ - ...evt, - isEmitted: state.emittedEvents === null || state.emittedEvents.includes(evt.key) - })) -) - // REFS const editWebhookForm = ref(null) @@ -459,20 +446,9 @@ async function save() { state.isLoading = false } -async function fetchEmittedEvents() { - try { - const resp = await API_CLIENT.get('hooks/events').json() - state.emittedEvents = (resp ?? []).filter((evt) => evt.isEmitted).map((evt) => evt.key) - } catch { - // -> Purely informational: on failure, flag nothing rather than flag everything - state.emittedEvents = null - } -} - // MOUNTED onMounted(() => { - fetchEmittedEvents() if (props.hookId) { fetchHook(props.hookId) }