From b5258a8005ce7a8c95a0cdcd19ebb4471f984be3 Mon Sep 17 00:00:00 2001 From: Dev-Sahad Date: Fri, 31 Jul 2026 00:06:30 +0400 Subject: [PATCH] fix contact and comment webhook delivery --- .env.example | 2 + src/app/api/contact/route.ts | 146 ++++++++++++++++------------ src/app/api/notify-comment/route.ts | 34 ++++--- src/lib/commentService.ts | 10 +- src/lib/webhookSettings.ts | 56 +++++++++-- 5 files changed, 161 insertions(+), 87 deletions(-) diff --git a/.env.example b/.env.example index a3360c8..e5e39a9 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,8 @@ ADMIN_EMAIL=dev.sxhd@gmail.com ADMIN_USER_ID= GITHUB_TOKEN= DISCORD_WEBHOOK_URL= +CONTACT_DISCORD_WEBHOOK_URL= +COMMENTS_DISCORD_WEBHOOK_URL= # Optional contact-form email delivery. SMTP_HOST= diff --git a/src/app/api/contact/route.ts b/src/app/api/contact/route.ts index 997e0a6..22616d7 100644 --- a/src/app/api/contact/route.ts +++ b/src/app/api/contact/route.ts @@ -1,6 +1,10 @@ import { NextResponse } from 'next/server' import nodemailer from 'nodemailer' -import { getWebhookDelivery, renderWebhookMessage } from '@/lib/webhookSettings' +import { + getWebhookDelivery, + renderWebhookMessage, + sendDiscordWebhook, +} from '@/lib/webhookSettings' import { getServiceDatabase } from '@/lib/supabaseAdmin' type ContactPayload = { @@ -14,10 +18,19 @@ type ContactPayload = { const clean = (value: unknown) => typeof value === 'string' ? value.trim().slice(0, 1000) : '' +const escapeHtml = (value: string) => + value.replace(/[&<>"']/g, (character) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[character] || character) + async function sendEmail(name: string, senderEmail: string, message: string) { try { if (!process.env.GMAIL_USER || !process.env.GMAIL_PASSWORD) { - throw new Error('Gmail delivery is not configured.') + return false } const transporter = nodemailer.createTransport({ @@ -36,11 +49,11 @@ async function sendEmail(name: string, senderEmail: string, message: string) {

New Contact Form Message

-

From: ${name}

-

Email: ${senderEmail}

+

From: ${escapeHtml(name)}

+

Email: ${escapeHtml(senderEmail)}

Message:

- ${message} + ${escapeHtml(message)}


