Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .contentrain/content/system/email-templates/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,5 +88,17 @@
"slug": "usage-overage-started",
"subject": "{workspaceName}: {meterName} is now past the plan limit",
"body": "<p>Hi,</p><p><strong>{workspaceName}</strong> has used all <strong>{limit}</strong> {meterName} included in the {planName} plan. Overage is on, so nothing has stopped: usage past the limit is billed at <strong>{unitPrice}</strong> per unit on your next invoice.</p><p>{resetLine} You can turn overage off or change the plan in Billing.</p><table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:24px 0;\"><tr><td style=\"background-color:#4B6BFB;border-radius:8px;\"><a href=\"{billingUrl}\" target=\"_blank\" style=\"display:inline-block;padding:12px 32px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;\">Open Billing</a></td></tr></table>"
},
"comment-pending": {
"body": "<p>Hi,</p><p>A new comment on <strong>{entryId}</strong> ({modelName}) of <strong>{projectName}</strong> ({workspaceName}) is waiting for your approval.</p><p><strong>{authorName}</strong> wrote:</p><blockquote>{excerptHtml}</blockquote><p><a href=\"{moderationUrl}\">Review comments</a></p>",
"name": "Comment — Waiting for Approval",
"slug": "comment-pending",
"subject": "New comment to approve on {projectName}"
},
"comment-published": {
"body": "<p>Hi,</p><p>A new comment was published on <strong>{entryId}</strong> ({modelName}) of <strong>{projectName}</strong> ({workspaceName}).</p><p><strong>{authorName}</strong> wrote:</p><blockquote>{excerptHtml}</blockquote><p><a href=\"{moderationUrl}\">Review comments</a></p>",
"name": "Comment — Published",
"slug": "comment-published",
"subject": "New comment on {projectName}"
}
}
2 changes: 2 additions & 0 deletions .contentrain/content/system/ui-strings/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,8 @@
"comments.entry": "Entry",
"comments.honeypot": "Honeypot Field",
"comments.honeypot_description": "Add a hidden field to catch spam bots.",
"comments.notifications": "Email Notifications",
"comments.notifications_description": "Email the workspace owner and admins when a comment arrives.",
"comments.import_button": "Upload comments-export.json",
"comments.import_description": "Upload the comments-export.json that `contentrain import` wrote next to your content. Re-uploading is safe: comments already imported are skipped.",
"comments.import_error": "Comment import failed.",
Expand Down
15 changes: 15 additions & 0 deletions app/components/organisms/CommentsConfigSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ interface CommentsConfigShape {
maxDepth?: number
requireEmail?: boolean
honeypot?: boolean
notifications?: boolean
captcha?: 'turnstile' | null
rateLimitPerIp?: number
maxBodyLength?: number
Expand All @@ -33,6 +34,7 @@ const requireApproval = ref(true)
const maxDepth = ref(4)
const requireEmail = ref(true)
const honeypot = ref(true)
const notifications = ref(true)
const captcha = ref<'turnstile' | ''>('')
const rateLimitPerIp = ref(5)
const maxBodyLength = ref(5000)
Expand All @@ -44,6 +46,7 @@ function syncFromBrain() {
maxDepth.value = cfg?.maxDepth ?? 4
requireEmail.value = cfg?.requireEmail ?? true
honeypot.value = cfg?.honeypot ?? true
notifications.value = cfg?.notifications ?? true
captcha.value = cfg?.captcha === 'turnstile' ? 'turnstile' : ''
rateLimitPerIp.value = cfg?.rateLimitPerIp ?? 5
maxBodyLength.value = cfg?.maxBodyLength ?? 5000
Expand All @@ -59,6 +62,7 @@ const hasChanges = computed(() => {
|| maxDepth.value !== (cfg?.maxDepth ?? 4)
|| requireEmail.value !== (cfg?.requireEmail ?? true)
|| honeypot.value !== (cfg?.honeypot ?? true)
|| notifications.value !== (cfg?.notifications ?? true)
|| (captcha.value || null) !== (cfg?.captcha ?? null)
|| rateLimitPerIp.value !== (cfg?.rateLimitPerIp ?? 5)
|| maxBodyLength.value !== (cfg?.maxBodyLength ?? 5000)
Expand All @@ -79,6 +83,7 @@ async function save() {
maxDepth: maxDepth.value,
requireEmail: requireEmail.value,
honeypot: honeypot.value,
notifications: notifications.value,
captcha: captcha.value || null,
rateLimitPerIp: rateLimitPerIp.value,
maxBodyLength: maxBodyLength.value,
Expand Down Expand Up @@ -289,6 +294,16 @@ async function onImportFile(event: Event) {
<AtomsFormSwitch :model-value="honeypot" :disabled="!editable" @update:model-value="honeypot = $event" />
</div>

<div class="flex items-center justify-between">
<div>
<span class="text-sm text-heading dark:text-secondary-100">{{ t('comments.notifications') }}</span>
<p class="text-xs text-muted">
{{ t('comments.notifications_description') }}
</p>
</div>
<AtomsFormSwitch :model-value="notifications" :disabled="!editable" @update:model-value="notifications = $event" />
</div>

<div class="flex items-center justify-between">
<span class="text-sm text-heading dark:text-secondary-100">{{ t('comments.captcha') }}</span>
<div :class="{ 'pointer-events-none opacity-50': !editable }">
Expand Down
1 change: 1 addition & 0 deletions docs/COMMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ WordPress export (contentrain-comments@1) ── import ┘
| `maxDepth` | `4` | Reply nesting cap for **new** public submissions (`0` = flat). Import never clamps |
| `requireEmail` | `true` | Commenters must supply an email; it is never shown publicly |
| `honeypot` | `true` | Hidden `_hp` field; a filled honeypot is silently accepted and dropped |
| `notifications` | `true` | Email the workspace owner and admins when a comment arrives (`comment-pending` asks for review, `comment-published` when it went live); a mail failure never affects the visitor's response |
| `captcha` | `null` | `'turnstile'` to require a Cloudflare Turnstile token (needs `comments.captcha` + `NUXT_TURNSTILE_SECRET_KEY`) |
| `rateLimitPerIp` | `5` | Submissions per IP per minute on one entry |
| `maxBodyLength` | `5000` | Body length cap for public submissions |
Expand Down
18 changes: 18 additions & 0 deletions server/api/comments/v1/[projectId]/[modelId]/[entryId].post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import { getClientIp } from '~~/server/utils/form-types'
import { toPublicComment } from '~~/server/utils/comment-thread'
import { notifyCommentSubmitted } from '~~/server/utils/comment-notifications'
import { normalizeLocaleParam, resolvePublicCommentContext } from '~~/server/utils/comment-public-context'
import { sanitizeString } from '~~/server/utils/sanitize-input'
import { verifyTurnstileToken } from '~~/server/utils/turnstile'
Expand Down Expand Up @@ -162,6 +163,23 @@ export default defineEventHandler(async (event) => {
if (!outcome.comment)
throw createError({ statusCode: 500, message: errorMessage('comments.create_failed', { detail: 'empty' }) })

// Notify the workspace owner/admins (fire-and-forget) — the model's
// `comments.notifications` flag defaults on.
if (ctx.config.notifications) {
notifyCommentSubmitted({
workspaceId: ctx.workspaceId,
workspaceName: String(ctx.workspace.name ?? ''),
workspaceSlug: String(ctx.workspace.slug ?? ctx.workspaceId),
projectId,
projectName: ctx.projectName,
modelId,
entryId,
status,
authorName,
body: text,
}).catch(() => {})
}

// Outbound webhook — gated exactly like forms.webhook_notification (ee).
if (hasFeature(ctx.plan, 'comments.webhook_notification')) {
emitWebhookEvent(projectId, ctx.workspaceId, 'comment.submitted', {
Expand Down
45 changes: 45 additions & 0 deletions server/utils/comment-notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { escapeHtml } from './email-layout'

const EXCERPT_LENGTH = 400

/**
* Email the workspace owner + admins about a new public comment. Best-effort
* and fire-and-forget: a mail failure never affects the public submit
* response. The caller gates it on the model's `comments.notifications`
* flag (default on). A pending comment asks for review; an auto-approved one
* is already live and says so.
*/
export async function notifyCommentSubmitted(input: {
workspaceId: string
workspaceName: string
workspaceSlug: string
projectId: string
projectName: string
modelId: string
entryId: string
status: 'pending' | 'approved'
authorName: string
body: string
}): Promise<void> {
const email = useEmailProvider()
if (!email) return

const db = useDatabaseProvider()
const recipients = await db.listWorkspaceNotificationRecipients(input.workspaceId)
if (recipients.length === 0) return

const config = useRuntimeConfig()
const excerpt = input.body.length > EXCERPT_LENGTH ? `${input.body.slice(0, EXCERPT_LENGTH).trimEnd()}…` : input.body

const tpl = emailTemplate(input.status === 'pending' ? 'comment-pending' : 'comment-published', {
workspaceName: escapeHtml(input.workspaceName),
projectName: escapeHtml(input.projectName),
modelName: escapeHtml(input.modelId),
entryId: escapeHtml(input.entryId),
authorName: escapeHtml(input.authorName),
excerptHtml: escapeHtml(excerpt),
moderationUrl: `${config.public.siteUrl}/w/${input.workspaceSlug}/projects/${input.projectId}`,
})

await Promise.all(recipients.map(r => email.sendEmail({ to: r.email, subject: tpl.subject, html: tpl.body }).catch(() => {})))
}
5 changes: 4 additions & 1 deletion server/utils/comment-public-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface PublicCommentContext {
workspaceId: string
plan: ReturnType<typeof getWorkspacePlan>
workspace: Record<string, unknown>
/** The project's repository (`owner/repo`), the name an owner knows it by. */
projectName: string
modelId: string
config: CommentsConfig
/** The project's default locale (`config.locales.default`), the fallback when a request names none. */
Expand All @@ -28,7 +30,7 @@ export async function resolvePublicCommentContext(projectId: string, modelId: st
if (!project)
throw createError({ statusCode: 404, message: errorMessage('comments.not_found') })

const workspace = await db.getWorkspaceById(project.workspace_id as string, 'id, type, plan, github_installation_id, overage_settings')
const workspace = await db.getWorkspaceById(project.workspace_id as string, 'id, name, slug, type, plan, github_installation_id, overage_settings')
if (!workspace)
throw createError({ statusCode: 404, message: errorMessage('comments.not_found') })

Expand Down Expand Up @@ -79,6 +81,7 @@ export async function resolvePublicCommentContext(projectId: string, modelId: st
workspaceId: workspace.id as string,
plan,
workspace: workspace as Record<string, unknown>,
projectName: String(project.repo_full_name),
modelId,
config,
defaultLocale: normalizeLocaleParam(configuredDefault, 'en'),
Expand Down
4 changes: 4 additions & 0 deletions server/utils/comment-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export interface CommentsConfig {
/** Commenters must supply an email (never shown publicly). */
requireEmail: boolean
honeypot: boolean
/** Email the workspace owner and admins when a comment arrives (default true). */
notifications: boolean
captcha: 'turnstile' | null
/** Public submissions per IP per minute per entry. */
rateLimitPerIp: number
Expand All @@ -26,6 +28,7 @@ export const COMMENTS_CONFIG_DEFAULTS: CommentsConfig = {
maxDepth: 4,
requireEmail: true,
honeypot: true,
notifications: true,
captcha: null,
rateLimitPerIp: 5,
maxBodyLength: 5000,
Expand Down Expand Up @@ -58,6 +61,7 @@ export function normalizeCommentsConfig(raw: Partial<CommentsConfig>): CommentsC
maxDepth: clampInt(raw.maxDepth, COMMENTS_CONFIG_DEFAULTS.maxDepth, 0, COMMENTS_CONFIG_LIMITS.maxDepth),
requireEmail: raw.requireEmail !== false,
honeypot: raw.honeypot !== false,
notifications: raw.notifications !== false,
captcha: raw.captcha === 'turnstile' ? 'turnstile' : null,
rateLimitPerIp: clampInt(raw.rateLimitPerIp, COMMENTS_CONFIG_DEFAULTS.rateLimitPerIp, 1, COMMENTS_CONFIG_LIMITS.rateLimitPerIp),
maxBodyLength: clampInt(raw.maxBodyLength, COMMENTS_CONFIG_DEFAULTS.maxBodyLength, 100, COMMENTS_CONFIG_LIMITS.maxBodyLength),
Expand Down
2 changes: 1 addition & 1 deletion server/utils/email-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export function emailButton(label: string, href: string): string {
return `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px 0;"><tr><td style="background-color:#4B6BFB;border-radius:8px;"><a href="${escapeAttr(href)}" target="_blank" style="display:inline-block;padding:12px 32px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;">${escapeHtml(label)}</a></td></tr></table>`
}

function escapeHtml(value: string): string {
export function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
Expand Down
55 changes: 55 additions & 0 deletions tests/integration/comment-routes.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,61 @@ describe('public comment routes', () => {
})
})

describe('POST notifies the workspace owner and admins', () => {
async function submit(config: Record<string, unknown>, comment: { author: string, body: string } = { author: 'Ada', body: 'hello' }) {
stubPublicGlobals({ config })
const listWorkspaceNotificationRecipients = vi.fn().mockResolvedValue([{ userId: 'u1', email: 'owner@acme.dev' }, { userId: 'u2', email: 'admin@acme.dev' }])
const sendEmail = vi.fn().mockResolvedValue(undefined)
const createCommentIfAllowed = vi.fn().mockImplementation(async (_ws: string, _limit: number, input: Record<string, unknown>) => ({
allowed: true,
currentCount: 1,
comment: { ...approvedRoot, id: '33333333-3333-4333-8333-333333333333', body: input.body, author_name: input.author_name, status: input.status },
}))
vi.stubGlobal('useEmailProvider', vi.fn().mockReturnValue({ sendEmail }))
vi.stubGlobal('emailTemplate', vi.fn((slug: string, params: Record<string, string>) => ({ subject: `${slug}:${params.projectName}`, body: `${params.authorName}|${params.excerptHtml}|${params.moderationUrl}|${params.entryId}` })))
vi.stubGlobal('useRuntimeConfig', () => ({ public: { siteUrl: 'https://studio.test' } }))
vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({
getProjectById: vi.fn().mockResolvedValue({ id: PROJECT, workspace_id: WORKSPACE, repo_full_name: 'acme/site', content_root: '.contentrain' }),
getWorkspaceById: vi.fn().mockResolvedValue({ id: WORKSPACE, name: 'Acme', slug: 'acme', plan: 'pro', github_installation_id: 42, overage_settings: null }),
createCommentIfAllowed,
listWorkspaceNotificationRecipients,
}))
await withTestServer({
routes: [{ path: '/api/comments/v1/project-1/posts/entry-1', handler: await loadPublicPost() }],
}, async ({ request }) => {
const response = await request('/api/comments/v1/project-1/posts/entry-1', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ author: { name: comment.author, email: 'a@b.co' }, body: comment.body }),
})
expect(response.status).toBe(200)
// Fire-and-forget — give it a tick.
await new Promise(resolve => setTimeout(resolve, 10))
})
return { sendEmail, listWorkspaceNotificationRecipients }
}

it('a comment waiting for approval asks for review, escaped, with a link to the project', async () => {
const { sendEmail, listWorkspaceNotificationRecipients } = await submit({ requireApproval: true }, { author: 'Ada', body: 'a < b & c' })
expect(listWorkspaceNotificationRecipients).toHaveBeenCalledWith(WORKSPACE)
expect(sendEmail).toHaveBeenCalledTimes(2)
expect(sendEmail).toHaveBeenCalledWith(expect.objectContaining({ to: 'owner@acme.dev', subject: 'comment-pending:acme/site' }))
expect(sendEmail).toHaveBeenCalledWith(expect.objectContaining({ to: 'admin@acme.dev' }))
expect(sendEmail.mock.calls[0]![0].html).toBe('Ada|a &lt; b &amp; c|https://studio.test/w/acme/projects/project-1|entry-1')
})

it('an auto-approved comment says it is already published', async () => {
const { sendEmail } = await submit({ requireApproval: false })
expect(sendEmail).toHaveBeenCalledWith(expect.objectContaining({ subject: 'comment-published:acme/site' }))
})

it('stays quiet when the model turns notifications off', async () => {
const { sendEmail, listWorkspaceNotificationRecipients } = await submit({ notifications: false })
expect(listWorkspaceNotificationRecipients).not.toHaveBeenCalled()
expect(sendEmail).not.toHaveBeenCalled()
})
})

it('POST maps RPC refusals: closed thread → 403, quota → 429, bad parent → field error', async () => {
stubPublicGlobals({ config: { requireApproval: false } })
const outcomes = [
Expand Down
5 changes: 5 additions & 0 deletions tests/unit/comment-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ describe('normalizeCommentsConfig', () => {
expect(normalizeCommentsConfig({ captcha: 'recaptcha' as unknown as 'turnstile' }).captcha).toBeNull()
})

it('emails owner and admins unless the model turns notifications off', () => {
expect(normalizeCommentsConfig({}).notifications).toBe(true)
expect(normalizeCommentsConfig({ notifications: false }).notifications).toBe(false)
})

it('treats non-numeric values as defaults', () => {
expect(normalizeCommentsConfig({ maxDepth: 'deep' as unknown as number }).maxDepth).toBe(4)
})
Expand Down
Loading