Files

91 lines
8.0 KiB
JavaScript
Executable File

'use strict';
// ============================================================================
// AI Webmaker 초기 설정 서버 (v1.5.7 요구 2, v1.5.11 요구 2 개정)
//
// 웹메이커 본 서비스와 별도로 "특정 포트"(설정 service.setupPort, 기본 8795) 에서 설정 전용 웹페이지를 연다.
// 설치 스크립트(deploy/install.sh · remote-install.sh)가 systemd 유닛 webmaker-setup 으로 등록하고, 설치가 끝나면
// 접속 주소(http://<서버 IP>:8795/?token=…)를 알려 준다. 운영자는 그 페이지에서 설치 점검표(관리자 비밀번호·AI 엔진 인증·
// 공개 주소)를 끝내고, 설정 파일(data/config.json)의 빈 값(연동 정보, AI 실행 파일 경로 …)을 채우고 본 서비스를 재시작한다.
//
// - 왜 별도 프로세스인가: 본 서비스가 설정이 없어 제대로 뜨지 않는 상황(웹서버에 도메인이 없거나 공개 주소가 틀림 등)에서도
// 설정을 넣을 수 있어야 한다. 의존성 없는 작은 서버라 항상 켜 두어도 부담이 없다. FQDN 을 모르는 서버에서는 http://<IP>:8795/ 가
// "기본 접속 위치" 다(요구 2). 본 서비스가 뜨면 같은 화면이 /setup 에도 열린다(server.js).
// - 인증(lib/setupauth.js): ① 접속 토큰 — data/setup.token(0600). 설치 스크립트가 이 값을 주소에 붙여 알려 준다.
// ② 웹메이커 관리자 계정(lib/auth.js, role=admin) 로그인. 둘 중 하나로 들어오면 서명된 세션 쿠키(wm_setup, 12시간)를 준다.
// 로그인 시도는 IP 당 15분 10회.
// - 바인드: service.setupBind(기본 0.0.0.0 — 공인 IP 만 있는 서버에서도 접속되게; 사설망만 열려면 lan).
// - 포트 겹침: 기본 8795 가 이미 쓰이면 8796, 8797 … 순으로 최대 20개를 시도하고, 잡은 포트를 설정 파일에 적어 둔다
// (scripts/config.js setup-url 과 설치 스크립트 안내가 같은 포트를 보도록).
// - API: /api/setup/login · logout · session, /api/setup/* → lib/setupapi.js (관리자 콘솔 /api/admin/config/* 와 같은 처리기)
// - 정적 파일: public/ 의 setup.html, setup.js, style.css, numfmt.js 와 테마 CSS(/theme.css)만 내준다. 검색 색인 금지(X-Robots-Tag).
//
// 실행: node setup-server.js (환경 변수 DATA_ROOT·WEBMAKER_CONFIG 는 본 서비스와 같게)
// ============================================================================
const http = require('http');
const fs = require('fs');
const path = require('path');
const config = require('./lib/config');
config.apply(); // 설정 파일 → process.env (본 서비스와 같은 순서). 파일이 없으면 환경 변수로 만든다
const setupapi = require('./lib/setupapi');
const setupauth = require('./lib/setupauth');
const VERSION = require('./package.json').version;
const PUBLIC_DIR = path.join(__dirname, 'public');
const WANT_PORT = parseInt(String(config.get('service.setupPort') || 8795), 10) || 8795;
const BIND = config.setupBindAddress();
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon' };
const STATIC = new Set(['setup.js', 'style.css', 'numfmt.js', 'favicon.ico', 'favicon.svg']);
function send(res, status, body, headers = {}) {
const isBuf = Buffer.isBuffer(body); const data = isBuf ? body : (typeof body === 'string' ? body : JSON.stringify(body));
res.writeHead(status, { 'Content-Type': isBuf ? (headers['Content-Type'] || 'application/octet-stream') : (typeof body === 'string' ? 'text/plain; charset=utf-8' : 'application/json; charset=utf-8'), 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'X-Frame-Options': 'DENY', 'X-Robots-Tag': 'noindex, nofollow', ...headers });
res.end(data);
}
function readJson(req) { return new Promise((resolve, reject) => { let s = ''; req.on('data', (d) => { s += d; if (s.length > 1e6) { reject(Object.assign(new Error('본문이 너무 큽니다'), { status: 413 })); req.destroy(); } }); req.on('end', () => { try { resolve(s ? JSON.parse(s) : {}); } catch (_) { reject(Object.assign(new Error('JSON 형식이 아닙니다'), { status: 400 })); } }); req.on('error', reject); }); }
async function handle(req, res) {
const url = new URL(req.url, 'http://x'); const p = url.pathname; const ip = req.socket.remoteAddress || '';
if (req.method === 'GET' && (p === '/' || p === '/setup' || p === '/setup.html')) {
// standalone 표시 — setup.js 가 API 기본 경로를 /api/setup 으로 잡고 본 서비스 링크는 공개 주소로 만든다
const html = fs.readFileSync(path.join(PUBLIC_DIR, 'setup.html'), 'utf8').replace(/<head>/i, '<head><meta name="wm-setup-standalone" content="1">');
return send(res, 200, Buffer.from(html), { 'Content-Type': MIME['.html'] });
}
if (req.method === 'GET' && p === '/theme.css') { let css = ''; try { css = require('./lib/settings').themeCss(); } catch (_) {} return send(res, 200, Buffer.from(css), { 'Content-Type': MIME['.css'] }); }
if (req.method === 'GET' && STATIC.has(p.slice(1))) { try { return send(res, 200, fs.readFileSync(path.join(PUBLIC_DIR, p.slice(1))), { 'Content-Type': MIME[path.extname(p)] || 'application/octet-stream' }); } catch (_) { return send(res, 404, { error: 'not found' }); } }
const sess = setupauth.readSession(req);
if (p === '/api/setup/session' && req.method === 'GET') return send(res, 200, { loggedIn: !!sess, who: sess ? sess.who : null, version: VERSION, standalone: true, publicBaseUrl: config.get('service.publicBaseUrl') || '' });
if (p === '/api/setup/login' && req.method === 'POST') { const r = setupauth.login(await readJson(req), ip); return send(res, r.status, r.body, r.cookie ? { 'Set-Cookie': r.cookie } : {}); }
if (p === '/api/setup/logout' && req.method === 'POST') return send(res, 200, { ok: true }, { 'Set-Cookie': setupauth.clearCookie() });
if (p === '/api/setup' || p.startsWith('/api/setup/')) {
if (!sess) return send(res, 401, { error: '로그인이 필요합니다' });
const sub = p.slice('/api/setup'.length).split('/').filter(Boolean).map(decodeURIComponent);
const body = req.method === 'PUT' || req.method === 'POST' ? await readJson(req) : null;
const r = await setupapi.handle({ method: req.method, sub, body, by: `setup:${sess.who}` });
return send(res, r.status, r.body);
}
return send(res, 404, { error: 'not found' });
}
const server = http.createServer((req, res) => { handle(req, res).catch((e) => { if (!res.headersSent) send(res, e.status || 500, { error: e.message }); else res.end(); }); });
/**
* 포트 잡기: 원하는 포트부터 차례로 시도(EADDRINUSE 면 다음). 잡은 포트가 설정값과 다르면 설정 파일에 적는다.
* @param {number} port 시도할 포트
* @param {number} tries 남은 시도 횟수
*/
function listen(port, tries) {
server.once('error', (e) => {
if (e.code === 'EADDRINUSE' && tries > 0) { console.log(`webmaker-setup: 포트 ${port} 가 이미 사용 중 → ${port + 1} 시도`); return listen(port + 1, tries - 1); }
console.error(`webmaker-setup: 포트를 열 수 없습니다 (${e.code || e.message})`); process.exit(1);
});
server.listen(port, BIND);
}
// 'listening' 처리기는 하나만 둔다 — listen() 마다 콜백을 넘기면 실패한 시도의 콜백도 남아 성공 때 모두 불린다(포트가 두 번 찍힘)
server.on('listening', () => {
const port = server.address().port;
if (port !== WANT_PORT) { try { config.set({ 'service.setupPort': port }, 'setup:autoport'); console.log(`webmaker-setup: 설정 service.setupPort 를 ${port} 로 기록했습니다 (원래 ${WANT_PORT} 는 사용 중)`); } catch (e) { console.error('webmaker-setup: 포트 기록 실패: ' + e.message); } }
const t = setupauth.token();
console.log(`webmaker-setup v${VERSION} listening on http://${BIND}:${port} config=${config.FILE}`);
console.log(`초기 설정 페이지: ${config.setupUrls(t).join(' ')}`);
});
listen(WANT_PORT, 20);