@@ -99,65 +112,74 @@ export async function POST(request: Request) { ]) } - // Send email - const emailSent = await sendEmail(name, email, message) - if (!emailSent) { - return NextResponse.json({ error: 'Unable to send your message right now.' }, { status: 502 }) - } - // Admin settings override the server environment value after the migration is applied. const { url: webhookUrl, message: customMessage } = await getWebhookDelivery('contact') - if (webhookUrl) { - try { - await fetch(webhookUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - username: 'Portfolio Contact', - content: renderWebhookMessage(customMessage, { name, email, message }, `New portfolio message from ${name}`), - allowed_mentions: { parse: [] }, - embeds: [ - { - title: 'New Portfolio Message', - color: 0xffffff, - author: { - name, - }, - description: message.slice(0, 3900), - fields: [ - { - name: 'Viewer Name', - value: name, - inline: true, - }, - { - name: 'Email', - value: email, - inline: true, - }, - { - name: 'Page', - value: page || 'Unknown', - inline: false, - }, - { - name: 'Browser', - value: userAgent || 'Unknown', - inline: false, - }, - ], - footer: { - text: 'Sent from the portfolio contact form', - }, - timestamp: new Date().toISOString(), - }, - ], - }), - }) - } catch (error) { - console.error('Error sending Discord notification:', error) - } + const webhookPayload = { + username: 'Portfolio Contact', + content: renderWebhookMessage( + customMessage, + { name, email, message }, + `New portfolio message from ${name}`, + ), + allowed_mentions: { parse: [] }, + embeds: [ + { + title: 'New Portfolio Message', + color: 0xffffff, + author: { + name: name.slice(0, 256), + }, + description: message.slice(0, 3900), + fields: [ + { + name: 'Viewer Name', + value: name.slice(0, 1024), + inline: true, + }, + { + name: 'Email', + value: email.slice(0, 1024), + inline: true, + }, + { + name: 'Page', + value: (page || 'Unknown').slice(0, 1024), + inline: false, + }, + { + name: 'Browser', + value: (userAgent || 'Unknown').slice(0, 1024), + inline: false, + }, + ], + footer: { + text: 'Sent from the portfolio contact form', + }, + timestamp: new Date().toISOString(), + }, + ], + } + + // Discord is the primary notification channel. Gmail is optional and must not + // prevent a configured webhook from receiving the contact message. + const [webhookResult, emailResult] = await Promise.allSettled([ + sendDiscordWebhook(webhookUrl, webhookPayload), + sendEmail(name, email, message), + ]) + + if (webhookResult.status === 'rejected') { + console.error('Contact webhook delivery failed:', webhookResult.reason) + return NextResponse.json( + { error: 'Unable to deliver your message right now.' }, + { status: 502 }, + ) } - return NextResponse.json({ ok: true }) + return NextResponse.json({ + ok: true, + deliveries: { + discord: true, + email: emailResult.status === 'fulfilled' && emailResult.value, + }, + }) } diff --git a/src/app/api/notify-comment/route.ts b/src/app/api/notify-comment/route.ts index c556732..1ea94eb 100644 --- a/src/app/api/notify-comment/route.ts +++ b/src/app/api/notify-comment/route.ts @@ -1,22 +1,30 @@ import { NextRequest, NextResponse } from 'next/server' -import { getWebhookDelivery, renderWebhookMessage } from '@/lib/webhookSettings' +import { + getWebhookDelivery, + renderWebhookMessage, + sendDiscordWebhook, +} from '@/lib/webhookSettings' const OWNER_EMAIL = 'dev.sxhd@gmail.com' + +const clean = (value: unknown, limit = 1000) => + typeof value === 'string' ? value.trim().slice(0, limit) : '' + export async function POST(req: NextRequest) { try { - const { name, comment, imageUrl } = await req.json() + const payload = await req.json() + const name = clean(payload.name, 100) + const comment = clean(payload.comment, 1000) + const imageUrl = clean(payload.imageUrl, 1000) - if (!name?.trim() || !comment?.trim()) { + if (!name || !comment) { return NextResponse.json({ error: 'Missing name or comment' }, { status: 400 }) } const { url: webhookUrl, message: customMessage } = await getWebhookDelivery('comments') // ── Discord embed ───────────────────────────────────────────── - if (webhookUrl) await fetch(webhookUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + await sendDiscordWebhook(webhookUrl, { content: renderWebhookMessage(customMessage, { name, comment }, `New portfolio comment from ${name}`), allowed_mentions: { parse: [] }, embeds: [{ @@ -25,14 +33,13 @@ export async function POST(req: NextRequest) { fields: [ { name: '👤 From', value: `**${name}**`, inline: true }, { name: '📧 Notify', value: OWNER_EMAIL, inline: true }, - { name: '💬 Message', value: comment, inline: false }, - ...(imageUrl ? [{ name: '🖼️ Image', value: imageUrl, inline: false }] : []), + { name: '💬 Message', value: comment.slice(0, 1024), inline: false }, + ...(imageUrl ? [{ name: '🖼️ Image', value: imageUrl.slice(0, 1024), inline: false }] : []), ], footer: { text: 'portfolio-v1 · Comments' }, timestamp: new Date().toISOString(), }], - }), - }).catch(() => null) // never block comment on webhook failure + }) // ── Build mailto URL (opened on client if user wants to reply) ─ const subject = encodeURIComponent(`Re: Comment from ${name} on your portfolio`) @@ -48,9 +55,10 @@ export async function POST(req: NextRequest) { ownerMail: `mailto:${OWNER_EMAIL}?subject=${encodeURIComponent(`New comment from ${name}`)}&body=${encodeURIComponent(`Name: ${name}\nComment: ${comment}`)}`, }) } catch (err: unknown) { + console.error('Comment webhook delivery failed:', err) return NextResponse.json( - { error: err instanceof Error ? err.message : 'Unexpected error' }, - { status: 500 }, + { error: 'Unable to deliver the comment notification right now.' }, + { status: 502 }, ) } } diff --git a/src/lib/commentService.ts b/src/lib/commentService.ts index f59bf00..3c128fd 100644 --- a/src/lib/commentService.ts +++ b/src/lib/commentService.ts @@ -59,12 +59,18 @@ export const createCommentService = async ({ if (error) throw error - // 2. Send notification email + Discord — fire and forget, never blocks the UI - fetch('/api/notify-comment', { + // 2. Wait for the server to complete its single Discord delivery attempt. + // The comment is already saved, so a notification error is logged instead of + // throwing and encouraging a retry that could create a duplicate comment. + const notificationResponse = await fetch('/api/notify-comment', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, comment, imageUrl }), }).catch(() => null) + if (!notificationResponse?.ok) { + console.warn('The comment was saved, but its Discord notification was not delivered.') + } + return data } diff --git a/src/lib/webhookSettings.ts b/src/lib/webhookSettings.ts index b547d8f..d8320de 100644 --- a/src/lib/webhookSettings.ts +++ b/src/lib/webhookSettings.ts @@ -1,4 +1,6 @@ -import { createClient } from '@supabase/supabase-js' +import 'server-only' + +import { getServiceDatabase } from '@/lib/supabaseAdmin' type DeliveryKind = 'contact' | 'comments' @@ -15,25 +17,24 @@ export async function getWebhookDelivery(kind: DeliveryKind) { const fallbackUrl = kind === 'contact' ? process.env.CONTACT_DISCORD_WEBHOOK_URL : process.env.COMMENTS_DISCORD_WEBHOOK_URL - const url = process.env.NEXT_PUBLIC_SUPABASE_URL - const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY - - if (!url || !serviceKey) return { url: fallbackUrl || '', message: '' } + const database = getServiceDatabase() + if (!database) return { url: clean(fallbackUrl), message: '' } try { - const database = createClient(url, serviceKey, { auth: { persistSession: false } }) - const { data } = await database + const { data, error } = await database .from('webhook_settings') .select('contact_webhook_url, comments_webhook_url, contact_custom_message, comments_custom_message') .eq('id', 1) .single() + if (error) throw error + return kind === 'contact' - ? { url: clean(data?.contact_webhook_url) || fallbackUrl || '', message: clean(data?.contact_custom_message) } - : { url: clean(data?.comments_webhook_url) || fallbackUrl || '', message: clean(data?.comments_custom_message) } + ? { url: clean(data?.contact_webhook_url) || clean(fallbackUrl), message: clean(data?.contact_custom_message) } + : { url: clean(data?.comments_webhook_url) || clean(fallbackUrl), message: clean(data?.comments_custom_message) } } catch { // The environment variables keep delivery working until the migration is applied. - return { url: fallbackUrl || '', message: '' } + return { url: clean(fallbackUrl), message: '' } } } @@ -41,3 +42,38 @@ export function renderWebhookMessage(template: string, values: Record values[key] || '') } + +export function isDiscordWebhookUrl(value: string) { + try { + const url = new URL(value) + const allowedHosts = new Set([ + 'discord.com', + 'discordapp.com', + 'canary.discord.com', + 'ptb.discord.com', + ]) + + return url.protocol === 'https:' + && allowedHosts.has(url.hostname) + && /^\/api\/webhooks\/\d+\/[A-Za-z0-9_-]+\/?$/.test(url.pathname) + } catch { + return false + } +} + +export async function sendDiscordWebhook(url: string, payload: Record) { + if (!url) throw new Error('Discord webhook is not configured.') + if (!isDiscordWebhookUrl(url)) throw new Error('Discord webhook URL is invalid.') + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + cache: 'no-store', + signal: AbortSignal.timeout(10_000), + }) + + if (!response.ok) { + throw new Error(`Discord webhook delivery failed with status ${response.status}.`) + } +}