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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
146 changes: 84 additions & 62 deletions src/app/api/contact/route.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
})[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({
Expand All @@ -36,11 +49,11 @@ async function sendEmail(name: string, senderEmail: string, message: string) {
<h1 style="margin: 0; font-size: 24px;">New Contact Form Message</h1>
</div>
<div style="border: 1px solid #ddd; padding: 20px; border-radius: 0 0 10px 10px;">
<p><strong>From:</strong> ${name}</p>
<p><strong>Email:</strong> ${senderEmail}</p>
<p><strong>From:</strong> ${escapeHtml(name)}</p>
<p><strong>Email:</strong> ${escapeHtml(senderEmail)}</p>
<p><strong>Message:</strong></p>
<p style="background: #f5f5f5; padding: 15px; border-left: 4px solid #667eea; border-radius: 5px; white-space: pre-wrap;">
${message}
${escapeHtml(message)}
</p>
<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;">
<p style="font-size: 12px; color: #999;">
Expand Down Expand Up @@ -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),
Comment on lines +165 to +167

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve successful email-only contact delivery

When an existing deployment has working GMAIL_USER/GMAIL_PASSWORD settings but no contact-specific Discord webhook, sendDiscordWebhook rejects even though the concurrently executed email is successfully sent, and the rejection path returns 502. This regresses the previously supported email-only configuration, tells visitors to retry a message that was already emailed and persisted, and can create duplicate emails and database rows; treat the request as delivered when either configured channel succeeds, or skip an unconfigured webhook.

Useful? React with 👍 / 👎.

])

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,
},
})
}
34 changes: 21 additions & 13 deletions src/app/api/notify-comment/route.ts
Original file line number Diff line number Diff line change
@@ -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: [{
Expand All @@ -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`)
Expand All @@ -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 },
)
}
}
10 changes: 8 additions & 2 deletions src/lib/commentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
56 changes: 46 additions & 10 deletions src/lib/webhookSettings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { createClient } from '@supabase/supabase-js'
import 'server-only'

import { getServiceDatabase } from '@/lib/supabaseAdmin'

type DeliveryKind = 'contact' | 'comments'

Expand All @@ -15,29 +17,63 @@ 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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Catch service-client construction before using the fallback

If NEXT_PUBLIC_SUPABASE_URL is present but malformed, getServiceDatabase() throws while constructing the Supabase client, and that call now occurs outside the try block. Consequently the contact route fails before reaching the configured environment webhook and the comment route returns 502, even though this function is explicitly intended to fall back to environment variables when database-backed settings are unavailable; move client construction into the guarded section.

Useful? React with 👍 / 👎.

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<DeliverySettings>()

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: '' }
}
}

export function renderWebhookMessage(template: string, values: Record<string, string>, fallback: string) {
const source = template || fallback
return source.replace(/\{\{(name|email|message|comment)\}\}/g, (_, key: string) => 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept versioned Discord webhook paths

Discord's API also accepts webhook endpoints under versioned paths such as /api/v10/webhooks/{id}/{token}. Any existing setting using that valid form previously reached Discord through the unrestricted fetch, but this regex now rejects it before sending, causing contact submissions to return 502 and comment notifications to be dropped. Allow the optional API-version segment while retaining the host and webhook-ID checks.

Useful? React with 👍 / 👎.

} catch {
return false
}
}

export async function sendDiscordWebhook(url: string, payload: Record<string, unknown>) {
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}.`)
}
}
Loading