'use strict'; // 생성 사이트의 문의 접수: 검증·스팸 차단·속도 제한 → 메일 발송 → 접수 기록(data/inquiries/.json) const fs = require('fs'); const path = require('path'); const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data'); const DIR = path.join(DATA_ROOT, 'inquiries'); const sitemsg = require('./sitemsg'); // v1.3.16 방문자·소유자에게 보이는 글자는 id 로 (locales/site/ko.json) fs.mkdirSync(DIR, { recursive: true }); const hits = new Map(); // key → timestamps function rateLimited(key, limit, windowMs) { const now = Date.now(); const arr = (hits.get(key) || []).filter((t) => now - t < windowMs); if (arr.length >= limit) { hits.set(key, arr); return true; } arr.push(now); hits.set(key, arr); return false; } function file(siteId) { return path.join(DIR, `${siteId}.json`); } function list(siteId) { try { return JSON.parse(fs.readFileSync(file(siteId), 'utf8')); } catch (_) { return []; } } function add(siteId, rec) { const l = list(siteId); l.push(rec); fs.writeFileSync(file(siteId), JSON.stringify(l.slice(-500), null, 1)); return rec; } function validate(b) { const s = (v, n) => String(v || '').trim().slice(0, n); const d = { name: s(b.name, 80), email: s(b.email, 120), phone: s(b.phone, 40), subject: s(b.subject, 120), message: s(b.message, 5000), kind: s(b.kind, 20) || 'contact', page: s(b.page, 300), website: s(b.website, 100) }; // 위젯에 추가된 항목(회사명·업종 등): 이름 40자, 값 500자, 최대 12개까지 메일에 함께 담는다 const skip = new Set(['name', 'email', 'phone', 'subject', 'message', 'kind', 'page', 'website', 'agree']); d.extra = {}; for (const [k, v] of Object.entries(b || {})) { if (skip.has(k) || Object.keys(d.extra).length >= 12) continue; const key = s(k, 40); const val = Array.isArray(v) ? v.map((x) => s(x, 200)).join(', ') : (typeof v === 'object' && v !== null ? '' : s(v, 500)); if (key && val) d.extra[key] = val; } if (!d.name) throw sitemsg.err('err.formName'); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(d.email)) throw sitemsg.err('err.formEmail'); if (!d.message) throw sitemsg.err('err.formMessage'); return d; } function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } /** * 사이트 소유자에게 보낼 문의 알림 메일. * v1.3.16: 글자는 sitemsg(id) 로, 언어는 소유자(사이트 관리) 언어 lang, 시각은 사이트 시간대 tz 로 쓴다. * @param {object} site * @param {object} d validate() 결과(+ extra) * @param {string} ip * @param {object} [opt] { lang: 메일 언어(기본 'ko'), locale: 날짜 로케일(ko-KR 등), tz: 시간대(기본 Asia/Seoul) } */ function compose(site, d, ip, opt = {}) { const t = sitemsg.forLang(opt.lang || 'ko'); const tz = opt.tz || 'Asia/Seoul'; const kindLabel = t(d.kind === 'booking' ? 'mail.kindBooking' : 'mail.kindContact'); let at; try { at = new Date().toLocaleString(opt.locale || 'ko-KR', { timeZone: tz, timeZoneName: 'short' }); } catch (_) { at = new Date().toISOString(); } const subject = t('mail.contactSubject', { site: site.title, kind: kindLabel, subject: d.subject ? ': ' + d.subject : '', name: d.name }); const rows = [[t('common.name'), d.name], [t('common.email'), d.email], [t('common.phone'), d.phone || '-'], [t('common.subject'), d.subject || '-'], ...Object.entries(d.extra || {}), [t('mail.fieldKind'), kindLabel], [t('mail.fieldReceivedAt'), at], [t('mail.fieldPage'), d.page || '-'], ['IP', ip || '-']]; const intro = t('mail.contactIntro', { site: site.title, kind: kindLabel }); const hint = t('mail.replyHint', { email: d.email }); const text = `${intro}\n\n${rows.map(([k, v]) => `${k}: ${v}`).join('\n')}\n\n${t('mail.content')}\n${d.message}\n\n— ${hint}`; const html = `

${esc(intro)}

${rows.map(([k, v]) => ``).join('')}
${esc(k)}${esc(v)}

${esc(d.message)}

${esc(hint)}

`; return { subject, text, html }; } /** 접수 기록 한 건을 갱신한다 (재발송 결과 반영). idx 는 list() 순서(접수 순) 기준 */ function update(siteId, idx, patch) { const all = list(siteId); if (!all[idx]) return null; Object.assign(all[idx], patch); fs.mkdirSync(DIR, { recursive: true }); const f = path.join(DIR, `${siteId}.json`); const tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(all, null, 1)); fs.renameSync(tmp, f); return all[idx]; } module.exports = { rateLimited, list, add, update, validate, compose };