import express from 'express'; import { rateLimit } from 'express-rate-limit'; import nodemailer from 'nodemailer'; import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2'; import crypto from 'node:crypto'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { dirname, join } from 'node:path'; const root = dirname(fileURLToPath(import.meta.url)); const dist = join(root, 'dist'); const port = Number.parseInt(process.env.PORT || '4321', 10); const isProduction = process.env.NODE_ENV === 'production'; const defaultContactEmail = 'kunthawat@moreminimore.com'; const mailConfigured = ['SES_ACCESS_KEY_ID', 'SES_SECRET_ACCESS_KEY', 'SES_REGION'] .every((key) => Boolean(process.env[key])); const fromEmail = process.env.SES_FROM_EMAIL || defaultContactEmail; const toEmail = process.env.CONTACT_TO_EMAIL || defaultContactEmail; const transporter = mailConfigured ? nodemailer.createTransport({ SES: { sesClient: new SESv2Client({ region: process.env.SES_REGION, credentials: { accessKeyId: process.env.SES_ACCESS_KEY_ID, secretAccessKey: process.env.SES_SECRET_ACCESS_KEY, }, }), SendEmailCommand, }, }) : null; export const app = express(); app.disable('x-powered-by'); const trustProxySetting = (process.env.TRUST_PROXY || '0').trim().toLowerCase(); if (!['0', 'false', ''].includes(trustProxySetting)) { const trustedHops = Number.parseInt(trustProxySetting, 10); if (!Number.isInteger(trustedHops) || trustedHops < 1 || trustedHops > 3 || String(trustedHops) !== trustProxySetting) { throw new Error('TRUST_PROXY must be 0, false, or an explicit proxy-hop count from 1 to 3'); } app.set('trust proxy', trustedHops); } app.use((_, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); next(); }); app.use('/api', express.json({ limit: '12kb', strict: true })); const contactLimiter = rateLimit({ windowMs: 10 * 60 * 1000, limit: 6, standardHeaders: 'draft-8', legacyHeaders: false, message: { ok: false, error: 'กรุณารอสักครู่แล้วลองใหม่' }, }); const eventIds = new Map(); const EVENT_TTL_MS = 15 * 60 * 1000; const EVENT_MAX = 5000; const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; export function claimEventId(eventId, now = Date.now()) { for (const [id, entry] of eventIds) { if (entry.expiresAt <= now) eventIds.delete(id); } if (!UUID_V4.test(eventId)) return 'invalid'; const existing = eventIds.get(eventId); if (existing) return existing.status; while (eventIds.size >= EVENT_MAX) eventIds.delete(eventIds.keys().next().value); eventIds.set(eventId, { status: 'pending', expiresAt: now + EVENT_TTL_MS }); return 'new'; } function completeEventId(eventId, now = Date.now()) { const existing = eventIds.get(eventId); if (existing?.status === 'pending') { eventIds.set(eventId, { status: 'completed', expiresAt: now + EVENT_TTL_MS }); } } function releaseEventId(eventId) { eventIds.delete(eventId); } function text(value, max) { return typeof value === 'string' ? value.trim().slice(0, max) : ''; } function escapeHtml(value) { return value.replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]); } function validateContact(body) { const name = text(body.name, 80); const contact = text(body.contact, 160); const problem = text(body.problem, 1500); const source = /^[a-z0-9-]{1,60}$/i.test(body.source || '') ? body.source : 'unknown'; const eventId = text(body.eventId, 64); const fields = {}; const email = contact.includes('@') ? contact : ''; const phoneDigits = contact.replace(/\D/g, ''); const tracking = body.tracking && typeof body.tracking === 'object' ? body.tracking : {}; const consent = tracking.consent && typeof tracking.consent === 'object' ? tracking.consent : {}; const analyticsConsent = consent.analytics === true; const marketingConsent = consent.marketing === true; if (name.length < 2) fields.name = 'กรุณากรอกชื่ออย่างน้อย 2 ตัวอักษร'; if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) fields.contact = 'กรุณากรอกอีเมลให้ถูกต้อง'; if (!email && (phoneDigits.length < 8 || phoneDigits.length > 15)) fields.contact = 'กรุณากรอกเบอร์โทรให้ถูกต้อง'; if (problem.length < 5) fields.problem = 'กรุณาเล่าปัญหาอย่างน้อย 5 ตัวอักษร'; const fbp = marketingConsent && /^fb\.\d\.\d{10,16}\.[A-Za-z0-9._-]{1,120}$/.test(tracking.fbp || '') ? tracking.fbp : ''; const fbc = marketingConsent && /^fb\.\d\.\d{10,16}\.[A-Za-z0-9._-]{1,160}$/.test(tracking.fbc || '') ? tracking.fbc : ''; const gaClientId = analyticsConsent && /^\d{1,20}\.\d{1,20}$/.test(tracking.gaClientId || '') ? tracking.gaClientId : ''; const gaSessionId = analyticsConsent && /^\d{6,20}$/.test(tracking.gaSessionId || '') ? tracking.gaSessionId : ''; return { fields, validEventId: UUID_V4.test(eventId), values: { name, contact, problem, source, eventId, email, phoneDigits, consent: { analytics: analyticsConsent, marketing: marketingConsent }, fbp, fbc, gaClientId, gaSessionId, }, }; } function sha256(value) { return crypto.createHash('sha256').update(value).digest('hex'); } function normalizedNameParts(name) { const parts = name.normalize('NFKC').trim().toLowerCase().split(/\s+/).filter(Boolean); return { first: parts[0] || '', last: parts.length > 1 ? parts[parts.length - 1] : '' }; } export function normalizeThaiPhone(value) { const digits = String(value || '').replace(/\D/g, ''); if (/^0\d{8,9}$/.test(digits)) return `66${digits.slice(1)}`; if (/^66\d{8,9}$/.test(digits)) return digits; return ''; } function sourceUrl(source) { if (source === 'home') return 'https://moreminimore.com/'; if (/^(website-development|marketing-consult|automation-workflow|ai-consult)$/.test(source)) { return `https://moreminimore.com/services/${source}/`; } return 'https://moreminimore.com/'; } async function providerPost(url, body, fetchImpl) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 2200); try { const response = await fetchImpl(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: controller.signal, }); if (!response.ok) throw new Error('provider_request_failed'); } finally { clearTimeout(timeout); } } export async function dispatchProviderConversions(values, requestContext, env = process.env, fetchImpl = globalThis.fetch) { const tasks = []; const eventTime = Math.floor(Date.now() / 1000); if (values.consent.marketing && env.META_ACCESS_TOKEN) { const names = normalizedNameParts(values.name); const normalizedPhone = normalizeThaiPhone(values.phoneDigits); const userData = { client_ip_address: requestContext.ip, client_user_agent: requestContext.userAgent, ...(values.email ? { em: [sha256(values.email.normalize('NFKC').trim().toLowerCase())] } : {}), ...(!values.email && normalizedPhone ? { ph: [sha256(normalizedPhone)] } : {}), ...(names.first ? { fn: [sha256(names.first)] } : {}), ...(names.last ? { ln: [sha256(names.last)] } : {}), ...(values.fbp ? { fbp: values.fbp } : {}), ...(values.fbc ? { fbc: values.fbc } : {}), }; const metaPixelId = env.META_PIXEL_ID || '418349260078648'; const metaUrl = `https://graph.facebook.com/v22.0/${encodeURIComponent(metaPixelId)}/events?access_token=${encodeURIComponent(env.META_ACCESS_TOKEN)}`; tasks.push(providerPost(metaUrl, { data: [{ event_name: 'Lead', event_time: eventTime, event_source_url: sourceUrl(values.source), action_source: 'website', event_id: values.eventId, user_data: userData, custom_data: { form_location: values.source }, }], }, fetchImpl)); } if (values.consent.analytics && env.GA4_API_SECRET && values.gaClientId) { const measurementId = env.GA4_MEASUREMENT_ID || 'G-74BHREDLC3'; const gaUrl = `https://www.google-analytics.com/mp/collect?measurement_id=${encodeURIComponent(measurementId)}&api_secret=${encodeURIComponent(env.GA4_API_SECRET)}`; tasks.push(providerPost(gaUrl, { client_id: values.gaClientId, consent: { ad_user_data: values.consent.marketing ? 'GRANTED' : 'DENIED', ad_personalization: values.consent.marketing ? 'GRANTED' : 'DENIED', }, events: [{ name: 'form_submitted', params: { ...(values.gaSessionId ? { session_id: values.gaSessionId } : {}), engagement_time_msec: 100, form_location: values.source, event_id: values.eventId, }, }], }, fetchImpl)); } return Promise.allSettled(tasks); } function isSameOriginRequest(req) { if (req.get('sec-fetch-site') === 'cross-site') return false; const origin = req.get('origin'); if (!origin) return true; try { return new URL(origin).host === req.get('host'); } catch { return false; } } app.get('/api/health', (_, res) => { res.setHeader('Cache-Control', 'no-store'); res.json({ ok: true }); }); const permanentRedirects = new Map([ ['/portfolio', '/#portfolio'], ['/portfolio/', '/#portfolio'], ['/contact', '/#contact'], ['/contact/', '/#contact'], ['/sitemap.xml', '/sitemap-index.xml'], ['/about', '/about/'], ['/services', '/services/'], ['/faq', '/faq/'], ['/privacy', '/privacy/'], ['/terms', '/terms/'], ['/blog', '/blog/'], ...['website-development', 'marketing-consult', 'automation-workflow', 'ai-consult'].map((slug) => [`/services/${slug}`, `/services/${slug}/`]), ...[ '2026-06-15-chatgpt-opens-ads-for-all', '2026-06-26-ai-visibility-operational-alignment', '2026-06-26-b2b-marketers-future-of-work', '2026-06-26-brand-reputation-precedes-you-with-ai', '2026-06-26-fintech-invisible-to-ai-agents', '2026-06-26-google-seo-for-ai-agents', '2026-06-26-two-things-b2b-marketers-should-do-with-ai', '2026-06-27-ai-brand-reputation-167k-citations', '2026-06-27-ai-recommendation-multi-industry-map', '2026-06-27-google-open-knowledge-format', '2026-06-27-opid-ai-agent-self-training', '2026-06-28-broken-form-cost-agency-leads', '2026-06-28-openknowledge-ai-first-obsidian', '2026-06-28-profound-vs-bluefish-aeo', '2026-07-01-chatgpt-reasoning-modes-overlap', '2026-07-01-chatgpt-source-selection', '2026-07-01-deepseek-dspark-devin-fusion', ].map((slug) => [`/blog/${slug}`, `/blog/${slug}/`]), ]); app.use((req, res, next) => { if (req.method !== 'GET' && req.method !== 'HEAD') return next(); const destination = permanentRedirects.get(req.path); if (!destination) return next(); return res.redirect(301, destination); }); app.post('/api/contact', contactLimiter, async (req, res) => { res.setHeader('Cache-Control', 'no-store'); if (!isSameOriginRequest(req)) return res.status(403).json({ ok: false, error: 'ไม่สามารถส่งข้อมูลได้' }); if (!req.is('application/json')) return res.status(415).json({ ok: false, error: 'ไม่สามารถส่งข้อมูลได้' }); if (text(req.body?.website, 200)) return res.json({ ok: true }); const { fields, validEventId, values } = validateContact(req.body || {}); if (Object.keys(fields).length) return res.status(400).json({ ok: false, error: 'กรุณาตรวจสอบข้อมูล', fields }); if (!validEventId) return res.status(400).json({ ok: false, error: 'ข้อมูลการส่งไม่ถูกต้อง' }); const eventState = claimEventId(values.eventId); if (eventState === 'completed') return res.json({ ok: true, duplicate: true }); if (eventState === 'pending') return res.status(409).json({ ok: false, processing: true, error: 'ระบบกำลังดำเนินการคำขอนี้' }); if (eventState !== 'new') return res.status(400).json({ ok: false, error: 'ข้อมูลการส่งไม่ถูกต้อง' }); if (!transporter) { if (!isProduction) { completeEventId(values.eventId); console.info('[api/contact] Development submission accepted; email delivery is disabled'); return res.json({ ok: true, devMode: true }); } releaseEventId(values.eventId); console.error('[api/contact] Email delivery is not configured'); return res.status(503).json({ ok: false, error: 'ระบบยังไม่พร้อม กรุณาลองใหม่ภายหลัง' }); } try { const subject = 'ข้อความติดต่อใหม่จากเว็บไซต์ MoreminiMore'; const plain = [ 'ข้อความติดต่อใหม่จากเว็บไซต์ MoreminiMore', '', `ชื่อ: ${values.name}`, `เบอร์โทรหรืออีเมล: ${values.contact}`, `ตอนนี้ติดเรื่องอะไร: ${values.problem}`, `หน้าที่ส่ง: ${values.source}`, ].join('\n'); const html = `

