fix: missing comments events in webhooks + reload site state after import

pull/8104/head
NGPixel 4 days ago
parent 7808ccbbd3
commit 739cf9630b
No known key found for this signature in database

@ -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 }
}
)

@ -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',

@ -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",

@ -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,

@ -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

@ -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)
}
}
/**

@ -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 @@
}}</w-chip>
<span class="min-w-0 flex-1">
<w-item-label>{{ opt.name }}</w-item-label>
<!-- Subscribing is allowed, but say plainly that nothing fires it yet -->
<w-item-label v-if="!opt.isEmitted" caption>{{
t('admin.webhooks.eventNotEmitted')
}}</w-item-label>
</span>
</span>
</template>
@ -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)
}

Loading…
Cancel
Save