Files
moreminimore-website-codex/server.js
2026-08-09 10:31:15 +07:00

357 lines
15 KiB
JavaScript

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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' })[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 = `
<h2>ข้อความติดต่อใหม่จากเว็บไซต์ MoreminiMore</h2>
<table cellpadding="8" cellspacing="0" border="0">
<tr><th align="left">ชื่อ</th><td>${escapeHtml(values.name)}</td></tr>
<tr><th align="left">เบอร์โทรหรืออีเมล</th><td>${escapeHtml(values.contact)}</td></tr>
<tr><th align="left">ตอนนี้ติดเรื่องอะไร</th><td>${escapeHtml(values.problem).replace(/\n/g, '<br>')}</td></tr>
<tr><th align="left">หน้าที่ส่ง</th><td>${escapeHtml(values.source)}</td></tr>
</table>`;
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}`));
}