357 lines
15 KiB
JavaScript
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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}`));
|
|
}
|