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) {
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