Complete MoreminiMore Astro site migration
This commit is contained in:
356
server.js
Normal file
356
server.js
Normal file
@@ -0,0 +1,356 @@
|
||||
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}`));
|
||||
}
|
||||
Reference in New Issue
Block a user