ข้อความติดต่อใหม่จากเว็บไซต์ MoreminiMore

ชื่อ${escapeHtml(values.name)}
เบอร์โทรหรืออีเมล${escapeHtml(values.contact)}
ตอนนี้ติดเรื่องอะไร${escapeHtml(values.problem).replace(/\n/g, '
')}
หน้าที่ส่ง${escapeHtml(values.source)}
`; await transporter.sendMail({ from: `MoreminiMore <${fromEmail}>`, to: toEmail, replyTo: values.email || undefined, subject, text: plain, html, }); completeEventId(values.eventId); await dispatchProviderConversions(values, { ip: req.ip || '', userAgent: text(req.get('user-agent'), 300), }); console.info('[api/contact] Message delivered'); return res.json({ ok: true }); } catch { releaseEventId(values.eventId); console.error('[api/contact] Email delivery failed'); return res.status(500).json({ ok: false, error: 'ส่งไม่สำเร็จ กรุณาลองใหม่ภายหลัง' }); } }); app.use(express.static(dist, { extensions: ['html'], setHeaders(res, filePath) { if (filePath.endsWith('.html')) res.setHeader('Cache-Control', 'no-cache'); else res.setHeader('Cache-Control', 'public, max-age=604800, immutable'); }, })); app.use((_, res) => { res.setHeader('Cache-Control', 'no-cache'); res.status(404).sendFile(join(dist, '404.html')); }); if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { app.listen(port, '0.0.0.0', () => console.info(`[server] Listening on port ${port}`)); }