Files
AriWeb-Suite/modules/webmaker/lib/forms.js

54 lines
4.8 KiB
JavaScript

'use strict';
// 생성 사이트의 문의 접수: 검증·스팸 차단·속도 제한 → 메일 발송 → 접수 기록(data/inquiries/<siteId>.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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 = `<div style="font-family:-apple-system,'Segoe UI','Noto Sans KR',sans-serif;font-size:14px;color:#1f2933"><p>${esc(intro)}</p><table style="border-collapse:collapse">${rows.map(([k, v]) => `<tr><td style="padding:4px 10px 4px 0;color:#6b7684">${esc(k)}</td><td style="padding:4px 0">${esc(v)}</td></tr>`).join('')}</table><p style="white-space:pre-wrap;border-left:3px solid #c7d2fe;padding-left:10px;margin:14px 0">${esc(d.message)}</p><p style="color:#6b7684;font-size:12px">${esc(hint)}</p></div>`;
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 };