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

519 lines
58 KiB
JavaScript
Raw Normal View History

'use strict';
// ============================================================================
// 공통 기능 모듈 (v1.0.3)
//
// 생성 사이트는 정적 HTML 이므로 게시판·문의·설문·예약·후기·FAQ·갤러리·회원 같은 "동적" 기능은
// 이 빌더 서비스가 백엔드(API + 데이터 저장소 + 관리자 페이지)를 맡고, 사이트 쪽에는
// (1) modules/<이름>/widget.html 의 표준 마크업(사이트 디자인에 맞게 CSS 만 바꿔 씀)
// (2) <script src="<서비스>/wm-modules.js"> 런타임(데이터 속성으로 설정)
// 만 들어간다. 사이트마다 다른 값(사이트 ID, API 주소, 스타일, 권한, 카테고리)은 모두
// data-* 속성과 이 모듈의 "설정"(관리자 페이지에서 변경)으로 분리되어 하드코딩되지 않는다.
//
// 저장: data/modules/<siteId>.json
// { enabled: {모듈: true}, settings: {모듈: {...}}, members: [], posts: [], comments: [], surveys: [], responses: [],
// bookings: [], reviews: [], faq: [], gallery: [], blogPosts: [], blogComments: [], libraryFiles: [], events: [] }
// 일정(v1.4.18): events — 반복·음력 일정의 규칙과 발생 전개는 lib/calendar.js, 음력·간지 계산은 lib/lunar.js
// 첨부 파일(문의): data/modules/<siteId>/files/<id>-<원본명>
// 자료실 파일(v1.4.0): data/modules/<siteId>/library/<저장 이름> — 사이트 폴더 밖(공개 배포 안 됨), API 로만 내려받는다(권한·횟수 기록)
//
// 인증
// - 사이트 회원(방문자): members 모듈. 비밀번호는 scrypt, 토큰은 HMAC(session.secret) 서명 — 사이트 범위.
// - 사이트 관리자 페이지: 빌더 로그인 세션(소유자/운영자) 또는 role=admin 회원 토큰.
// ============================================================================
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const https = require('https');
const ROOT = path.resolve(__dirname, '..');
const DATA_ROOT = process.env.DATA_ROOT || path.join(ROOT, 'data');
const DIR = path.join(DATA_ROOT, 'modules');
const TEMPLATES = path.join(ROOT, 'modules');
fs.mkdirSync(DIR, { recursive: true });
function httpError(msg, status = 400) { return Object.assign(new Error(msg), { status }); }
/** v1.3.16: 사이트 방문자에게 보이는 오류는 문구 id 로 만든다 (글자는 locales/site/ko.json, API 가 요청 언어로 바꿔 보냄) */
const sitemsg = require('./sitemsg');
const calendar = require('./calendar'); // v1.4.18 일정관리 모듈 규칙
const E = (id, status = 400, vars) => sitemsg.err(id, status, vars);
const nowIso = () => new Date().toISOString();
const newId = () => crypto.randomBytes(8).toString('hex');
const clip = (v, n) => String(v == null ? '' : v).trim().slice(0, n);
// ---------- 모듈 카탈로그 ----------
// label/desc: 화면 표시. options: 관리자 페이지에서 편집하는 설정 스키마(type: boolean|number|text|select|list|time).
// collections: 관리자 페이지 표에 보이는 데이터와 열. feature: Brief 의 features 키(생성 프롬프트 연결).
const CATALOG = {
members: { label: '회원', desc: '사이트 방문자의 회원가입·로그인. 게시판 회원 전용 글쓰기 등에 쓰입니다.', feature: 'members',
options: [{ key: 'allowSignup', label: '회원가입 허용', type: 'boolean', def: true }, { key: 'approve', label: '가입 승인제(관리자 승인 후 활동)', type: 'boolean', def: false }],
collections: { members: { label: '회원', columns: ['name', 'email', 'role', 'approved', 'createdAt'] } } },
board: { label: '게시판', desc: '회원/비회원 글쓰기·읽기 제한, 비밀글, 댓글·답글(비밀 댓글 포함). 리스트형/포럼형.', feature: 'board',
options: [{ key: 'style', label: '표시 형식', type: 'select', values: [['list', '리스트형'], ['forum', '포럼형(본문 일부 노출)']], def: 'list' }, { key: 'readRole', label: '글 읽기', type: 'select', values: [['all', '누구나'], ['member', '회원만']], def: 'all' }, { key: 'writeRole', label: '글쓰기', type: 'select', values: [['all', '누구나(비회원 비밀번호)'], ['member', '회원만'], ['admin', '관리자만(공지형)']], def: 'all' }, { key: 'commentRole', label: '댓글', type: 'select', values: [['all', '누구나'], ['member', '회원만'], ['none', '댓글 없음']], def: 'all' }, { key: 'allowSecret', label: '비밀글 허용', type: 'boolean', def: true }, { key: 'categories', label: '말머리(카테고리, 쉼표 구분)', type: 'list', def: ['공지', '일반', '질문'] }, { key: 'pageSize', label: '한 페이지 글 수', type: 'number', def: 15 }],
collections: { posts: { label: '글', columns: ['title', 'author', 'category', 'secret', 'views', 'createdAt'] }, comments: { label: '댓글', columns: ['postId', 'author', 'body', 'secret', 'createdAt'] } } },
inquiry: { label: '문의폼', desc: '유형·발신자·제목·내용·첨부파일을 받아 사이트 관리자 페이지와 빌더 관리 화면에 기록하고 이메일로 보냅니다.', feature: 'contactForm',
options: [{ key: 'categories', label: '문의 유형(쉼표 구분)', type: 'list', def: ['일반 문의', '견적 문의', '제휴', '기타'] }, { key: 'notifyEmail', label: '이메일로 받기', type: 'boolean', def: true }, { key: 'allowFiles', label: '첨부파일 허용', type: 'boolean', def: true }, { key: 'maxFileMb', label: '첨부 최대 크기(MB)', type: 'number', def: 10 }],
collections: { inquiries: { label: '문의', columns: ['category', 'name', 'email', 'subject', 'sent', 'at'] } } },
survey: { label: '설문조사폼', desc: '구글폼 방식의 설문·참가 신청 양식. 결과는 관리자 페이지에서 보고 xlsx 로 내려받거나 이메일로 받습니다.', feature: 'survey',
options: [{ key: 'notifyEmail', label: '응답을 이메일로 받기', type: 'boolean', def: false }, { key: 'oneResponsePerMember', label: '회원당 1회 응답', type: 'boolean', def: false }],
collections: { surveys: { label: '설문 양식', columns: ['title', 'active', 'responses', 'createdAt'] }, responses: { label: '응답', columns: ['surveyId', 'respondent', 'createdAt'] } } },
booking: { label: '예약', desc: '방문자가 날짜·시간·내용을 골라 예약. 관리자가 가능 요일·시간대·간격(1시간/2시간 등)·정원을 설정합니다.', feature: 'booking',
options: [{ key: 'weekdays', label: '예약 가능 요일(0=일 … 6=토, 쉼표)', type: 'list', def: ['1', '2', '3', '4', '5'] }, { key: 'start', label: '시작 시각', type: 'time', def: '10:00' }, { key: 'end', label: '종료 시각', type: 'time', def: '18:00' }, { key: 'interval', label: '예약 간격(분)', type: 'select', values: [['30', '30분'], ['60', '1시간'], ['90', '1시간 30분'], ['120', '2시간'], ['180', '3시간']], def: '60' }, { key: 'capacity', label: '시간대별 정원', type: 'number', def: 1 }, { key: 'leadDays', label: '최소 며칠 전부터 예약', type: 'number', def: 0 }, { key: 'maxDays', label: '최대 며칠 뒤까지 예약', type: 'number', def: 60 }, { key: 'autoConfirm', label: '자동 확정(끄면 관리자 확인 후 확정)', type: 'boolean', def: true }, { key: 'notifyEmail', label: '예약을 이메일로 받기', type: 'boolean', def: true }, { key: 'closedDates', label: '휴무일(YYYY-MM-DD, 쉼표)', type: 'list', def: [] }],
collections: { bookings: { label: '예약', columns: ['date', 'time', 'name', 'contact', 'status', 'createdAt'] } } },
gallery: { label: '갤러리', desc: '사진·이미지 모음(포트폴리오로도 사용). 관리자 페이지에서 이미지를 올리고 제목·설명·태그를 붙입니다.', feature: 'gallery',
options: [{ key: 'columns', label: '열 수', type: 'select', values: [['2', '2열'], ['3', '3열'], ['4', '4열']], def: '3' }, { key: 'lightbox', label: '클릭 시 크게 보기', type: 'boolean', def: true }],
collections: { gallery: { label: '이미지', columns: ['title', 'tags', 'src', 'createdAt'] } } },
map: { label: '지도', desc: '주소를 지도에 표시(확대·축소). OpenStreetMap 기반이라 API 키가 필요 없습니다.', feature: 'map',
options: [{ key: 'address', label: '주소', type: 'text', def: '' }, { key: 'lat', label: '위도(비우면 주소로 자동)', type: 'text', def: '' }, { key: 'lng', label: '경도', type: 'text', def: '' }, { key: 'zoom', label: '확대 수준(10~19)', type: 'number', def: 16 }, { key: 'label', label: '마커 문구', type: 'text', def: '' }],
collections: {} },
reviews: { label: '후기', desc: '방문자 후기를 받아 관리자 승인 후 리스트 또는 칸반보드 형식으로 보여 줍니다.', feature: 'reviews',
options: [{ key: 'style', label: '표시 형식', type: 'select', values: [['list', '리스트'], ['kanban', '칸반보드']], def: 'list' }, { key: 'kanbanBy', label: '칸반 열 기준', type: 'select', values: [['rating', '별점'], ['category', '분류']], def: 'rating' }, { key: 'categories', label: '분류(쉼표 구분)', type: 'list', def: ['서비스', '가격', '친절'] }, { key: 'approve', label: '관리자 승인 후 공개', type: 'boolean', def: true }, { key: 'notifyEmail', label: '새 후기를 이메일로 받기', type: 'boolean', def: false }],
collections: { reviews: { label: '후기', columns: ['name', 'rating', 'category', 'text', 'approved', 'createdAt'] } } },
// v1.4.0 블로그: 복잡한 블로그 기능(테마·구독·예약 발행 등)은 빼고 "글 쓰기·보여 주기" 에 집중 — 목록·검색·분류·태그·글 보기·댓글
blog: { label: '블로그', desc: '관리자가 글을 쓰면 목록·검색·글 보기로 보여 줍니다. 분류·태그, 방문자 댓글(관리자 승인 선택)을 지원합니다.', feature: 'blog',
options: [{ key: 'pageSize', label: '한 페이지 글 수', type: 'number', def: 10 }, { key: 'categories', label: '분류(쉼표 구분, 비우면 분류 없음)', type: 'list', def: [] }, { key: 'commentRole', label: '댓글', type: 'select', values: [['all', '누구나'], ['member', '회원만'], ['none', '받지 않음']], def: 'all' }, { key: 'approveComments', label: '댓글은 관리자 승인 후 공개', type: 'boolean', def: false }, { key: 'showAuthor', label: '글쓴이 표시', type: 'boolean', def: true }],
collections: { blogPosts: { label: '블로그 글', columns: ['title', 'category', 'published', 'views', 'createdAt'] }, blogComments: { label: '블로그 댓글', columns: ['postId', 'name', 'body', 'approved', 'createdAt'] } } },
// v1.4.0 자료실: 원본 파일(문서·이미지·압축·바이너리)을 올리고 방문자가 내려받는다 — 회원만 내려받기 설정 가능
library: { label: '자료실', desc: '문서·이미지·압축 파일 등 원본 파일을 올려 두고 방문자가 검색·내려받기 합니다. 회원만 내려받게 할 수 있고 내려받은 횟수를 셉니다.', feature: 'library',
options: [{ key: 'downloadRole', label: '내려받기', type: 'select', values: [['all', '누구나'], ['member', '회원만']], def: 'all' }, { key: 'categories', label: '분류(쉼표 구분, 비우면 분류 없음)', type: 'list', def: [] }, { key: 'maxFileMb', label: '올릴 수 있는 파일 크기(MB)', type: 'number', def: 100 }, { key: 'showDownloads', label: '내려받은 횟수 표시', type: 'boolean', def: true }],
collections: { libraryFiles: { label: '자료', columns: ['title', 'originalName', 'category', 'size', 'downloads', 'published', 'createdAt'] } } },
// v1.4.18 일정관리: 월간·주간·일간 캘린더에 일정을 표시. 만세력 기준 음력 날짜와 사주식 간지(연주·월주·일주), 24절기를 옵션으로 보이고
// 일정은 시·분 단위 / 일 단위 / 주 단위, 반복(매일·매주·매월·매년), 음력 기준(음력 생일·제사·명절) 등록을 지원한다 (lib/calendar.js, lib/lunar.js)
calendar: { label: '일정관리', desc: '월간·주간·일간 캘린더에 예정된 일정을 보여 줍니다. 음력 날짜·만세력 간지(사주식 연·월·일주)·24절기 표시를 켤 수 있고, 반복 일정과 음력 기준 일정을 등록합니다.', feature: 'calendar',
options: [{ key: 'defaultView', label: '기본 보기', type: 'select', values: [['month', '월간'], ['week', '주간'], ['day', '일간']], def: 'month' }, { key: 'showLunar', label: '음력 날짜 표시', type: 'boolean', def: true }, { key: 'showGanji', label: '만세력 간지 표시(사주식 연주·월주·일주, 예: 병오년 무술월 신유일)', type: 'boolean', def: false }, { key: 'showTerms', label: '24절기 표시', type: 'boolean', def: true }, { key: 'weekStart', label: '한 주의 시작', type: 'select', values: [['0', '일요일'], ['1', '월요일']], def: '0' }, { key: 'dayStart', label: '주간·일간 보기 시작 시각(0~23)', type: 'number', def: 8 }, { key: 'dayEnd', label: '주간·일간 보기 끝 시각(1~24)', type: 'number', def: 21 }, { key: 'categories', label: '일정 분류(쉼표 구분)', type: 'list', def: ['행사', '모임', '휴무'] }],
collections: { events: { label: '일정', columns: ['title', 'date', 'unit', 'category', 'published', 'createdAt'] } } },
faq: { label: 'FAQ', desc: '관리자가 질문과 답변을 입력하면 아코디언으로 보여 줍니다.', feature: 'faq',
options: [{ key: 'categories', label: '분류(쉼표 구분, 비우면 분류 없음)', type: 'list', def: [] }],
collections: { faq: { label: '질문·답변', columns: ['q', 'category', 'order', 'createdAt'] } } },
};
const MODULE_IDS = Object.keys(CATALOG);
const COLLECTIONS = ['members', 'posts', 'comments', 'surveys', 'responses', 'bookings', 'reviews', 'faq', 'gallery', 'blogPosts', 'blogComments', 'libraryFiles', 'events'];
// ---------- 저장소 ----------
function fileOf(siteId) { return path.join(DIR, `${siteId}.json`); }
function filesDir(siteId) { const d = path.join(DIR, siteId, 'files'); fs.mkdirSync(d, { recursive: true }); return d; }
/** v1.4.0 자료실 파일 폴더 (사이트 폴더 밖 — 웹서버가 직접 내보내지 않음) */
function libraryDir(siteId) { const d = path.join(DIR, siteId, 'library'); fs.mkdirSync(d, { recursive: true }); return d; }
function defaults(moduleId) { return Object.fromEntries((CATALOG[moduleId].options || []).map((o) => [o.key, Array.isArray(o.def) ? o.def.slice() : o.def])); }
function load(siteId) {
let d; try { d = JSON.parse(fs.readFileSync(fileOf(siteId), 'utf8')); } catch (_) { d = { enabled: {}, settings: {} }; }
for (const c of COLLECTIONS) if (!Array.isArray(d[c])) d[c] = [];
d.enabled = d.enabled || {}; d.settings = d.settings || {};
for (const m of MODULE_IDS) d.settings[m] = { ...defaults(m), ...(d.settings[m] || {}) };
return d;
}
function save(siteId, d) { const tmp = fileOf(siteId) + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(d)); fs.renameSync(tmp, fileOf(siteId)); }
function remove(siteId) { try { fs.unlinkSync(fileOf(siteId)); } catch (_) {} try { fs.rmSync(path.join(DIR, siteId), { recursive: true, force: true }); } catch (_) {} }
/** 사이트 ID 가 유효하고 모듈 데이터가 있는지와 무관하게 항상 로드 가능 (모듈이 꺼져 있으면 API 는 404) */
function isEnabled(d, m) { return !!d.enabled[m]; }
// ---------- 설정 ----------
/** 관리자 입력을 스키마에 맞춰 정리(타입 강제, 목록은 쉼표 분리) */
function normalizeSettings(moduleId, input) {
const out = {}; const cat = CATALOG[moduleId]; if (!cat) throw E('err.unknownModule');
for (const o of cat.options || []) {
let v = input[o.key]; if (v === undefined) continue;
if (o.type === 'boolean') v = v === true || v === 'true' || v === 'on' || v === 1;
else if (o.type === 'number') { v = Number(v); if (!Number.isFinite(v)) continue; }
else if (o.type === 'list') v = (Array.isArray(v) ? v : String(v).split(/[,\n]/)).map((x) => clip(x, 60)).filter(Boolean).slice(0, 50);
else if (o.type === 'select') { if (!(o.values || []).some((p) => p[0] === String(v))) continue; v = String(v); }
else v = clip(v, o.type === 'time' ? 5 : 500);
out[o.key] = v;
}
return out;
}
/** 방문자(위젯)에게 공개해도 되는 설정만 */
function publicSettings(d, m) {
const s = d.settings[m] || {};
if (m === 'board') return { style: s.style, readRole: s.readRole, writeRole: s.writeRole, commentRole: s.commentRole, allowSecret: s.allowSecret, categories: s.categories, pageSize: s.pageSize };
if (m === 'inquiry') return { categories: s.categories, allowFiles: s.allowFiles, maxFileMb: s.maxFileMb };
if (m === 'booking') return { weekdays: s.weekdays, start: s.start, end: s.end, interval: s.interval, capacity: s.capacity, leadDays: s.leadDays, maxDays: s.maxDays, closedDates: s.closedDates };
if (m === 'reviews') return { style: s.style, kanbanBy: s.kanbanBy, categories: s.categories };
if (m === 'gallery') return { columns: s.columns, lightbox: s.lightbox };
if (m === 'map') return { address: s.address, lat: s.lat, lng: s.lng, zoom: s.zoom, label: s.label };
if (m === 'faq') return { categories: s.categories };
if (m === 'members') return { allowSignup: s.allowSignup, approve: s.approve };
if (m === 'blog') return { pageSize: s.pageSize, categories: s.categories, commentRole: s.commentRole, approveComments: s.approveComments, showAuthor: s.showAuthor };
if (m === 'library') return { downloadRole: s.downloadRole, categories: s.categories, showDownloads: s.showDownloads };
// v1.4.18 일정: 시작·끝 시각은 0~24 범위로 정리해 보낸다 (관리자가 거꾸로 넣으면 기본값)
if (m === 'calendar') { const ds = Math.min(23, Math.max(0, Number(s.dayStart) || 0)); let de = Math.min(24, Math.max(1, Number(s.dayEnd) || 24)); if (de <= ds) de = Math.min(24, ds + 8); return { defaultView: s.defaultView, showLunar: s.showLunar !== false, showGanji: !!s.showGanji, showTerms: s.showTerms !== false, weekStart: s.weekStart === '1' ? 1 : 0, dayStart: ds, dayEnd: de, categories: s.categories || [] }; }
return {};
}
// ---------- 회원 인증 (사이트 범위) ----------
function secret() { try { return fs.readFileSync(path.join(DATA_ROOT, 'session.secret'), 'utf8').trim(); } catch (_) { return 'wm-modules'; } }
function hashPw(pw, salt) { return crypto.scryptSync(String(pw), salt, 64).toString('hex'); }
function makeToken(siteId, member) {
const payload = Buffer.from(JSON.stringify({ s: siteId, m: member.id, r: member.role, exp: Date.now() + 30 * 86400000 })).toString('base64url');
const sig = crypto.createHmac('sha256', secret()).update(payload).digest('base64url');
return `${payload}.${sig}`;
}
/** 토큰 검증 → 회원 레코드 (없거나 만료/위조면 null) */
function memberFromToken(d, siteId, token) {
if (!token || typeof token !== 'string' || !token.includes('.')) return null;
const [payload, sig] = token.split('.'); const want = crypto.createHmac('sha256', secret()).update(payload).digest('base64url');
if (sig.length !== want.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want))) return null;
let p; try { p = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); } catch (_) { return null; }
if (p.s !== siteId || !Number.isFinite(p.exp) || p.exp < Date.now()) return null;
const m = d.members.find((x) => x.id === p.m) || null;
// v1.3.17 (S06): 관리자가 승인을 취소한(approved=false) 회원의 토큰은 이미 발급된 것이라도 쓰지 못한다 — 모든 회원 확인이 이 함수를 거친다
return m && m.approved !== false ? m : null;
}
const pubMember = (m) => (m ? { id: m.id, name: m.name, email: m.email, role: m.role, approved: m.approved !== false, createdAt: m.createdAt, mail: !!m.mail, mailAddress: m.mailAddress || null } : null);
/**
* 회원 비밀번호 확인 (v1.3.0) — 메일 사용 끄기처럼 되돌릴 수 없는 작업 전에 본인 확인용.
* @returns {boolean}
*/
function verifyPassword(siteId, memberId, pw) {
const m = load(siteId).members.find((x) => x.id === memberId); if (!m || !pw) return false;
const h = hashPw(String(pw), m.salt); return h.length === m.hash.length && crypto.timingSafeEqual(Buffer.from(h), Buffer.from(m.hash));
}
/** 사이트 관리자 계정 한 건 (내부용, 해시 포함) */
function getSiteAdmin(siteId, memberId) { const m = load(siteId).members.find((x) => x.id === memberId); return m && m.role === 'admin' && m.loginId ? m : null; }
/**
* 계정의 메일 사용 표시 저장 (v1.3.0). 실제 메일함 생성·삭제는 lib/mailsys.js 가 하고, 여기서는 회원 레코드만 바꾼다.
* @param {boolean} on 메일 사용 여부
* @param {string} address 메일 주소 (on 일 때)
*/
function setMemberMail(siteId, memberId, on, address) {
const d = load(siteId); const m = d.members.find((x) => x.id === memberId); if (!m) throw E('err.noAccount', 404);
m.mail = !!on; m.mailAddress = on ? address : null; m.mailEnabledAt = on ? nowIso() : null; save(siteId, d); return pubMember(m);
}
function signup(siteId, body) {
const d = load(siteId); const s = d.settings.members;
if (!isEnabled(d, 'members') || s.allowSignup === false) throw E('err.signupClosed', 403);
const email = clip(body.email, 120).toLowerCase(); const name = clip(body.name, 60); const pw = String(body.password || '');
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw E('err.emailFormat'); if (!name) throw E('err.nameRequired'); if (pw.length < 6) throw E('err.passwordMin6');
if (d.members.some((m) => m.email === email)) throw E('err.emailTaken', 409);
const salt = crypto.randomBytes(16).toString('hex');
const m = { id: newId(), name, email, salt, hash: hashPw(pw, salt), role: 'member', approved: !s.approve, createdAt: nowIso() };
d.members.push(m); save(siteId, d);
return { member: pubMember(m), token: m.approved ? makeToken(siteId, m) : null, pending: !m.approved };
}
function login(siteId, body) {
const d = load(siteId); const email = clip(body.email || body.id, 120).toLowerCase();
const m = d.members.find((x) => x.email === email || x.loginId === email); if (!m) throw E('err.loginFailed', 401);
const h = hashPw(String(body.password || ''), m.salt); if (h.length !== m.hash.length || !crypto.timingSafeEqual(Buffer.from(h), Buffer.from(m.hash))) throw E('err.loginFailed', 401);
if (m.approved === false) throw E('err.approvalPending', 403);
return { member: pubMember(m), token: makeToken(siteId, m) };
}
/** 사이트 관리자 계정(별도 관리자 페이지 로그인용) 생성 — 소유자가 관리 화면에서 만든다 */
function createSiteAdmin(siteId, { loginId, password, name }) {
const d = load(siteId); loginId = clip(loginId, 60).toLowerCase(); if (!loginId || !password) throw E('err.siteAdminInput');
require('./passpolicy').check(password, { id: loginId }); // v1.3.17 관리자 콘솔의 비밀번호 규칙 (기본 6자 이상 — 이전과 같음)
if (d.members.some((m) => m.loginId === loginId || m.email === loginId)) throw E('err.idTaken', 409);
const salt = crypto.randomBytes(16).toString('hex');
const m = { id: newId(), name: clip(name, 60) || loginId, email: '', loginId, salt, hash: hashPw(password, salt), role: 'admin', approved: true, createdAt: nowIso() };
d.members.push(m); save(siteId, d); return pubMember(m);
}
// ---------- 게시판 ----------
function guestHash(pw) { return pw ? crypto.createHash('sha256').update(secret() + '|' + pw).digest('hex') : null; }
const pubPost = (p, { full = false, canSee = true } = {}) => ({ id: p.id, title: p.secret && !canSee ? '🔒 비밀글입니다' : p.title, author: p.author, category: p.category, secret: !!p.secret, views: p.views || 0, createdAt: p.createdAt, updatedAt: p.updatedAt, memberId: p.memberId || null, pinned: !!p.pinned, excerpt: !p.secret || canSee ? clip(p.body, 160) : '', body: full && canSee ? p.body : undefined, comments: p.commentCount || 0 });
function canRead(s, member) { return s.readRole !== 'member' || !!member; }
function canWrite(s, member) { if (s.writeRole === 'admin') return !!(member && member.role === 'admin'); if (s.writeRole === 'member') return !!member; return true; }
function canComment(s, member) { if (s.commentRole === 'none') return false; if (s.commentRole === 'member') return !!member; return true; }
function listPosts(siteId, q, member) {
const d = load(siteId); const s = d.settings.board; if (!isEnabled(d, 'board')) throw E('err.noBoard', 404);
if (!canRead(s, member)) throw E('err.membersOnly', 401);
const size = Math.min(50, Math.max(1, parseInt(q.size, 10) || s.pageSize || 15)); const page = Math.max(1, parseInt(q.page, 10) || 1);
let items = d.posts.filter((p) => !q.cat || p.category === q.cat);
// v1.3.17 (S03): 검색은 부르는 사람이 볼 수 있는 글자에서만 — 볼 수 없는 비밀글은 제목·본문 모두 검색 대상에서 뺀다
// (예전에는 가려진 비밀글 본문까지 검색해, 어떤 낱말이 들어 있는지 알아낼 수 있었다)
if (q.q) { const k = String(q.q).toLowerCase(); items = items.filter((p) => (!p.secret || isAuthorOrAdmin(p, member, null)) && (p.title + ' ' + p.body).toLowerCase().includes(k)); }
items.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0) || b.createdAt.localeCompare(a.createdAt));
const total = items.length; const slice = items.slice((page - 1) * size, page * size);
for (const p of slice) p.commentCount = d.comments.filter((c) => c.postId === p.id).length;
return { total, page, size, items: slice.map((p) => pubPost(p, { canSee: !p.secret || isAuthorOrAdmin(p, member, null) })), settings: publicSettings(d, 'board'), me: pubMember(member), canWrite: canWrite(s, member), canComment: canComment(s, member) };
}
function isAuthorOrAdmin(p, member, pw) { if (member && (member.role === 'admin' || (p.memberId && p.memberId === member.id))) return true; if (pw && p.guestHash && guestHash(pw) === p.guestHash) return true; return false; }
function getPost(siteId, id, member, pw) {
const d = load(siteId); const s = d.settings.board; if (!isEnabled(d, 'board')) throw E('err.noBoard', 404);
if (!canRead(s, member)) throw E('err.membersOnly', 401);
const p = d.posts.find((x) => x.id === id); if (!p) throw E('err.noPost', 404);
const canSee = !p.secret || isAuthorOrAdmin(p, member, pw);
if (p.secret && !canSee) return { post: pubPost(p, { canSee: false }), locked: true, needPassword: !!p.guestHash };
p.views = (p.views || 0) + 1; save(siteId, d);
const comments = d.comments.filter((c) => c.postId === id).sort((a, b) => a.createdAt.localeCompare(b.createdAt)).map((c) => { const see = !c.secret || isAuthorOrAdmin(p, member, pw) || (member && c.memberId === member.id) || (pw && c.guestHash && guestHash(pw) === c.guestHash); return { id: c.id, parentId: c.parentId || null, author: c.author, secret: !!c.secret, body: see ? c.body : '🔒 비밀 댓글입니다', createdAt: c.createdAt, memberId: c.memberId || null }; });
return { post: pubPost(p, { full: true, canSee: true }), comments, canComment: canComment(s, member), canEdit: isAuthorOrAdmin(p, member, pw) };
}
function createPost(siteId, body, member) {
const d = load(siteId); const s = d.settings.board; if (!isEnabled(d, 'board')) throw E('err.noBoard', 404);
if (!canWrite(s, member)) throw httpError(s.writeRole === 'admin' ? '관리자만 글을 쓸 수 있습니다' : '회원만 글을 쓸 수 있습니다', 401);
const title = clip(body.title, 120); const text = clip(body.body, 20000); if (!title || !text) throw E('err.titleBody');
const guestPw = member ? '' : String(body.password || ''); if (!member && guestPw.length < 4) throw E('err.guestPwPost');
const p = { id: newId(), title, body: text, author: member ? member.name : (clip(body.name, 40) || '손님'), memberId: member ? member.id : null, guestHash: member ? null : guestHash(guestPw), category: (s.categories || []).includes(body.category) ? body.category : ((s.categories || [])[0] || ''), secret: !!(s.allowSecret && (body.secret === true || body.secret === 'true' || body.secret === 'on')), views: 0, pinned: false, createdAt: nowIso(), updatedAt: nowIso() };
d.posts.push(p); save(siteId, d); return pubPost(p, { full: true });
}
function updatePost(siteId, id, body, member) {
const d = load(siteId); const p = d.posts.find((x) => x.id === id); if (!p) throw E('err.noPost', 404);
if (!isAuthorOrAdmin(p, member, body.password)) throw E('err.noEditPerm', 403);
if (body.title !== undefined) p.title = clip(body.title, 120) || p.title; if (body.body !== undefined) p.body = clip(body.body, 20000) || p.body; if (body.category !== undefined) p.category = clip(body.category, 40); if (body.secret !== undefined) p.secret = !!(body.secret === true || body.secret === 'true'); if (member && member.role === 'admin' && body.pinned !== undefined) p.pinned = !!body.pinned;
p.updatedAt = nowIso(); save(siteId, d); return pubPost(p, { full: true });
}
function deletePost(siteId, id, body, member) {
const d = load(siteId); const i = d.posts.findIndex((x) => x.id === id); if (i < 0) throw E('err.noPost', 404);
if (!isAuthorOrAdmin(d.posts[i], member, body.password)) throw E('err.noDeletePerm', 403);
d.posts.splice(i, 1); d.comments = d.comments.filter((c) => c.postId !== id); save(siteId, d); return { ok: true };
}
function addComment(siteId, postId, body, member) {
const d = load(siteId); const s = d.settings.board; const p = d.posts.find((x) => x.id === postId); if (!p) throw E('err.noPost', 404);
if (!canComment(s, member)) throw httpError(s.commentRole === 'none' ? '댓글을 받지 않는 게시판입니다' : '회원만 댓글을 쓸 수 있습니다', 401);
const text = clip(body.body, 3000); if (!text) throw E('err.commentRequired');
const guestPw = member ? '' : String(body.password || ''); if (!member && guestPw.length < 4) throw E('err.guestPwComment');
const parent = body.parentId ? d.comments.find((c) => c.id === body.parentId && c.postId === postId) : null;
const c = { id: newId(), postId, parentId: parent ? parent.id : null, body: text, author: member ? member.name : (clip(body.name, 40) || '손님'), memberId: member ? member.id : null, guestHash: member ? null : guestHash(guestPw), secret: !!(body.secret === true || body.secret === 'true' || body.secret === 'on'), createdAt: nowIso() };
d.comments.push(c); save(siteId, d); return { id: c.id, parentId: c.parentId, author: c.author, body: c.body, secret: c.secret, createdAt: c.createdAt };
}
function deleteComment(siteId, id, body, member) {
const d = load(siteId); const i = d.comments.findIndex((x) => x.id === id); if (i < 0) throw E('err.noComment', 404); const c = d.comments[i];
const ok = (member && (member.role === 'admin' || c.memberId === member.id)) || (body.password && c.guestHash && guestHash(body.password) === c.guestHash);
if (!ok) throw E('err.noDeletePerm', 403);
d.comments = d.comments.filter((x) => x.id !== id && x.parentId !== id); save(siteId, d); return { ok: true };
}
// ---------- 설문 ----------
// 설문 양식: { id, title, description, active, questions: [{ id, type: short|long|single|multi|scale|date|email|number, label, required, options: [], min, max }] }
function normalizeSurvey(body) {
const qs = (Array.isArray(body.questions) ? body.questions : []).slice(0, 60).map((q) => ({ id: q.id && /^[a-z0-9]{4,16}$/i.test(q.id) ? q.id : newId().slice(0, 8), type: ['short', 'long', 'single', 'multi', 'scale', 'date', 'email', 'number'].includes(q.type) ? q.type : 'short', label: clip(q.label, 300), required: !!q.required, options: (Array.isArray(q.options) ? q.options : String(q.options || '').split('\n')).map((o) => clip(o, 100)).filter(Boolean).slice(0, 30), min: Number(q.min) || 1, max: Number(q.max) || 5 })).filter((q) => q.label);
return { title: clip(body.title, 120) || '설문', description: clip(body.description, 2000), active: body.active !== false && body.active !== 'false', questions: qs, thanks: clip(body.thanks, 300) || '응답해 주셔서 감사합니다.' };
}
function submitResponse(siteId, surveyId, body, member, ip) {
const d = load(siteId); const s = d.settings.survey; if (!isEnabled(d, 'survey')) throw E('err.noSurvey', 404);
const f = d.surveys.find((x) => x.id === surveyId); if (!f || !f.active) throw E('err.surveyClosed', 404);
if (s.oneResponsePerMember && member && d.responses.some((r) => r.surveyId === surveyId && r.memberId === member.id)) throw E('err.alreadyAnswered', 409);
const answers = {}; const src = body.answers || {};
for (const q of f.questions) {
let v = src[q.id]; if (q.type === 'multi') v = (Array.isArray(v) ? v : (v ? [v] : [])).map((x) => clip(x, 100)).filter((x) => q.options.includes(x)); else if (q.type === 'single') v = q.options.includes(v) ? v : ''; else if (q.type === 'scale' || q.type === 'number') v = v === '' || v == null ? '' : Number(v); else v = clip(v, q.type === 'long' ? 5000 : 300);
const empty = v === '' || v == null || (Array.isArray(v) && !v.length) || (typeof v === 'number' && Number.isNaN(v));
if (q.required && empty) throw httpError(`"${q.label}" 은(는) 필수 항목입니다`);
answers[q.id] = v;
}
const r = { id: newId(), surveyId, answers, respondent: member ? member.name : clip(body.respondent, 60) || '익명', memberId: member ? member.id : null, ip, createdAt: nowIso() };
d.responses.push(r); save(siteId, d);
return { response: r, survey: f, notify: !!s.notifyEmail };
}
/** 설문 응답 → xlsx 행 (헤더: 응답일시, 응답자, 질문들) */
function surveyRows(siteId, surveyId) {
const d = load(siteId); const f = d.surveys.find((x) => x.id === surveyId); if (!f) throw E('err.noSurvey', 404);
const rows = [['응답 일시', '응답자', ...f.questions.map((q) => q.label)]];
for (const r of d.responses.filter((x) => x.surveyId === surveyId)) rows.push([new Date(r.createdAt).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }), r.respondent, ...f.questions.map((q) => { const v = r.answers[q.id]; return Array.isArray(v) ? v.join(', ') : (v == null ? '' : v); })]);
return { title: f.title, rows };
}
// ---------- 예약 ----------
const pad = (n) => String(n).padStart(2, '0');
/** 날짜의 예약 가능 시간대와 남은 정원 */
function slotsFor(siteId, date) {
const d = load(siteId); const s = d.settings.booking; if (!isEnabled(d, 'booking')) throw E('err.noBooking', 404);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw E('err.dateFormat');
const day = new Date(date + 'T00:00:00+09:00'); const today = new Date(new Date().toLocaleString('en-US', { timeZone: 'Asia/Seoul' })); today.setHours(0, 0, 0, 0);
const diffDays = Math.round((day - today) / 86400000);
const closed = (s.closedDates || []).includes(date) || !(s.weekdays || []).map(String).includes(String(day.getDay())) || diffDays < (s.leadDays || 0) || diffDays > (s.maxDays || 60);
const [sh, sm] = String(s.start || '10:00').split(':').map(Number); const [eh, em] = String(s.end || '18:00').split(':').map(Number); const step = parseInt(s.interval, 10) || 60;
const slots = [];
if (!closed) for (let t = sh * 60 + sm; t + step <= eh * 60 + em; t += step) { const time = `${pad(Math.floor(t / 60))}:${pad(t % 60)}`; const taken = d.bookings.filter((b) => b.date === date && b.time === time && b.status !== 'cancelled').length; slots.push({ time, left: Math.max(0, (s.capacity || 1) - taken) }); }
return { date, closed, slots, settings: publicSettings(d, 'booking') };
}
function reserve(siteId, body, member, ip) {
const { closed, slots } = slotsFor(siteId, String(body.date || '')); const d = load(siteId); const s = d.settings.booking;
if (closed) throw E('err.bookingClosedDate'); const slot = slots.find((x) => x.time === body.time); if (!slot) throw E('err.slotRequired'); if (slot.left <= 0) throw E('err.slotFull', 409);
const name = clip(body.name, 60) || (member && member.name); const contact = clip(body.contact, 120); if (!name || !contact) throw E('err.nameContact');
const b = { id: newId(), date: body.date, time: body.time, name, contact, note: clip(body.note, 1000), memberId: member ? member.id : null, status: s.autoConfirm ? 'confirmed' : 'pending', ip, createdAt: nowIso() };
d.bookings.push(b); save(siteId, d); return { booking: b, notify: !!s.notifyEmail };
}
// ---------- 후기 / FAQ / 갤러리 ----------
function submitReview(siteId, body, member, ip) {
const d = load(siteId); const s = d.settings.reviews; if (!isEnabled(d, 'reviews')) throw E('err.noReviews', 404);
const text = clip(body.text, 2000); const rating = Math.min(5, Math.max(1, parseInt(body.rating, 10) || 5)); if (!text) throw E('err.reviewRequired');
const r = { id: newId(), name: clip(body.name, 40) || (member ? member.name : '익명'), rating, text, category: (s.categories || []).includes(body.category) ? body.category : '', approved: !s.approve, memberId: member ? member.id : null, ip, createdAt: nowIso() };
d.reviews.push(r); save(siteId, d); return { review: r, notify: !!s.notifyEmail, pending: !r.approved };
}
function listReviews(siteId) { const d = load(siteId); if (!isEnabled(d, 'reviews')) throw E('err.noReviews', 404); return { items: d.reviews.filter((r) => r.approved).sort((a, b) => b.createdAt.localeCompare(a.createdAt)).map((r) => ({ id: r.id, name: r.name, rating: r.rating, text: r.text, category: r.category, createdAt: r.createdAt })), settings: publicSettings(d, 'reviews') }; }
/**
* v1.4.18 일정 캘린더 조회 (방문자): 기간의 날짜 정보(음력·간지·절기)와 공개 일정의 발생.
* @param {{from, to, cat}} q from·to 'YYYY-MM-DD' (최대 70일), cat 분류 필터
*/
function listCalendar(siteId, q) { const d = load(siteId); if (!isEnabled(d, 'calendar')) throw E('err.noCalendar', 404); return { ...calendar.range(d.events, q.from, q.to, { category: q.cat || '', publishedOnly: true }), settings: publicSettings(d, 'calendar') }; }
/** v1.4.18 일정 모듈이 켜져 있어야 쓰는 날짜 조회·음력 변환 (위젯·관리자 입력 보조) */
function calendarDay(siteId, date) { const d = load(siteId); if (!isEnabled(d, 'calendar')) throw E('err.noCalendar', 404); return calendar.dayInfo(date); }
function calendarLunar(siteId, q) { const d = load(siteId); if (!isEnabled(d, 'calendar')) throw E('err.noCalendar', 404); return calendar.lunarToSolar(q); }
function listFaq(siteId) { const d = load(siteId); if (!isEnabled(d, 'faq')) throw E('err.noFaq', 404); return { items: d.faq.slice().sort((a, b) => (a.order || 0) - (b.order || 0) || a.createdAt.localeCompare(b.createdAt)).map((f) => ({ id: f.id, q: f.q, a: f.a, category: f.category || '' })), settings: publicSettings(d, 'faq') }; }
function listGallery(siteId) { const d = load(siteId); if (!isEnabled(d, 'gallery')) throw E('err.noGallery', 404); return { items: d.gallery.slice().sort((a, b) => (a.order || 0) - (b.order || 0) || a.createdAt.localeCompare(b.createdAt)).map((g) => ({ id: g.id, src: g.src, thumb: g.thumb || g.src, title: g.title, caption: g.caption, tags: g.tags || [] })), settings: publicSettings(d, 'gallery') }; }
// ---------- 블로그 (v1.4.0) ----------
/** 방문자에게 보이는 블로그 글 (본문은 글 보기에서만) */
const pubBlog = (p, s, full) => ({ id: p.id, title: p.title, summary: p.summary || clip(String(p.body || '').replace(/\s+/g, ' '), 200), cover: p.cover || '', category: p.category || '', tags: p.tags || [], author: s.showAuthor === false ? '' : (p.author || ''), views: p.views || 0, comments: p.commentCount || 0, createdAt: p.createdAt, updatedAt: p.updatedAt, body: full ? p.body : undefined });
/**
* 블로그 글 목록: 공개(published)된 글만, 최신순. q 는 제목·요약·본문·태그에서 찾는다(여러 낱말이면 모두 포함), cat·tag 로 거른다.
* @param {object} q { q, cat, tag, page, size }
*/
function listBlog(siteId, q) {
const d = load(siteId); const s = d.settings.blog; if (!isEnabled(d, 'blog')) throw E('err.noBlog', 404);
const size = Math.min(50, Math.max(1, parseInt(q.size, 10) || s.pageSize || 10)); const page = Math.max(1, parseInt(q.page, 10) || 1);
let items = d.blogPosts.filter((p) => p.published !== false && (!q.cat || p.category === q.cat) && (!q.tag || (p.tags || []).includes(q.tag)));
const words = String(q.q || '').toLowerCase().split(/\s+/).filter(Boolean).slice(0, 8);
if (words.length) items = items.filter((p) => { const hay = `${p.title} ${p.summary || ''} ${p.body} ${(p.tags || []).join(' ')}`.toLowerCase(); return words.every((w) => hay.includes(w)); });
items.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0) || b.createdAt.localeCompare(a.createdAt));
const total = items.length; const slice = items.slice((page - 1) * size, page * size);
for (const p of slice) p.commentCount = d.blogComments.filter((c) => c.postId === p.id && c.approved !== false).length;
const tags = {}; for (const p of d.blogPosts) if (p.published !== false) for (const t of p.tags || []) tags[t] = (tags[t] || 0) + 1;
return { total, page, size, items: slice.map((p) => pubBlog(p, s, false)), tags: Object.entries(tags).sort((a, b) => b[1] - a[1]).slice(0, 30).map(([t, n]) => ({ tag: t, n })), settings: publicSettings(d, 'blog') };
}
/** 블로그 글 보기 (조회수 +1, 공개된 댓글 포함) */
function getBlog(siteId, id, member) {
const d = load(siteId); const s = d.settings.blog; if (!isEnabled(d, 'blog')) throw E('err.noBlog', 404);
const p = d.blogPosts.find((x) => x.id === id && x.published !== false); if (!p) throw E('err.noPost', 404);
p.views = (p.views || 0) + 1; save(siteId, d);
const comments = d.blogComments.filter((c) => c.postId === id && c.approved !== false).sort((a, b) => a.createdAt.localeCompare(b.createdAt)).map((c) => ({ id: c.id, name: c.name, body: c.body, createdAt: c.createdAt }));
return { post: pubBlog(p, s, true), comments, canComment: canComment(s, member), settings: publicSettings(d, 'blog') };
}
/** 블로그 댓글 쓰기 — 설정에 따라 누구나/회원만/받지 않음, 승인제면 관리자 승인 뒤 공개 */
function addBlogComment(siteId, postId, body, member, ip) {
const d = load(siteId); const s = d.settings.blog; if (!isEnabled(d, 'blog')) throw E('err.noBlog', 404);
if (!d.blogPosts.some((p) => p.id === postId && p.published !== false)) throw E('err.noPost', 404);
if (s.commentRole === 'none') throw E('err.commentsClosed', 403); if (s.commentRole === 'member' && !member) throw E('err.loginRequired', 401);
const text = clip(body.body, 3000); if (!text) throw E('err.commentRequired');
const name = member ? member.name : clip(body.name, 40); if (!name) throw E('err.nameRequired');
const c = { id: newId(), postId, name, body: text, memberId: member ? member.id : null, ip: ip || null, approved: !s.approveComments, createdAt: nowIso() };
d.blogComments.push(c); save(siteId, d);
return { id: c.id, pending: !c.approved, comment: c.approved ? { id: c.id, name: c.name, body: c.body, createdAt: c.createdAt } : null };
}
// ---------- 자료실 (v1.4.0) ----------
const pubFile = (f, s) => ({ id: f.id, title: f.title || f.originalName, description: f.description || '', category: f.category || '', tags: f.tags || [], originalName: f.originalName, size: f.size, mime: f.mime || '', downloads: s.showDownloads === false ? undefined : (f.downloads || 0), createdAt: f.createdAt, updatedAt: f.updatedAt });
/** 자료 목록 (공개 자료만, 최신순, 제목·설명·파일 이름·태그 검색) */
function listLibrary(siteId, q) {
const d = load(siteId); const s = d.settings.library; if (!isEnabled(d, 'library')) throw E('err.noLibrary', 404);
let items = d.libraryFiles.filter((f) => f.published !== false && (!q.cat || f.category === q.cat));
const words = String(q.q || '').toLowerCase().split(/\s+/).filter(Boolean).slice(0, 8);
if (words.length) items = items.filter((f) => { const hay = `${f.title} ${f.description || ''} ${f.originalName} ${(f.tags || []).join(' ')}`.toLowerCase(); return words.every((w) => hay.includes(w)); });
items.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0) || b.createdAt.localeCompare(a.createdAt));
return { items: items.slice(0, 500).map((f) => pubFile(f, s)), settings: publicSettings(d, 'library') };
}
/**
* 내려받기 준비: 권한(회원만) 확인 후 파일 경로와 원래 이름을 돌려주고 횟수를 센다.
* @returns {{ path: string, name: string, mime: string, size: number }}
*/
function libraryDownload(siteId, id, member) {
const d = load(siteId); const s = d.settings.library; if (!isEnabled(d, 'library')) throw E('err.noLibrary', 404);
const f = d.libraryFiles.find((x) => x.id === id && x.published !== false); if (!f) throw E('err.noItem', 404);
if (s.downloadRole === 'member' && !member) throw E('err.loginRequired', 401);
const fp = path.join(libraryDir(siteId), f.stored); if (!f.stored || path.basename(f.stored) !== f.stored || !fs.existsSync(fp)) throw E('err.fileMissing', 404);
f.downloads = (f.downloads || 0) + 1; save(siteId, d);
return { path: fp, name: f.originalName, mime: f.mime || 'application/octet-stream', size: f.size };
}
/**
* 관리자 자료 올리기. 파일은 사이트 폴더 밖 library/ 에 무작위 이름으로 저장하고(원래 이름은 기록에만), 크기 상한은 설정 maxFileMb.
* @param {Array<{name, size, type, buf}>} files 이미 읽은 파일
* @param {object} meta { title, description, category, tags, published }
*/
function addLibraryFiles(siteId, files, meta = {}) {
const d = load(siteId); const s = d.settings.library; const max = Math.max(1, Number(s.maxFileMb) || 100) * 1048576; const added = []; const skipped = [];
for (const f of files) {
if (!f.buf || f.size > max) { skipped.push({ name: f.name, reason: `크기 초과(${Math.round(max / 1048576)}MB)` }); continue; }
const original = clip(path.basename(String(f.name || 'file')), 200) || 'file'; const ext = (path.extname(original).toLowerCase().match(/^\.[a-z0-9]{1,10}$/) || [''])[0];
const stored = `${Date.now().toString(36)}-${crypto.randomBytes(6).toString('hex')}${ext}`;
fs.writeFileSync(path.join(libraryDir(siteId), stored), f.buf, { mode: 0o640 });
const item = { id: newId(), title: clip(meta.title, 200) && files.length === 1 ? clip(meta.title, 200) : original.replace(/\.[^.]+$/, ''), description: clip(meta.description, 2000), category: clip(meta.category, 40), tags: (Array.isArray(meta.tags) ? meta.tags : String(meta.tags || '').split(',')).map((t) => clip(t, 30)).filter(Boolean), originalName: original, stored, size: f.size, mime: clip(f.type, 120) || 'application/octet-stream', downloads: 0, published: meta.published !== false && meta.published !== 'false', createdAt: nowIso(), updatedAt: nowIso() };
d.libraryFiles.push(item); added.push(item);
}
save(siteId, d); return { added, skipped };
}
// ---------- 지도: 주소 → 좌표 (Nominatim, 캐시) ----------
const geoCache = new Map();
function geocode(q) {
q = clip(q, 200); if (!q) return Promise.resolve(null); if (geoCache.has(q)) return Promise.resolve(geoCache.get(q));
return new Promise((resolve) => {
const url = `https://nominatim.openstreetmap.org/search?format=json&limit=1&q=${encodeURIComponent(q)}`;
const req = https.get(url, { headers: { 'User-Agent': 'webmaker-map/1.0 (site builder)' }, timeout: 10000 }, (res) => { let d = ''; res.on('data', (c) => { d += c; }); res.on('end', () => { try { const j = JSON.parse(d); const r = j[0] ? { lat: parseFloat(j[0].lat), lng: parseFloat(j[0].lon), display: j[0].display_name } : null; geoCache.set(q, r); resolve(r); } catch (_) { resolve(null); } }); });
req.on('error', () => resolve(null)); req.on('timeout', () => { req.destroy(); resolve(null); });
});
}
// ---------- 관리자(소유자/사이트 관리자) 용 범용 CRUD ----------
const COL_FIELDS = {
members: ['name', 'email', 'role', 'approved'], posts: ['title', 'body', 'category', 'secret', 'pinned', 'author'], comments: ['body', 'secret'],
surveys: null, responses: [], bookings: ['date', 'time', 'name', 'contact', 'note', 'status'], reviews: ['name', 'rating', 'text', 'category', 'approved'],
faq: ['q', 'a', 'category', 'order'], gallery: ['src', 'thumb', 'title', 'caption', 'tags', 'order'],
blogPosts: ['title', 'summary', 'body', 'cover', 'category', 'tags', 'author', 'published', 'pinned'], blogComments: ['name', 'body', 'approved'],
libraryFiles: ['title', 'description', 'category', 'tags', 'published', 'pinned'],
events: null, // v1.4.18 일정은 lib/calendar.js normalizeEvent 가 통째로 정리한다
};
function adminList(siteId, coll) {
const d = load(siteId); if (!COLLECTIONS.includes(coll)) throw E('err.unknownItem', 404);
const items = d[coll].slice().sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''));
if (coll === 'members') return items.map((m) => ({ ...pubMember(m), loginId: m.loginId || null }));
if (coll === 'surveys') return items.map((f) => ({ ...f, responses: d.responses.filter((r) => r.surveyId === f.id).length }));
if (coll === 'posts') return items.map((p) => ({ ...pubPost(p, { full: true }), body: p.body }));
// v1.4.18 일정: 시작일 순으로, 오늘 이후 첫 발생일(next)을 붙여 준다 (반복·음력 일정의 실제 날짜 확인용)
if (coll === 'events') return items.sort((a, b) => (a.date || '').localeCompare(b.date || '') || (a.time || '').localeCompare(b.time || '')).map((e) => ({ ...e, next: calendar.nextOccurrence(e) }));
return items.map((x) => { const { guestHash: _g, salt: _s, hash: _h, ...rest } = x; return rest; });
}
function adminCreate(siteId, coll, body) {
const d = load(siteId); if (!COLLECTIONS.includes(coll)) throw E('err.unknownItem', 404);
let item;
if (coll === 'surveys') item = { id: newId(), ...normalizeSurvey(body), createdAt: nowIso() };
else if (coll === 'posts') item = { id: newId(), title: clip(body.title, 120), body: clip(body.body, 20000), author: clip(body.author, 40) || '관리자', category: clip(body.category, 40), secret: !!body.secret, pinned: !!body.pinned, views: 0, createdAt: nowIso(), updatedAt: nowIso() };
else if (coll === 'faq') item = { id: newId(), q: clip(body.q, 300), a: clip(body.a, 5000), category: clip(body.category, 40), order: Number(body.order) || 0, createdAt: nowIso() };
else if (coll === 'gallery') item = { id: newId(), src: clip(body.src, 500), thumb: clip(body.thumb, 500), title: clip(body.title, 120), caption: clip(body.caption, 500), tags: (Array.isArray(body.tags) ? body.tags : String(body.tags || '').split(',')).map((t) => clip(t, 30)).filter(Boolean), order: Number(body.order) || 0, createdAt: nowIso() };
else if (coll === 'reviews') item = { id: newId(), name: clip(body.name, 40) || '익명', rating: Math.min(5, Math.max(1, parseInt(body.rating, 10) || 5)), text: clip(body.text, 2000), category: clip(body.category, 40), approved: body.approved !== false, createdAt: nowIso() };
else if (coll === 'blogPosts') item = { id: newId(), title: clip(body.title, 200), summary: clip(body.summary, 500), body: clip(body.body, 100000), cover: clip(body.cover, 500), category: clip(body.category, 40), tags: (Array.isArray(body.tags) ? body.tags : String(body.tags || '').split(',')).map((t) => clip(t, 30)).filter(Boolean), author: clip(body.author, 40) || '관리자', published: body.published !== false && body.published !== 'false', pinned: body.pinned === true || body.pinned === 'true', views: 0, createdAt: nowIso(), updatedAt: nowIso() };
else if (coll === 'events') item = { ...calendar.normalizeEvent(body), id: newId(), createdAt: nowIso(), updatedAt: nowIso() }; // v1.4.18 일정
else if (coll === 'bookings') item = { id: newId(), date: clip(body.date, 10), time: clip(body.time, 5), name: clip(body.name, 60), contact: clip(body.contact, 120), note: clip(body.note, 1000), status: ['pending', 'confirmed', 'cancelled'].includes(body.status) ? body.status : 'confirmed', createdAt: nowIso() };
else throw E('err.cannotAddHere');
if ((coll === 'posts' && !item.title) || (coll === 'faq' && !item.q) || (coll === 'gallery' && !item.src) || (coll === 'blogPosts' && (!item.title || !item.body))) throw E('err.requiredFields');
d[coll].push(item); save(siteId, d); return item;
}
function adminUpdate(siteId, coll, id, body) {
const d = load(siteId); if (!COLLECTIONS.includes(coll)) throw E('err.unknownItem', 404); const x = d[coll].find((i) => i.id === id); if (!x) throw E('err.noItem', 404);
if (coll === 'surveys') Object.assign(x, normalizeSurvey({ ...x, ...body }));
else if (coll === 'events') { const n = calendar.normalizeEvent(body, x); for (const k of Object.keys(n)) if (k !== 'id') x[k] = n[k]; } // v1.4.18 일정: 보내지 않은 항목은 기존 값 유지
else for (const k of COL_FIELDS[coll] || []) if (body[k] !== undefined) { let v = body[k]; if (['secret', 'pinned', 'approved', 'published'].includes(k)) v = v === true || v === 'true'; else if (['rating', 'order'].includes(k)) v = Number(v) || 0; else if (k === 'tags') v = (Array.isArray(v) ? v : String(v).split(',')).map((t) => clip(t, 30)).filter(Boolean); else if (k === 'role') v = v === 'admin' ? 'admin' : 'member'; else if (k === 'status') v = ['pending', 'confirmed', 'cancelled'].includes(v) ? v : x.status; else v = clip(v, k === 'body' || k === 'a' ? 20000 : 1000); x[k] = v; }
x.updatedAt = nowIso(); save(siteId, d); return x;
}
function adminDelete(siteId, coll, id) {
const d = load(siteId); if (!COLLECTIONS.includes(coll)) throw E('err.unknownItem', 404); const i = d[coll].findIndex((x) => x.id === id); if (i < 0) throw E('err.noItem', 404);
const [removed] = d[coll].splice(i, 1);
if (coll === 'posts') d.comments = d.comments.filter((c) => c.postId !== id); if (coll === 'surveys') d.responses = d.responses.filter((r) => r.surveyId !== id);
if (coll === 'blogPosts') d.blogComments = d.blogComments.filter((c) => c.postId !== id);
// 자료실 기록을 지우면 저장된 파일도 지운다 (이름은 우리가 만든 무작위 이름만 — 경로 문자 없음)
if (coll === 'libraryFiles' && removed && removed.stored && path.basename(removed.stored) === removed.stored) { try { fs.unlinkSync(path.join(libraryDir(siteId), removed.stored)); } catch (_) {} }
save(siteId, d); return { ok: true };
}
function setEnabled(siteId, moduleId, on) { if (!CATALOG[moduleId]) throw E('err.unknownModule'); const d = load(siteId); d.enabled[moduleId] = !!on; if (on && moduleId === 'board' && !d.settings.members) d.settings.members = defaults('members'); save(siteId, d); return d.enabled; }
function updateSettings(siteId, moduleId, body) { const d = load(siteId); d.settings[moduleId] = { ...d.settings[moduleId], ...normalizeSettings(moduleId, body || {}) }; save(siteId, d); return d.settings[moduleId]; }
/** 관리자 페이지 첫 화면: 켜진 모듈, 설정, 건수 */
function overview(siteId) {
const d = load(siteId);
const counts = Object.fromEntries(COLLECTIONS.map((c) => [c, d[c].length]));
counts.pendingBookings = d.bookings.filter((b) => b.status === 'pending').length; counts.pendingReviews = d.reviews.filter((r) => !r.approved).length; counts.pendingMembers = d.members.filter((m) => m.approved === false).length;
return { enabled: d.enabled, settings: d.settings, counts, catalog: catalogPublic() };
}
function catalogPublic() { return MODULE_IDS.map((id) => ({ id, label: CATALOG[id].label, desc: CATALOG[id].desc, feature: CATALOG[id].feature, options: CATALOG[id].options, collections: CATALOG[id].collections })); }
// ---------- 생성 프롬프트용 템플릿 ----------
/** modules/<id>/widget.html 을 읽어 사이트 값으로 치환 (없으면 빈 문자열) */
function widgetTemplate(moduleId, { siteId, apiBase, settings, lang }) { // lang: 사이트 본문 언어 — {{t:id}} 자리를 그 언어 글자로 (v1.3.16)
const f = path.join(TEMPLATES, moduleId, 'widget.html'); let html; try { html = sitemsg.renderTemplate(fs.readFileSync(f, 'utf8'), lang || 'ko'); } catch (_) { return ''; }
const s = settings || {};
return html.replace(/\{\{SITE_ID\}\}/g, siteId).replace(/\{\{API\}\}/g, `${apiBase}/api/m/${siteId}`).replace(/\{\{SCRIPT\}\}/g, `${apiBase}/wm-modules.js`).replace(/\{\{(\w+)\}\}/g, (m0, k) => (s[k.toLowerCase()] !== undefined ? String(Array.isArray(s[k.toLowerCase()]) ? s[k.toLowerCase()].join(',') : s[k.toLowerCase()]) : m0));
}
/** 사이트에 켜진(또는 Brief 기능으로 요청된) 모듈의 표준 코드와 사용 규칙을 프롬프트 섹션으로 */
function promptSection(siteId, apiBase, wanted, lang) {
const d = load(siteId); const ids = MODULE_IDS.filter((m) => d.enabled[m] || (wanted || []).includes(m));
if (!ids.length) return '';
const parts = ids.map((m) => `--- ${CATALOG[m].label} 모듈 (modules/${m}/widget.html) ---\n${widgetTemplate(m, { siteId, apiBase, settings: d.settings[m], lang })}`);
// v1.5.7: 쓸 수 있는 모듈 id 를 분명히 적는다 — AI 가 비슷한 이름(schedule 등)의 모듈을 새로 만들어 넣지 않게 (2026-10-14 운영 사이트 사례)
return `[공통 기능 모듈 — 표준 코드 그대로 삽입]\n아래 모듈 코드는 빌더가 관리하는 표준 위젯입니다. 각 코드의 주석(재사용 설정)을 따르고, data-wm-module·data-site·data-api 등 data-* 속성과 <script src> 는 바꾸지 않습니다. 런타임이 아는 모듈 id 는 ${MODULE_IDS.join(', ')} 뿐입니다 — 다른 id(schedule, events 등)로 새 위젯을 만들거나 모듈 동작을 직접 구현하지 말고, 요청한 기능에 맞는 아래 모듈 코드를 넣습니다(예: 일정·캘린더·행사 일정 → calendar). 모양은 wm- 로 시작하는 클래스에 CSS 를 더해 사이트 디자인에 맞춥니다. 한 페이지에 같은 모듈을 두 번 넣지 않습니다. 모듈이 들어갈 페이지는 Brief 의 페이지·섹션 구성을 따릅니다(예: 문의 → contact 섹션, 게시판 → news/board 페이지).\n\n${parts.join('\n\n')}\n`;
}
module.exports = { CATALOG, MODULE_IDS, COLLECTIONS, load, save, remove, filesDir, isEnabled, publicSettings, normalizeSettings, updateSettings, setEnabled, overview, catalogPublic,
signup, login, memberFromToken, pubMember, createSiteAdmin, verifyPassword, getSiteAdmin, setMemberMail,
listPosts, getPost, createPost, updatePost, deletePost, addComment, deleteComment,
normalizeSurvey, submitResponse, surveyRows, slotsFor, reserve, submitReview, listReviews, listFaq, listGallery, geocode,
adminList, adminCreate, adminUpdate, adminDelete, widgetTemplate, promptSection,
listBlog, getBlog, addBlogComment, listLibrary, libraryDownload, addLibraryFiles, libraryDir,
listCalendar, calendarDay, calendarLunar }; // v1.4.18 일정관리