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:
zzabisagent-lab
2026-10-26 11:02:58 +00:00
co-authored by Claude Fable 5.1
parent 9ca4cee341
commit c7aea8d419
295 changed files with 90146 additions and 0 deletions
+77
View File
@@ -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); });
+15
View File
@@ -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); }
+24
View File
@@ -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)}개)`);
+50
View File
@@ -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}건)`);
})();
+59
View File
@@ -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); }
+10
View File
@@ -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}`);
+59
View File
@@ -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')
+37
View File
@@ -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); });
+60
View File
@@ -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);