Ariweb Suite 0.1.1 기반: 공통 라이브러리(db·http·세션·작업 엔진·할당/예약·알림·감사·보관소), 포털 뼈대, Webmaker/AriMail 도입 소스와 manifest
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
9ca4cee341
commit
c7aea8d419
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
// ============================================================================
|
||||
// certbot DNS-01 훅 (v1.4.14, 요구 9) — 와일드카드 인증서(*.플랫폼 도메인) 발급에 쓴다.
|
||||
//
|
||||
// certbot 이 --manual-auth-hook / --manual-cleanup-hook 으로 이 스크립트를 부른다 (sudo 로 실행되므로 root).
|
||||
// 인자: auth | cleanup
|
||||
// 환경(certbot 이 넣음): CERTBOT_DOMAIN(인증할 도메인, 와일드카드는 '*' 없이), CERTBOT_VALIDATION(TXT 값), CERTBOT_REMAINING_CHALLENGES
|
||||
// 환경(lib/ssl.js 가 넣음): WM_ACME_MODE(openprovider|manual), WM_ACME_DIR(대기 파일 폴더), WM_ACME_WAIT_MIN(수동 대기 분), WM_ACME_ZONE(플랫폼 도메인), DATA_ROOT
|
||||
//
|
||||
// 방식
|
||||
// openprovider : lib/openprovider 로 영역(WM_ACME_ZONE)에 _acme-challenge TXT 를 넣고(cleanup 때 지움), 공용 DNS 에 보일 때까지 최대 5분 기다린다.
|
||||
// manual : 운영자가 DNS 관리 화면에 넣어야 하므로, 필요한 TXT 를 WM_ACME_DIR/<도메인>.json 에 적어 두고(관리자 콘솔이 보여 줌)
|
||||
// 공용 DNS 에 그 값이 보일 때까지 최대 WM_ACME_WAIT_MIN 분 기다린다. 시간 안에 안 보이면 1 로 끝나 certbot 이 실패한다.
|
||||
// 출력은 certbot 로그(/var/log/letsencrypt)에 남는다. 이 파일은 서비스 코드(lib/*)를 그대로 쓰므로 서비스와 같은 폴더에 있어야 한다.
|
||||
// ============================================================================
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const dns = require('dns');
|
||||
|
||||
const action = process.argv[2];
|
||||
const domain = String(process.env.CERTBOT_DOMAIN || '').toLowerCase().replace(/^\*\./, '');
|
||||
const value = process.env.CERTBOT_VALIDATION || '';
|
||||
// v1.5.36 (요구 5): 방식은 DNS 공급자 id(openprovider·porkbun·route53·gcloud) 또는 manual — lib/dnszone 공통 창구로 TXT 를 넣고 지운다
|
||||
const AUTO_MODES = ['openprovider', 'porkbun', 'route53', 'gcloud'];
|
||||
const mode = AUTO_MODES.includes(process.env.WM_ACME_MODE) ? process.env.WM_ACME_MODE : 'manual';
|
||||
const dir = process.env.WM_ACME_DIR || '/var/tmp/webmaker-acme';
|
||||
const zone = (process.env.WM_ACME_ZONE || domain).toLowerCase();
|
||||
const waitMin = Math.max(1, parseInt(process.env.WM_ACME_WAIT_MIN || '40', 10));
|
||||
const name = `_acme-challenge.${domain}`;
|
||||
const rel = name === zone ? '' : (name.endsWith('.' + zone) ? name.slice(0, -(zone.length + 1)) : name); // 영역 기준 상대 이름
|
||||
const log = (m) => process.stdout.write(`[acme-hook ${action} ${mode}] ${m}\n`);
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** 공용 DNS(1.1.1.1·8.8.8.8)에 TXT 값이 보이는지 */
|
||||
async function visible() {
|
||||
const r = new dns.promises.Resolver({ timeout: 4000, tries: 1 }); r.setServers(['1.1.1.1', '8.8.8.8']);
|
||||
try { return (await r.resolveTxt(name)).some((t) => t.join('') === value); } catch (_) { return false; }
|
||||
}
|
||||
async function waitVisible(maxMs, stepMs) {
|
||||
const until = Date.now() + maxMs; let n = 0;
|
||||
while (Date.now() < until) { if (await visible()) { log(`TXT 확인됨 (${n}회 조회)`); return true; } n += 1; await sleep(stepMs); if (n % 4 === 0) log(`TXT 대기 중… ${Math.round((until - Date.now()) / 60000)}분 남음`); }
|
||||
return false;
|
||||
}
|
||||
async function main() {
|
||||
if (!domain || (action === 'auth' && !value)) { log('CERTBOT_DOMAIN/CERTBOT_VALIDATION 이 없습니다'); process.exit(1); }
|
||||
const pendingFile = path.join(dir, `${zone}.json`);
|
||||
if (mode !== 'manual') {
|
||||
const dz = require(path.join(__dirname, '..', 'lib', 'dnszone'));
|
||||
if (action === 'auth') {
|
||||
log(`${dz.LABELS[mode] || mode} 영역 ${zone} 에 TXT ${name} 추가`);
|
||||
await dz.addRecords(zone, [{ name: rel, type: 'TXT', value, ttl: 300 }], 'certbot-hook', mode);
|
||||
try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(pendingFile, JSON.stringify({ domain: zone, name, value, mode, at: new Date().toISOString(), auto: true }), { mode: 0o644 }); } catch (_) {}
|
||||
if (!(await waitVisible(5 * 60000, 10000))) log('5분 안에 공용 DNS 에 보이지 않았지만 계속 진행합니다 (Let\'s Encrypt 가 권한 서버에 직접 묻습니다)');
|
||||
// Let's Encrypt 는 권한 네임서버에 직접 묻는다 — 레코드 전파를 조금 더 기다린다
|
||||
await sleep(20000);
|
||||
} else {
|
||||
log(`TXT ${name} 삭제`);
|
||||
try { await dz.removeRecords(zone, [{ name: rel, type: 'TXT', value, ttl: 300 }], 'certbot-hook', mode); } catch (e) { log('삭제 실패(무시): ' + e.message); }
|
||||
try { fs.unlinkSync(pendingFile); } catch (_) {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 수동 DNS: 운영자가 넣을 TXT 를 적어 두고 보일 때까지 기다린다
|
||||
if (action === 'auth') {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(pendingFile, JSON.stringify({ domain: zone, name, value, mode, at: new Date().toISOString(), waitMinutes: waitMin }), { mode: 0o644 });
|
||||
log(`DNS 에 TXT 레코드를 넣어 주세요: ${name} = ${value} (최대 ${waitMin}분 대기, 관리자 콘솔 › 설정 › SSL 에도 표시됨)`);
|
||||
const ok = await waitVisible(waitMin * 60000, 15000);
|
||||
if (!ok) { log(`${waitMin}분 안에 TXT 가 보이지 않아 중단합니다`); process.exit(1); }
|
||||
await sleep(15000); // 권한 서버 전파 여유
|
||||
} else {
|
||||
try { fs.unlinkSync(pendingFile); } catch (_) {}
|
||||
log(`인증이 끝났습니다. DNS 의 TXT ${name} 는 지워도 됩니다 (다음 갱신 때 새 값이 필요합니다)`);
|
||||
}
|
||||
}
|
||||
main().catch((e) => { log('오류: ' + (e && e.message || e)); process.exit(1); });
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
// 관리자 계정 관리: node scripts/admin.js grant <id> | revoke <id> | create <id> <name> [password] | list | bootstrap
|
||||
// bootstrap (v1.5.11→v1.6.31): 관리자 admin 을 "무작위 1회 비밀번호" 로 만든다(고정 admin/admin 폐지)(이미 있으면 그대로). 다른 방식으로 설치해 관리자 비밀번호를 모르는 서버에서 쓴다 — 첫 로그인 뒤 비밀번호 변경이 강제된다
|
||||
const auth = require('../lib/auth');
|
||||
const crypto = require('crypto');
|
||||
const [cmd, id, name, pw] = process.argv.slice(2);
|
||||
try {
|
||||
if (cmd === 'grant') console.log(auth.setRole(id, 'admin'));
|
||||
else if (cmd === 'revoke') console.log(auth.setRole(id, 'user'));
|
||||
else if (cmd === 'create') { const password = pw || crypto.randomBytes(9).toString('base64url'); const u = auth.createUser({ id, password, name: name || id, role: 'admin' }); console.log(JSON.stringify({ ...u, password }, null, 1)); }
|
||||
else if (cmd === 'list') console.table(auth.listUsers());
|
||||
else if (cmd === 'bootstrap') { const r = auth.bootstrapAdmin('cli'); console.log(r.created ? `관리자 ${auth.BOOTSTRAP_ID} 를 만들었습니다. 1회용 비밀번호(다시 표시되지 않음): ${r.password} — 첫 로그인 뒤 비밀번호 변경 필요 (v1.6.31: 고정 비밀번호 admin 은 쓰지 않습니다)` : `관리자 ${auth.BOOTSTRAP_ID} 가 이미 있습니다`); }
|
||||
else { console.log('usage: admin.js grant <id> | revoke <id> | create <id> <name> [password] | list | bootstrap'); process.exit(1); }
|
||||
} catch (e) { console.error(e.message); process.exit(1); }
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
// ============================================================================
|
||||
// 데이터 백업 (v1.6.31, 요구 29) — DATA_ROOT(설정·사용자·사이트 기록·캐시·도메인·모듈·보관소)를 tar.gz 로 묶어 DATA_ROOT/backups/ 에 둔다.
|
||||
// node scripts/backup-data.js [--keep 10] [--tag update] → backups/data-<시각>[-tag].tar.gz, 오래된 것은 keep 개만 남기고 지운다
|
||||
// node scripts/backup-data.js --list
|
||||
// 갱신 스크립트(deploy/remote-update.sh, install.sh)가 재시작 전에 한 번 부른다. 사이트 파일(SITES_ROOT)은 크기가 커서 포함하지 않는다(Git 보관·배포본이 따로 있음).
|
||||
// 복구: tar -xzf backups/<파일> -C <DATA_ROOT 의 상위> (서비스 중지 상태에서). 설정 파일(data/config.json)·보관소(credentials.json)·session.secret 도 들어 있으므로 백업 파일은 0600 으로 둔다.
|
||||
// ============================================================================
|
||||
const fs = require('fs'); const path = require('path'); const { execFileSync } = require('child_process');
|
||||
const DATA_ROOT = path.resolve(process.env.DATA_ROOT || path.join(__dirname, '..', 'data'));
|
||||
const argv = process.argv.slice(2); const opt = (n, d) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : d; };
|
||||
const dir = path.join(DATA_ROOT, 'backups'); fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
if (argv.includes('--list')) { for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.tar.gz')).sort()) { const st = fs.statSync(path.join(dir, f)); console.log(`${f}\t${(st.size / 1048576).toFixed(1)} MB\t${st.mtime.toISOString()}`); } process.exit(0); }
|
||||
const keep = Math.max(1, parseInt(opt('--keep', '10'), 10) || 10); const tag = opt('--tag', '');
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const out = path.join(dir, `data-${stamp}${tag ? '-' + tag : ''}.tar.gz`);
|
||||
const excludes = ['--exclude=./backups', '--exclude=./run', '--exclude=./site-i18n/_runtime/*.tmp', '--exclude=*.tmp'];
|
||||
// 읽을 수 없는 파일(다른 계정 소유 등)은 건너뛰고 나머지를 묶는다(--ignore-failed-read: 종료 코드 0, 경고만) — 건너뛴 파일은 아래에 보여 운영자가 알 수 있게 한다.
|
||||
let warn = '';
|
||||
try { const r = require('child_process').spawnSync('tar', ['-C', DATA_ROOT, ...excludes, '--ignore-failed-read', '--warning=no-file-changed', '-czf', out, '.'], { stdio: ['ignore', 'pipe', 'pipe'] }); warn = String(r.stderr || '').trim(); if (r.status !== 0 && r.status !== 1) throw new Error(warn || ('tar 종료 코드 ' + r.status)); fs.chmodSync(out, 0o600); }
|
||||
catch (e) { console.error('백업 실패: ' + String(e.message).slice(0, 300)); try { fs.unlinkSync(out); } catch (_) {} process.exit(1); }
|
||||
if (warn) console.error('백업에서 건너뛴 파일(읽기 권한 없음):\n' + warn.split('\n').filter((l) => /Cannot open|Permission denied/.test(l)).slice(0, 10).map((l) => ' ' + l.replace(/^tar: /, '')).join('\n'));
|
||||
const files = fs.readdirSync(dir).filter((x) => x.endsWith('.tar.gz')).sort(); for (const f of files.slice(0, Math.max(0, files.length - keep))) { try { fs.unlinkSync(path.join(dir, f)); } catch (_) {} }
|
||||
console.log(`백업 완료: ${out} (${(fs.statSync(out).size / 1048576).toFixed(1)} MB, 보관 ${Math.min(keep, files.length)}개)`);
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
// ============================================================================
|
||||
// 작업 서버용 "다음 작업 반영 목록" 수집기 (v1.1.6)
|
||||
//
|
||||
// 관리자가 AI 분석을 보고 "다음 작업에 반영" 으로 결정하고 반영할 내용을 적은 사용자 제안·오류를
|
||||
// 각 운영 서버에서 가져와 하나의 작업 목록(BACKLOG.md + backlog.json)으로 만든다.
|
||||
// 다음 릴리스 작업을 시작할 때 이 파일을 읽어 요구 사항에 포함한다.
|
||||
//
|
||||
// 설정: ~/.config/ai-webmaker/backlog-sources.json (권한 600)
|
||||
// [ { "name": "webmaker", "url": "https://webmaker.ibit.kr", "key": "<관리자 콘솔 › 설정 › 작업 서버 수집 키>" }, ... ]
|
||||
// 출력: $BACKLOG_DIR (기본 ~/work/ai-webmaker-backlog) 아래 BACKLOG.md, backlog.json
|
||||
// 사용: node scripts/collect-backlog.js [--mark] --mark 를 주면 가져간 글에 "수집됨" 표시를 남긴다
|
||||
// node scripts/collect-backlog.js --implemented <id,id,...> --version 1.2.0 [--note "…"]
|
||||
// → 릴리스에 반영한 항목을 각 서버에 "반영됨" 으로 표시 (목록에서 빠지고 사용자 화면에 버전과 함께 표시)
|
||||
// 주기 실행: crontab 예) 0 * * * * node /home/master/work/WebHoting_AI_Agent/scripts/collect-backlog.js --mark >> ~/.local/state/ai-webmaker-backlog.log 2>&1
|
||||
// ============================================================================
|
||||
const fs = require('fs'); const path = require('path'); const os = require('os');
|
||||
const CONF = process.env.BACKLOG_SOURCES || path.join(os.homedir(), '.config', 'ai-webmaker', 'backlog-sources.json');
|
||||
const OUT = process.env.BACKLOG_DIR || path.join(os.homedir(), 'work', 'ai-webmaker-backlog');
|
||||
const MARK = process.argv.includes('--mark');
|
||||
const feedback = require('../lib/feedback'); // backlogMarkdown 재사용 (데이터 파일은 읽지 않음)
|
||||
|
||||
const argOf = (name) => { const i = process.argv.indexOf(name); return i > 0 ? process.argv[i + 1] : null; };
|
||||
(async () => {
|
||||
let sources; try { sources = JSON.parse(fs.readFileSync(CONF, 'utf8')); } catch (e) { console.error(`설정 파일을 읽지 못했습니다: ${CONF} (${e.message})`); process.exit(1); }
|
||||
const impl = argOf('--implemented');
|
||||
if (impl) {
|
||||
// 반영 완료 표시 모드: 각 서버에 같은 ID 목록을 보낸다(그 서버에 없는 ID 는 무시됨)
|
||||
const ids = impl.split(',').map((x) => x.trim()).filter(Boolean); const version = argOf('--version') || ''; const note = argOf('--note') || '';
|
||||
for (const src of sources) { try { const r = await fetch(`${src.url.replace(/\/$/, '')}/api/service/backlog/implemented`, { method: 'POST', headers: { 'X-Backlog-Key': src.key, 'Content-Type': 'application/json' }, body: JSON.stringify({ ids, version, note }) }); const j = await r.json(); console.log(`${src.name}: ${r.ok ? `${j.marked}건 반영됨 표시 (v${version})` : '실패 — ' + (j.error || r.status)}`); } catch (e) { console.log(`${src.name}: 실패 — ${e.message}`); } }
|
||||
return;
|
||||
}
|
||||
const all = []; const report = [];
|
||||
for (const src of sources) {
|
||||
try {
|
||||
const r = await fetch(`${src.url.replace(/\/$/, '')}/api/service/backlog`, { headers: { 'X-Backlog-Key': src.key } });
|
||||
const j = await r.json(); if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
|
||||
for (const x of j.items) all.push({ ...x, server: src.name, serverUrl: src.url, serverVersion: j.version });
|
||||
report.push(`${src.name}: ${j.items.length}건 (v${j.version})`);
|
||||
if (MARK && j.items.length) await fetch(`${src.url.replace(/\/$/, '')}/api/service/backlog/collected`, { method: 'POST', headers: { 'X-Backlog-Key': src.key, 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: j.items.map((x) => x.id), by: `work-server ${os.hostname()}` }) });
|
||||
} catch (e) { report.push(`${src.name}: 실패 — ${e.message}`); }
|
||||
}
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
const at = new Date().toISOString();
|
||||
fs.writeFileSync(path.join(OUT, 'backlog.json'), JSON.stringify({ at, sources: report, items: all }, null, 1));
|
||||
const md = feedback.backlogMarkdown(all.map((x) => ({ ...x, title: `${x.title} (${x.server})` })), { server: sources.map((s) => s.name).join(', '), at });
|
||||
fs.writeFileSync(path.join(OUT, 'BACKLOG.md'), md);
|
||||
console.log(`[${at}] ${report.join(' / ')} → ${path.join(OUT, 'BACKLOG.md')} (${all.length}건)`);
|
||||
})();
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
// ============================================================================
|
||||
// 설정 파일 명령줄 도구 (v1.5.7, 요구 1·3·4)
|
||||
//
|
||||
// 설치·연동 스크립트(deploy/*.sh)와 운영자가 설정 파일(data/config.json)을 읽고 쓰는 데 쓴다. 웹 설정 페이지와 같은 규칙(lib/config.js).
|
||||
//
|
||||
// node scripts/config.js list 모든 항목의 동작 값과 출처
|
||||
// node scripts/config.js get <key> 값 하나 (스크립트에서 $(…) 로 쓰기 좋게 값만 출력)
|
||||
// node scripts/config.js set <key> <value> [<key> <value> …] 값 저장 (비밀값 항목이면 보관소에 암호화 저장)
|
||||
// node scripts/config.js unset <key> 항목을 파일에서 지움 (환경 변수·기본값으로 돌아감)
|
||||
// node scripts/config.js import-env [--unit <유닛 파일>] 환경 변수(또는 systemd 유닛의 Environment= 줄)에서 아직 없는 항목을 옮겨 적음
|
||||
// node scripts/config.js path 설정 파일 경로
|
||||
// node scripts/config.js setup-token 초기 설정 페이지 접속 토큰(없으면 만듦) — 설치 스크립트가 접속 주소 안내에 쓴다
|
||||
// node scripts/config.js setup-url 초기 설정 페이지 접속 주소(토큰 포함) 한 줄씩 — v1.5.11 기본 http://<서버 IP>:8795/?token=…
|
||||
// node scripts/config.js check 필수 값 점검 (비어 있으면 종료 코드 2 와 안내)
|
||||
//
|
||||
// 환경 변수 DATA_ROOT / WEBMAKER_CONFIG 는 서비스와 같게 준다 (기본: 앱 폴더의 data/config.json).
|
||||
// ============================================================================
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const config = require('../lib/config');
|
||||
const argv = process.argv.slice(2); const cmd = argv[0];
|
||||
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
|
||||
|
||||
/** systemd 유닛 파일의 Environment= 줄을 객체로 */
|
||||
function envFromUnit(file) {
|
||||
const out = {}; const text = fs.readFileSync(file, 'utf8');
|
||||
for (const line of text.split('\n')) { const m = line.match(/^\s*Environment=\s*"?([A-Za-z_][A-Za-z0-9_]*)=([^"]*)"?\s*$/); if (m) out[m[1]] = m[2]; }
|
||||
return out;
|
||||
}
|
||||
function setupToken() {
|
||||
const f = path.join(DATA_ROOT, 'setup.token');
|
||||
try { const t = fs.readFileSync(f, 'utf8').trim(); if (t.length >= 24) return t; } catch (_) {}
|
||||
fs.mkdirSync(DATA_ROOT, { recursive: true }); const t = crypto.randomBytes(24).toString('base64url'); fs.writeFileSync(f, t + '\n', { mode: 0o600 }); return t;
|
||||
}
|
||||
try {
|
||||
if (cmd === 'list') { const s = config.summary(); const d = config.load(); console.log(`# ${config.FILE} ${d.exists ? `(저장 ${d.updatedAt || '-'} · ${d.updatedBy || '-'})` : '(파일 없음 — 환경 변수·기본값으로 동작)'}`); for (const [k, v] of Object.entries(s)) { const e = config.KEYS[k].type === 'secret' ? { source: 'vault' } : config.effective(k); console.log(`${k.padEnd(30)} = ${JSON.stringify(v)} [${e.source}]`); } }
|
||||
else if (cmd === 'get') { const k = argv[1]; if (!k) throw new Error('key 를 주세요'); const v = config.get(k); process.stdout.write((v === undefined || v === null ? '' : String(v)) + '\n'); }
|
||||
else if (cmd === 'set') { const patch = {}; for (let i = 1; i + 1 < argv.length; i += 2) patch[argv[i]] = argv[i + 1]; if (!Object.keys(patch).length) throw new Error('set <key> <value> …'); const r = config.set(patch, 'cli'); console.log(`저장: ${r.changed.join(', ') || '(변경 없음)'}${r.restart ? ' — 서비스를 재시작해야 적용되는 항목이 있습니다' : ''}`); }
|
||||
else if (cmd === 'unset') { if (!argv[1]) throw new Error('unset <key>'); config.unset(argv[1], 'cli'); console.log(`지움: ${argv[1]}`); }
|
||||
else if (cmd === 'import-env') { const ui = argv.indexOf('--unit'); const env = ui >= 0 ? { ...process.env, ...envFromUnit(argv[ui + 1]) } : process.env; const moved = config.migrateFromEnv(ui >= 0 ? `migrate:unit:${path.basename(argv[ui + 1])}` : 'migrate:cli', env); console.log(moved.length ? `옮김: ${moved.join(', ')}` : '옮길 항목 없음 (이미 설정 파일에 있거나 환경 변수가 비어 있음)'); console.log(`설정 파일: ${config.FILE}`); }
|
||||
else if (cmd === 'path') console.log(config.FILE);
|
||||
else if (cmd === 'setup-token') console.log(setupToken());
|
||||
else if (cmd === 'setup-url') {
|
||||
// v1.6.31 (요구 6): 쓸 수 있는 관리자가 있으면 토큰 로그인이 거절되므로 토큰 없는 주소와 "관리자 계정으로 로그인" 안내만 보인다 (토큰은 관리자가 없을 때만 유효)
|
||||
let usable = false; try { usable = !!require('../lib/auth').adminState().usable; } catch (_) {}
|
||||
if (usable) { for (const u of config.setupUrls('')) console.log(u.replace(/\?token=.*$/, '') + ' (관리자 계정으로 로그인 — 관리자가 있으면 접속 토큰은 쓰이지 않습니다)'); }
|
||||
else { const t = setupToken(); for (const u of config.setupUrls(t)) console.log(u); }
|
||||
} // v1.5.11: 바인드 주소·포트(기본 8795)에 맞춘 주소 (lib/config.setupUrls)
|
||||
else if (cmd === 'check') {
|
||||
const missing = []; for (const def of Object.values(config.KEYS)) if (def.required && !config.get(def.key)) missing.push(`${def.key} (${def.label})`);
|
||||
const warn = []; if (!config.get('source.remote')) warn.push('source.remote — 사이트 소스를 원격(Gitea)에 보관하지 않습니다 (deploy/install-gitea.sh)'); if ((config.get('mail.provider') || 'none') === 'none') warn.push('mail.provider=none — 받는 메일·웹메일 서버(Arimail)가 연결되지 않았습니다 (deploy/install-arimail.sh)');
|
||||
if (missing.length) { console.log('필수 값 없음: ' + missing.join(', ')); } for (const w of warn) console.log('참고: ' + w); if (!missing.length) console.log('필수 값 모두 있음');
|
||||
process.exit(missing.length ? 2 : 0);
|
||||
}
|
||||
else { console.log(fs.readFileSync(__filename, 'utf8').split('\n').filter((l) => l.startsWith('// node')).map((l) => l.slice(3)).join('\n')); process.exit(1); }
|
||||
} catch (e) { console.error(e.message); process.exit(1); }
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
// 사용자 화면의 한국어 문구 카탈로그를 locales/ko.json 으로 내보낸다 (릴리스 때 실행해 소스에 함께 저장).
|
||||
// 사용: node scripts/i18n-extract.js → locales/ko.json (번역용 원본 파일, translation 비어 있음)
|
||||
const fs = require('fs'); const path = require('path');
|
||||
const i18n = require('../lib/i18n');
|
||||
const out = i18n.exportFile('', null);
|
||||
const f = path.join(__dirname, '..', 'locales', 'ko.json');
|
||||
fs.writeFileSync(f, JSON.stringify(out, null, 1));
|
||||
console.log(`locales/ko.json: ${out.count} entries, version ${out.sourceVersion}`);
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# 사이트 소스 버전 브랜치(sites/<ID>/...)를 옛 원격 저장소에서 새 저장소로 옮긴다 (v1.4.14, 요구 8)
|
||||
#
|
||||
# 1) 옛 원격의 refs/heads/sites/* 를 모두 받아 새 원격에 그대로 올린다 (빠른 진행만 — 새 저장소에 이미 더 새 이력이 있으면 덮지 않음)
|
||||
# 2) 이 서버의 사이트별 로컬 저장소(data/git/<ID>)는 서비스가 실제로 쓰는 최신 상태이므로, 그 main 과 변경 브랜치를 새 원격에 올리고
|
||||
# (main 은 강제 — 이 서버의 작업 폴더가 기준) origin 주소를 새 원격으로 바꾼다
|
||||
# 3) 옛 원격과 새 원격의 브랜치 목록을 비교해 빠진 것이 있으면 알린다
|
||||
# 옛 원격의 브랜치는 지우지 않는다(확인 뒤 운영자가 정리). 다시 실행해도 안전하다.
|
||||
#
|
||||
# 사용: scripts/migrate-gitstore.sh <옛 원격> <새 원격> [--prefix sites] [--data-root ./data]
|
||||
# 예: scripts/migrate-gitstore.sh git@source-webhosting:OZ-Group/WebHosting_AI_agent.git git@webmaker-gitea:webmaker/sites.git
|
||||
# ============================================================================
|
||||
set -euo pipefail
|
||||
OLD=${1:?옛 원격 git 주소}; NEW=${2:?새 원격 git 주소}; shift 2
|
||||
PREFIX=sites; DATA_ROOT=$(cd "$(dirname "$0")/.." && pwd)/data
|
||||
while [ $# -gt 0 ]; do case "$1" in --prefix) PREFIX=$2; shift 2;; --data-root) DATA_ROOT=$2; shift 2;; *) echo "알 수 없는 옵션: $1"; exit 1;; esac; done
|
||||
export GIT_SSH_COMMAND='ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15'
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
|
||||
echo "== 1/3 옛 원격 $OLD 의 $PREFIX/* 브랜치 받기"
|
||||
git init -q --bare "$TMP/mirror"
|
||||
git -C "$TMP/mirror" fetch -q "$OLD" "+refs/heads/$PREFIX/*:refs/heads/$PREFIX/*" 2>&1 | grep -v '^From ' || true
|
||||
N=$(git -C "$TMP/mirror" for-each-ref --format='%(refname)' "refs/heads/$PREFIX/" | wc -l)
|
||||
echo " 브랜치 $N 개"
|
||||
if [ "$N" -gt 0 ]; then
|
||||
echo "== 2/3 새 원격 $NEW 에 올리기 (빠른 진행만)"
|
||||
# 한 번에 너무 많으면 ssh 명령줄이 길어지므로 40개씩 묶어 올린다
|
||||
git -C "$TMP/mirror" for-each-ref --format='%(refname)' "refs/heads/$PREFIX/" > "$TMP/refs.txt"
|
||||
split -l 40 "$TMP/refs.txt" "$TMP/chunk."
|
||||
for c in "$TMP"/chunk.*; do
|
||||
specs=(); while read -r r; do [ -n "$r" ] && specs+=("$r:$r"); done < "$c"
|
||||
git -C "$TMP/mirror" push -q "$NEW" "${specs[@]}" 2>&1 | grep -vE '^To |^ \* |^$|^remote:' || true
|
||||
done
|
||||
fi
|
||||
# 이 서버의 로컬 사이트 저장소: 최신 상태를 새 원격에 (main 강제), origin 을 새 원격으로
|
||||
# 다른 서버에서 복사해 온 저장소는 core.worktree 가 그 서버 경로(/var/www/sites/…)라 git 이 "Invalid path" 로 멈춘다 →
|
||||
# 여기서는 작업 트리가 필요 없으므로 임시 빈 폴더를 GIT_WORK_TREE 로 준다 (서비스는 사용 때마다 alignRepo 로 바로잡는다)
|
||||
if [ -d "$DATA_ROOT/git" ]; then
|
||||
echo "== 2b 로컬 사이트 저장소 $DATA_ROOT/git/* → $NEW (origin 변경)"
|
||||
mkdir -p "$TMP/wt"; export GIT_WORK_TREE="$TMP/wt"
|
||||
for g in "$DATA_ROOT"/git/*/; do
|
||||
id=$(basename "$g"); [ -f "$g/HEAD" ] || continue
|
||||
git --git-dir="$g" remote set-url origin "$NEW" 2>/dev/null || git --git-dir="$g" remote add origin "$NEW" 2>/dev/null || { echo " $id: origin 설정 실패"; continue; }
|
||||
if git --git-dir="$g" rev-parse -q --verify main >/dev/null 2>&1; then
|
||||
refs="+refs/heads/main:refs/heads/$PREFIX/$id/main"
|
||||
for b in $(git --git-dir="$g" for-each-ref --format='%(refname:short)' "refs/heads/$PREFIX/$id/changes/"); do refs="$refs $b:refs/heads/$b"; done
|
||||
if git --git-dir="$g" push -q origin $refs 2>/dev/null; then echo " $id: main + 변경 브랜치 올림"; else echo " $id: 일부 푸시 실패 (옛 저장소와 다른 커밋의 변경 브랜치 — 서비스가 다음 변경 때 다시 시도)"; fi
|
||||
fi
|
||||
done
|
||||
unset GIT_WORK_TREE
|
||||
fi
|
||||
echo "== 3/3 확인"
|
||||
git ls-remote --heads "$OLD" "refs/heads/$PREFIX/*" 2>/dev/null | awk '{print $2}' | sort > "$TMP/old.txt" || true
|
||||
git ls-remote --heads "$NEW" "refs/heads/$PREFIX/*" 2>/dev/null | awk '{print $2}' | sort > "$TMP/new.txt" || true
|
||||
MISSING=$(comm -23 "$TMP/old.txt" "$TMP/new.txt" | wc -l)
|
||||
echo " 옛 원격 $(wc -l < "$TMP/old.txt")개 · 새 원격 $(wc -l < "$TMP/new.txt")개 · 새 원격에 없는 옛 브랜치 $MISSING 개"
|
||||
[ "$MISSING" = 0 ] || comm -23 "$TMP/old.txt" "$TMP/new.txt" | sed 's/^/ 없음: /'
|
||||
echo " 옛 원격의 브랜치는 그대로 두었습니다. 새 원격을 확인한 뒤 지우려면: git push $OLD --delete <브랜치…>"
|
||||
@@ -0,0 +1,105 @@
|
||||
# 사이트 런타임 문구 카탈로그 원본 (v1.3.16) — 이 파일을 고치고 실행하면 locales/site/ko.json, en.json 이 다시 만들어진다.
|
||||
# 형식: (id, 그룹, 한국어 원문, 영어) — {이름} 은 자리 표시(번역문에도 그대로 둔다)
|
||||
import json, collections
|
||||
M = [
|
||||
# 공통
|
||||
('common.all','common','전체','All'), ('common.list','common','목록','List'), ('common.delete','common','삭제','Delete'), ('common.confirm','common','확인','OK'),
|
||||
('common.loading','common','불러오는 중…','Loading…'), ('common.sending','common','보내는 중…','Sending…'), ('common.send','common','보내기','Send'), ('common.submit','common','제출','Submit'),
|
||||
('common.register','common','등록','Post'), ('common.cancel','common','취소','Cancel'), ('common.name','form','이름','Name'), ('common.nameOptional','form','이름(선택)','Name (optional)'),
|
||||
('common.email','form','이메일','Email'), ('common.password','form','비밀번호','Password'), ('common.phone','form','연락처','Phone'), ('common.subject','form','제목','Subject'),
|
||||
('common.content','form','내용','Message'), ('common.date','form','날짜','Date'), ('common.category','form','분류','Category'), ('common.other','common','기타','Other'), ('common.search','nav','검색','Search'),
|
||||
('common.requiredCheck','error','필수 항목을 확인해 주세요.','Please check the required fields.'), ('common.received','notice','접수되었습니다.','Received.'),
|
||||
('common.receivedContact','notice','접수되었습니다. 곧 연락드리겠습니다.','Thank you. We will get back to you soon.'), ('common.tooMany','error','요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.','Too many requests. Please try again later.'),
|
||||
('common.sendFailed','error','전송에 실패했습니다. 잠시 후 다시 시도해 주세요.','Sending failed. Please try again later.'), ('common.networkFailed','error','전송에 실패했습니다. 네트워크를 확인해 주세요.','Sending failed. Please check your network.'),
|
||||
('common.siteNotFound','error','사이트를 찾을 수 없습니다','Site not found'), ('common.originDenied','error','허용되지 않은 출처입니다','This origin is not allowed'),
|
||||
# 회원
|
||||
('members.welcome','members','{name}님 환영합니다.','Welcome, {name}.'), ('members.logout','members','로그아웃','Log out'), ('members.login','members','로그인','Log in'), ('members.signup','members','회원가입','Sign up'),
|
||||
('members.join','members','가입','Join'), ('members.toLogin','members','로그인으로','Back to log in'), ('members.passwordMin6','members','비밀번호 (6자 이상)','Password (6+ characters)'),
|
||||
('members.pending','members','가입 신청이 접수되었습니다. 관리자 승인 후 로그인할 수 있습니다.','Your sign-up request was received. You can log in after an administrator approves it.'),
|
||||
('members.loginRequired','members','회원 로그인 후 볼 수 있습니다.','Please log in to view this.'),
|
||||
# 게시판
|
||||
('board.title','board','게시판','Board'), ('board.write','board','글쓰기','Write'), ('board.author','board','글쓴이','Author'), ('board.views','board','조회','Views'),
|
||||
('board.metaViews','board','· 조회 {n}','· {n} views'), ('board.metaComments','board','· 댓글 {n}','· {n} comments'), ('board.empty','board','아직 글이 없습니다.','No posts yet.'),
|
||||
('board.secret','board','비밀글','Private post'), ('board.secretAskPw','board','비밀글입니다. 작성 시 입력한 비밀번호를 입력하세요.','This is a private post. Enter the password used when it was written.'),
|
||||
('board.secretOnlyOwner','board','비밀글입니다. 작성자 또는 관리자만 볼 수 있습니다.','This is a private post. Only the author or an administrator can view it.'),
|
||||
('board.reply','board','답글','Reply'), ('board.replyPlaceholder','board','답글을 입력하세요','Write a reply'), ('board.commentPlaceholder','board','댓글을 입력하세요','Write a comment'),
|
||||
('board.commentPw','board','댓글 비밀번호','Comment password'), ('board.comments','board','댓글 {n}','Comments {n}'), ('board.commentSecret','board','비밀 댓글','Private comment'), ('board.commentSubmit','board','댓글 등록','Post comment'),
|
||||
('board.guestPw','board','비밀번호(수정·삭제용, 4자 이상)','Password (for edit/delete, 4+ characters)'), ('board.guestPwDelete','board','비밀번호(삭제용)','Password (for delete)'),
|
||||
('board.deleteWithPw','board','삭제(비밀번호)','Delete (password)'), ('board.deleteAskPw','board','삭제하려면 글 비밀번호를 입력하세요','Enter the post password to delete it'), ('board.deleteConfirm','board','이 글을 삭제할까요?','Delete this post?'),
|
||||
# 문의
|
||||
('inquiry.title','inquiry','문의하기','Contact us'), ('inquiry.button','inquiry','문의 보내기','Send inquiry'), ('inquiry.type','inquiry','문의 유형','Inquiry type'), ('inquiry.files','inquiry','첨부파일','Attachments'),
|
||||
('inquiry.fileTooBig','error','첨부파일이 너무 큽니다 (최대 {mb}MB)','Attachment is too large (max {mb} MB)'),
|
||||
('consent.privacy','consent','문의 처리를 위한 개인정보 수집·이용에 동의합니다.','I agree to the collection and use of my personal information to handle this inquiry.'),
|
||||
# 예약
|
||||
('booking.title','booking','예약하기','Book now'), ('booking.note','booking','요청 사항','Requests'), ('booking.contactPh','booking','전화 또는 이메일','Phone or email'), ('booking.submit','booking','예약 신청','Request booking'),
|
||||
('booking.closedDate','booking','예약할 수 없는 날짜입니다.','Bookings are not available on this date.'), ('booking.noSlots','booking','이 날은 예약 시간대가 없습니다.','There are no time slots on this day.'),
|
||||
('booking.seatsLeft','booking','({n}자리)','({n} left)'), ('booking.confirmed','booking','예약이 확정되었습니다.','Your booking is confirmed.'), ('booking.received','booking','예약이 접수되었습니다. 확인 후 연락드리겠습니다.','Your booking request was received. We will contact you after confirming it.'),
|
||||
# 갤러리·지도·후기·FAQ·설문
|
||||
('gallery.title','gallery','갤러리','Gallery'), ('gallery.empty','gallery','등록된 이미지가 없습니다.','No images yet.'),
|
||||
('map.notFound','map','주소를 찾지 못했습니다: {address}','Address not found: {address}'), ('map.loadFailed','map','지도를 불러오지 못했습니다.','Could not load the map.'), ('map.noAddress','map','주소가 설정되지 않았습니다.','No address is set.'),
|
||||
('reviews.title','reviews','고객 후기','Reviews'), ('reviews.write','reviews','후기 남기기','Write a review'), ('reviews.placeholder','reviews','후기를 남겨 주세요','Share your experience'), ('reviews.empty','reviews','아직 후기가 없습니다.','No reviews yet.'),
|
||||
('reviews.low','reviews','★3 이하','★3 or less'), ('reviews.pending','reviews','후기가 접수되었습니다. 확인 후 게시됩니다.','Thank you. Your review will be published after review.'), ('reviews.posted','reviews','후기가 등록되었습니다.','Your review has been posted.'),
|
||||
('faq.title','faq','자주 묻는 질문','Frequently asked questions'), ('faq.empty','faq','등록된 질문이 없습니다.','No questions yet.'), ('survey.none','survey','진행 중인 설문이 없습니다','There is no active survey'),
|
||||
# 모듈·폼 API 오류
|
||||
('err.unknownModule','error','알 수 없는 모듈','Unknown module'), ('err.noAccount','error','계정이 없습니다','Account not found'), ('err.signupClosed','error','회원가입을 받지 않습니다','Sign-up is closed'),
|
||||
('err.emailFormat','error','이메일 형식을 확인하세요','Please check the email format'), ('err.nameRequired','error','이름을 입력하세요','Please enter your name'), ('err.passwordMin6','error','비밀번호는 6자 이상','Password must be at least 6 characters'),
|
||||
('err.emailTaken','error','이미 가입된 이메일입니다','This email is already registered'), ('err.loginFailed','error','이메일 또는 비밀번호가 맞지 않습니다','Incorrect email or password'), ('err.approvalPending','error','관리자 승인 대기 중인 계정입니다','This account is waiting for administrator approval'),
|
||||
('err.siteAdminInput','error','아이디와 6자 이상 비밀번호를 입력하세요','Enter an ID and a password of 6+ characters'), ('err.idTaken','error','이미 있는 아이디입니다','This ID already exists'),
|
||||
('err.noBoard','error','게시판이 없습니다','Board not found'), ('err.membersOnly','error','회원만 볼 수 있는 게시판입니다','This board is for members only'), ('err.noPost','error','글이 없습니다','Post not found'),
|
||||
('err.titleBody','error','제목과 내용을 입력하세요','Please enter a title and content'), ('err.guestPwPost','error','비회원은 수정·삭제용 비밀번호(4자 이상)를 입력하세요','Guests must enter a password (4+ characters) for edit/delete'),
|
||||
('err.noEditPerm','error','수정 권한이 없습니다','You do not have permission to edit'), ('err.noDeletePerm','error','삭제 권한이 없습니다','You do not have permission to delete'), ('err.commentRequired','error','댓글 내용을 입력하세요','Please enter a comment'),
|
||||
('err.guestPwComment','error','비회원은 삭제용 비밀번호(4자 이상)를 입력하세요','Guests must enter a password (4+ characters) for delete'), ('err.noComment','error','댓글이 없습니다','Comment not found'),
|
||||
('err.noSurvey','error','설문이 없습니다','Survey not found'), ('err.surveyClosed','error','진행 중인 설문이 아닙니다','This survey is not active'), ('err.alreadyAnswered','error','이미 응답하셨습니다','You have already responded'),
|
||||
('err.noBooking','error','예약 기능이 없습니다','Booking is not available'), ('err.dateFormat','error','날짜 형식 오류','Invalid date format'), ('err.bookingClosedDate','error','예약할 수 없는 날짜입니다','Bookings are not available on this date'),
|
||||
('err.slotRequired','error','시간대를 선택하세요','Please choose a time slot'), ('err.slotFull','error','마감된 시간대입니다','This time slot is full'), ('err.nameContact','error','이름과 연락처를 입력하세요','Please enter your name and contact'),
|
||||
('err.noReviews','error','후기 기능이 없습니다','Reviews are not available'), ('err.reviewRequired','error','후기 내용을 입력하세요','Please enter your review'), ('err.noFaq','error','FAQ 가 없습니다','FAQ not found'), ('err.noGallery','error','갤러리가 없습니다','Gallery not found'),
|
||||
('err.unknownItem','error','알 수 없는 항목','Unknown item'), ('err.cannotAddHere','error','이 항목은 여기서 추가할 수 없습니다','This item cannot be added here'), ('err.requiredFields','error','필수 항목을 입력하세요','Please fill in the required fields'), ('err.noItem','error','항목이 없습니다','Item not found'),
|
||||
('err.formName','error','이름을 입력해 주세요.','Please enter your name.'), ('err.formEmail','error','이메일 주소를 확인해 주세요.','Please check your email address.'), ('err.formMessage','error','문의 내용을 입력해 주세요.','Please enter your message.'),
|
||||
# 사이트 소유자에게 가는 알림 메일
|
||||
('mail.kindContact','mail','문의','inquiry'), ('mail.kindBooking','mail','예약 문의','booking inquiry'),
|
||||
('mail.contactSubject','mail','[{site}] {kind}{subject} — {name}','[{site}] New {kind}{subject} — {name}'), ('mail.contactIntro','mail','{site} 웹사이트로 접수된 {kind}입니다.','A new {kind} was received through the {site} website.'),
|
||||
('mail.fieldKind','mail','종류','Type'), ('mail.fieldReceivedAt','mail','접수 시각','Received at'), ('mail.fieldPage','mail','접수 페이지','Page'), ('mail.content','mail','[내용]','[Message]'),
|
||||
('mail.replyHint','mail','이 메일에 답장하면 문의자({email})에게 전달됩니다.','Reply to this email to answer the sender ({email}).'),
|
||||
('mail.surveySubject','mail','[{site}] 설문 응답: {survey} — {who}','[{site}] Survey response: {survey} — {who}'), ('mail.surveyIntro','mail','{site} 사이트의 설문 "{survey}" 에 새 응답이 접수되었습니다.','A new response was submitted to the survey "{survey}" on {site}.'),
|
||||
('mail.adminPage','mail','관리자 페이지: {url}','Admin page: {url}'),
|
||||
('mail.bookingSubject','mail','[{site}] 예약 {state}: {date} {time} — {name}','[{site}] Booking {state}: {date} {time} — {name}'), ('mail.bookingConfirmed','mail','확정','confirmed'), ('mail.bookingRequested','mail','신청','requested'),
|
||||
('mail.bookingIntro','mail','{site} 사이트에 예약이 접수되었습니다.','A booking was received on {site}.'), ('mail.fieldRequest','mail','요청','Request'), ('mail.fieldStatus','mail','상태','Status'),
|
||||
('mail.statusAuto','mail','자동 확정','Confirmed automatically'), ('mail.statusManual','mail','관리자 확인 필요','Needs administrator confirmation'),
|
||||
('mail.reviewSubject','mail','[{site}] 새 후기 {stars} — {name}','[{site}] New review {stars} — {name}'), ('mail.reviewHint','mail','관리자 페이지에서 공개 여부를 정하세요: {url}','Decide whether to publish it on the admin page: {url}'),
|
||||
('mail.inquirySubject','mail','[{site}] {category} 문의 — {name}','[{site}] {category} inquiry — {name}'),
|
||||
# v1.4.18 일정관리 캘린더
|
||||
('calendar.title','calendar','일정','Schedule'), ('calendar.today','calendar','오늘','Today'), ('calendar.prev','calendar','이전','Previous'), ('calendar.next','calendar','다음','Next'),
|
||||
('calendar.month','calendar','월간','Month'), ('calendar.week','calendar','주간','Week'), ('calendar.day','calendar','일간','Day'), ('calendar.allDay','calendar','종일','All day'),
|
||||
('calendar.empty','calendar','이 기간에는 일정이 없습니다.','There are no events in this period.'), ('calendar.more','calendar','+{n}개 더','+{n} more'),
|
||||
('calendar.lunarPrefix','calendar','음','Lunar'), ('calendar.leap','calendar','윤','Leap'), ('calendar.lunarBased','calendar','음력 기준 일정','Lunar calendar event'),
|
||||
('calendar.ganjiLabel','calendar','간지','Sexagenary'), ('calendar.termLabel','calendar','절기','Solar term'), ('calendar.location','calendar','장소','Location'),
|
||||
('calendar.unitTime','calendar','시간 단위','Timed'), ('calendar.unitDay','calendar','일 단위 ({n}일)','Daily ({n} days)'), ('calendar.unitWeek','calendar','주 단위 ({n}주)','Weekly ({n} weeks)'),
|
||||
('calendar.close','calendar','닫기','Close'), ('calendar.loadFailed','calendar','일정을 불러오지 못했습니다.','Could not load the schedule.'),
|
||||
('err.noCalendar','error','일정 기능이 없습니다','Calendar is not available'), ('err.eventDate','error','일정 날짜를 확인하세요 (양력 YYYY-MM-DD 또는 음력 연·월·일 1~30)','Please check the event date (solar YYYY-MM-DD or lunar year/month/day 1–30)'),
|
||||
('err.eventTime','error','시작 시각은 HH:MM 형식으로 입력하세요','Enter the start time as HH:MM'), ('err.rangeTooLong','error','조회 기간은 최대 {n}일입니다','The range may be at most {n} days'),
|
||||
# 법적 고지·쿠키·동의 (data-wm-legal 자리에 언어별로 채움)
|
||||
('legal.copyright','legal','© {year} {name}. 모든 권리 보유.','© {year} {name}. All rights reserved.'), ('legal.privacy','legal','개인정보처리방침','Privacy Policy'), ('legal.terms','legal','이용약관','Terms of Use'),
|
||||
('legal.cookieNotice','legal','이 사이트는 서비스 제공과 이용 통계를 위해 쿠키를 사용합니다.','This site uses cookies to provide its services and measure usage.'), ('legal.cookieAccept','legal','동의','Accept'), ('legal.cookieDecline','legal','거부','Decline'),
|
||||
('legal.businessInfo','legal','상호 {name} · 대표 {owner} · 사업자등록번호 {bizNo}','{name} · Representative {owner} · Business registration no. {bizNo}'),
|
||||
('lang.label','nav','언어','Language'),
|
||||
('err.loginRequired','error','로그인이 필요합니다','Please log in'), ('err.inquiryOff','error','문의 기능이 꺼져 있습니다','Inquiries are turned off'), ('err.surveyOff','error','설문 기능이 꺼져 있습니다','Surveys are turned off'),
|
||||
('err.bookingOff','error','예약 기능이 꺼져 있습니다','Booking is turned off'), ('err.tryLater','error','잠시 후 다시 시도하세요','Please try again later'), ('err.addressNotFound','error','주소를 찾지 못했습니다','Address not found'),
|
||||
('mail.attachments','mail','[첨부] {files}','[Attachments] {files}'),
|
||||
# v1.4.0 블로그·자료실
|
||||
('blog.title','blog','블로그','Blog'), ('blog.searchPh','blog','블로그 글 검색','Search posts'), ('blog.empty','blog','아직 글이 없습니다.','No posts yet.'), ('blog.noResults','blog','검색 결과가 없습니다.','No results found.'),
|
||||
('blog.readMore','blog','더 보기','Read more'), ('blog.back','blog','목록으로','Back to list'), ('blog.views','blog','조회 {n}','{n} views'), ('blog.comments','blog','댓글 {n}','Comments ({n})'),
|
||||
('blog.commentPh','blog','댓글을 남겨 주세요','Leave a comment'), ('blog.commentSubmit','blog','댓글 남기기','Post comment'), ('blog.commentPending','blog','댓글이 접수되었습니다. 관리자 확인 후 보입니다.','Thanks! Your comment will appear after review.'),
|
||||
('blog.commentPosted','blog','댓글이 등록되었습니다.','Your comment has been posted.'), ('blog.commentsClosed','blog','댓글을 받지 않는 글입니다.','Comments are closed.'), ('blog.tagAll','blog','모든 태그','All tags'),
|
||||
('library.title','library','자료실','Downloads'), ('library.searchPh','library','자료 검색','Search files'), ('library.empty','library','등록된 자료가 없습니다.','No files yet.'), ('library.download','library','내려받기','Download'),
|
||||
('library.downloads','library','내려받음 {n}','{n} downloads'), ('library.memberOnly','library','회원만 내려받을 수 있습니다. 로그인해 주세요.','Please log in to download.'),
|
||||
('err.noBlog','error','블로그가 없습니다','Blog not found'), ('err.noLibrary','error','자료실이 없습니다','Downloads not found'), ('err.fileMissing','error','파일을 찾을 수 없습니다','File not found'), ('err.commentsClosed','error','댓글을 받지 않습니다','Comments are closed'),
|
||||
('contact.messageLabel','inquiry','문의 내용','Message'), ('contact.bookingMessageLabel','booking','예약 희망 일시와 요청 사항','Preferred date/time and requests'), ('contact.bookingButton','booking','예약 문의 보내기','Send booking request'),
|
||||
]
|
||||
ids = [m[0] for m in M]; assert len(ids) == len(set(ids)), [i for i in ids if ids.count(i) > 1]
|
||||
GROUP = {'common':'공통','form':'폼 입력','nav':'내비게이션','error':'오류 메시지','notice':'안내','members':'회원','board':'게시판','inquiry':'문의','consent':'동의문','booking':'예약','gallery':'갤러리','map':'지도','reviews':'후기','faq':'FAQ','survey':'설문','blog':'블로그','library':'자료실','calendar':'일정관리','mail':'메일 알림','legal':'법적 고지·쿠키'}
|
||||
ko = collections.OrderedDict(); en = collections.OrderedDict()
|
||||
for i, g, k, e in M: ko[i] = {'text': k, 'group': GROUP[g]}; en[i] = e
|
||||
head = {'about': '생성 사이트의 시스템 문구(모듈 위젯·폼·오류·동의문·예약·문의·메일 알림·법적 고지) 원본. 코드에는 id 만 쓰고 글자는 이 파일에서 가져온다(lib/sitemsg.js). 번역은 locales/site/<언어>.json(id → 번역문) 과 data/site-i18n/_runtime/<언어>.json(AI 번역 캐시)에 둔다. {이름} 자리 표시는 번역문에도 그대로 둔다.', 'formatVersion': 1}
|
||||
json.dump({**head, 'count': len(ko), 'messages': ko}, open('locales/site/ko.json', 'w'), ensure_ascii=False, indent=1)
|
||||
json.dump({'lang': 'en', 'formatVersion': 1, 'messages': en}, open('locales/site/en.json', 'w'), ensure_ascii=False, indent=1)
|
||||
print(len(ko), 'messages')
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
// ============================================================================
|
||||
// 와일드카드 인증서 감지·적용 명령줄 도구 (v1.5.17, 요구 1)
|
||||
//
|
||||
// 서버를 설치할 때 이미 *.<플랫폼 도메인> 인증서를 받아 둔 경우(certbot certonly -d '*.example.com' 등, 이름 무관)를 인지해
|
||||
// 서브 도메인 사이트에 자동으로 HTTPS 를 적용하기 위한 도구. 설치·갱신 스크립트(deploy/install.sh, remote-install.sh, remote-update.sh)가
|
||||
// 마지막에 한 번 부르고, 운영자가 직접 확인할 때도 쓴다.
|
||||
//
|
||||
// node scripts/ssl-wildcard.js 감지 결과만 출력 (플랫폼 도메인 · 찾은 인증서 · 후보 목록)
|
||||
// node scripts/ssl-wildcard.js --apply 감지 뒤, HTTP 로 배포돼 있는 서브 도메인 사이트의 nginx 설정을 그 인증서로 다시 쓴다 (서비스 기동 때 autoApply 와 같음)
|
||||
//
|
||||
// 플랫폼 도메인은 관리자 설정(settings.domains.platformDomain), 비어 있으면 서비스 공개 주소에서 유도한다(webmaker.example.com → example.com).
|
||||
// 환경 변수 DATA_ROOT / SITES_ROOT 는 서비스와 같게 준다. 배포 서버 목록이 아직 없으면(설치 직후) 이 서버 자신을 local 로 보고 감지만 한다.
|
||||
// 종료 코드: 0 = 인증서 있음, 1 = 없음(또는 확인 못 함) — 스크립트에서는 || true 로 받는다.
|
||||
// ============================================================================
|
||||
const path = require('path');
|
||||
const deploy = require('../lib/deploy');
|
||||
const ssl = require('../lib/ssl');
|
||||
|
||||
(async () => {
|
||||
const apply = process.argv.includes('--apply');
|
||||
let d = await ssl.detect();
|
||||
if (!d.target && d.platformDomain) { // 배포 서버가 아직 없음(설치 직후): 이 서버를 local 로 보고 감지만
|
||||
try { d = { ...d, target: { id: null, name: '(이 서버)', type: 'local' }, cert: await deploy.findWildcardCert({ type: 'local' }, d.platformDomain), reason: '' }; } catch (e) { d.reason = e.message; }
|
||||
}
|
||||
const c = d.cert;
|
||||
console.log(`플랫폼 도메인: ${d.platformDomain || '(없음 — 서비스 공개 주소가 IP 이거나 설정이 없음)'}${d.target ? ` · 배포 서버: ${d.target.name}` : ''}`);
|
||||
if (!d.platformDomain) { console.log('와일드카드 인증서: 확인할 도메인이 없습니다. 도메인이 정해지면 설정 페이지에서 서비스 공개 주소(또는 관리자 콘솔 › 설정 › 도메인 서비스 › 플랫폼 도메인)를 넣으세요.'); process.exit(1); }
|
||||
if (!c) { console.log(`와일드카드 인증서: 확인 못 함 — ${d.reason}`); process.exit(1); }
|
||||
if (!c.hasCert) { console.log(`와일드카드 인증서 *.${d.platformDomain}: 없음 (letsencrypt 인증서 ${c.scanned || 0}개 확인) — 서브 도메인 사이트는 HTTP 로 열립니다. 관리자 콘솔 › 설정 › SSL 에서 발급하거나 서버에서 certbot 으로 *.${d.platformDomain} 인증서를 받아 두면(이름 무관) 자동 적용됩니다.`); process.exit(1); }
|
||||
console.log(`와일드카드 인증서 *.${d.platformDomain}: 있음 — 이름 ${c.certName}${c.external ? ' (서버에 미리 발급된 인증서, 서비스가 발급한 것이 아님)' : ''}, SAN ${c.sans.join(', ')}, 만료 ${c.expires}${c.daysLeft != null ? ` (${c.daysLeft}일)` : ''} → 서브 도메인 사이트에 자동 적용`);
|
||||
if (c.candidates && c.candidates.length > 1) console.log(` 후보 ${c.candidates.length}개: ${c.candidates.map((x) => `${x.name} (${x.expires})`).join(', ')}`);
|
||||
if (apply && d.target && d.target.id) { const r = await ssl.autoApply(); console.log(`적용: 서브 도메인 사이트 ${r.reconfigured}개 설정을 HTTPS 로 갱신${r.sites.length ? ' (' + r.sites.join(', ') + ')' : ''}`); }
|
||||
else if (apply) console.log('적용: 배포 서버가 아직 등록되지 않아 건너뜀 (서비스 기동 때 자동 적용됨)');
|
||||
process.exit(0);
|
||||
})().catch((e) => { console.error('오류: ' + e.message); process.exit(2); });
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
// ============================================================================
|
||||
// 서비스 유닛 보안 옵션 점검·보정 도구 (v1.5.31, 요구 4)
|
||||
//
|
||||
// 배경: webmaker.ariweb.net 처럼 다른 절차로 설치된 서비스 유닛에 NoNewPrivileges=yes 와 ProtectSystem=strict(ReadWritePaths 에 /etc/nginx 없음)가 있으면
|
||||
// 서비스 안에서 sudo 가 막히고 /etc/nginx 가 읽기 전용이라 사이트 도메인 연결(nginx 설정)·SSL 발급·기본 오류 서버 설정이 모두 실패한다(1.5.19~1.5.27 안내).
|
||||
// 운영자가 유닛 파일을 손으로 고쳐야 했으므로, 이 도구가 점검하고(기본) 원하면 보정한다(--fix).
|
||||
//
|
||||
// node scripts/unit-check.js [--unit webmaker] 점검만: 문제 옵션과 보정 방법 출력. 종료 코드 0 = 문제 없음, 1 = 문제 있음
|
||||
// node scripts/unit-check.js --fix [--unit webmaker] 보정: 유닛 파일을 고치지 않고 드롭인(override) 파일을 만든다 —
|
||||
// /etc/systemd/system/<유닛>.service.d/50-webmaker-deploy.conf
|
||||
// [Service]
|
||||
// NoNewPrivileges=no
|
||||
// ReadWritePaths=/etc/nginx /etc/letsencrypt /var/lib/letsencrypt /var/log/letsencrypt /var/tmp
|
||||
// (드롭인의 ReadWritePaths 는 원래 값에 더해지고, NoNewPrivileges 는 뒤의 값이 이긴다.) 그 뒤 daemon-reload 와 재시작은
|
||||
// 사람이 한다(실행 중인 AI 작업을 끊지 않도록). root 권한이 필요하므로 sudo 로 실행한다.
|
||||
// --dir <경로> /etc/systemd/system 대신 다른 디렉터리 (시험용)
|
||||
//
|
||||
// 유닛 이름은 --unit, 없으면 설정 파일 service.systemdUnit, 그것도 없으면 webmaker. 보정 뒤에는 설치 점검표(배포 서버 도메인 연결)와 배포 서버 연결 테스트로 확인한다.
|
||||
// ============================================================================
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const argv = process.argv.slice(2);
|
||||
const opt = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : null; };
|
||||
const fix = argv.includes('--fix');
|
||||
let unit = opt('--unit'); if (!unit) { try { unit = require('../lib/config').get('service.systemdUnit'); } catch (_) {} } unit = (unit || 'webmaker').replace(/\.service$/, '');
|
||||
const dir = opt('--dir') || '/etc/systemd/system';
|
||||
const unitFile = path.join(dir, `${unit}.service`); const dropDir = path.join(dir, `${unit}.service.d`); const dropFile = path.join(dropDir, '50-webmaker-deploy.conf');
|
||||
const NEED_RW = ['/etc/nginx', '/etc/letsencrypt', '/var/lib/letsencrypt', '/var/log/letsencrypt', '/var/tmp'];
|
||||
|
||||
/** 유닛 파일 + 드롭인들을 합쳐 유효 값을 계산한다 (NoNewPrivileges 는 마지막 값, ReadWritePaths 는 모두 더함, ProtectSystem 은 마지막 값) */
|
||||
function effective() {
|
||||
const files = [unitFile]; try { for (const f of fs.readdirSync(dropDir).filter((x) => x.endsWith('.conf')).sort()) files.push(path.join(dropDir, f)); } catch (_) {}
|
||||
const v = { nnp: null, protect: null, rw: [], files: [] };
|
||||
for (const f of files) { let t = ''; try { t = fs.readFileSync(f, 'utf8'); } catch (_) { continue; } v.files.push(f);
|
||||
for (const line of t.split('\n')) { const m = line.match(/^\s*([A-Za-z]+)\s*=\s*(.*)$/); if (!m) continue; const [, k, val] = m;
|
||||
if (k === 'NoNewPrivileges') v.nnp = /^(yes|true|1|on)$/i.test(val.trim()); else if (k === 'ProtectSystem') v.protect = val.trim(); else if (k === 'ReadWritePaths') { if (!val.trim()) v.rw = []; else v.rw.push(...val.trim().split(/\s+/)); } }
|
||||
}
|
||||
return v;
|
||||
}
|
||||
if (!fs.existsSync(unitFile)) { console.log(`유닛 파일이 없습니다: ${unitFile}`); process.exit(0); }
|
||||
const v = effective(); const problems = [];
|
||||
if (v.nnp === true) problems.push({ key: 'nnp', text: `NoNewPrivileges=yes — 서비스 안에서 sudo 가 "no new privileges" 로 막혀 도메인 연결(nginx 설정)·SSL 발급·기본 오류 서버 설정이 실패합니다` });
|
||||
const strict = v.protect && /^(strict|full)$/i.test(v.protect); const missing = NEED_RW.filter((p) => !v.rw.some((r) => r === p || p.startsWith(r.replace(/\/$/, '') + '/')));
|
||||
if (strict && missing.length) problems.push({ key: 'rw', text: `ProtectSystem=${v.protect} 인데 ReadWritePaths 에 ${missing.join(' ')} 이(가) 없음 — sudo 가 되어도 그 경로가 읽기 전용이라 nginx 설정·certbot 이 실패합니다` });
|
||||
console.log(`유닛 ${unit} (${v.files.join(', ')})`);
|
||||
if (!problems.length) { console.log('배포에 필요한 권한 옵션: 문제 없음 (NoNewPrivileges 없음/no' + (strict ? `, ProtectSystem=${v.protect} + ReadWritePaths 충분` : '') + ')'); process.exit(0); }
|
||||
for (const p of problems) console.log(' 문제: ' + p.text);
|
||||
if (!fix) {
|
||||
console.log(` 보정: sudo node ${path.relative(process.cwd(), __filename) || __filename} --fix --unit ${unit} (드롭인 ${dropFile} 생성) → sudo systemctl daemon-reload && sudo systemctl restart ${unit} (AI 작업이 없을 때)`);
|
||||
process.exit(1);
|
||||
}
|
||||
const lines = ['[Service]', '# webmaker (v1.5.31): 배포(nginx 설정·certbot)에 필요한 권한 — scripts/unit-check.js --fix 가 만든 드롭인. 지우면 원래 유닛 값으로 돌아간다'];
|
||||
if (v.nnp === true) lines.push('NoNewPrivileges=no');
|
||||
if (strict && missing.length) lines.push('ReadWritePaths=' + missing.join(' '));
|
||||
try { fs.mkdirSync(dropDir, { recursive: true }); fs.writeFileSync(dropFile, lines.join('\n') + '\n', { mode: 0o644 }); }
|
||||
catch (e) { console.log(` 드롭인 쓰기 실패: ${e.message} (sudo 로 실행하세요)`); process.exit(2); }
|
||||
console.log(` 드롭인 작성: ${dropFile}\n${lines.map((l) => ' ' + l).join('\n')}\n 다음: sudo systemctl daemon-reload && sudo systemctl restart ${unit} (실행 중인 AI 작업이 없을 때)`);
|
||||
process.exit(0);
|
||||
Reference in New Issue
Block a user