사이트 목록·생성·상세, 관리형/외부 배포, 배포 방식 변경, 도메인·HTTPS, Source 옵션과 Webmaker 도입 수정(SSO, 프로젝트↔사이트 구분, 삭제 영향 확인, 동시 편집 충돌, 발행 경로) (§7~§12·§0.2)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
a1cde81332
commit
b1c06a0304
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* portal/domains/api.mjs — 도메인 라우트. 화면 /domains, /domains/connect, /domains/:id · API /api/domains(GET,POST) /api/domains/:id(GET,PATCH,DELETE) /records /check(POST) /primary(POST) /jobs/:jobId/:action
|
||||
* 운영자: /internal/ops/domains (전체 도메인·반복 실패·만료). 권한: 도메인 변경은 사이트 권한 domain_mail(그룹 admin 이상 포함).
|
||||
*/
|
||||
import { requireGroup, requireSitePerm, requireRole } from '../auth/guard.mjs';
|
||||
import { HttpError } from '../../lib/http.mjs';
|
||||
import { verifyCtx } from '../../lib/serviceauth.mjs';
|
||||
import { describe as describeJob } from '../../lib/jobs.mjs';
|
||||
import { createDomainsService } from './service.mjs';
|
||||
import * as ui from './ui.mjs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
export async function register(app, deps) {
|
||||
const domains = createDomainsService(deps); deps.domains = domains;
|
||||
app.static('/ui/domains', join(HERE, 'public'));
|
||||
const domainOf = (ctx, perm = 'view') => { requireGroup(ctx); const d = domains.getForGroup(ctx.group.id, ctx.params.id); if (!d) throw new HttpError(404, 'not_found', '도메인을 찾을 수 없습니다'); if (perm === 'view') return d; if (d.site_id) { const s = deps.store.get('SELECT * FROM sites WHERE id=?', d.site_id); if (s) requireSitePerm(ctx, s, perm); } else requireRole(ctx, 'admin'); return d; };
|
||||
app.get('/domains', (ctx) => { requireGroup(ctx); return ctx.html(ui.listPage(ctx, deps)); });
|
||||
app.get('/domains/connect', (ctx) => { requireGroup(ctx); return ctx.html(ui.connectPage(ctx, deps, { siteId: ctx.query.site || '' })); });
|
||||
app.get('/domains/:id', (ctx) => ctx.html(ui.detailPage(ctx, deps, domainOf(ctx))));
|
||||
app.get('/api/domains', (ctx) => { requireGroup(ctx); return { ok: true, domains: domains.list(ctx.group.id, { siteId: ctx.query.site || null, mailServiceId: ctx.query.mail || null }) }; });
|
||||
app.post('/api/domains', async (ctx) => { requireGroup(ctx); const b = await ctx.body(); if (b.siteId) { const s = deps.store.get('SELECT * FROM sites WHERE id=?', b.siteId); if (!s || s.group_id !== ctx.group.id) throw new HttpError(404, 'not_found', '사이트를 찾을 수 없습니다'); requireSitePerm(ctx, s, 'domain_mail'); } else requireRole(ctx, 'member'); const d = domains.add({ group: ctx.group, user: ctx.user, ip: ctx.ip }, b); const job = b.startCheck !== false ? domains.startCheck(d, ctx.user, { makePrimary: !!b.makePrimary }) : null; return { ok: true, domain: domains.detail(domains.get(d.id)), records: await domains.requiredRecords(d), job }; });
|
||||
app.get('/api/domains/:id', async (ctx) => { const d = domainOf(ctx); return { ok: true, domain: domains.detail(d), records: await domains.requiredRecords(d) }; });
|
||||
app.get('/api/domains/:id/records', async (ctx) => ({ ok: true, records: await domains.requiredRecords(domainOf(ctx)) }));
|
||||
app.patch('/api/domains/:id', async (ctx) => { const d = domainOf(ctx, 'domain_mail'); return { ok: true, domain: domains.detail(domains.update(d, await ctx.body(), ctx.user)) }; });
|
||||
app.delete('/api/domains/:id', (ctx) => { const d = domainOf(ctx, 'domain_mail'); return { ok: true, ...domains.release(d, ctx.user) }; });
|
||||
app.post('/api/domains/:id/check', async (ctx) => { const d = domainOf(ctx, 'domain_mail'); const b = await ctx.body(); return { ok: true, job: domains.startCheck(d, ctx.user, { makePrimary: !!b.makePrimary }) }; });
|
||||
app.post('/api/domains/:id/primary', async (ctx) => { const d = domainOf(ctx, 'domain_mail'); const b = await ctx.body(); return { ok: true, domain: domains.detail(domains.setPrimary(d, { redirectOld: !!b.redirectOld, actor: ctx.user })) }; });
|
||||
app.post('/api/domains/:id/jobs/:jobId/:action', async (ctx) => { const d = domainOf(ctx, 'domain_mail'); const j = deps.jobs.get(ctx.params.jobId); if (!j || j.subject_id !== d.id) throw new HttpError(404, 'not_found', '작업을 찾을 수 없습니다'); const a = ctx.params.action; const r = a === 'retry' ? deps.jobs.retry(j.id, ctx.user.id) : a === 'cancel' ? deps.jobs.cancel(j.id, ctx.user.id) : a === 'resume' ? deps.jobs.resume(j.id, ctx.user.id) : null; if (!r) throw new HttpError(400, 'bad_action', '알 수 없는 동작'); return { ok: true, job: describeJob(r) }; });
|
||||
const secretOf = (id) => deps.vault.get('service:' + id);
|
||||
app.get('/internal/ops/domains', async (ctx) => { const v = await verifyCtx(ctx, (id) => (['hal', 'portal'].includes(id) ? secretOf(id) : null)); if (!v.ok) throw new HttpError(401, 'service_auth_failed', v.reason); const rows = deps.store.all('SELECT * FROM domains ORDER BY updated_at DESC LIMIT 1000'); const soon = new Date(Date.now() + 14 * 86400000).toISOString(); return { ok: true, domains: rows.map((r) => ({ ...domains.summarize(domains.get(r.id)), groupId: r.group_id })), expiring: rows.filter((r) => r.cert_expires_at && r.cert_expires_at < soon).map((r) => ({ id: r.id, name: r.name, expiresAt: r.cert_expires_at })), repeatedFailures: rows.map((r) => domains.summarize(domains.get(r.id))).filter((s) => s.recentFailures >= 3) }; });
|
||||
app.post('/internal/ops/domains/sweep', async (ctx) => { const v = await verifyCtx(ctx, (id) => (['hal', 'portal'].includes(id) ? secretOf(id) : null)); if (!v.ok) throw new HttpError(401, 'service_auth_failed', v.reason); return { ok: true, ...domains.sweep() }; });
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* portal/domains/model.mjs — 도메인 영역 세부 표(중심 표 domains 는 portal/core/model.mjs).
|
||||
* domain_checks 확인 기록(소유·DNS·인증서·외부 접속)의 시계열 — 반복 실패·만료 표시(§30.1)
|
||||
* domains 표에 확인 결과 세부(dns_detail_json)·인증서 실패 사유·외부 확인 결과 열을 더한다.
|
||||
*/
|
||||
export const MIGRATIONS = [{ id: 'domains_0001', sql: `
|
||||
CREATE TABLE IF NOT EXISTS domain_checks (id TEXT PRIMARY KEY, domain_id TEXT NOT NULL, kind TEXT NOT NULL, ok INTEGER NOT NULL, detail_json TEXT, checked_at TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS domain_checks_domain ON domain_checks(domain_id, checked_at);
|
||||
ALTER TABLE domains ADD COLUMN dns_detail_json TEXT;
|
||||
ALTER TABLE domains ADD COLUMN cert_error TEXT;
|
||||
ALTER TABLE domains ADD COLUMN external_check_json TEXT;
|
||||
ALTER TABLE domains ADD COLUMN expected_ip TEXT;
|
||||
ALTER TABLE domains ADD COLUMN redirect_from TEXT;
|
||||
ALTER TABLE domains ADD COLUMN created_by TEXT;
|
||||
` }];
|
||||
@@ -0,0 +1,22 @@
|
||||
/* portal/domains/public/domains.js — 도메인 화면 스크립트(목록·연결·상세). */
|
||||
(() => {
|
||||
const $ = (s, r = document) => r.querySelector(s); const esc = aw.esc; const fmt = aw.fmt;
|
||||
const OWN = { pending: ['warning', '확인 전'], verified: ['ok', '확인됨'] }; const DNS = { pending: ['warning', '미반영'], wrong: ['failed', '잘못된 값'], ok: ['ok', '정상'], customer: ['neutral', '고객 관리'], 'n/a': ['neutral', '해당 없음'] }; const CERT = { none: ['neutral', '없음'], issuing: ['running', '발급 중'], valid: ['ok', '정상'], expiring_soon: ['warning', '만료 임박'], failed: ['failed', '실패'], customer_managed: ['neutral', '고객 관리'] }; const MDNS = { ok: ['ok', '정상'], warn: ['warning', '권장 개선'], error: ['failed', '오류'], unknown: ['neutral', '확인 전'] };
|
||||
const badge = (m, k) => { const v = m[k] || ['neutral', k || '-']; return `<span class="badge ${v[0]}">${({ ok: '●', failed: '✘', warning: '!', running: '◌', neutral: '○' })[v[0]]} ${esc(v[1])}</span>`; };
|
||||
const onErr = (el, e, retry) => aw.state(el, e.status === 403 ? 'forbidden' : e.code === 'service_unreachable' || e.status === 503 ? 'stale' : 'failed', e.message, { retry });
|
||||
async function list() { const el = $('#list'); try { const r = await aw.api('GET', '/api/domains'); if (!r.domains.length) return aw.state(el, 'empty', '연결된 도메인이 없습니다', { href: '/domains/connect', hrefLabel: '도메인 연결' }); el.innerHTML = `<table class="r"><thead><tr><th>도메인</th><th>연결</th><th>용도</th><th>소유 확인</th><th>DNS</th><th>HTTPS</th><th>메일 보안</th><th>마지막 확인</th></tr></thead><tbody>${r.domains.map((d) => `<tr><td data-l="도메인"><a href="/domains/${d.id}"><b>${esc(d.name)}</b></a>${d.isPrimary ? ' ★' : ''}${d.actionNeeded ? `<br><a href="${esc(d.actionNeeded.href)}">! ${esc(d.actionNeeded.text)}</a>` : ''}</td><td data-l="연결">${d.site ? `사이트 <a href="/sites/${d.site.id}">${esc(d.site.name)}</a>` : ''}${d.mailService ? `<br>메일 <a href="/mail/${d.mailService.id}">${esc(d.mailService.name)}</a>` : ''}${!d.site && !d.mailService ? '<span class="muted">없음</span>' : ''}</td><td data-l="용도">${esc({ web: '웹', mail: '메일', both: '웹+메일' }[d.purpose])}</td><td data-l="소유">${badge(OWN, d.ownershipState)}</td><td data-l="DNS">${badge(DNS, d.dnsState)}</td><td data-l="HTTPS">${badge(CERT, d.certState)}${d.certExpiresAt ? `<br><span class="sub">만료 ${esc(d.certExpiresAt.slice(0, 10))}</span>` : ''}</td><td data-l="메일 보안">${d.mailDnsState ? badge(MDNS, d.mailDnsState) : '-'}</td><td data-l="확인">${d.lastCheckedAt ? esc(fmt(d.lastCheckedAt)) : '-'}${d.recentFailures ? `<br><span class="err">최근 실패 ${d.recentFailures}회</span>` : ''}</td></tr>`).join('')}</tbody></table>`; } catch (e) { onErr(el, e, list); } }
|
||||
async function connect() { const f = $('#connect'); const sel = $('#siteId'); try { const r = await aw.api('GET', '/api/sites'); sel.innerHTML = '<option value="">선택 안 함</option>' + r.sites.map((s) => `<option value="${s.id}" ${f.dataset.site === s.id ? 'selected' : ''}>${esc(s.name)} (${esc(s.lifecycle)})</option>`).join(''); } catch (_) {}
|
||||
f.onsubmit = async (e) => { e.preventDefault(); f.querySelectorAll('.err').forEach((x) => { x.textContent = ''; }); const fd = new FormData(f); try { const r = await aw.api('POST', '/api/domains', { name: fd.get('name'), purpose: fd.get('purpose'), siteId: fd.get('siteId') || null, makePrimary: fd.get('makePrimary') === 'on' }); location.href = `/domains/${r.domain.id}`; } catch (err) { (f.querySelector(`[data-err="${err.detail && err.detail.field || 'form'}"]`) || f.querySelector('[data-err=form]')).innerHTML = esc(err.message) + (err.detail && err.detail.href ? ` <a href="${esc(err.detail.href)}">열기</a>` : ''); } }; }
|
||||
async function detail() { const el = $('#detail'); const id = el.dataset.domain;
|
||||
const load = async () => { try { const r = await aw.api('GET', `/api/domains/${id}`); const d = r.domain; const job = d.job;
|
||||
el.innerHTML = `<div class="cards"><div class="card"><h2>상태</h2><dl class="kv"><dt>용도</dt><dd>${esc({ web: '웹', mail: '메일', both: '웹+메일' }[d.purpose])}</dd><dt>연결</dt><dd>${d.site ? `사이트 <a href="/sites/${d.site.id}">${esc(d.site.name)}</a>${d.site.deployMode === 'external' ? ' (외부 배포 — DNS·인증서는 고객 관리)' : ''}` : '사이트 없음'}${d.mailService ? ` · 메일 <a href="/mail/${d.mailService.id}">${esc(d.mailService.name)}</a>` : ''}</dd><dt>대표 도메인</dt><dd>${d.isPrimary ? '★ 대표' : d.site ? '<button class="btn" id="btnPrimary">대표로 지정</button>' : '-'}${d.redirectFrom ? `<br><span class="sub">이전 주소 ${esc(d.redirectFrom)} 는 이 도메인으로 이동 처리</span>` : ''}</dd><dt>소유 확인</dt><dd>${badge(OWN, d.ownershipState)}</dd><dt>DNS</dt><dd>${badge(DNS, d.dnsState)}${d.dnsDetail && d.dnsDetail.got ? ` <span class="sub">지금 값: ${esc(d.dnsDetail.got.join(', ') || '없음')} / 필요 ${esc(d.dnsDetail.expected)}</span>` : ''}</dd><dt>HTTPS 인증서</dt><dd>${badge(CERT, d.certState)}${d.certExpiresAt ? ` <span class="sub">만료 ${esc(d.certExpiresAt.slice(0, 10))}</span>` : ''}${d.certError ? `<br><span class="err">${esc(d.certError)}</span>` : ''}</dd>${d.mailDnsState ? `<dt>메일 보안 레코드</dt><dd>${badge(MDNS, d.mailDnsState)} <a href="/mail?domain=${esc(d.name)}">메일 화면에서 확인</a></dd>` : ''}<dt>마지막 외부 확인</dt><dd>${d.externalCheck ? `${d.externalCheck.ok ? '정상' : '실패'} (${d.externalCheck.status || '-'}, TLS ${d.externalCheck.tls && d.externalCheck.tls.valid ? '유효' : '무효/미확인'})` : '-'}</dd><dt>마지막 확인</dt><dd>${d.lastCheckedAt ? esc(fmt(d.lastCheckedAt)) : '-'}</dd></dl><div class="actions"><button class="btn primary" id="btnCheck">다시 확인</button><button class="btn danger" id="btnRelease">연결 해제</button></div></div>
|
||||
<div class="card"><h2>필요한 DNS 값</h2><p class="hint">DNS 를 직접 관리하면 아래 값을 그대로 넣으세요. 미반영(레코드 없음)과 잘못된 값(다른 값)을 구분해 표시합니다.</p><table class="r"><thead><tr><th>종류</th><th>이름</th><th>값</th><th>용도</th><th>상태</th></tr></thead><tbody>${r.records.map((x) => `<tr><td data-l="종류">${esc(x.type)}</td><td data-l="이름"><span class="mono copy" data-action="copy" data-copy="${esc(x.name)}">${esc(x.name)}</span></td><td data-l="값">${x.value ? `<span class="mono copy" data-action="copy" data-copy="${esc(x.value)}">${esc(x.value)}</span>` : `<span class="muted">${esc(x.note || '-')}</span>`}</td><td data-l="용도">${esc(x.purpose)}</td><td data-l="상태">${esc({ ok: '정상', pending: '미반영', wrong: '잘못된 값', optional: '선택', customer: '고객 관리', unknown: '확인 전', 'n/a': '-' }[x.state] || x.state || '-')}</td></tr>`).join('')}</tbody></table></div></div>
|
||||
<div class="card"><h2>연결 진행</h2><div id="job"></div></div><div class="card"><h2>확인 기록</h2>${d.checks.length ? `<table class="r"><thead><tr><th>시각</th><th>종류</th><th>결과</th></tr></thead><tbody>${d.checks.map((c) => `<tr><td data-l="시각">${esc(fmt(c.checked_at))}</td><td data-l="종류">${esc(c.kind)}</td><td data-l="결과">${c.ok ? '✔ 성공' : '✘ 실패'} <span class="sub">${esc(JSON.stringify(c.detail || {})).slice(0, 160)}</span></td></tr>`).join('')}</tbody></table>` : '<p class="muted">기록 없음</p>'}</div>`;
|
||||
if (job) aw.job($('#job'), job, { onAction: async (a, j) => { try { await aw.api('POST', `/api/domains/${id}/jobs/${j.id}/${a === 'go' ? 'resume' : a}`); load(); } catch (e) { aw.toast(e.message); } } }); else $('#job').innerHTML = '<p class="muted">진행 중인 확인 작업이 없습니다. "다시 확인" 으로 시작하세요</p>';
|
||||
$('#btnCheck').onclick = async () => { try { await aw.api('POST', `/api/domains/${id}/check`, {}); aw.toast('확인을 시작했습니다'); load(); } catch (e) { aw.toast(e.message); } };
|
||||
const bp = $('#btnPrimary'); if (bp) bp.onclick = async () => { const redirect = confirm('기존 대표 주소를 새 대표 도메인으로 이동(301) 처리할까요?'); try { await aw.api('POST', `/api/domains/${id}/primary`, { redirectOld: redirect }); aw.toast('대표 도메인으로 지정했습니다'); load(); } catch (e) { aw.toast(e.message + (e.detail ? ` (소유 ${e.detail.ownership}, DNS ${e.detail.dns}, 인증서 ${e.detail.cert})` : '')); } };
|
||||
$('#btnRelease').onclick = async () => { if (!(await aw.confirmDanger({ title: '도메인 연결 해제', target: d.name, impacts: [d.isPrimary ? '대표 도메인이라 사이트가 "도메인 설정 필요" 상태가 됩니다' : '이 도메인으로의 접속이 끊깁니다', '인증서·DNS 상태 기록이 지워집니다(도메인 자체는 등록기관에 그대로 있음)'], typeToConfirm: d.name }))) return; try { await aw.api('DELETE', `/api/domains/${id}`); location.href = '/domains'; } catch (e) { aw.toast(e.message); } };
|
||||
return !!(job && ['queued', 'running'].includes(job.status)); } catch (e) { onErr(el, e, load); return false; } };
|
||||
if (await load()) aw.poll(load, 4000); }
|
||||
const page = document.querySelector('[data-page]')?.dataset.page; ({ 'domains-list': list, 'domains-connect': connect, 'domains-detail': detail }[page] || (() => {}))();
|
||||
})();
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* portal/domains/service.mjs — 도메인과 HTTPS 업무 규칙(개발 지시 §12·§30.1).
|
||||
*
|
||||
* 연결 흐름(§12.2)을 lib/jobs 작업 'domain.connect' 로 구현한다: 소유 확인(TXT) → DNS 반영 확인 → HTTPS 인증서 발급 → 외부 접속 확인 → (요청 시) 대표 도메인 지정.
|
||||
* 사용자가 DNS 를 직접 바꾸는 동안은 '외부 작업 대기'(AWAITING_EXTERNAL)로 멈추고 "다시 확인" 으로 이어 간다. 미반영(레코드 없음)과 잘못된 값(다른 값)을 구분한다.
|
||||
* 규칙
|
||||
* - 같은 도메인은 한 그룹에만 속한다(UNIQUE). 다른 그룹에 이미 있으면 누가 쓰는지 드러내지 않고 거부한다(§30.1·§37).
|
||||
* - 한 사이트에 여러 웹 도메인을 연결해도 운영 사이트 수는 하나(§12.3). 대표 도메인은 하나이며, DNS·HTTPS 가 정상이어야 지정할 수 있다.
|
||||
* - 유효하지 않은 인증서를 '정상 HTTPS' 로 표시하지 않는다: cert_state 는 none | issuing | valid | expiring_soon | failed | customer_managed(외부 배포).
|
||||
* - 등록기관·네임서버·레코드 자동화는 Webmaker 의 도메인 탭(SSO)에서 그대로 쓰고, 포털은 상태와 검증만 가진다(§0.1).
|
||||
*/
|
||||
import { newId, randomToken } from '../../lib/crypto.mjs';
|
||||
import { HttpError } from '../../lib/http.mjs';
|
||||
import { nowIso, parseJson, toJson } from '../../lib/db.mjs';
|
||||
import { JobError, describe as describeJob } from '../../lib/jobs.mjs';
|
||||
import { notify, resolveIncident } from '../../lib/notify.mjs';
|
||||
import { audit } from '../../lib/audit.mjs';
|
||||
|
||||
const NAME_RE = /^(?=.{4,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
||||
export const CONNECT_STEPS = [{ id: 'ownership', title: '소유 확인' }, { id: 'dns', title: 'DNS 반영 확인' }, { id: 'cert', title: 'HTTPS 인증서 발급' }, { id: 'external_check', title: '외부 접속 확인' }, { id: 'primary', title: '대표 도메인 지정' }];
|
||||
const EXPIRING_DAYS = 14;
|
||||
|
||||
export function createDomainsService(deps) {
|
||||
const { store, jobs, log = () => {} } = deps;
|
||||
const hal = () => { if (!deps.hal) throw Object.assign(new Error('HAL 연결이 설정되지 않았습니다'), { code: 'service_unreachable' }); return deps.hal; };
|
||||
const row = (r) => (r ? { ...r, dnsDetail: parseJson(r.dns_detail_json, null), externalCheck: parseJson(r.external_check_json, null), check: parseJson(r.check_json, null) } : null);
|
||||
const get = (id) => row(store.get('SELECT * FROM domains WHERE id=?', id));
|
||||
const getForGroup = (groupId, id) => { const d = get(id); return d && d.group_id === groupId ? d : null; };
|
||||
const byName = (name) => row(store.get('SELECT * FROM domains WHERE name=?', normalize(name)));
|
||||
const save = (id, patch) => { const sets = []; const vals = []; for (const [k, v] of Object.entries(patch)) { sets.push(`${k}=?`); vals.push(v && typeof v === 'object' ? toJson(v) : v); } sets.push('updated_at=?'); vals.push(nowIso()); store.run(`UPDATE domains SET ${sets.join(', ')} WHERE id=?`, ...vals, id); return get(id); };
|
||||
const record = (domainId, kind, ok, detail) => store.run('INSERT INTO domain_checks(id, domain_id, kind, ok, detail_json, checked_at) VALUES(?,?,?,?,?,?)', newId('dc_'), domainId, kind, ok ? 1 : 0, toJson(detail || null), nowIso());
|
||||
function normalize(name) { return String(name || '').trim().toLowerCase().replace(/\.$/, '').replace(/^https?:\/\//, '').split('/')[0]; }
|
||||
/** 그룹의 도메인 목록(§12.1). site 로 좁힐 수 있다 */
|
||||
function list(groupId, { siteId = null, mailServiceId = null } = {}) {
|
||||
const rows = store.all(`SELECT * FROM domains WHERE group_id=? ${siteId ? 'AND site_id=?' : ''} ${mailServiceId ? 'AND mail_service_id=?' : ''} ORDER BY is_primary DESC, name`, ...[groupId, ...(siteId ? [siteId] : []), ...(mailServiceId ? [mailServiceId] : [])]).map(row);
|
||||
return rows.map(summarize);
|
||||
}
|
||||
function summarize(d) {
|
||||
const site = d.site_id ? store.get('SELECT id, name, deploy_mode, lifecycle FROM sites WHERE id=?', d.site_id) : null; const ms = d.mail_service_id ? store.get('SELECT id, name, status FROM mail_services WHERE id=?', d.mail_service_id) : null;
|
||||
const fails = store.get('SELECT COUNT(*) AS n FROM domain_checks WHERE domain_id=? AND ok=0 AND checked_at>?', d.id, new Date(Date.now() - 7 * 86400000).toISOString()).n;
|
||||
return { id: d.id, name: d.name, purpose: d.purpose, site: site ? { id: site.id, name: site.name, deployMode: site.deploy_mode } : null, mailService: ms ? { id: ms.id, name: ms.name, status: ms.status } : null, isPrimary: !!d.is_primary, ownershipState: d.ownership_state, dnsState: d.dns_state, certState: d.cert_state, certExpiresAt: d.cert_expires_at, certError: d.cert_error, mailDnsState: d.mail_dns_state || (['mail', 'both'].includes(d.purpose) ? 'unknown' : null), lastCheckedAt: d.last_checked_at, recentFailures: fails, jobId: d.job_id, actionNeeded: actionFor(d) };
|
||||
}
|
||||
function actionFor(d) {
|
||||
if (d.ownership_state !== 'verified') return { code: 'ownership', text: '소유 확인 TXT 레코드를 넣고 "다시 확인"', href: `/domains/${d.id}` };
|
||||
if (d.dns_state === 'wrong') return { code: 'dns_wrong', text: 'DNS 값이 다릅니다(안내 값으로 수정)', href: `/domains/${d.id}` };
|
||||
if (d.dns_state !== 'ok') return { code: 'dns', text: 'DNS 반영 대기 중', href: `/domains/${d.id}` };
|
||||
if (d.cert_state === 'failed') return { code: 'cert', text: `인증서 발급 실패${d.cert_error ? ': ' + d.cert_error : ''}`, href: `/domains/${d.id}` };
|
||||
if (d.cert_state === 'expiring_soon') return { code: 'cert_exp', text: '인증서 만료 임박(자동 갱신 진행)', href: `/domains/${d.id}` };
|
||||
return null;
|
||||
}
|
||||
/** 필요한 DNS 값(§12.2 4단계). 사용자가 복사할 수 있는 정확한 값 */
|
||||
async function requiredRecords(d) {
|
||||
const recs = [{ type: 'TXT', name: `_ariweb-verify.${d.name}`, value: `ariweb-verify=${d.ownership_token}`, purpose: '소유 확인', state: d.ownership_state === 'verified' ? 'ok' : 'pending' }];
|
||||
if (['web', 'both'].includes(d.purpose)) {
|
||||
let ip = d.expected_ip; if (!ip && d.site_id) { const s = store.get('SELECT server_id, deploy_mode FROM sites WHERE id=?', d.site_id); if (s && s.deploy_mode === 'managed' && s.server_id) { try { const srv = (await hal().listServers({ role: 'web' })).find((x) => x.id === s.server_id); ip = srv ? srv.publicIp : null; if (ip) save(d.id, { expected_ip: ip }); } catch (_) {} } else if (s && s.deploy_mode === 'external') ip = null; }
|
||||
const siteExt = d.site_id ? store.get('SELECT deploy_mode FROM sites WHERE id=?', d.site_id)?.deploy_mode === 'external' : false;
|
||||
recs.push({ type: 'A', name: d.name, value: ip || null, purpose: '웹 접속', note: siteExt ? '외부 배포: 고객 서버의 공인 IP 를 넣으세요(포털이 값을 확인하지 않습니다)' : ip ? null : '관리형 배포를 시작하면 서버 IP 가 정해져 표시됩니다', state: d.dns_state });
|
||||
if (!d.name.startsWith('www.')) recs.push({ type: 'CNAME', name: `www.${d.name}`, value: d.name, purpose: 'www 별칭(선택)', state: 'optional' });
|
||||
}
|
||||
if (['mail', 'both'].includes(d.purpose)) { let mail = null; try { mail = deps.mailDns && deps.mailDns.recordsFor ? await deps.mailDns.recordsFor(d.name, { groupId: d.group_id, mailServiceId: d.mail_service_id }) : null; } catch (_) {} if (mail && mail.length) recs.push(...mail.map((r) => ({ ...r, purpose: r.purpose || '메일' }))); else recs.push({ type: 'MX/SPF/DKIM/DMARC', name: d.name, value: null, purpose: '메일', note: '메일 서비스 화면의 DNS 설정에서 정확한 값을 확인하세요', state: d.mail_dns_state || 'unknown' }); }
|
||||
return recs;
|
||||
}
|
||||
/** 도메인 추가(§12.2 1~2단계). 중복은 거부(다른 고객 정보 비노출) */
|
||||
function add({ group, user, ip = '' }, { name, purpose = 'web', siteId = null, mailServiceId = null }) {
|
||||
name = normalize(name); if (!NAME_RE.test(name)) throw new HttpError(400, 'bad_domain', '도메인 형식이 올바르지 않습니다 (예: example.com)', { field: 'name' });
|
||||
if (!['web', 'mail', 'both'].includes(purpose)) throw new HttpError(400, 'bad_purpose', '용도는 웹·메일·둘 다 중 하나입니다');
|
||||
if (byName(name)) { const ex = byName(name); throw new HttpError(409, 'domain_taken', ex.group_id === group.id ? '이미 등록한 도메인입니다' : '이 도메인은 이미 다른 고객에게 연결되어 있어 활성화할 수 없습니다. 소유자라면 문의하세요', { href: ex.group_id === group.id ? `/domains/${ex.id}` : null }); }
|
||||
if (['web', 'both'].includes(purpose) && siteId) { const s = store.get('SELECT id, group_id, lifecycle FROM sites WHERE id=?', siteId); if (!s || s.group_id !== group.id) throw new HttpError(404, 'not_found', '사이트를 찾을 수 없습니다'); if (s.lifecycle === 'deleted') throw new HttpError(409, 'site_deleted', '삭제된 사이트에는 연결할 수 없습니다'); }
|
||||
if (['mail', 'both'].includes(purpose) && mailServiceId) { const m = store.get('SELECT id, group_id FROM mail_services WHERE id=?', mailServiceId); if (!m || m.group_id !== group.id) throw new HttpError(404, 'not_found', '메일 서비스를 찾을 수 없습니다'); }
|
||||
const id = newId('d_'); const now = nowIso();
|
||||
store.run('INSERT INTO domains(id, group_id, name, purpose, site_id, mail_service_id, is_primary, ownership_state, ownership_token, dns_state, cert_state, dns_mode, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,0,\'pending\',?,\'pending\',\'none\',\'manual\',?,?,?)', id, group.id, name, purpose, siteId || null, mailServiceId || null, randomToken(18), user.id, now, now);
|
||||
audit(store, { actorType: 'user', actorId: user.id, groupId: group.id, subjectType: 'domain', subjectId: id, action: 'domain.add', detail: { name, purpose, siteId, mailServiceId }, ip });
|
||||
return get(id);
|
||||
}
|
||||
/** 확인 작업 시작(또는 이어서). makePrimary 면 마지막 단계에서 대표 지정 */
|
||||
function startCheck(d, user, { makePrimary = false } = {}) {
|
||||
const active = jobs.list({ subjectType: 'domain', subjectId: d.id, status: ['queued', 'running', 'awaiting_external', 'needs_user'], limit: 5 }).find((j) => j.kind === 'domain.connect');
|
||||
if (active) { if (['awaiting_external', 'needs_user'].includes(active.status)) { if (makePrimary && !active.subject.makePrimary) store.run('UPDATE jobs SET subject_json=? WHERE id=?', toJson({ ...active.subject, makePrimary: true }), active.id); jobs.resume(active.id, user?.id); } return describeJob(jobs.get(active.id)); }
|
||||
const { job } = jobs.enqueue({ kind: 'domain.connect', subject: { domainId: d.id, makePrimary }, idempotencyKey: `domain.connect:${d.id}:${jobs.list({ subjectType: 'domain', subjectId: d.id, kind: 'domain.connect', limit: 1000 }).length}`, createdBy: user?.id, groupId: d.group_id, subjectType: 'domain', subjectId: d.id, label: `도메인 연결 확인 (${d.name})`, maxAttempts: 3 });
|
||||
save(d.id, { job_id: job.id }); return describeJob(job);
|
||||
}
|
||||
/** 확인 작업에서 쓸 HAL 대상: 사이트 서버 → 아무 web 서버 */
|
||||
async function probeTarget(d) { if (d.site_id) { const s = store.get('SELECT server_id, deploy_mode, external_agent_id FROM sites WHERE id=?', d.site_id); if (s && s.deploy_mode === 'managed' && s.server_id) return { target: s.server_id, external: false, agent: null }; const a = s && s.deploy_mode === 'external' ? store.get('SELECT hal_agent_id FROM external_agents WHERE site_id=? AND revoked_at IS NULL AND status=\'connected\'', d.site_id) : null; const srv = (await hal().listServers({ role: 'web' }))[0]; return { target: srv ? srv.id : null, external: s && s.deploy_mode === 'external', agent: a ? a.hal_agent_id : null }; } const srv = (await hal().listServers({ role: 'web' }))[0]; return { target: srv ? srv.id : null, external: false, agent: null }; }
|
||||
/** 단계에 묶인 HAL 작업 보장(portal/sites/service.mjs 의 halJobFor 와 같은 규칙): 재진입 때 같은 작업, 실패 뒤 재시도 때만 새 작업 */
|
||||
async function halJobFor(ctx, job, stepId, factory) {
|
||||
const st = ctx.steps().find((x) => x.id === stepId); const prev = st && st.result && st.result.halJobId ? st.result.halJobId : null;
|
||||
const fresh = (job.steps || []).some((x) => x.id === stepId && x.state === 'failed'); // 이 단계가 실패한 뒤의 재시도면 새 HAL 작업
|
||||
if (prev && !fresh) { let hj; try { hj = await hal().getJob(prev); } catch (e) { if (e.code === 'service_unreachable') ctx.retryLater(15000, 'HAL 연결 대기(정보 확인 지연)'); throw e; } if (!['failed', 'cancelled'].includes(hj.status)) return prev; }
|
||||
const r = await factory(`portal:${job.id}:${stepId}${prev ? ':r' + job.attempt : ''}`); if (st) { st.result = { ...(st.result || {}), halJobId: r.halJobId }; ctx.flush(); } return r.halJobId;
|
||||
}
|
||||
async function awaitHal(ctx, halJobId) { let hj; try { hj = await hal().getJob(halJobId); } catch (e) { if (e.code === 'service_unreachable') ctx.retryLater(15000, 'HAL 연결 대기(정보 확인 지연)'); throw e; } if (hj.status === 'done') return hj; if (['failed', 'cancelled'].includes(hj.status)) throw new JobError(hj.errorCode || 'hal_job_failed', hj.error || '서버 작업 실패'); ctx.retryLater(5000, '서버 작업 진행 중'); }
|
||||
jobs.register('domain.connect', { title: '도메인 연결 확인', steps: CONNECT_STEPS, maxAttempts: 3, retryable: (e) => e && e.code === 'service_unreachable',
|
||||
async run(job, ctx) {
|
||||
const d0 = get(job.subject.domainId); if (!d0) throw new JobError('domain_missing', '도메인이 없습니다'); const pt = await probeTarget(d0); if (!pt.target) throw new JobError('no_server', '확인에 쓸 서버가 없습니다', { retryable: true });
|
||||
// 1 소유 확인 — TXT 값이 보일 때까지 외부 작업 대기(사용자가 DNS 를 넣는다)
|
||||
const d1 = get(d0.id);
|
||||
if (d1.ownership_state !== 'verified') { const r = await hal().createJob({ kind: 'domain.probe', target: pt.target, idempotencyKey: `portal:${job.id}:own:${job.attempt}:${Date.now() / 30000 | 0}`, payload: { name: `_ariweb-verify.${d1.name}`, expectTxt: { name: `_ariweb-verify.${d1.name}`, value: `ariweb-verify=${d1.ownership_token}` } } }); const hj = await awaitHal(ctx, r.halJobId); const ok = !!(hj.result && hj.result.txtOk); record(d1.id, 'ownership', ok, hj.result); if (!ok) { save(d1.id, { last_checked_at: nowIso(), check_json: { stage: 'ownership', detail: hj.result && hj.result.txt ? `TXT 값이 다릅니다(지금 값: ${String(hj.result.txt).slice(0, 60)})` : 'TXT 레코드가 아직 보이지 않습니다' } }); ctx.waitExternal(hj.result && hj.result.txt ? `소유 확인 TXT 값이 다릅니다 — 안내된 값으로 수정한 뒤 "다시 확인"` : `소유 확인 TXT 레코드가 아직 반영되지 않았습니다 — DNS 에 넣은 뒤 "다시 확인"`); } save(d1.id, { ownership_state: 'verified', last_checked_at: nowIso() }); }
|
||||
await ctx.step('ownership', () => ({ verified: true }));
|
||||
// 2 DNS 반영(웹 용도만). 미반영(레코드 없음) 과 잘못된 값(다른 IP) 을 구분
|
||||
const d2 = get(d0.id);
|
||||
if (['web', 'both'].includes(d2.purpose)) {
|
||||
const recs = await requiredRecords(d2); const a = recs.find((x) => x.type === 'A');
|
||||
if (pt.external) { save(d2.id, { dns_state: 'customer', dns_detail_json: toJson({ note: '외부 배포: DNS 값은 고객이 관리하며 포털이 검증하지 않습니다' }) }); }
|
||||
else if (!a.value) { save(d2.id, { dns_state: 'pending', check_json: { stage: 'dns', detail: '관리형 배포를 시작해 서버가 정해진 뒤 A 레코드 값을 안내합니다' } }); ctx.waitExternal('사이트를 먼저 관리형으로 배포하면 A 레코드 값을 안내합니다'); }
|
||||
else { const r = await hal().createJob({ kind: 'domain.probe', target: pt.target, idempotencyKey: `portal:${job.id}:dns:${job.attempt}:${Date.now() / 30000 | 0}`, payload: { name: d2.name, expectA: [a.value] } }); const hj = await awaitHal(ctx, r.halJobId); const got = (hj.result && hj.result.a) || []; const state = !got.length ? 'pending' : got.includes(a.value) ? 'ok' : 'wrong'; record(d2.id, 'dns', state === 'ok', { expected: a.value, got }); save(d2.id, { dns_state: state, dns_detail_json: toJson({ expected: a.value, got, resolvedAt: hj.result && hj.result.resolvedAt }), last_checked_at: nowIso() }); if (state !== 'ok') ctx.waitExternal(state === 'wrong' ? `A 레코드 값이 다릅니다 (지금 ${got.join(', ')} / 필요 ${a.value}) — 수정 뒤 "다시 확인"` : `A 레코드가 아직 반영되지 않았습니다 (필요 ${a.value}) — 반영 뒤 "다시 확인"`); }
|
||||
} else save(d2.id, { dns_state: 'n/a' });
|
||||
await ctx.step('dns', () => ({ state: get(d0.id).dns_state }));
|
||||
// 3 인증서(관리형 웹 도메인만). 외부 배포는 고객 관리
|
||||
const d3 = get(d0.id);
|
||||
if (['web', 'both'].includes(d3.purpose) && !pt.external && d3.site_id) {
|
||||
await ctx.step('cert', async () => { save(d3.id, { cert_state: d3.cert_state === 'valid' ? 'valid' : 'issuing' }); const email = deps.config.operatorEmail || deps.config.mailFrom; const hid = await halJobFor(ctx, job, 'cert', (key) => hal().createJob({ kind: 'domain.cert', target: pt.target, idempotencyKey: key, payload: { siteId: d3.site_id, domains: [d3.name], email } })); const hj = await awaitHal(ctx, hid); const res = hj.result || {}; const failed = (res.failed || []).find((f) => f.domain === d3.name); if (failed || !(res.issued || []).includes(d3.name)) { record(d3.id, 'cert', false, res); save(d3.id, { cert_state: 'failed', cert_error: failed ? failed.reason : '발급 결과에 도메인이 없습니다' }); throw new JobError('cert_failed', `인증서 발급 실패: ${failed ? failed.reason : '알 수 없음'}`); } record(d3.id, 'cert', true, { expiresAt: res.expiresAt }); save(d3.id, { cert_state: 'valid', cert_expires_at: res.expiresAt || null, cert_error: null }); const site = store.get('SELECT * FROM sites WHERE id=?', d3.site_id); const all = store.all('SELECT name FROM domains WHERE site_id=? AND ownership_state=\'verified\' AND purpose IN (\'web\',\'both\')', d3.site_id).map((x) => x.name); const v = await hal().createJob({ kind: 'domain.vhost', target: pt.target, idempotencyKey: `portal:${job.id}:vhost:${hid}`, payload: { siteId: d3.site_id, domains: all, primaryDomain: site.primary_domain, redirectOld: false, maintenance: !!site.maintenance } }); await awaitHal(ctx, v.halJobId); return { issued: true, expiresAt: res.expiresAt }; });
|
||||
} else { await ctx.step('cert', () => { if (pt.external) save(d3.id, { cert_state: 'customer_managed' }); return { skipped: pt.external ? '외부 배포(고객 관리)' : '웹 용도 아님' }; }); }
|
||||
// 4 외부 접속 확인
|
||||
const d4 = get(d0.id);
|
||||
if (['web', 'both'].includes(d4.purpose) && d4.site_id && store.get('SELECT current_version FROM sites WHERE id=?', d4.site_id)?.current_version) {
|
||||
await ctx.step('external_check', async () => { const target = pt.external ? pt.agent : pt.target; if (!target) throw new JobError('agent_not_connected', '외부 서버 Agent 가 연결되어 있지 않아 외부 확인을 할 수 없습니다'); const cid = await halJobFor(ctx, job, 'external_check', (key) => hal().createJob({ kind: 'site.check', target, idempotencyKey: key, payload: { siteId: d4.site_id, urls: [`https://${d4.name}/`] } })); const hj = await awaitHal(ctx, cid); const c = ((hj.result && hj.result.checks) || [])[0] || null; record(d4.id, 'external', !!(c && c.ok), c); const tlsOk = !!(c && c.tls && c.tls.valid); const patch = { external_check_json: toJson(c), last_checked_at: nowIso() }; if (!pt.external) { if (!tlsOk) { patch.cert_state = 'failed'; patch.cert_error = '외부 확인에서 인증서가 유효하지 않습니다'; } } save(d4.id, patch); if (!(c && c.ok)) throw new JobError('external_check_failed', `외부 접속 확인 실패 (${c ? c.status || '응답 없음' : '결과 없음'})`); if (!pt.external && !tlsOk) throw new JobError('tls_invalid', '인증서가 유효하지 않습니다'); return c; });
|
||||
} else await ctx.step('external_check', () => ({ skipped: '배포된 사이트가 없어 외부 확인을 건너뜀(배포 후 다시 확인)' }));
|
||||
// 5 대표 지정(요청 시)
|
||||
await ctx.step('primary', () => { const d5 = get(d0.id); if (job.subject.makePrimary && d5.site_id) { setPrimaryNow(d5, { redirectOld: false, actor: null }); return { primary: true }; } return { primary: !!d5.is_primary }; });
|
||||
const fin = get(d0.id); notify(store, { groupId: fin.group_id, category: 'domain', severity: 'info', incidentKey: `domain:${fin.id}`, title: `도메인 ${fin.name} 연결 확인 완료`, body: `소유 확인 ${fin.ownership_state} · DNS ${fin.dns_state} · 인증서 ${fin.cert_state}`, link: `/domains/${fin.id}`, subjectType: 'domain', subjectId: fin.id });
|
||||
if (fin.is_primary && fin.site_id && deps.sites) deps.sites.onPrimaryDomainVerified(fin.site_id, fin.name);
|
||||
return { ownership: fin.ownership_state, dns: fin.dns_state, cert: fin.cert_state, primary: !!fin.is_primary };
|
||||
} });
|
||||
jobs.onChange((j) => { if (j.kind !== 'domain.connect' || j.status !== 'failed') return; const d = get(j.subject.domainId); if (!d) return; notify(store, { groupId: d.group_id, category: 'domain', severity: 'warning', incidentKey: `domain:${d.id}`, title: `도메인 ${d.name} 연결 확인 실패`, body: `${j.error || ''} (문의 번호 ${j.id})`, link: `/domains/${d.id}`, subjectType: 'domain', subjectId: d.id, actionRequired: '안내 값 확인 후 다시 시도' }); });
|
||||
/** 대표 도메인 지정(§12.3). DNS·HTTPS 정상(또는 외부 배포의 고객 관리) 조건 */
|
||||
function setPrimaryNow(d, { redirectOld = false, actor = null } = {}) {
|
||||
if (!d.site_id) throw new HttpError(409, 'no_site', '사이트에 연결된 도메인만 대표로 지정할 수 있습니다');
|
||||
const siteExt = store.get('SELECT deploy_mode, server_id, maintenance FROM sites WHERE id=?', d.site_id);
|
||||
const ready = d.ownership_state === 'verified' && (siteExt.deploy_mode === 'external' ? true : d.dns_state === 'ok' && ['valid', 'expiring_soon'].includes(d.cert_state));
|
||||
if (!ready) throw new HttpError(409, 'domain_not_ready', '대표 도메인은 소유 확인·DNS·HTTPS 가 정상일 때 지정할 수 있습니다', { ownership: d.ownership_state, dns: d.dns_state, cert: d.cert_state });
|
||||
const prev = store.get('SELECT id, name FROM domains WHERE site_id=? AND is_primary=1 AND id<>?', d.site_id, d.id);
|
||||
store.tx(() => { store.run('UPDATE domains SET is_primary=0, updated_at=? WHERE site_id=?', nowIso(), d.site_id); store.run('UPDATE domains SET is_primary=1, redirect_from=?, updated_at=? WHERE id=?', redirectOld && prev ? prev.name : null, nowIso(), d.id); });
|
||||
if (siteExt.deploy_mode === 'managed' && siteExt.server_id) { const all = store.all('SELECT name FROM domains WHERE site_id=? AND ownership_state=\'verified\' AND purpose IN (\'web\',\'both\')', d.site_id).map((x) => x.name); jobs.enqueue({ kind: 'domain.vhost_apply', subject: { siteId: d.site_id, serverId: siteExt.server_id, domains: all, primaryDomain: d.name, redirectOld: !!redirectOld, maintenance: !!siteExt.maintenance }, idempotencyKey: `vhost:${d.site_id}:${d.name}:${redirectOld}:${Date.now() / 60000 | 0}`, createdBy: actor?.id, groupId: d.group_id, subjectType: 'site', subjectId: d.site_id, label: `대표 도메인 적용 (${d.name})` }); }
|
||||
audit(store, { actorType: actor ? 'user' : 'system', actorId: actor?.id, groupId: d.group_id, subjectType: 'domain', subjectId: d.id, action: 'domain.primary', detail: { name: d.name, siteId: d.site_id, redirectOld, previous: prev ? prev.name : null }, automatic: !actor });
|
||||
if (deps.sites) deps.sites.onPrimaryDomainVerified(d.site_id, d.name);
|
||||
return get(d.id);
|
||||
}
|
||||
jobs.register('domain.vhost_apply', { title: 'vhost 적용', steps: [{ id: 'vhost', title: '웹서버 설정 적용' }], maxAttempts: 3, retryable: (e) => e && e.code === 'service_unreachable', async run(job, ctx) { return ctx.step('vhost', async () => { const s = job.subject; const id = await halJobFor(ctx, job, 'vhost', (key) => hal().createJob({ kind: 'domain.vhost', target: s.serverId, idempotencyKey: key, payload: { siteId: s.siteId, domains: s.domains, primaryDomain: s.primaryDomain, redirectOld: s.redirectOld, maintenance: s.maintenance } })); return (await awaitHal(ctx, id)).result; }); } });
|
||||
/** 연결 해제(§12.1). 대표였으면 사이트는 '도메인 설정 필요' 로 */
|
||||
function release(d, actor) {
|
||||
const active = jobs.list({ subjectType: 'domain', subjectId: d.id, status: ['queued', 'running'], limit: 3 }); if (active.length) throw new HttpError(409, 'job_running', '확인 작업이 진행 중입니다. 끝난 뒤 해제하세요');
|
||||
for (const j of jobs.list({ subjectType: 'domain', subjectId: d.id, status: ['awaiting_external', 'needs_user'], limit: 5 })) { try { jobs.cancel(j.id, actor?.id); } catch (_) {} }
|
||||
const wasPrimary = !!d.is_primary; store.run('DELETE FROM domains WHERE id=?', d.id); store.run('DELETE FROM domain_checks WHERE domain_id=?', d.id);
|
||||
if (wasPrimary && d.site_id && deps.sites) deps.sites.onPrimaryDomainLost(d.site_id);
|
||||
const s = d.site_id ? store.get('SELECT deploy_mode, server_id, maintenance, primary_domain FROM sites WHERE id=?', d.site_id) : null;
|
||||
if (s && s.deploy_mode === 'managed' && s.server_id) { const all = store.all('SELECT name FROM domains WHERE site_id=? AND ownership_state=\'verified\' AND purpose IN (\'web\',\'both\')', d.site_id).map((x) => x.name); jobs.enqueue({ kind: 'domain.vhost_apply', subject: { siteId: d.site_id, serverId: s.server_id, domains: all, primaryDomain: wasPrimary ? null : s.primary_domain, redirectOld: false, maintenance: !!s.maintenance }, idempotencyKey: `vhost:${d.site_id}:release:${d.name}:${Date.now() / 60000 | 0}`, createdBy: actor?.id, groupId: d.group_id, subjectType: 'site', subjectId: d.site_id, label: `도메인 해제 적용 (${d.name})` }); }
|
||||
audit(store, { actorType: 'user', actorId: actor?.id, groupId: d.group_id, subjectType: 'domain', subjectId: d.id, action: 'domain.release', detail: { name: d.name, siteId: d.site_id, wasPrimary } });
|
||||
return { released: true, wasPrimary };
|
||||
}
|
||||
/** 용도·연결 대상 변경 */
|
||||
function update(d, patch, actor) { const p = {}; if (patch.purpose && ['web', 'mail', 'both'].includes(patch.purpose)) p.purpose = patch.purpose; if (patch.siteId !== undefined) { if (patch.siteId) { const s = store.get('SELECT id, group_id FROM sites WHERE id=?', patch.siteId); if (!s || s.group_id !== d.group_id) throw new HttpError(404, 'not_found', '사이트를 찾을 수 없습니다'); } if (d.is_primary && patch.siteId !== d.site_id) { p.is_primary = 0; if (deps.sites && d.site_id) deps.sites.onPrimaryDomainLost(d.site_id); } p.site_id = patch.siteId || null; p.dns_state = 'pending'; p.cert_state = 'none'; p.expected_ip = null; } if (patch.mailServiceId !== undefined) p.mail_service_id = patch.mailServiceId || null; if (Object.keys(p).length) { save(d.id, p); audit(store, { actorType: 'user', actorId: actor?.id, groupId: d.group_id, subjectType: 'domain', subjectId: d.id, action: 'domain.update', detail: p }); } return get(d.id); }
|
||||
/** 메일 보안 레코드 상태 갱신(메일 영역이 부른다) */
|
||||
function setMailDnsState(name, state, detail = null) { const d = byName(name); if (!d) return null; return save(d.id, { mail_dns_state: state, check_json: detail ? { ...(d.check || {}), mailDns: detail } : d.check_json }); }
|
||||
/** 주기 작업: 인증서 만료 임박 표시·갱신 작업, 오래된 확인 기록 정리 */
|
||||
function sweep(now = Date.now()) {
|
||||
const soon = new Date(now + EXPIRING_DAYS * 86400000).toISOString(); let renew = 0;
|
||||
for (const d of store.all('SELECT * FROM domains WHERE cert_state IN (\'valid\',\'expiring_soon\') AND cert_expires_at IS NOT NULL AND cert_expires_at<=?', soon).map(row)) {
|
||||
if (Date.parse(d.cert_expires_at) <= now) { save(d.id, { cert_state: 'failed', cert_error: '인증서가 만료되었습니다' }); notify(store, { groupId: d.group_id, category: 'domain', severity: 'critical', mandatory: true, incidentKey: `cert:${d.id}`, title: `도메인 ${d.name} 인증서 만료`, body: '갱신 작업을 다시 시도합니다. 실패가 계속되면 DNS 값을 확인하세요', link: `/domains/${d.id}`, subjectType: 'domain', subjectId: d.id, actionRequired: 'DNS 확인 후 다시 확인' }); }
|
||||
else if (d.cert_state !== 'expiring_soon') { save(d.id, { cert_state: 'expiring_soon' }); notify(store, { groupId: d.group_id, category: 'domain', severity: 'warning', incidentKey: `cert:${d.id}`, title: `도메인 ${d.name} 인증서 만료 임박 (${d.cert_expires_at.slice(0, 10)})`, body: '자동 갱신을 진행합니다', link: `/domains/${d.id}`, subjectType: 'domain', subjectId: d.id }); }
|
||||
const r = jobs.enqueue({ kind: 'domain.connect', subject: { domainId: d.id, makePrimary: false }, idempotencyKey: `domain.renew:${d.id}:${new Date(now).toISOString().slice(0, 10)}`, actorType: 'system', groupId: d.group_id, subjectType: 'domain', subjectId: d.id, label: `인증서 갱신 (${d.name})`, maxAttempts: 3 }); if (r.created) renew++;
|
||||
}
|
||||
store.run('DELETE FROM domain_checks WHERE checked_at<?', new Date(now - 90 * 86400000).toISOString());
|
||||
return { renew };
|
||||
}
|
||||
function detail(d) { return { ...summarize(d), ownershipToken: d.ownership_token, dnsDetail: d.dnsDetail, externalCheck: d.externalCheck, check: d.check, redirectFrom: d.redirect_from, checks: store.all('SELECT kind, ok, detail_json, checked_at FROM domain_checks WHERE domain_id=? ORDER BY checked_at DESC LIMIT 30', d.id).map((c) => ({ ...c, detail: parseJson(c.detail_json, null) })), job: d.job_id ? describeJob(jobs.get(d.job_id)) : null }; }
|
||||
return { CONNECT_STEPS, normalize, get, getForGroup, byName, list, summarize, detail, requiredRecords, add, startCheck, setPrimary: setPrimaryNow, release, update, setMailDnsState, sweep, save };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** portal/domains/ui.mjs — 도메인 화면 뼈대(목록·연결·상세). 데이터는 /ui/domains/domains.js 가 채운다. */
|
||||
import { esc } from '../../lib/http.mjs';
|
||||
import { t } from '../../lib/i18n.mjs';
|
||||
const shell = (ctx, deps, o) => deps.page({ ...o, user: ctx.user, group: ctx.group, groups: ctx.groups || [], unread: ctx.unread || 0, csrf: ctx.session ? ctx.session.csrf : '', scripts: ['/ui/domains/domains.js'] });
|
||||
export const listPage = (ctx, deps) => shell(ctx, deps, { title: t('domains.title'), nav: 'domains', crumbs: [{ label: t('nav.dashboard'), href: '/' }, { label: t('domains.title') }], actions: [{ label: t('domains.connect'), href: '/domains/connect', kind: 'primary' }, { label: '도메인 구매·DNS 자동화(Webmaker)', href: '/sso/webmaker?path=/' }], body: `<div id="list" data-page="domains-list">${deps.statePanel('loading')}</div><p class="hint">도메인 등록·갱신·네임서버·레코드 자동화는 Webmaker 의 도메인 탭에서 기존 기능 그대로 제공됩니다. 도메인 등록 기능이 제공되지 않는 환경에서는 외부 등록기관에서 구매한 도메인을 여기서 연결하세요.</p>` });
|
||||
export const connectPage = (ctx, deps, { siteId = '' } = {}) => shell(ctx, deps, { title: t('domains.connect'), nav: 'domains', crumbs: [{ label: t('domains.title'), href: '/domains' }, { label: t('domains.connect') }], body: `<form id="connect" class="card" data-page="domains-connect" data-site="${esc(siteId)}"><ol class="hint"><li>도메인 입력</li><li>웹·메일 용도 선택</li><li>소유 확인(TXT)</li><li>필요한 DNS 값 안내</li><li>DNS 반영 확인</li><li>HTTPS 인증서 발급</li><li>외부 접속 확인</li><li>대표 도메인 지정</li></ol>
|
||||
<div class="f"><label for="name">도메인</label><input id="name" name="name" placeholder="example.com" required><span class="hint">외부 등록기관에서 구매한 도메인도 연결할 수 있습니다. www 없이 입력하세요</span><span class="err" data-err="name"></span></div>
|
||||
<fieldset class="f"><legend>용도</legend><label><input type="radio" name="purpose" value="web" checked> 웹</label><label><input type="radio" name="purpose" value="mail"> 메일</label><label><input type="radio" name="purpose" value="both"> 웹 + 메일</label></fieldset>
|
||||
<div class="f"><label for="siteId">연결할 사이트</label><select id="siteId" name="siteId"><option value="">선택 안 함</option></select><span class="hint">한 사이트에 여러 도메인을 연결해도 운영 사이트 수는 하나만 씁니다</span></div>
|
||||
<div class="f"><label><input type="checkbox" name="makePrimary"> 확인이 끝나면 대표 웹 도메인으로 지정</label></div>
|
||||
<div class="actions"><button class="btn primary" type="submit">도메인 추가</button></div><div class="err" data-err="form"></div></form>` });
|
||||
export const detailPage = (ctx, deps, d) => shell(ctx, deps, { title: d.name, nav: 'domains', crumbs: [{ label: t('domains.title'), href: '/domains' }, { label: d.name }], body: `<div id="detail" data-page="domains-detail" data-domain="${esc(d.id)}">${deps.statePanel('loading')}</div>` });
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* portal/sites/api.mjs — 사이트 영역 라우트(화면 + JSON API + 내부/운영자 API).
|
||||
*
|
||||
* 화면: /sites, /sites/new, /sites/:id(?tab=), /sites/:id/deploy, /sites/:id/deploy/mode, /sites/:id/external
|
||||
* API : /api/sites (GET 목록, POST 생성) · /api/sites/:id (GET, PATCH 설정) · /api/sites/:id/overview · /versions · /precheck · /deploy(POST) · /deployments · /rollback · /cleanup
|
||||
* /api/sites/:id/mode (GET 안내, POST 선택) · /consent(POST) · /external (GET 상태, POST 토큰 발급, DELETE 해제) · /external/remove(POST) · /jobs/:jobId/(retry|cancel|resume|revert)
|
||||
* /api/sites/:id/usage · /activity
|
||||
* 내부: /internal/bundles/:token (Agent 가 저장본을 받아 감) · /internal/webmaker/project-impact (Webmaker 가 폐기 전 영향 조회, 키 id 'webmaker')
|
||||
* 운영자: /internal/ops/sites, /internal/ops/sites/:id (키 id 'hal')
|
||||
* 권한: 조회 view · 설정 edit/admin · 배포 deploy · 외부 Agent deploy · 해지 billing(다른 영역)
|
||||
*/
|
||||
import { requireGroup, requireSitePerm, requireRole } from '../auth/guard.mjs';
|
||||
import { HttpError } from '../../lib/http.mjs';
|
||||
import { verifyCtx } from '../../lib/serviceauth.mjs';
|
||||
import { listAudit } from '../../lib/audit.mjs';
|
||||
import { describe as describeJob } from '../../lib/jobs.mjs';
|
||||
import * as quota from '../../lib/quota.mjs';
|
||||
import { createSitesService } from './service.mjs';
|
||||
import * as ui from './ui.mjs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createReadStream, existsSync, statSync } from 'node:fs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
export async function register(app, deps) {
|
||||
const sites = createSitesService(deps); deps.sites = sites;
|
||||
app.static('/ui/sites', join(HERE, 'public'));
|
||||
const secretOf = (keyId) => deps.vault.get('service:' + keyId) || (keyId === 'webmaker' && deps.webmaker ? deps.webmaker.secret() : null);
|
||||
async function requireService(ctx, keyIds) { const v = await verifyCtx(ctx, (id) => (keyIds.includes(id) ? secretOf(id) : null)); if (!v.ok) throw new HttpError(401, 'service_auth_failed', `서비스 인증 실패(${v.reason})`); return v.keyId; }
|
||||
/** 현재 그룹의 사이트를 권한과 함께 가져온다(다른 그룹 사이트는 404) */
|
||||
/** 조회(view)는 그룹 구성원 누구나(viewer 포함 §4.3 '조회'), 그 밖의 권한은 guard.requireSitePerm */
|
||||
const siteOf = (ctx, perm = 'view') => { requireGroup(ctx); const s = sites.getForGroup(ctx.group.id, ctx.params.id); if (!s) throw new HttpError(404, 'not_found', '사이트를 찾을 수 없습니다'); if (perm !== 'view') requireSitePerm(ctx, s, perm); return s; };
|
||||
const pageCtx = (ctx) => { requireGroup(ctx); return ctx; };
|
||||
|
||||
// ---------- 화면 ----------
|
||||
app.get('/sites', (ctx) => ctx.html(ui.listPage(pageCtx(ctx), deps, { filter: ctx.query.filter || '' })));
|
||||
app.get('/sites/new', (ctx) => { pageCtx(ctx); requireRole(ctx, 'member'); return ctx.html(ui.newPage(ctx, deps)); });
|
||||
app.get('/sites/:id', (ctx) => ctx.html(ui.detailPage(ctx, deps, siteOf(ctx, 'view'), { tab: ctx.query.tab || 'overview' })));
|
||||
app.get('/sites/:id/deploy', (ctx) => ctx.html(ui.deployPage(ctx, deps, siteOf(ctx, 'view'))));
|
||||
app.get('/sites/:id/deploy/mode', (ctx) => ctx.html(ui.modePage(ctx, deps, siteOf(ctx, 'view'))));
|
||||
app.get('/sites/:id/external', (ctx) => ctx.html(ui.externalPage(ctx, deps, siteOf(ctx, 'view'))));
|
||||
/** Webmaker SSO 진입(별도 로그인 없음). site 를 주면 그 프로젝트로, path 로 Webmaker 안의 특정 화면 */
|
||||
app.get('/sso/webmaker', (ctx) => { requireGroup(ctx); let site = null; if (ctx.query.site) { site = sites.getForGroup(ctx.group.id, ctx.query.site); if (!site) throw new HttpError(404, 'not_found', '사이트를 찾을 수 없습니다'); requireSitePerm(ctx, site, 'edit'); } if (!deps.webmaker) throw new HttpError(503, 'service_unreachable', 'Webmaker 연동이 설정되지 않았습니다'); return ctx.redirect(deps.webmaker.ssoUrl({ user: ctx.user, group: ctx.group, site, projectId: ctx.query.project || null, path: ctx.query.path || null })); });
|
||||
|
||||
// ---------- JSON API ----------
|
||||
app.get('/api/sites', (ctx) => { requireGroup(ctx); return { ok: true, sites: sites.list(ctx.group.id, { lifecycle: ctx.query.filter || null }), siteCount: quota.figures(deps.store, ctx.group.id, 'sites') }; });
|
||||
app.post('/api/sites', async (ctx) => { requireRole(ctx, 'member'); if (!ctx.user.email_verified_at) throw new HttpError(403, 'email_unverified', '이메일 확인이 끝나야 사이트를 만들 수 있습니다'); const r = await sites.create({ user: ctx.user, group: ctx.group, ip: ctx.ip }, await ctx.body()); return { ok: true, ...r }; });
|
||||
app.get('/api/sites/:id', (ctx) => { const s = siteOf(ctx); return { ok: true, site: pub(s), summary: sites.summarize(s) }; });
|
||||
app.patch('/api/sites/:id', async (ctx) => { const s = siteOf(ctx, 'admin'); const r = sites.update(s, await ctx.body(), ctx.user); return { ok: true, site: pub(r.site), impact: r.impact, jobId: r.jobId || null }; });
|
||||
app.get('/api/sites/:id/overview', (ctx) => { const s = siteOf(ctx); let refreshed = null; try { refreshed = sites.refresh(s.group_id); } catch (_) {} return { ok: true, overview: sites.overview(s), refreshJob: refreshed ? refreshed.id : null }; });
|
||||
/** 제작 탭: Webmaker 프로젝트 요약·저장본·AI Wallet(호스팅 사용량과 분리) */
|
||||
app.get('/api/sites/:id/versions', async (ctx) => { const s = siteOf(ctx); try { const v = await sites.versionsFor(s); let wallet = null; try { wallet = deps.webmaker ? await deps.webmaker.credits(sites.userLogin(ctx.user, ctx.group)) : null; } catch (_) { wallet = { stale: true }; } return { ok: true, ...v, wallet, webmakerUrl: `/sso/webmaker?site=${s.id}` }; } catch (e) { if (e.code === 'service_unreachable') return ctx.json({ ok: false, error: 'service_unreachable', message: 'Webmaker 에 연결할 수 없습니다(정보 확인 지연)', stale: true }, 503); throw e; } });
|
||||
app.get('/api/sites/:id/precheck', async (ctx) => { const s = siteOf(ctx, 'deploy'); return { ok: true, precheck: await sites.precheck(s, { versionRef: ctx.query.version || 'current' }) }; });
|
||||
app.post('/api/sites/:id/deploy', async (ctx) => { const s = siteOf(ctx, 'deploy'); const b = await ctx.body(); const r = await sites.startDeploy({ user: ctx.user, group: ctx.group, site: s, versionRef: b.versionRef || 'current', versionLabel: b.versionLabel || null, ip: ctx.ip }); return { ok: true, ...r }; });
|
||||
app.get('/api/sites/:id/deployments', (ctx) => { const s = siteOf(ctx); return { ok: true, deployments: sites.deployments(s.id), activeJobs: sites.activeJobsFor(s.id).map(describeJob), site: pub(sites.get(s.id)) }; });
|
||||
app.post('/api/sites/:id/rollback', (ctx) => { const s = siteOf(ctx, 'deploy'); return { ok: true, job: sites.rollback(s, ctx.user) }; });
|
||||
app.post('/api/sites/:id/cleanup', (ctx) => { const s = siteOf(ctx, 'deploy'); return { ok: true, job: sites.cleanupAndReturn(s, ctx.user) }; });
|
||||
app.get('/api/sites/:id/mode', async (ctx) => { const s = siteOf(ctx); return { ok: true, ...(await modeInfo(s, ctx)) }; });
|
||||
app.post('/api/sites/:id/mode', async (ctx) => { const s = siteOf(ctx, 'deploy'); const b = await ctx.body(); const r = await sites.setDeployMode(s, b.mode, ctx.user); return { ok: true, site: pub(r.site), changed: r.changed, job: r.job || null }; });
|
||||
app.post('/api/sites/:id/consent', async (ctx) => { const s = siteOf(ctx, 'deploy'); const b = await ctx.body(); if (b.version !== sites.EXTERNAL_CONSENT.version || !b.agree) throw new HttpError(400, 'consent_required', '확인 사항에 동의해야 합니다'); return { ok: true, site: pub(sites.recordConsent(s, ctx.user, { ip: ctx.ip })) }; });
|
||||
app.get('/api/sites/:id/external', async (ctx) => { const s = siteOf(ctx); const a = await sites.refreshExternalAgent(s); return { ok: true, agent: a, consent: { version: sites.EXTERNAL_CONSENT.version, agreed: s.consent_version === sites.EXTERNAL_CONSENT.version }, lastDeploy: sites.deployments(s.id, 1)[0] || null }; });
|
||||
app.post('/api/sites/:id/external', async (ctx) => { const s = siteOf(ctx, 'deploy'); if (s.consent_version !== sites.EXTERNAL_CONSENT.version) throw new HttpError(409, 'consent_required', '외부 배포 확인 사항에 먼저 동의하세요', { href: `/sites/${s.id}/deploy/mode` }); const b = await ctx.body(); return { ok: true, ...(await sites.createExternalAgent(s, ctx.user, String(b.label || '').slice(0, 80))) }; });
|
||||
app.delete('/api/sites/:id/external', async (ctx) => { const s = siteOf(ctx, 'deploy'); await sites.revokeExternalAgent(s, ctx.user); return { ok: true }; });
|
||||
app.post('/api/sites/:id/external/remove', (ctx) => { const s = siteOf(ctx, 'deploy'); return { ok: true, job: sites.removeExternalDeploy(s, ctx.user) }; });
|
||||
app.post('/api/sites/:id/jobs/:jobId/:action', async (ctx) => { const s = siteOf(ctx, 'deploy'); const j = deps.jobs.get(ctx.params.jobId); if (!j || j.subject_id !== s.id) throw new HttpError(404, 'not_found', '작업을 찾을 수 없습니다'); const a = ctx.params.action; let r; if (a === 'retry') r = deps.jobs.retry(j.id, ctx.user.id); else if (a === 'cancel') r = deps.jobs.cancel(j.id, ctx.user.id); else if (a === 'resume') r = deps.jobs.resume(j.id, ctx.user.id); else if (a === 'revert') r = await deps.jobs.revert(j.id, ctx.user.id); else throw new HttpError(400, 'bad_action', '알 수 없는 작업 동작'); return { ok: true, job: describeJob(r) }; });
|
||||
app.get('/api/sites/:id/jobs/:jobId', (ctx) => { const s = siteOf(ctx); const j = deps.jobs.get(ctx.params.jobId); if (!j || j.subject_id !== s.id) throw new HttpError(404, 'not_found', '작업을 찾을 수 없습니다'); return { ok: true, job: describeJob(j) }; });
|
||||
app.get('/api/sites/:id/usage', (ctx) => { const s = siteOf(ctx); const o = sites.overview(s); const alloc = ['web_disk', 'web_out'].map((r) => ({ resource: r, ...quota.RESOURCES[r], allocation: quota.allocationOf(deps.store, s.group_id, r, 'site', s.id), usage: quota.usageOf(deps.store, s.group_id, r, 'site', s.id) })); return { ok: true, overview: o, allocations: alloc, samples: deps.store.all('SELECT metric, value, period, measured_at FROM usage_samples WHERE subject_type=\'site\' AND subject_id=? AND metric<>\'series_json\' ORDER BY measured_at DESC LIMIT 200', s.id) }; });
|
||||
app.get('/api/sites/:id/activity', (ctx) => { const s = siteOf(ctx); return { ok: true, items: listAudit(deps.store, { subjectType: 'site', subjectId: s.id, limit: 100 }), jobs: deps.jobs.list({ subjectType: 'site', subjectId: s.id, limit: 50 }).map(describeJob) }; });
|
||||
|
||||
// ---------- 내부 ----------
|
||||
/** 저장본 다운로드(Agent). 토큰은 1시간 유효. 비밀값 없는 tar.gz */
|
||||
app.get('/internal/bundles/:token', (ctx) => { const t = sites.bundleByToken(ctx.params.token); if (!t || !existsSync(t.file)) throw new HttpError(404, 'not_found', '번들을 찾을 수 없거나 만료되었습니다'); ctx.ended = true; ctx.res.writeHead(200, { 'Content-Type': 'application/gzip', 'Content-Length': statSync(t.file).size, 'Cache-Control': 'no-store' }); createReadStream(t.file).pipe(ctx.res); return undefined; });
|
||||
app.post('/internal/webmaker/project-impact', async (ctx) => { await requireService(ctx, ['webmaker']); const b = await ctx.body(); return { ok: true, impact: sites.impactForProject(String(b.projectId || ''), b.siteId || null) }; });
|
||||
app.post('/internal/webmaker/project-destroyed', async (ctx) => { await requireService(ctx, ['webmaker']); const b = await ctx.body(); return { ok: true, unlinked: sites.onProjectDestroyed(String(b.projectId || '')) }; });
|
||||
// ---------- 운영자(HAL 콘솔이 부른다) ----------
|
||||
app.get('/internal/ops/sites', async (ctx) => { await requireService(ctx, ['hal', 'portal']); const { groupId, lifecycle, q } = ctx.query; const w = ['lifecycle<>\'deleted\'']; const p = []; if (groupId) { w.push('group_id=?'); p.push(groupId); } if (lifecycle) { w.push('lifecycle=?'); p.push(lifecycle); } if (q) { w.push('(name LIKE ? OR primary_domain LIKE ? OR id=?)'); p.push(`%${q}%`, `%${q}%`, q); } return { ok: true, sites: deps.store.all(`SELECT * FROM sites WHERE ${w.join(' AND ')} ORDER BY updated_at DESC LIMIT 500`, ...p).map((r) => sites.summarize(sites.get(r.id))).map((s) => ({ ...s, groupId: sites.get(s.id).group_id, serverId: sites.get(s.id).server_id })) }; });
|
||||
app.get('/internal/ops/sites/:id', async (ctx) => { await requireService(ctx, ['hal', 'portal']); const s = sites.get(ctx.params.id); if (!s) throw new HttpError(404, 'not_found', '사이트 없음'); return { ok: true, site: s, overview: sites.overview(s), deployments: sites.deployments(s.id, 50), jobs: deps.jobs.list({ subjectType: 'site', subjectId: s.id, limit: 100 }).map(describeJob), externalAgent: sites.externalAgentFor(s.id), domains: deps.store.all('SELECT * FROM domains WHERE site_id=?', s.id), audit: listAudit(deps.store, { subjectType: 'site', subjectId: s.id, limit: 100 }) }; });
|
||||
app.post('/internal/ops/sites/:id/refresh', async (ctx) => { await requireService(ctx, ['hal', 'portal']); const s = sites.get(ctx.params.id); if (!s) throw new HttpError(404, 'not_found', '사이트 없음'); return { ok: true, job: sites.refresh(s.group_id, { force: true }) }; });
|
||||
app.post('/internal/ops/sites/sweep', async (ctx) => { await requireService(ctx, ['hal', 'portal']); return { ok: true, ...sites.sweep() }; });
|
||||
|
||||
/** 배포 방식 안내 데이터(§10.1·§10.2): 권리·보장량·백업 가능 여부·비용(활성 상품정책에서) */
|
||||
async function modeInfo(s, ctx) {
|
||||
const ent = ['sites', 'web_disk', 'web_out', 'backup'].map((r) => quota.figures(deps.store, s.group_id, r));
|
||||
const policy = deps.productPolicy ? await deps.productPolicy.activeFor(s.group_id).catch(() => null) : null; // 상품 영역(fork C)이 제공하면 비용 표시
|
||||
let servers = null; let halStale = false; try { servers = (await deps.hal.listServers({ role: 'web' })).filter((x) => x.acceptsNew).length; } catch (_) { halStale = true; }
|
||||
const agent = sites.externalAgentFor(s.id);
|
||||
return { current: s.deploy_mode, runtime: s.runtime, consent: { ...sites.EXTERNAL_CONSENT, agreed: s.consent_version === sites.EXTERNAL_CONSENT.version }, agent: agent ? { status: agent.status, label: agent.label } : null,
|
||||
managed: { description: 'Ariweb 가 서버 배치와 운영(모니터링·자동 재시작·용량 조정 이전)을 관리합니다', monitoring: ['외부 접속 확인(대표 도메인, 10분 주기)', '디스크·데이터베이스·네트워크 사용량 수집', '오류율·응답 시간'], limits: ent.filter((e) => ['web_disk', 'web_out'].includes(e.resource)), backupAvailable: ent.find((e) => e.resource === 'backup').purchased > 0 || true, backupNote: '백업은 관리형 사이트 단위 유료 옵션입니다(백업 화면에서 구매)', relocation: '정적 사이트는 조건이 맞으면 자동 이전, 동적 사이트는 운영자 승인 후 이전', estimatedMinutes: s.runtime === 'node' ? 10 : 3, cost: policy ? policy.managedDeployCost || null : null, costNote: policy ? null : '승인된 비용은 활성 상품정책에서 표시됩니다(상품 영역 연결 전)', supportedRuntimes: ['static', 'node'], runtimeNotes: { static: 'HTML·CSS·JS·이미지. 서버 코드 실행 없음', node: 'Ariweb 가 승인한 Node 실행 형태. 데이터베이스: SQLite 파일만. 외부 DB·cron·메일 발송·소켓 서버는 이번 버전에서 지원하지 않음' }, serversAvailable: servers, halStale },
|
||||
external: { targets: ['External Pull Agent 를 설치할 수 있는 지원 Linux 서버(Ubuntu 22.04/24.04, Debian 12, systemd, sudo, 외부 HTTPS 접속)'], prepare: ['root 또는 sudo 가능한 계정', '서버에서 HAL(hal.ariweb.net) 로 나가는 HTTPS 연결', '웹서버(nginx) 또는 Agent 가 설치할 nginx 허용', '도메인 DNS 를 고객이 직접 관리'], perDeployCost: policy ? policy.externalDeployCost || null : null, costNote: policy ? null : '건별 비용은 활성 상품정책에서 표시됩니다(상품 영역 연결 전)', notProvided: ['관리형 모니터링', '백업·복원', '서버 이전', 'SLA', '메일(외부 메일 배포 미지원)'], isolation: '외부 환경 장애는 Ariweb 정상 상태와 분리되어 상태 페이지·알림에 Ariweb 장애로 표시되지 않습니다', siteCountNote: '외부 배포도 공개 사이트라면 운영 사이트 수를 소비합니다. 관리형 디스크·트래픽·메일·백업 권리는 자동 할당되지 않습니다' } };
|
||||
}
|
||||
function pub(s) { if (!s) return null; const { settings_json, pending_cleanup_json, last_external_check_json, next_job_json, ...rest } = s; return rest; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* portal/sites/fake-hal.mjs — 테스트용 가짜 HAL 클라이언트(docs/HAL-JOBS.md 와 같은 인터페이스).
|
||||
* 작업은 즉시 '완료' 로 두되, 시나리오(script)로 특정 kind 를 실패·단계 중간 상태로 만들 수 있다. 실제 HAL 클라이언트는 fork A 의 portal/hal/client.mjs.
|
||||
*
|
||||
* site.deploy 자식 단계 id(실제 Agent 도 같은 id 를 쓴다): apply · data_check · start · internal_check · external_check
|
||||
*/
|
||||
import { newId } from '../../lib/crypto.mjs';
|
||||
import { nowIso } from '../../lib/db.mjs';
|
||||
|
||||
export function createFakeHal(opts = {}) {
|
||||
const jobs = new Map(); const calls = []; const agents = new Map();
|
||||
const servers = opts.servers || [{ id: 'srv_web1', name: 'web-1', role: 'web', location: 'KR', status: 'ready', acceptsNew: true, health: 'ok', lastSeenAt: nowIso(), publicIp: '203.0.113.10', capacity: { diskFreeBytes: 50e9, diskTotalBytes: 100e9, memFreeBytes: 4e9, cpuLoad: 0.2, sitesHosted: 3, reservedBytes: 0 }, version: { agent: '0.1.1', modules: {} } }, { id: 'srv_web2', name: 'web-2', role: 'web', location: 'KR', status: 'ready', acceptsNew: true, health: 'ok', lastSeenAt: nowIso(), publicIp: '203.0.113.20', capacity: { diskFreeBytes: 80e9, diskTotalBytes: 100e9, memFreeBytes: 4e9, cpuLoad: 0.1, sitesHosted: 1, reservedBytes: 0 }, version: { agent: '0.1.1', modules: {} } }];
|
||||
const script = { failKinds: new Set(), pendingKinds: new Set(), results: {}, dns: { a: [], txt: {} }, unreachable: false };
|
||||
const guard = () => { if (script.unreachable) { const e = new Error('HAL 에 연결할 수 없습니다'); e.code = 'service_unreachable'; throw e; } };
|
||||
const defaultResult = (kind, payload, target) => ({
|
||||
'site.deploy': { version: payload.versionRef, deployedAt: nowIso(), internalCheck: { ok: true, status: 200, ms: 12 }, externalCheck: { ok: true, url: `https://${payload.primaryDomain || 'preview.example.test'}/`, status: 200 }, siteUrl: `https://${target}.example.test/${payload.siteId}/`, leftovers: [] },
|
||||
'site.rollback': { version: payload.toVersion }, 'site.remove': { removed: true, leftovers: [] },
|
||||
'site.check': { checks: (payload.urls || []).map((u) => ({ url: u, status: 200, ok: true, ms: 30, tls: { valid: true, expiresAt: new Date(Date.now() + 60 * 86400000).toISOString() } })) },
|
||||
'site.usage': { sites: Object.fromEntries((payload.siteIds || []).map((s) => [s, { diskBytes: 120e6, dbBytes: 5e6, uploadsBytes: 20e6, outBytesDay: 3e8, outBytesMonth: 5e9, outBytesTotal: 2e10, inBytesDay: 1e7, inBytesMonth: 2e8, inBytesTotal: 1e9, req24h: 1200, pv24h: 800, series7d: [1, 2, 3, 4, 5, 6, 7].map((d) => ({ date: `2026-10-0${d}`, pv: 100 * d, req: 150 * d })), series30d: [], errorRate: 0.4, p95ms: 210, measuredAt: nowIso() }])) },
|
||||
'domain.vhost': { applied: payload.domains || [], certs: Object.fromEntries((payload.domains || []).map((d) => [d, { state: 'valid', expiresAt: new Date(Date.now() + 80 * 86400000).toISOString() }])) },
|
||||
'domain.cert': { issued: payload.domains || [], failed: [], expiresAt: new Date(Date.now() + 80 * 86400000).toISOString() },
|
||||
'domain.probe': { a: script.dns.a, txtOk: !!(payload.expectTxt && script.dns.txt[payload.expectTxt.name] === payload.expectTxt.value), txt: payload.expectTxt ? script.dns.txt[payload.expectTxt.name] || null : null, resolvedAt: nowIso() },
|
||||
'site.relocate': { newServerId: payload.toServerId, switchedAt: nowIso(), oldRetainedUntil: new Date(Date.now() + 7 * 86400000).toISOString() },
|
||||
}[kind] || { ok: true });
|
||||
const hal = {
|
||||
calls, jobs, script, servers, agents,
|
||||
async createJob({ kind, target, payload, idempotencyKey }) {
|
||||
guard(); calls.push({ kind, target, payload, idempotencyKey });
|
||||
if (idempotencyKey) { const ex = [...jobs.values()].find((j) => j.idempotencyKey === idempotencyKey); if (ex) return { halJobId: ex.id, status: ex.status }; }
|
||||
const id = newId('hj_'); const fail = script.failKinds.has(kind); const pending = script.pendingKinds.has(kind);
|
||||
const steps = kind === 'site.deploy' ? ['apply', 'data_check', 'start', 'internal_check', 'external_check'].map((s, i) => ({ id: s, title: s, state: fail && i === 3 ? 'failed' : (fail && i > 3) || pending ? 'pending' : 'done' })) : [];
|
||||
jobs.set(id, { id, kind, target, payload, idempotencyKey, status: pending ? 'running' : fail ? 'failed' : 'done', steps, progress: pending ? 40 : 100, result: fail || pending ? null : (script.results[kind] || defaultResult(kind, payload, target)), error: fail ? '내부 확인 실패: HTTP 502' : null, errorCode: fail ? 'internal_check_failed' : null, updatedAt: nowIso() });
|
||||
return { halJobId: id, status: jobs.get(id).status };
|
||||
},
|
||||
async getJob(id) { guard(); const j = jobs.get(id); if (!j) throw Object.assign(new Error('작업 없음'), { code: 'hal_404' }); return { ...j }; },
|
||||
async cancelJob(id) { guard(); const j = jobs.get(id); if (j) j.status = 'cancelled'; return { ok: true }; },
|
||||
async retryJob(id) { guard(); const j = jobs.get(id); if (j) { j.status = 'done'; j.result = defaultResult(j.kind, j.payload, j.target); j.steps.forEach((s) => { s.state = 'done'; }); } return { ok: true }; },
|
||||
async revertJob(id) { guard(); return { ok: true }; }, async resumeJob(id) { guard(); return { ok: true }; },
|
||||
async listServers({ role } = {}) { guard(); return servers.filter((s) => !role || s.role === role); },
|
||||
async pickServer({ role = 'web', needBytes = 0 } = {}) { guard(); const s = servers.find((x) => x.role === role && x.acceptsNew && x.status === 'ready' && x.capacity.diskFreeBytes > needBytes); return s ? { serverId: s.id } : null; },
|
||||
async issueExternalAgentToken({ groupId, siteId, label }) { guard(); const agentId = newId('xa_'); agents.set(agentId, { id: agentId, status: 'pending', lastSeenAt: null, version: null, lastDeploy: null }); return { agentId, token: 'xat_' + newId(''), expiresAt: new Date(Date.now() + 600000).toISOString(), installCommand: `curl -fsSL https://hal.example.test/agent/install.sh | sudo bash -s -- --hal https://hal.example.test --token <토큰> --mode external` }; },
|
||||
async getExternalAgent(id) { guard(); return agents.get(id) || { id, status: 'revoked' }; },
|
||||
async revokeExternalAgent(id) { guard(); const a = agents.get(id); if (a) a.status = 'revoked'; return { ok: true }; },
|
||||
async health() { guard(); return { ok: true, version: '0.1.1', uptime: 1 }; },
|
||||
/** 테스트 도우미: 진행 중 작업을 끝낸다 */
|
||||
_finish(id, { fail = false } = {}) { const j = jobs.get(id); j.status = fail ? 'failed' : 'done'; j.progress = 100; j.steps.forEach((s) => { s.state = 'done'; }); j.result = fail ? null : defaultResult(j.kind, j.payload, j.target); j.error = fail ? '외부 접속 확인 실패' : null; j.updatedAt = nowIso(); },
|
||||
_connectAgent(id) { const a = agents.get(id); if (a) { a.status = 'connected'; a.lastSeenAt = nowIso(); a.version = '0.1.1'; } },
|
||||
};
|
||||
return hal;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* portal/sites/model.mjs — 사이트 영역의 세부 표(중심 표 sites·deployments 는 portal/core/model.mjs).
|
||||
* sso_tickets 포털 → Webmaker SSO 티켓 발급 기록(nonce 만, 티켓 원문 없음)
|
||||
* external_agents 외부 배포(External Pull Agent) 연결: HAL 이 발급한 agent id·상태·마지막 배포/확인 결과
|
||||
* site_consents 외부 배포 동의(버전 기록 §10.2) 등 사용자가 확인한 약관 버전
|
||||
* bundle_tokens 저장본 tar.gz 를 Agent 가 받아 갈 1회용 다운로드 토큰(해시 저장)
|
||||
* deploy_mode_changes 배포 방식 변경 작업(§10.3) 기록
|
||||
* sites 표에는 runtime(static|node)·consent_version·pending_cleanup(정리하지 않은 중간 결과)·return_due_at(예약 반환 예정) 열을 더한다.
|
||||
*/
|
||||
export const MIGRATIONS = [{ id: 'sites_0001', sql: `
|
||||
CREATE TABLE IF NOT EXISTS sso_tickets (id TEXT PRIMARY KEY, nonce TEXT NOT NULL, user_id TEXT NOT NULL, group_id TEXT NOT NULL, site_id TEXT, project_id TEXT, issued_at TEXT NOT NULL, expires_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS external_agents (
|
||||
id TEXT PRIMARY KEY, site_id TEXT NOT NULL, group_id TEXT NOT NULL, hal_agent_id TEXT NOT NULL, label TEXT, status TEXT NOT NULL DEFAULT 'pending', token_expires_at TEXT, install_command TEXT,
|
||||
last_seen_at TEXT, agent_version TEXT, last_deploy_json TEXT, last_check_json TEXT, revoked_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS external_agents_site ON external_agents(site_id);
|
||||
CREATE TABLE IF NOT EXISTS site_consents (id TEXT PRIMARY KEY, site_id TEXT NOT NULL, group_id TEXT NOT NULL, user_id TEXT NOT NULL, kind TEXT NOT NULL, version TEXT NOT NULL, text_hash TEXT, agreed_at TEXT NOT NULL, ip TEXT);
|
||||
CREATE TABLE IF NOT EXISTS bundle_tokens (id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, site_id TEXT NOT NULL, deployment_id TEXT, file TEXT NOT NULL, bytes INTEGER, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, used_at TEXT);
|
||||
CREATE TABLE IF NOT EXISTS deploy_mode_changes (id TEXT PRIMARY KEY, site_id TEXT NOT NULL, group_id TEXT NOT NULL, from_mode TEXT, to_mode TEXT NOT NULL, from_target TEXT, to_target TEXT, status TEXT NOT NULL, job_id TEXT, created_by TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ended_at TEXT);
|
||||
ALTER TABLE sites ADD COLUMN runtime TEXT NOT NULL DEFAULT 'static';
|
||||
ALTER TABLE sites ADD COLUMN consent_version TEXT;
|
||||
ALTER TABLE sites ADD COLUMN pending_cleanup_json TEXT;
|
||||
ALTER TABLE sites ADD COLUMN return_due_at TEXT;
|
||||
ALTER TABLE sites ADD COLUMN last_deploy_at TEXT;
|
||||
ALTER TABLE sites ADD COLUMN last_external_check_json TEXT;
|
||||
ALTER TABLE sites ADD COLUMN next_job_json TEXT;
|
||||
` }];
|
||||
@@ -0,0 +1,103 @@
|
||||
/* portal/sites/public/sites.js — 사이트 화면 스크립트. data-page 로 화면을 고르고 /api/sites/* 를 채운다. 모든 화면: 불러오는 중 → 데이터 | 비어 있음 | 지연(stale) | 부분/전체 실패 | 권한 없음. */
|
||||
(() => {
|
||||
const $ = (s, r = document) => r.querySelector(s); const esc = aw.esc; const fmt = aw.fmt;
|
||||
const B = (n) => { if (n == null) return '제공되지 않음'; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; let v = Number(n); while (v >= 1024 && i < 4) { v /= 1024; i++; } return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${u[i]}`; };
|
||||
const L = { draft: '초안', building: '제작 중', deploying: '배포 중', live: '운영 중', needs_domain: '도메인 설정 필요', paused: '일시 중지', cancel_scheduled: '해지 예약', readonly_retention: '읽기 전용 보존', pending_delete: '삭제 대기', deleted: '삭제 완료', delete_hold: '삭제 보류' };
|
||||
const H = { ok: ['ok', '정상'], degraded: ['warning', '성능 저하'], partial_outage: ['warning', '일부 장애'], down: ['failed', '중단'], unknown: ['neutral', '확인 전'], stale: ['warning', '정보 확인 지연'], not_provided: ['neutral', '제공되지 않음'] };
|
||||
const V = { verified: '배포 검증 완료', needs_domain: '도메인 설정 필요', public: '공개 운영 중' };
|
||||
const SC = { none: '-', reserved: '예약됨', in_use: '사용 중', returning: '반환 중' };
|
||||
const badge = (kind, text) => `<span class="badge ${kind}">${({ ok: '●', done: '✔', failed: '✘', warning: '!', running: '◌', waiting: '◔', neutral: '○' })[kind] || '○'} ${esc(text)}</span>`;
|
||||
const onErr = (el, e, retry) => aw.state(el, e.status === 403 ? 'forbidden' : e.code === 'service_unreachable' || e.status === 503 ? 'stale' : 'failed', e.message, { retry });
|
||||
const siteId = () => $('[data-site]')?.dataset.site;
|
||||
const jobAction = (sid) => async (a, j) => { if (a === 'cancel' && !(await aw.confirmDanger({ title: '작업 취소', target: j.label, impacts: ['진행 중인 단계는 안전한 지점에서 멈추고, 남은 중간 결과는 배포 화면에 표시됩니다'] }))) return; if (a === 'revert' && !(await aw.confirmDanger({ title: '되돌리기', target: j.label, impacts: ['지금 배포된 파일이 이전 배포본으로 바뀝니다', '업로드 데이터와 데이터베이스는 유지됩니다', '실제 외부 접속을 확인한 뒤 완료로 표시합니다'] }))) return; try { await aw.api('POST', `/api/sites/${sid}/jobs/${j.id}/${a === 'go' ? 'resume' : a}`); aw.toast('요청했습니다'); location.reload(); } catch (e) { aw.toast(e.message); } };
|
||||
|
||||
// ---------- 목록 ----------
|
||||
async function list() {
|
||||
const el = $('#list'); try { const r = await aw.api('GET', `/api/sites${el.dataset.filter ? '?filter=' + el.dataset.filter : ''}`); if (!r.sites.length) return aw.state(el, 'empty', '아직 사이트가 없습니다. "새 사이트" 로 시작하세요', { href: '/sites/new', hrefLabel: '새 사이트' });
|
||||
const sc = r.siteCount; el.innerHTML = `<p class="sub">운영 사이트 수: 사용 중 ${sc.allocated} · 예약 ${sc.reserved} · 구매 ${sc.purchased} (사용 가능 ${sc.unallocated}) — 초안·제작 중 사이트는 포함되지 않습니다</p><table class="r"><thead><tr><th>이름 · 대표 도메인</th><th>제작·배포 상태</th><th>배포 방식</th><th>메일 · 백업</th><th>최근 배포</th><th>건강 · 조치</th></tr></thead><tbody>${r.sites.map((s) => { const h = H[s.health] || H.unknown; return `<tr><td data-l="이름"><a href="/sites/${s.id}"><b>${esc(s.name)}</b></a><br><span class="mono sub">${esc(s.primaryDomain || '대표 도메인 없음')}</span></td><td data-l="상태">${badge(s.lifecycle === 'live' ? 'ok' : ['paused', 'needs_domain'].includes(s.lifecycle) ? 'warning' : 'neutral', L[s.lifecycle] || s.lifecycle)}${s.deployState ? `<br><span class="sub">${esc(V[s.deployState.verify] || s.deployState.status)}</span>` : ''}<br><span class="sub">사이트 수 ${esc(SC[s.siteCountState] || s.siteCountState)}</span></td><td data-l="배포 방식">${s.deployMode === 'managed' ? '관리형' : s.deployMode === 'external' ? '외부' : '<span class="muted">미선택</span>'}</td><td data-l="메일·백업">${s.mail ? '✉ 메일' : '<span class="muted">메일 없음</span>'}<br>${s.backup ? '⟲ 백업 ' + esc(s.backup.status) : '<span class="muted">백업 없음</span>'}</td><td data-l="최근 배포">${s.lastDeployAt ? esc(fmt(s.lastDeployAt)) : '<span class="muted">없음</span>'}</td><td data-l="건강·조치">${badge(h[0], h[1])}${s.actionNeeded.map((a) => `<br><a href="${esc(a.href)}">! ${esc(a.text)}</a>`).join('')}</td></tr>`; }).join('')}</tbody></table>`; } catch (e) { onErr(el, e, list); }
|
||||
}
|
||||
// ---------- 생성 ----------
|
||||
async function create() {
|
||||
const f = $('#newSite'); const pick = $('#projectPick'); const bm = $('#buildModePick');
|
||||
f.querySelectorAll('[name=projectMode]').forEach((r) => { r.onchange = async () => { pick.hidden = r.value !== 'existing' || !r.checked; bm.hidden = r.value !== 'new'; if (r.value === 'existing' && r.checked) { const sel = $('#projectId'); try { const r2 = await aw.api('GET', '/api/webmaker/projects'); sel.innerHTML = r2.projects.length ? r2.projects.map((p) => `<option value="${esc(p.id)}" ${p.link ? 'disabled' : ''}>${esc(p.title)} (${p.versions}개 저장본${p.link ? ', 이미 연결됨' : ''})</option>`).join('') : '<option value="">연결할 수 있는 프로젝트가 없습니다</option>'; } catch (e) { sel.innerHTML = `<option value="">${esc(e.message)}</option>`; } } }; });
|
||||
f.onsubmit = async (e) => { e.preventDefault(); f.querySelectorAll('.err').forEach((x) => { x.textContent = ''; }); const fd = new FormData(f); const body = Object.fromEntries(fd.entries()); const btn = f.querySelector('button[type=submit]'); btn.disabled = true;
|
||||
try { const r = await aw.api('POST', '/api/sites', body); aw.markClean(); f.hidden = true; const c = $('#created'); c.hidden = false; c.innerHTML = `<h2>사이트 "${esc(r.site.name)}" 을(를) 만들었습니다</h2>${r.projectNote ? `<p class="err">${esc(r.projectNote)}</p>` : ''}<p>다음 단계</p><ol>${r.nextSteps.map((s) => `<li>${s.unsupported ? `<span class="muted">${esc(s.label)} — ${esc(s.unsupported)}</span>` : `<a href="${esc(s.href)}">${esc(s.label)}</a>`}${s.done ? ' ✔' : ''}</li>`).join('')}</ol><div class="actions"><a class="btn primary" href="/sites/${r.site.id}">사이트 상세로</a><a class="btn" href="/sso/webmaker?site=${r.site.id}" target="_blank" rel="noopener">제작 화면 열기</a></div>`; }
|
||||
catch (err) { btn.disabled = false; const t = f.querySelector(`[data-err="${err.detail && err.detail.field || 'form'}"]`) || f.querySelector('[data-err=form]'); t.textContent = err.message; } };
|
||||
}
|
||||
// ---------- 개요 ----------
|
||||
async function overview() {
|
||||
const el = $('#overview'); const sid = siteId();
|
||||
const render = (o) => {
|
||||
const head = `<div class="cards"><div class="card"><h2>상태</h2><dl class="kv"><dt>서비스 상태</dt><dd>${badge(o.lifecycle === 'live' ? 'ok' : 'neutral', L[o.lifecycle] || o.lifecycle)}</dd><dt>배포 작업</dt><dd>${o.lastDeploy ? badge(o.lastDeploy.status === 'done' ? 'done' : o.lastDeploy.status === 'failed' ? 'failed' : 'running', aw.JOB_LABEL[o.lastDeploy.status] || o.lastDeploy.status) + (o.lastDeploy.verify ? ' ' + esc(V[o.lastDeploy.verify]) : '') : '<span class="muted">없음</span>'}</dd><dt>외부 건강</dt><dd>${o.kind === 'external' ? badge('neutral', '제공되지 않음') : o.health ? badge((H[o.health] || H.unknown)[0], (H[o.health] || H.unknown)[1]) + (o.healthCheckedAt ? ` <span class="sub">${esc(fmt(o.healthCheckedAt))}</span>` : '') : '-'}</dd><dt>사이트 수</dt><dd>${esc(SC[o.siteCountState] || '-')}${o.returnDueAt ? ` <span class="sub">(반환 예정 ${esc(fmt(o.returnDueAt))})</span>` : ''}</dd>${o.nextJob ? `<dt>다음 예정 작업</dt><dd>${esc(o.nextJob.label)} ${o.nextJob.at ? esc(fmt(o.nextJob.at)) : ''}</dd>` : ''}</dl></div>`;
|
||||
const actions = o.actionNeeded.length ? `<div class="card"><h2>조치 필요</h2><ul>${o.actionNeeded.map((a) => `<li><a href="${esc(a.href)}">${esc(a.text)}</a></li>`).join('')}</ul></div>` : '';
|
||||
if (o.kind === 'not_deployed') return `${head}${actions}<div class="card"><h2>준비 단계</h2><p>${esc(o.message)}</p><ol>${o.readiness.map((s) => `<li>${s.unsupported ? `<span class="muted">${esc(s.label)} — ${esc(s.unsupported)}</span>` : `<a href="${esc(s.href)}">${esc(s.label)}</a>`} ${s.done ? '✔' : ''}</li>`).join('')}</ol></div></div>`;
|
||||
if (o.kind === 'external') return `${head}${actions}<div class="card"><h2>외부 배포 사이트</h2><dl class="kv"><dt>Agent 연결</dt><dd>${o.agent ? badge(o.agent.status === 'connected' ? 'ok' : 'warning', { connected: '연결됨', pending: '연결 대기', offline: '응답 없음', revoked: '해제됨' }[o.agent.status] || o.agent.status) + (o.agent.lastSeenAt ? ` <span class="sub">마지막 응답 ${esc(fmt(o.agent.lastSeenAt))}</span>` : '') : '<span class="muted">없음</span>'}</dd><dt>마지막 배포</dt><dd>${o.lastDeployResult ? `${o.lastDeployResult.ok ? '성공' : '실패'} · ${esc(fmt(o.lastDeployResult.at))} · 버전 ${esc(o.lastDeployResult.version || '-')}` : '없음'}</dd><dt>마지막 외부 확인</dt><dd>${o.lastExternalCheck ? `${o.lastExternalCheck.ok ? '정상' : '실패'} ${esc(o.lastExternalCheck.url || '')} (${o.lastExternalCheck.status || '-'})` : '제공되지 않음'}</dd><dt>고객 책임 범위</dt><dd>${o.customerResponsibility.map(esc).join('<br>')}</dd></dl><h2>제공되지 않음</h2><ul>${o.notProvided.map((x) => `<li>${esc(x)} — 제공되지 않음</li>`).join('')}</ul></div></div>`;
|
||||
const w = (p) => p == null ? '' : p >= 100 ? 'w100' : p >= 95 ? 'w95' : p >= 90 ? 'w90' : p >= 80 ? 'w80' : p >= 70 ? 'w70' : '';
|
||||
return `${head}${actions}<div class="card"><h2>접속</h2>${o.stale ? `<p class="aw-state stale"><b>정보 확인 지연</b> ${esc(o.staleReason)}</p>` : ''}<dl class="kv"><dt>대표 도메인</dt><dd>${o.primaryDomain ? `<a href="https://${esc(o.primaryDomain)}/" target="_blank" rel="noopener">${esc(o.primaryDomain)}</a>` : '없음'}</dd><dt>현재 배포 버전</dt><dd>${esc(o.currentVersion || '-')} <span class="sub">${esc(fmt(o.lastDeployAt))}</span></dd><dt>최근 24시간 접속</dt><dd>${o.visits24h != null ? `${o.visits24h.toLocaleString()} 방문 · ${(o.requests24h || 0).toLocaleString()} 요청` : '수집 전'}</dd><dt>7일 추이</dt><dd>${o.series.series7d.length ? o.series.series7d.map((d) => `${esc(d.date.slice(5))}: ${d.pv}`).join(' · ') : '수집 전'}</dd><dt>30일 추이</dt><dd>${o.series.series30d.length ? o.series.series30d.length + '일 수집' : '수집 전'}</dd><dt>마지막 수집</dt><dd>${o.measuredAt ? esc(fmt(o.measuredAt)) : '-'}</dd></dl></div>
|
||||
<div class="card"><h2>디스크와 데이터베이스</h2><div class="bar ${w(o.disk.percent)}"><i style="width:${Math.min(100, o.disk.percent || 0)}%"></i></div><dl class="kv"><dt>사용</dt><dd>${B(o.disk.used)} / 한도 ${B(o.disk.limit)} ${o.disk.percent != null ? `(${o.disk.percent}%)` : ''}</dd><dt>데이터베이스</dt><dd>${B(o.disk.db)}</dd><dt>업로드</dt><dd>${B(o.disk.uploads)}</dd></dl></div>
|
||||
<div class="card"><h2>네트워크</h2><dl class="kv"><dt>아웃바운드 일/월/누적</dt><dd>${B(o.network.out.day)} / ${B(o.network.out.month)} / ${B(o.network.out.total)} ${o.network.out.limit ? `(월 한도 ${B(o.network.out.limit)})` : ''}</dd><dt>인바운드 일/월/누적</dt><dd>${B(o.network.in.day)} / ${B(o.network.in.month)} / ${B(o.network.in.total)}</dd></dl></div>
|
||||
<div class="card"><h2>오류와 성능</h2><dl class="kv"><dt>오류율</dt><dd>${o.errors.errorRate != null ? o.errors.errorRate + '%' : '수집 전'}</dd><dt>응답 시간(p95)</dt><dd>${o.errors.p95ms != null ? o.errors.p95ms + ' ms' : '수집 전'}</dd><dt>마지막 외부 확인</dt><dd>${o.lastExternalCheck ? `${o.lastExternalCheck.ok ? '정상' : '실패'} (${o.lastExternalCheck.status || '-'}, ${o.lastExternalCheck.ms || '-'}ms)` : '-'}</dd></dl></div></div>`;
|
||||
};
|
||||
const load = async () => { try { const r = await aw.api('GET', `/api/sites/${sid}/overview`); el.innerHTML = render(r.overview); return !!r.refreshJob; } catch (e) { onErr(el, e, load); return false; } };
|
||||
if (await load()) aw.poll(async () => { await load(); return false; }, 15000);
|
||||
}
|
||||
// ---------- 제작 탭 ----------
|
||||
async function build() {
|
||||
const el = $('#buildInfo'); const sid = siteId(); try { const r = await aw.api('GET', `/api/sites/${sid}/versions`); if (!r.project) return aw.state(el, 'empty', 'Webmaker 프로젝트가 연결되어 있지 않습니다'); el.innerHTML = `<dl class="kv"><dt>프로젝트</dt><dd>${esc(r.project.title)} <span class="sub">(${esc(r.project.id.slice(0, 8))}…, rev ${r.project.rev})</span></dd><dt>제작 방식</dt><dd>${esc({ ai: 'AI에게 맡기기', manual: '내가 고르기', quick: '제목·내용 입력형' }[r.project.mode] || r.project.mode)}</dd><dt>내용</dt><dd>${r.project.hasContent ? '홈 페이지 있음' : '아직 페이지가 없습니다'}</dd><dt>저장본</dt><dd>${r.versions.length}개 ${r.versions.length ? `(최근: #${r.versions[0].no} ${esc(r.versions[0].message || r.versions[0].kind)} · ${esc(fmt(r.versions[0].at))})` : ''}</dd></dl>`; const wl = $('#wallet'); if (wl) wl.innerHTML = r.wallet && !r.wallet.stale && r.wallet.credit ? `<h2>AI Wallet</h2><p class="hint">AI 생성·수정 비용은 AI Wallet(캐시)에서 차감되며 호스팅 디스크·트래픽 사용량과 분리됩니다. 충전·사용 이력은 상품 및 결제 화면에서 봅니다.</p><dl class="kv"><dt>잔액</dt><dd>${r.wallet.credit.balance.toLocaleString()} ${esc(r.wallet.unit || '원')}</dd><dt>사용</dt><dd>${r.wallet.credit.used.toLocaleString()} ${esc(r.wallet.unit || '원')}</dd></dl>` : `<h2>AI Wallet</h2><p class="aw-state stale"><b>정보 확인 지연</b> 잔액을 지금 확인할 수 없습니다</p>`; } catch (e) { onErr(el, e, build); }
|
||||
}
|
||||
// ---------- 배포 탭 ----------
|
||||
async function deploy() {
|
||||
const sid = siteId(); const cur = $('#deployCurrent'); const start = $('#deployStart'); const hist = $('#deployHistory');
|
||||
const load = async () => { try { const r = await aw.api('GET', `/api/sites/${sid}/deployments`); const s = r.site; const active = r.activeJobs.find((j) => ['site.deploy', 'site.rollback', 'site.cleanup', 'site.mode_change'].includes(j.kind)); const last = r.deployments[0];
|
||||
cur.innerHTML = `<h2>현재 배포</h2><dl class="kv"><dt>현재 버전</dt><dd>${esc(s.current_version || '없음')} ${s.deploy_verify ? badge(s.deploy_verify === 'public' ? 'ok' : 'warning', V[s.deploy_verify]) : ''}</dd><dt>배포 방식</dt><dd>${s.deploy_mode === 'managed' ? '관리형' : s.deploy_mode === 'external' ? '외부' : '미선택'} <a href="/sites/${sid}/deploy/mode">변경</a></dd><dt>사이트 수</dt><dd>${esc(SC[s.site_count_state] || '-')}${s.return_due_at ? ` · 반환 예정 ${esc(fmt(s.return_due_at))} <button class="btn" id="btnCleanup">지금 정리하고 반환</button>` : ''}</dd></dl>${s.pending_cleanup_json ? '' : ''}<div id="activeJob"></div>${active ? '' : `<div class="actions">${last && last.previous_version ? '<button class="btn danger" id="btnRollback">이전 버전으로 되돌리기</button>' : ''}</div>`}`;
|
||||
if (active) { aw.job($('#activeJob'), active, { onAction: jobAction(sid) }); } else if (last && last.job && ['failed', 'needs_user'].includes(last.job.status)) { aw.job($('#activeJob'), last.job, { onAction: jobAction(sid) }); }
|
||||
const rb = $('#btnRollback'); if (rb) rb.onclick = async () => { if (!(await aw.confirmDanger({ title: '이전 버전으로 되돌리기', target: `${s.current_version} → ${last.previous_version}`, impacts: ['지금 배포된 파일이 이전 배포본으로 바뀝니다', '업로드 데이터와 데이터베이스는 유지됩니다', '되돌린 뒤 실제 외부 접속을 확인한 다음 완료로 표시합니다'] }))) return; try { await aw.api('POST', `/api/sites/${sid}/rollback`); aw.toast('되돌리기를 시작했습니다'); load(); } catch (e) { aw.toast(e.message); } };
|
||||
const cl = $('#btnCleanup'); if (cl) cl.onclick = async () => { if (!(await aw.confirmDanger({ title: '중간 결과 정리와 사이트 수 반환', target: s.name, impacts: ['배포 위치에 남은 중간 파일을 제거합니다', '사이트 수 예약을 반환합니다(다른 사이트가 쓸 수 있게 됨)', '현재 운영 중인 버전이 있으면 유지됩니다'] }))) return; try { await aw.api('POST', `/api/sites/${sid}/cleanup`); aw.toast('정리 작업을 시작했습니다'); load(); } catch (e) { aw.toast(e.message); } };
|
||||
hist.innerHTML = `<h2>배포 기록</h2>${r.deployments.length ? `<table class="r"><thead><tr><th>시각</th><th>버전</th><th>결과</th><th>완료 상태</th><th>작업</th></tr></thead><tbody>${r.deployments.map((d) => `<tr><td data-l="시각">${esc(fmt(d.created_at))}</td><td data-l="버전">${esc(d.version_label || d.version_ref)}${d.previous_version ? `<br><span class="sub">이전 ${esc(d.previous_version)}</span>` : ''}</td><td data-l="결과">${badge(d.status === 'done' ? 'done' : d.status === 'failed' ? 'failed' : 'running', aw.JOB_LABEL[d.status] || d.status)}${d.result && d.result.error ? `<br><span class="err">${esc(d.result.error)}</span><br><span class="sub">문의 번호 ${esc(d.job_id || '')}</span>` : ''}</td><td data-l="완료 상태">${d.verify_state ? esc(V[d.verify_state]) : '-'}</td><td data-l="작업">${d.job ? `${esc(aw.JOB_LABEL[d.job.status] || d.job.status)} ${d.job.attempt > 1 ? `(${d.job.attempt}회)` : ''}` : '-'}</td></tr>`).join('')}</tbody></table>` : '<p class="muted">배포 기록이 없습니다</p>'}`;
|
||||
if (!active) await startForm(s); else start.innerHTML = '';
|
||||
return !!active; } catch (e) { onErr(cur, e, load); return false; } };
|
||||
const startForm = async (s) => { try { const r = await aw.api('GET', `/api/sites/${sid}/versions`); const opts = [`<option value="current">현재 저장 상태 (rev ${r.project ? r.project.rev : '-'})</option>`, ...r.versions.map((v) => `<option value="${v.no}">#${v.no} ${esc(v.message || v.kind)} · ${esc(fmt(v.at))}</option>`)].join(''); start.innerHTML = `<h2>배포 시작</h2><div class="f"><label for="ver">배포할 저장본</label><select id="ver">${opts}</select><span class="hint">이전 저장본을 골라 배포할 수 있습니다. 배포 전 Webmaker 미리보기로 내용을 확인하세요(운영 버전은 배포 완료 전까지 바뀌지 않습니다)</span></div><div class="actions"><button class="btn" id="btnPrecheck">배포 전 확인</button><button class="btn primary" id="btnDeploy" disabled>배포 시작</button></div><div id="precheck"></div>`; $('#btnPrecheck').onclick = precheck; $('#ver').onchange = () => { $('#btnDeploy').disabled = true; $('#precheck').innerHTML = ''; }; $('#btnDeploy').onclick = async () => { $('#btnDeploy').disabled = true; try { const v = $('#ver').value; const r2 = await aw.api('POST', `/api/sites/${sid}/deploy`, { versionRef: v, versionLabel: $('#ver').selectedOptions[0].textContent.trim() }); aw.toast(r2.created ? '배포를 시작했습니다. 화면을 닫아도 계속 진행됩니다' : '이미 같은 배포가 진행 중입니다'); load(); } catch (e) { $('#precheck').innerHTML = `<p class="err">${esc(e.message)}</p>` + (e.detail && e.detail.items ? renderPrecheck(e.detail.items) : ''); } }; } catch (e) { start.innerHTML = `<h2>배포 시작</h2>`; aw.state(start.appendChild(document.createElement('div')), e.code === 'service_unreachable' ? 'stale' : 'failed', e.message); } };
|
||||
const renderPrecheck = (items) => `<ul class="steps">${items.map((i) => `<li class="${i.state === 'block' ? 'failed' : i.state === 'pass' ? 'done' : 'pending'}"><span class="s">${i.state === 'block' ? '✘' : i.state === 'pass' ? '✔' : i.state === 'warn' ? '!' : 'ⓘ'}</span><span><b>${esc(i.title)}</b> — ${esc(i.text)} ${i.href ? `<a href="${esc(i.href)}">해결하기</a>` : ''}</span></li>`).join('')}</ul>`;
|
||||
const precheck = async () => { const pc = $('#precheck'); aw.state(pc, 'loading'); try { const r = await aw.api('GET', `/api/sites/${sid}/precheck?version=${encodeURIComponent($('#ver').value)}`); pc.innerHTML = `<p>${r.precheck.ok ? '✔ 모든 항목을 통과했습니다' : '✘ 차단된 항목이 있습니다. 해결 후 다시 확인하세요'}</p>${renderPrecheck(r.precheck.items)}`; $('#btnDeploy').disabled = !r.precheck.ok; } catch (e) { onErr(pc, e, precheck); } };
|
||||
if (await load()) aw.poll(load, 4000);
|
||||
}
|
||||
// ---------- 배포 방식 ----------
|
||||
async function mode() {
|
||||
const el = $('#modeInfo'); const sid = siteId(); try { const r = await aw.api('GET', `/api/sites/${sid}/mode`); const m = r.managed; const x = r.external;
|
||||
el.innerHTML = `<div class="cards"><div class="card"><h2>관리형 배포 ${r.current === 'managed' ? badge('ok', '현재') : ''}</h2><p>${esc(m.description)}</p><dl class="kv"><dt>모니터링 범위</dt><dd>${m.monitoring.map(esc).join('<br>')}</dd><dt>용량·트래픽 한도</dt><dd>${m.limits.map((l) => `${esc(l.label)}: 구매 ${B(l.purchased)} · 미배정 ${B(l.unallocated)}`).join('<br>') || '구매한 권리 없음'}</dd><dt>백업 옵션</dt><dd>${esc(m.backupNote)}</dd><dt>서버 이전</dt><dd>${esc(m.relocation)}</dd><dt>예상 배포 시간</dt><dd>약 ${m.estimatedMinutes}분</dd><dt>승인된 비용</dt><dd>${m.cost != null ? esc(String(m.cost)) : esc(m.costNote || '-')}</dd><dt>지원 실행 형태</dt><dd>정적: ${esc(m.runtimeNotes.static)}<br>동적: ${esc(m.runtimeNotes.node)}</dd><dt>배치 가능 서버</dt><dd>${m.halStale ? '<span class="aw-state stale" style="padding:2px 6px">정보 확인 지연</span>' : m.serversAvailable + '대'}</dd></dl><button class="btn primary" data-mode="managed" ${r.current === 'managed' ? 'disabled' : ''}>관리형 선택</button></div>
|
||||
<div class="card"><h2>외부 배포 ${r.current === 'external' ? badge('ok', '현재') : ''}</h2><dl class="kv"><dt>지원 대상</dt><dd>${x.targets.map(esc).join('<br>')}</dd><dt>준비할 것</dt><dd>${x.prepare.map(esc).join('<br>')}</dd><dt>건별 배포 비용</dt><dd>${x.perDeployCost != null ? esc(String(x.perDeployCost)) : esc(x.costNote || '-')}</dd><dt>제공하지 않음</dt><dd>${x.notProvided.map(esc).join(', ')}</dd><dt>장애 분리</dt><dd>${esc(x.isolation)}</dd><dt>사이트 수</dt><dd>${esc(x.siteCountNote)}</dd><dt>Agent</dt><dd>${r.agent ? esc(r.agent.status) : '없음'} — <a href="/sites/${sid}/external">외부 서버 연결 화면</a></dd></dl><h3>${esc(r.consent.title)} (버전 ${esc(r.consent.version)})</h3><ul>${r.consent.items.map((i) => `<li>${esc(i)}</li>`).join('')}</ul><label><input type="checkbox" id="agree" ${r.consent.agreed ? 'checked disabled' : ''}> 위 내용을 확인했고 동의합니다${r.consent.agreed ? ' (동의 기록됨)' : ''}</label><div class="actions"><button class="btn primary" data-mode="external" ${r.current === 'external' ? 'disabled' : ''}>외부 배포 선택</button></div></div></div><div id="modeJob"></div>`;
|
||||
el.querySelectorAll('[data-mode]').forEach((b) => { b.onclick = async () => { const md = b.dataset.mode; try { if (md === 'external' && !r.consent.agreed) { if (!$('#agree').checked) return aw.toast('확인 사항에 동의해야 합니다'); await aw.api('POST', `/api/sites/${sid}/consent`, { version: r.consent.version, agree: true }); } if (r.current && r.current !== md && !(await aw.confirmDanger({ title: '배포 방식 변경', target: `${r.current} → ${md}`, impacts: ['새 위치 준비 → 별도 배포 → 검증 → 도메인 전환 → 이전 위치 정리 순서로 진행합니다', '완료 전까지 현재 위치가 유지되고, 실패하면 되돌립니다'] }))) return; const r2 = await aw.api('POST', `/api/sites/${sid}/mode`, { mode: md }); if (r2.job) { aw.toast('배포 방식 변경 작업을 시작했습니다'); aw.job($('#modeJob'), r2.job, { onAction: jobAction(sid) }); } else { aw.toast('배포 방식을 저장했습니다'); location.href = `/sites/${sid}?tab=deploy`; } } catch (e) { aw.toast(e.message); } }; }); } catch (e) { onErr(el, e, mode); }
|
||||
}
|
||||
// ---------- 외부 서버 ----------
|
||||
async function external() {
|
||||
const el = $('#external'); const sid = siteId();
|
||||
const load = async () => { try { const r = await aw.api('GET', `/api/sites/${sid}/external`); const a = r.agent; const st = a ? ({ connected: ['ok', '연결됨'], pending: ['warning', '연결 대기'], offline: ['failed', '응답 없음'], revoked: ['neutral', '해제됨'] }[a.status] || ['neutral', a.status]) : null;
|
||||
el.innerHTML = `<div class="card"><h2>External Pull Agent</h2>${!r.consent.agreed ? `<p class="aw-state partial"><b>동의 필요</b> 외부 배포 확인 사항에 먼저 동의하세요. <a href="/sites/${sid}/deploy/mode">배포 방식 화면</a></p>` : ''}<dl class="kv"><dt>연결 상태</dt><dd>${a ? badge(st[0], st[1]) + (a.stale ? ` <span class="aw-state stale" style="padding:2px 6px">${esc(a.staleReason)}</span>` : '') : '없음'}</dd>${a ? `<dt>이름</dt><dd>${esc(a.label || '-')}</dd><dt>마지막 응답</dt><dd>${a.last_seen_at ? esc(fmt(a.last_seen_at)) : '-'}</dd><dt>Agent 버전</dt><dd>${esc(a.agent_version || '-')}</dd><dt>마지막 배포</dt><dd>${a.lastDeploy ? esc(JSON.stringify(a.lastDeploy)).slice(0, 200) : '-'}</dd><dt>마지막 외부 확인</dt><dd>${a.lastCheck ? esc(JSON.stringify(a.lastCheck)).slice(0, 200) : '제공되지 않음'}</dd>` : ''}</dl>
|
||||
<div class="actions">${!a || a.status !== 'connected' ? `<button class="btn primary" id="btnIssue" ${r.consent.agreed ? '' : 'disabled'}>연결 토큰 발급</button>` : ''}${a && a.status === 'pending' ? '<button class="btn" id="btnCheck">연결 확인</button>' : ''}${a && a.status === 'connected' ? `<a class="btn primary" href="/sites/${sid}?tab=deploy">배포 / 재배포</a><button class="btn danger" id="btnRemove">배포 제거</button>` : ''}${a && a.status !== 'revoked' ? '<button class="btn danger" id="btnRevoke">Agent 해제</button>' : ''}</div><div id="issued"></div></div>
|
||||
<div class="card"><h2>설치 안내</h2><ol><li>고객 서버(지원 Linux)에 root 또는 sudo 계정으로 접속합니다.</li><li>"연결 토큰 발급" 을 누르면 설치 명령이 한 번만 표시됩니다(토큰은 10분 유효, 1회용). 복사해 서버에서 실행하세요.</li><li>Agent 가 HAL 에 연결되면 상태가 "연결됨" 으로 바뀝니다. 그 뒤 배포 탭에서 배포합니다.</li><li>실패한 배포의 남은 파일·실행 상태는 "배포 제거" 로 정리할 수 있습니다.</li></ol><p class="hint">외부 서버의 운영·보안·장애 대응은 고객 책임입니다. Ariweb 는 배포·재배포·제거와 마지막 외부 확인 결과만 제공합니다.</p></div>`;
|
||||
const bi = $('#btnIssue'); if (bi) bi.onclick = async () => { try { const r2 = await aw.api('POST', `/api/sites/${sid}/external`, { label: prompt('서버 이름(표시용)', '') || '' }); $('#issued').innerHTML = `<p class="aw-state partial"><b>한 번만 표시됩니다</b> 아래 명령을 복사해 서버에서 실행하세요 (만료 ${esc(fmt(r2.expiresAt))})</p><pre class="mono" style="white-space:pre-wrap">${esc(r2.installCommand)}</pre><button class="btn copy" data-action="copy" data-copy="${esc(r2.installCommand)}">복사</button>`; } catch (e) { aw.toast(e.message); } };
|
||||
const bc = $('#btnCheck'); if (bc) bc.onclick = load;
|
||||
const br = $('#btnRevoke'); if (br) br.onclick = async () => { if (!(await aw.confirmDanger({ title: 'Agent 해제', target: a.label || a.hal_agent_id, impacts: ['이 서버로의 배포·재배포·제거가 더 이상 되지 않습니다', '이미 배포된 파일은 서버에 남습니다(고객이 직접 정리)'] }))) return; try { await aw.api('DELETE', `/api/sites/${sid}/external`); load(); } catch (e) { aw.toast(e.message); } };
|
||||
const bm = $('#btnRemove'); if (bm) bm.onclick = async () => { if (!(await aw.confirmDanger({ title: '외부 서버 배포 제거', target: a.label || a.hal_agent_id, impacts: ['외부 서버의 사이트 파일과 실행 상태를 제거합니다', '남은 파일 목록이 결과에 표시됩니다'] }))) return; try { await aw.api('POST', `/api/sites/${sid}/external/remove`); aw.toast('제거 작업을 시작했습니다'); load(); } catch (e) { aw.toast(e.message); } };
|
||||
} catch (e) { onErr(el, e, load); } };
|
||||
load();
|
||||
}
|
||||
// ---------- 사용량·활동·설정·도메인 ----------
|
||||
async function usage() { const el = $('#usage'); const sid = siteId(); try { const r = await aw.api('GET', `/api/sites/${sid}/usage`); if (r.overview.kind !== 'managed') return aw.state(el, 'empty', r.overview.kind === 'external' ? '외부 배포 사이트에는 관리형 사용량이 제공되지 않습니다' : '정상 배포되지 않은 사이트에는 운영 사용량을 표시하지 않습니다'); el.innerHTML = `<div class="card"><h2>배정과 사용</h2><table class="r"><thead><tr><th>자원</th><th>배정</th><th>사용</th><th>마지막 계측</th></tr></thead><tbody>${r.allocations.map((a) => `<tr><td data-l="자원">${esc(a.label)}</td><td data-l="배정">${a.allocation ? B(a.allocation.amount) : '미배정'}</td><td data-l="사용">${a.usage ? B(a.usage.used) : '-'}</td><td data-l="계측">${a.usage ? esc(fmt(a.usage.measured_at)) : '-'}</td></tr>`).join('')}</tbody></table><p class="hint">배정량 변경은 <a href="/usage">사용량 화면</a>에서 합니다.</p></div><div class="card"><h2>최근 계측값</h2><table class="r"><thead><tr><th>항목</th><th>값</th><th>기간</th><th>시각</th></tr></thead><tbody>${r.samples.slice(0, 60).map((s) => `<tr><td data-l="항목">${esc(s.metric)}</td><td data-l="값">${/bytes/.test(s.metric) ? B(s.value) : s.value}</td><td data-l="기간">${esc(s.period || '')}</td><td data-l="시각">${esc(fmt(s.measured_at))}</td></tr>`).join('') || '<tr><td colspan="4">수집 전</td></tr>'}</tbody></table></div>`; } catch (e) { onErr(el, e, usage); } }
|
||||
async function activity() { const el = $('#activity'); const sid = siteId(); try { const r = await aw.api('GET', `/api/sites/${sid}/activity`); if (!r.items.length && !r.jobs.length) return aw.state(el, 'empty', '기록이 없습니다'); el.innerHTML = `<div class="card"><h2>작업</h2>${r.jobs.map((j) => `<p><b>${esc(j.label)}</b> ${esc(aw.JOB_LABEL[j.status] || j.status)} <span class="sub">${esc(fmt(j.updatedAt))}</span></p>`).join('') || '<p class="muted">없음</p>'}</div><div class="card"><h2>활동 기록</h2><table class="r"><thead><tr><th>시각</th><th>누가</th><th>무엇</th><th>결과</th></tr></thead><tbody>${r.items.map((a) => `<tr><td data-l="시각">${esc(fmt(a.at))}</td><td data-l="누가">${a.automatic ? '자동' : esc(a.actor_label || a.actor_id || '-')}</td><td data-l="무엇">${esc(a.action)}</td><td data-l="결과">${esc(a.outcome)}</td></tr>`).join('')}</tbody></table></div>`; } catch (e) { onErr(el, e, activity); } }
|
||||
async function settings() { const f = $('#settings'); const sid = siteId(); f.onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(f); const body = { name: fd.get('name'), description: fd.get('description'), lang: fd.get('lang'), timezone: fd.get('timezone'), runtime: fd.get('runtime'), maintenance: fd.get('maintenance') === 'on', searchVisible: fd.get('searchVisible') === 'on' }; if (body.maintenance !== (f.dataset.maint === '1') && $('[data-lifecycle]')?.dataset.lifecycle === 'live') { if (!(await aw.confirmDanger({ title: '유지보수 모드 변경', target: fd.get('name'), impacts: [body.maintenance ? '방문자에게 안내 페이지가 보이고 사이트를 볼 수 없습니다(예상 적용 1분 이내)' : '즉시 다시 공개됩니다'] }))) return; } try { const r = await aw.api('PATCH', `/api/sites/${sid}`, body); aw.markClean(); aw.toast(r.impact.length ? r.impact.join(' ') : '저장했습니다'); } catch (err) { f.querySelector('[data-err=form]').textContent = err.message; } }; f.dataset.maint = f.querySelector('[name=maintenance]').checked ? '1' : '0';
|
||||
const pe = $('#perms'); try { const r = await aw.api('GET', `/api/sites/${sid}/permissions`); pe.innerHTML = r.members && r.members.length ? `<table class="r"><thead><tr><th>구성원</th><th>권한</th></tr></thead><tbody>${r.members.map((m) => `<tr><td data-l="구성원">${esc(m.alias || m.email)}</td><td data-l="권한">${(m.perms || []).join(', ') || '(그룹 역할 기준)'}</td></tr>`).join('')}</tbody></table><p class="hint">권한 변경은 사용자 그룹 설정 › 사이트별 권한에서 합니다.</p>` : '<p class="muted">사이트별 권한이 없습니다(그룹 역할 기준)</p>'; } catch (e) { aw.state(pe, e.status === 404 ? 'empty' : 'failed', e.status === 404 ? '사이트별 권한 기능은 사용자 그룹 설정에서 제공됩니다' : e.message); } }
|
||||
async function siteDomains() { const el = $('#siteDomains'); const sid = siteId(); try { const r = await aw.api('GET', `/api/domains?site=${sid}`); if (!r.domains.length) return aw.state(el, 'empty', '연결된 도메인이 없습니다. 여러 도메인을 연결해도 운영 사이트 수는 하나만 씁니다'); el.innerHTML = `<table class="r"><thead><tr><th>도메인</th><th>대표</th><th>소유 확인</th><th>DNS</th><th>HTTPS</th><th>마지막 확인</th></tr></thead><tbody>${r.domains.map((d) => `<tr><td data-l="도메인"><a href="/domains/${d.id}">${esc(d.name)}</a></td><td data-l="대표">${d.is_primary ? '★ 대표' : `<button class="btn" data-primary="${d.id}">대표로 지정</button>`}</td><td data-l="소유">${esc(d.ownership_state)}</td><td data-l="DNS">${esc(d.dns_state)}</td><td data-l="HTTPS">${esc(d.cert_state)}</td><td data-l="확인">${d.last_checked_at ? esc(fmt(d.last_checked_at)) : '-'}</td></tr>`).join('')}</tbody></table>`; el.querySelectorAll('[data-primary]').forEach((b) => { b.onclick = async () => { const redirect = confirm('기존 대표 주소를 새 대표 도메인으로 이동(301) 처리할까요?'); try { await aw.api('POST', `/api/domains/${b.dataset.primary}/primary`, { redirectOld: redirect }); aw.toast('대표 도메인을 지정했습니다'); siteDomains(); } catch (e) { aw.toast(e.message); } }; }); } catch (e) { onErr(el, e, siteDomains); } }
|
||||
// ---------- Source ----------
|
||||
async function source() { const el = $('#source'); const sid = siteId(); const load = async () => { try { const r = await aw.api('GET', `/api/source/${sid}`); const o = r.option; const s = r.source;
|
||||
if (!o) return (el.innerHTML = `<div class="card"><h2>Source 옵션</h2><p>${r.entitlement.unallocated > 0 ? 'Source 옵션 권리가 있습니다. 이 사이트에 활성화하면 Gitea 저장소·변경 이력·clone·export 를 쓸 수 있습니다.' : 'Source 옵션을 구매한 사이트에서 제공합니다. <a href="/billing">상품 및 결제</a>에서 옵션을 추가하세요.'}</p>${r.entitlement.unallocated > 0 ? '<button class="btn primary" id="btnAct">이 사이트에 Source 옵션 활성화</button>' : ''}</div>`, $('#btnAct') && ($('#btnAct').onclick = async () => { try { await aw.api('POST', `/api/source/${sid}/activate`); load(); } catch (e) { aw.toast(e.message); } }));
|
||||
const stBadge = o.status === 'active' ? badge('ok', '활성') : o.status === 'readonly' ? badge('warning', '읽기 전용 보존') : badge('failed', '삭제 완료');
|
||||
el.innerHTML = `<div class="card"><h2>Source 옵션 ${stBadge}</h2><dl class="kv">${o.status === 'active' ? `<dt>만료 예정</dt><dd>${o.expires_at ? esc(fmt(o.expires_at)) : '상품 갱신과 연동'}</dd>` : ''}${o.status === 'readonly' ? `<dt>읽기 전용 기간</dt><dd>${esc(fmt(o.readonly_until))} 까지 조회·clone·export 가능 (push·새 변경 불가)</dd><dt>삭제 예정일</dt><dd>${esc(fmt(o.delete_after))}</dd><dt>재활성화</dt><dd><a href="/billing">Source 옵션을 다시 구매</a>하면 삭제 전까지 복구됩니다</dd>` : ''}${o.status === 'deleted' ? `<dt>삭제 완료</dt><dd>${esc(fmt(o.deleted_at))} — 저장소 데이터는 복구할 수 없습니다</dd>` : ''}</dl></div>
|
||||
${s ? `<div class="card"><h2>저장소</h2>${s.stale ? '<p class="aw-state stale"><b>정보 확인 지연</b> Webmaker/Gitea 상태를 지금 확인할 수 없습니다</p>' : ''}<dl class="kv"><dt>저장소</dt><dd>${esc(s.remote || '-')} (${esc(s.provider)})</dd><dt>기본 브랜치</dt><dd class="mono">${esc(s.mainBranch || '-')} ${s.mainUrl ? `<a href="${esc(s.mainUrl)}" target="_blank" rel="noopener">열기</a>` : ''}</dd><dt>선택한 revision</dt><dd class="mono">${esc(r.selectedRevision || '현재 작업 트리')}</dd><dt>마지막 배포 revision</dt><dd class="mono">${esc(r.lastDeployedRevision || '-')} ${r.lastDeployedVersion ? `(배포 버전 ${esc(r.lastDeployedVersion)})` : ''}</dd><dt>저장소 사용량</dt><dd>${B(s.repoBytes)} / 한도 ${r.limits ? B(r.limits.repoBytes) : '상품정책'}</dd><dt>LFS</dt><dd>${s.lfsSupported ? B(s.lfsBytes) : '제공되지 않음(이번 버전)'}</dd><dt>첨부 파일</dt><dd>${B(s.uploadBytes)}</dd><dt>clone</dt><dd class="mono">${esc(r.cloneInfo || '-')}</dd></dl><div class="actions">${o.status !== 'deleted' ? `<a class="btn" href="/api/source/${sid}/export" target="_blank">export (tar.gz)</a>` : ''}${o.status === 'active' ? `<button class="btn" id="btnUnlink">저장소 연결 해제</button>` : ''}</div></div>
|
||||
<div class="card"><h2>변경 이력</h2>${s.changes && s.changes.length ? `<table class="r"><thead><tr><th>#</th><th>종류</th><th>메시지</th><th>시각</th><th>브랜치</th><th>배포</th></tr></thead><tbody>${s.changes.slice().reverse().map((c) => `<tr><td data-l="#">${c.no}</td><td data-l="종류">${esc(c.kind)}</td><td data-l="메시지">${esc(c.message || '')}</td><td data-l="시각">${esc(fmt(c.at))}</td><td data-l="브랜치" class="mono">${esc(c.branch || '')} ${c.pushed ? '' : '(push 대기)'}</td><td data-l="배포">${r.deployedCommits && r.deployedCommits[c.commit] ? '✔ ' + esc(r.deployedCommits[c.commit]) : ''}</td></tr>`).join('')}</tbody></table>` : '<p class="muted">변경 이력이 없습니다</p>'}</div>` : ''}`;
|
||||
const bu = $('#btnUnlink'); if (bu) bu.onclick = async () => { if (!(await aw.confirmDanger({ title: '저장소 연결 해제', target: s.remote, impacts: ['새 변경이 Gitea 에 push 되지 않습니다', '30일 동안 read-only 조회·clone·export 가 가능하고 그 뒤 삭제됩니다'] }))) return; try { await aw.api('POST', `/api/source/${sid}/unlink`); load(); } catch (e) { aw.toast(e.message); } };
|
||||
} catch (e) { onErr(el, e, load); } }; load(); }
|
||||
|
||||
const page = document.querySelector('[data-page]')?.dataset.page;
|
||||
({ 'sites-list': list, 'sites-new': create, 'site-overview': overview, 'site-build': build, 'site-deploy': deploy, 'site-mode': mode, 'site-external': external, 'site-usage': usage, 'site-activity': activity, 'site-settings': settings, 'site-domains': siteDomains, 'site-source': source }[page] || (() => {}))();
|
||||
})();
|
||||
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* portal/sites/service.mjs — 사이트·배포 업무 규칙(개발 지시 §7·§8·§10·§11·§13.1·§35·§36).
|
||||
*
|
||||
* 이 모듈이 지키는 규칙
|
||||
* - 초안(draft) 사이트는 운영 사이트 수를 쓰지 않는다. 최초 공개 배포 직전에 lib/quota 로 1 을 **예약**(site_count_state='reserved')하고,
|
||||
* 배포와 대표 도메인 확인이 끝나면 **확정**(in_use)한다. 취소·실패 정리는 예약을 **반환**한다(§5.1·§11.2).
|
||||
* - 배포는 lib/jobs 의 내구성 작업(kind 'site.deploy')이다. 화면을 닫아도 계속되고, 같은 사이트·저장본의 요청은 멱등 키로 한 번만 만들어진다(§36).
|
||||
* 실제 서버 작업은 HAL(docs/HAL-JOBS.md)에 맡기고 결과(실제 실행 버전·내부/외부 확인)를 받아야만 완료로 표시한다(§32.2 "명령 성공만으로 완료하지 않는다").
|
||||
* - 배포 완료는 세 단계로 구분한다: verified(배포 검증 완료) → needs_domain(도메인 설정 필요) → public(공개 운영 중)(§11.2).
|
||||
* - 외부 배포 사이트에는 관리형 지표(uptime·디스크·트래픽·백업 보호)를 보이지 않고 '제공되지 않음' 으로 둔다(§8.1).
|
||||
* - Webmaker 프로젝트(webmaker_project_id)와 운영 사이트는 다른 것이다. 프로젝트 삭제는 포털 사이트를 지우지 않는다(impactForProject).
|
||||
*
|
||||
* HAL site.deploy 작업의 자식 단계 id(Agent 가 보고): apply · data_check · start · internal_check · external_check — 포털의 10단계(§11.2) 4~8 에 대응.
|
||||
*/
|
||||
import { newId, randomToken, sha256 } from '../../lib/crypto.mjs';
|
||||
import { HttpError } from '../../lib/http.mjs';
|
||||
import { nowIso, parseJson, toJson } from '../../lib/db.mjs';
|
||||
import * as quota from '../../lib/quota.mjs';
|
||||
import { JobError, describe as describeJob } from '../../lib/jobs.mjs';
|
||||
import { notify } from '../../lib/notify.mjs';
|
||||
import { audit } from '../../lib/audit.mjs';
|
||||
import { SERVICE_STATE, DEPLOY_VERIFY, HEALTH, isStale } from '../../lib/status.mjs';
|
||||
import { monthStart } from '../../lib/kst.mjs';
|
||||
import { loginFor } from '../webmaker/adapter.mjs';
|
||||
import { mkdirSync, existsSync, unlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/** 사용자용 10단계(§11.2). id 는 작업 steps 와 HAL 단계 대응에 쓴다. */
|
||||
export const DEPLOY_STEPS = [
|
||||
{ id: 'accept', title: '요청 접수' }, { id: 'prepare_version', title: '저장본 준비' }, { id: 'prepare_target', title: '배포 위치 준비' }, { id: 'apply', title: '파일과 설정 적용' },
|
||||
{ id: 'data_check', title: '데이터베이스와 업로드 데이터 확인' }, { id: 'start', title: '서비스 시작' }, { id: 'internal_check', title: '내부 확인' }, { id: 'external_check', title: '외부 접속 확인' },
|
||||
{ id: 'domain_https', title: '도메인과 HTTPS 확인' }, { id: 'done', title: '완료' },
|
||||
];
|
||||
export const EXTERNAL_CONSENT_VERSION = 'external-deploy-2026-10-0.1.1';
|
||||
export const EXTERNAL_CONSENT = {
|
||||
version: EXTERNAL_CONSENT_VERSION,
|
||||
title: '외부 서버 배포 확인 사항',
|
||||
items: ['외부 서버의 준비·보안·운영·장애 대응은 고객 책임입니다. Ariweb 는 External Pull Agent 를 통한 배포·재배포·제거와 마지막 외부 확인 결과만 제공합니다.',
|
||||
'관리형 모니터링·백업·서버 이전·SLA 는 제공되지 않습니다. 외부 환경 장애는 Ariweb 서비스 정상 상태와 분리됩니다.',
|
||||
'배포 건마다 건별 비용이 적용됩니다(상품 조건의 "외부 배포 건별 비용"). 승인된 금액은 배포 화면에 표시됩니다.',
|
||||
'외부 배포 사이트도 공개 사이트이면 운영 사이트 수를 소비합니다. 관리형 디스크·트래픽·메일·백업 권리는 자동 할당되지 않습니다.',
|
||||
'버전 0.1.1 에서는 외부 메일 배포를 지원하지 않습니다.'],
|
||||
};
|
||||
const RETURN_GRACE_MS = 24 * 3600 * 1000; // 실패한 중간 결과를 정리하지 않을 때 예약이 반환되는 유예(§11.2 "반환 예정 시각")
|
||||
const USAGE_BUCKET_MS = 10 * 60 * 1000; // 사용량·건강 확인 작업을 10분 단위로 하나만 만든다(멱등)
|
||||
const LIFECYCLE_MODIFIABLE = new Set(['draft', 'building', 'deploying', 'live', 'needs_domain', 'paused']);
|
||||
|
||||
export function createSitesService(deps) {
|
||||
const { store, jobs, config, log = () => {} } = deps;
|
||||
const hal = () => { if (!deps.hal) throw Object.assign(new Error('HAL 연결이 설정되지 않았습니다'), { code: 'service_unreachable' }); return deps.hal; };
|
||||
const wm = () => { if (!deps.webmaker) throw Object.assign(new Error('Webmaker 연결이 설정되지 않았습니다'), { code: 'service_unreachable' }); return deps.webmaker; };
|
||||
const bundleDir = join(config.dataDir, 'bundles');
|
||||
const row = (r) => (r ? { ...r, settings: parseJson(r.settings_json, {}), pendingCleanup: parseJson(r.pending_cleanup_json, null), lastExternalCheck: parseJson(r.last_external_check_json, null), nextJob: parseJson(r.next_job_json, null) } : null);
|
||||
const dep = (r) => (r ? { ...r, result: parseJson(r.result_json, null) } : null);
|
||||
const log2 = (...a) => log('[sites]', ...a);
|
||||
|
||||
function get(id) { return row(store.get('SELECT * FROM sites WHERE id=?', id)); }
|
||||
function getForGroup(groupId, id) { const s = get(id); return s && s.group_id === groupId ? s : null; }
|
||||
function save(id, patch) { const sets = []; const vals = []; for (const [k, v] of Object.entries(patch)) { sets.push(`${k}=?`); vals.push(v && typeof v === 'object' ? toJson(v) : v); } sets.push('updated_at=?'); vals.push(nowIso()); store.run(`UPDATE sites SET ${sets.join(', ')} WHERE id=?`, ...vals, id); return get(id); }
|
||||
function userLogin(user, group) { return loginFor(user, group); }
|
||||
|
||||
// ------------------------------------------------------------------ 목록·생성·설정
|
||||
/** 사이트 목록(§7.1). lifecycle 필터: draft|building|deploying|live|paused|cancel_pending */
|
||||
function list(groupId, { lifecycle = null } = {}) {
|
||||
const map = { draft: ['draft'], building: ['building'], deploying: ['deploying'], live: ['live', 'needs_domain'], paused: ['paused'], cancel_pending: ['cancel_scheduled', 'readonly_retention', 'pending_delete', 'delete_hold'] };
|
||||
const states = lifecycle ? map[lifecycle] || [lifecycle] : null;
|
||||
const rows = store.all(`SELECT * FROM sites WHERE group_id=? AND lifecycle<>'deleted' ${states ? `AND lifecycle IN (${states.map(() => '?').join(',')})` : ''} ORDER BY created_at DESC`, ...(states ? [groupId, ...states] : [groupId])).map(row);
|
||||
return rows.map((s) => summarize(s));
|
||||
}
|
||||
/** 목록 한 줄 요약: 메일·백업 옵션 여부, 마지막 배포, 건강, 사용자 조치 필요 */
|
||||
function summarize(s) {
|
||||
const mail = store.get('SELECT id, status FROM mail_services WHERE site_id=? AND deleted_at IS NULL', s.id) || null;
|
||||
const backup = store.get('SELECT id, status FROM backup_options WHERE site_id=?', s.id) || null;
|
||||
const lastDeploy = dep(store.get('SELECT * FROM deployments WHERE site_id=? ORDER BY created_at DESC LIMIT 1', s.id));
|
||||
const actions = actionNeeded(s, lastDeploy);
|
||||
return { id: s.id, name: s.name, primaryDomain: s.primary_domain, lifecycle: s.lifecycle, deployMode: s.deploy_mode, runtime: s.runtime, buildState: buildState(s), deployState: lastDeploy ? { status: lastDeploy.status, verify: lastDeploy.verify_state, at: lastDeploy.created_at, version: lastDeploy.version_label || lastDeploy.version_ref } : null,
|
||||
mail: mail ? { id: mail.id, status: mail.status } : null, backup: backup ? { id: backup.id, status: backup.status } : null, lastDeployAt: s.last_deploy_at, health: s.deploy_mode === 'external' ? HEALTH.NOT_PROVIDED : (isStale(s.health_checked_at, 15 * 60 * 1000) && ['live', 'needs_domain'].includes(s.lifecycle) ? HEALTH.STALE : s.health),
|
||||
siteCountState: s.site_count_state, actionNeeded: actions, webmakerProjectId: s.webmaker_project_id, updatedAt: s.updated_at, createdAt: s.created_at };
|
||||
}
|
||||
function buildState(s) { if (!s.webmaker_project_id) return 'no_project'; return s.lifecycle === 'draft' ? 'drafting' : 'ready'; }
|
||||
/** 사용자가 해야 할 일(§7.1 "조치 필요"). 링크는 조치 화면 */
|
||||
function actionNeeded(s, lastDeploy) {
|
||||
const out = [];
|
||||
if (s.lifecycle === 'needs_domain') out.push({ code: 'needs_domain', text: '대표 도메인 연결과 HTTPS 확인이 필요합니다', href: `/sites/${s.id}?tab=domains` });
|
||||
if (lastDeploy && lastDeploy.status === 'failed') out.push({ code: 'deploy_failed', text: '마지막 배포가 실패했습니다. 재시도 또는 되돌리기', href: `/sites/${s.id}?tab=deploy` });
|
||||
if (s.pendingCleanup) out.push({ code: 'cleanup', text: `정리하지 않은 중간 결과가 있습니다 (예약 반환 예정 ${s.return_due_at || '-'})`, href: `/sites/${s.id}?tab=deploy` });
|
||||
if (s.deploy_mode === 'external') { const a = store.get('SELECT status FROM external_agents WHERE site_id=? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1', s.id); if (!a || a.status !== 'connected') out.push({ code: 'agent', text: '외부 서버 Agent 연결이 필요합니다', href: `/sites/${s.id}/external` }); }
|
||||
if (!s.deploy_mode && s.lifecycle === 'draft') out.push({ code: 'mode', text: '배포 방식을 선택하세요', href: `/sites/${s.id}/deploy/mode` });
|
||||
return out;
|
||||
}
|
||||
/**
|
||||
* 사이트 생성(§7.2). 초안이며 사이트 수를 쓰지 않는다. projectMode: new(Webmaker 프로젝트를 새로 만들어 연결) | existing(내 프로젝트 연결) | none(나중에).
|
||||
*/
|
||||
async function create({ user, group, ip = '' }, input) {
|
||||
const name = String(input.name || '').trim().slice(0, 120); if (!name) throw new HttpError(400, 'name_required', '사이트 이름을 입력하세요', { field: 'name' });
|
||||
const purpose = String(input.purpose || '').trim().slice(0, 300);
|
||||
const lang = /^[a-z]{2}(-[A-Z]{2})?$/.test(input.lang || '') ? input.lang : 'ko'; const timezone = String(input.timezone || 'Asia/Seoul').slice(0, 60);
|
||||
const deployMode = ['managed', 'external'].includes(input.deployMode) ? input.deployMode : null;
|
||||
const projectMode = ['new', 'existing', 'none'].includes(input.projectMode) ? input.projectMode : 'new';
|
||||
const id = newId('s_'); const now = nowIso(); const login = userLogin(user, group);
|
||||
let projectId = null; let projectNote = null;
|
||||
if (projectMode === 'existing') {
|
||||
const pid = String(input.projectId || ''); if (!/^[a-f0-9]{32}$/.test(pid)) throw new HttpError(400, 'project_required', '연결할 Webmaker 프로젝트를 고르세요', { field: 'projectId' });
|
||||
const p = await wm().getProject(pid).catch((e) => { throw new HttpError(e.status === 404 ? 404 : 502, e.code || 'webmaker_error', e.status === 404 ? '프로젝트를 찾을 수 없습니다' : 'Webmaker 에 연결할 수 없어 프로젝트를 확인하지 못했습니다'); });
|
||||
if (p.ownerId !== login) throw new HttpError(403, 'project_not_owned', '내 Webmaker 계정의 프로젝트만 연결할 수 있습니다');
|
||||
if (p.link && p.link.siteId && get(p.link.siteId)) throw new HttpError(409, 'project_linked', `이 프로젝트는 이미 다른 사이트(${p.link.siteName || p.link.siteId})에 연결되어 있습니다`);
|
||||
const other = store.get('SELECT id FROM sites WHERE webmaker_project_id=? AND lifecycle<>\'deleted\'', pid); if (other) throw new HttpError(409, 'project_linked', '이 프로젝트는 이미 다른 사이트에 연결되어 있습니다');
|
||||
projectId = pid;
|
||||
}
|
||||
store.run('INSERT INTO sites(id, group_id, name, purpose, webmaker_project_id, template, lang, timezone, lifecycle, deploy_mode, site_count_state, health, runtime, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,\'draft\',?,\'none\',\'unknown\',?,?,?,?)',
|
||||
id, group.id, name, purpose, projectId, String(input.template || '').slice(0, 80) || null, lang, timezone, deployMode, ['static', 'node'].includes(input.runtime) ? input.runtime : 'static', user.id, now, now);
|
||||
const link = { siteId: id, groupId: group.id, siteName: name, groupName: group.name, portalUrl: config.publicUrl };
|
||||
try {
|
||||
if (projectMode === 'new') { const p = await wm().createProject({ login, ownerName: user.display_name || user.email, title: name, mode: ['ai', 'manual', 'quick'].includes(input.buildMode) ? input.buildMode : 'manual', purpose, link }); projectId = p.id; save(id, { webmaker_project_id: projectId }); }
|
||||
else if (projectMode === 'existing') await wm().linkProject(projectId, link);
|
||||
} catch (e) { projectNote = e.code === 'service_unreachable' ? 'Webmaker 에 연결할 수 없어 프로젝트를 만들지 못했습니다. 제작 탭에서 다시 시도하세요' : `프로젝트 연결 실패: ${e.message}`; log2(projectNote); }
|
||||
audit(store, { actorType: 'user', actorId: user.id, groupId: group.id, subjectType: 'site', subjectId: id, action: 'site.create', detail: { name, projectMode, projectId, deployMode }, ip });
|
||||
return { site: get(id), nextSteps: nextSteps(get(id)), projectNote };
|
||||
}
|
||||
/** 생성 후 안내할 다음 단계(§7.2) */
|
||||
function nextSteps(s) {
|
||||
const steps = [{ id: 'build', label: 'Webmaker 에서 사이트 제작', href: `/sites/${s.id}?tab=build`, done: !!s.webmaker_project_id && s.lifecycle !== 'draft' }, { id: 'import', label: '콘텐츠 가져오기', href: `/sites/${s.id}?tab=build`, done: false, unsupported: '이번 버전에서 지원하지 않음' }, { id: 'domain', label: '도메인 연결', href: `/sites/${s.id}?tab=domains`, done: !!s.primary_domain }, { id: 'mode', label: '배포 방식 선택', href: `/sites/${s.id}/deploy/mode`, done: !!s.deploy_mode }, { id: 'perms', label: '구성원 권한 설정', href: `/sites/${s.id}?tab=settings`, done: false }];
|
||||
return steps;
|
||||
}
|
||||
/** 설정 변경(§8.2). 서비스 중단이 예상되는 항목(유지보수 모드·검색 공개)은 impact 를 함께 돌려준다 */
|
||||
function update(site, patch, actor) {
|
||||
if (!LIFECYCLE_MODIFIABLE.has(site.lifecycle)) throw new HttpError(409, 'site_not_modifiable', `지금 상태(${site.lifecycle})에서는 설정을 바꿀 수 없습니다`);
|
||||
const p = {}; const impact = [];
|
||||
if (patch.name !== undefined) { const n = String(patch.name).trim().slice(0, 120); if (!n) throw new HttpError(400, 'name_required', '사이트 이름을 입력하세요', { field: 'name' }); p.name = n; }
|
||||
if (patch.description !== undefined) p.description = String(patch.description).slice(0, 2000);
|
||||
if (patch.lang !== undefined && /^[a-z]{2}(-[A-Z]{2})?$/.test(patch.lang)) p.lang = patch.lang;
|
||||
if (patch.timezone !== undefined) p.timezone = String(patch.timezone).slice(0, 60);
|
||||
if (patch.maintenance !== undefined) { p.maintenance = patch.maintenance ? 1 : 0; if (site.lifecycle === 'live' && p.maintenance !== site.maintenance) impact.push(p.maintenance ? '유지보수 모드를 켜면 공개 주소에 안내 페이지가 보이고 방문자는 사이트를 볼 수 없습니다(예상 적용 1분 이내)' : '유지보수 모드를 끄면 즉시 공개됩니다'); }
|
||||
if (patch.searchVisible !== undefined) p.search_visible = patch.searchVisible ? 1 : 0;
|
||||
if (patch.runtime !== undefined && ['static', 'node'].includes(patch.runtime)) p.runtime = patch.runtime;
|
||||
if (Object.keys(p).length) { save(site.id, p); audit(store, { actorType: 'user', actorId: actor?.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'site.settings', detail: p }); }
|
||||
if (p.maintenance !== undefined && ['live', 'needs_domain'].includes(site.lifecycle) && site.deploy_mode === 'managed' && site.server_id) {
|
||||
// 유지보수 모드는 vhost 안내 페이지 전환 작업으로 적용한다(HAL domain.vhost maintenance)
|
||||
const { job } = jobs.enqueue({ kind: 'site.vhost', subject: { siteId: site.id, maintenance: !!p.maintenance }, idempotencyKey: `site.vhost:${site.id}:maint:${p.maintenance}:${Date.now() / 60000 | 0}`, createdBy: actor?.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, label: p.maintenance ? '유지보수 모드 켜기' : '유지보수 모드 끄기' });
|
||||
return { site: get(site.id), impact, jobId: job.id };
|
||||
}
|
||||
return { site: get(site.id), impact };
|
||||
}
|
||||
/** 생명주기 전환(다른 영역 — 중지·해지·이전 — 이 부른다). 상태별 부수 효과: 공개 중지는 vhost 안내 페이지, 삭제는 프로젝트 연결 해제 */
|
||||
function setLifecycle(siteId, lifecycle, { actor = null, extra = {} } = {}) {
|
||||
if (!Object.values(SERVICE_STATE).includes(lifecycle)) throw new HttpError(400, 'bad_lifecycle', `알 수 없는 상태: ${lifecycle}`);
|
||||
const before = get(siteId); if (!before) throw new HttpError(404, 'not_found', '사이트가 없습니다');
|
||||
const p = { lifecycle, ...extra }; if (lifecycle === 'paused') p.paused_at = nowIso(); if (lifecycle === 'deleted') p.deleted_at = nowIso();
|
||||
const after = save(siteId, p);
|
||||
audit(store, { actorType: actor ? 'user' : 'system', actorId: actor?.id, groupId: before.group_id, subjectType: 'site', subjectId: siteId, action: `site.lifecycle.${lifecycle}`, detail: { from: before.lifecycle, to: lifecycle }, automatic: !actor, immutable: true });
|
||||
return after;
|
||||
}
|
||||
/** 운영 사이트 수 반환 완료(§14 조건은 호출자가 검사) → 배정 삭제·상태 none */
|
||||
function returnSiteCount(siteId, actor = null) {
|
||||
const s = get(siteId); if (!s) return null;
|
||||
const a = quota.allocationOf(store, s.group_id, 'sites', 'site', s.id); if (a) quota.finishReturn(store, a.id);
|
||||
if (s.site_count_reservation_id) quota.release(store, s.site_count_reservation_id);
|
||||
audit(store, { actorType: actor ? 'user' : 'system', actorId: actor?.id, groupId: s.group_id, subjectType: 'site', subjectId: s.id, action: 'site_count.returned', detail: { allocation: a ? a.id : null } });
|
||||
return save(siteId, { site_count_state: 'none', site_count_reservation_id: null, return_due_at: null });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Webmaker 연동
|
||||
function buildUrl({ user, group, site, path = null }) { return wm().ssoUrl({ user, group, site, projectId: site.webmaker_project_id, path }); }
|
||||
async function versionsFor(site) { if (!site.webmaker_project_id) return { project: null, versions: [] }; const p = await wm().getProject(site.webmaker_project_id); return { project: p, versions: (p.versionList || []).slice().reverse() }; }
|
||||
/** Webmaker 프로젝트 삭제 영향(Webmaker → 포털 /internal/webmaker/project-impact). blocked 면 Webmaker 가 폐기를 거부한다 */
|
||||
function impactForProject(projectId, siteId = null) {
|
||||
const s = siteId ? get(siteId) : row(store.get('SELECT * FROM sites WHERE webmaker_project_id=? AND lifecycle<>\'deleted\'', projectId));
|
||||
if (!s) return { blocked: false, liveSite: false, lifecycle: null, deployments: 0, domains: [], message: '연결된 운영 사이트가 없습니다' };
|
||||
const n = store.get('SELECT COUNT(*) AS n FROM deployments WHERE site_id=?', s.id).n; const domains = store.all('SELECT name FROM domains WHERE site_id=?', s.id).map((d) => d.name);
|
||||
const live = ['live', 'needs_domain', 'deploying', 'paused'].includes(s.lifecycle);
|
||||
return { blocked: live, liveSite: live, lifecycle: s.lifecycle, siteName: s.name, deployments: n, domains, message: live ? `사이트 "${s.name}" 이(가) ${s.lifecycle === 'paused' ? '일시 중지' : '운영'} 중이라 프로젝트를 폐기할 수 없습니다. 포털에서 사이트를 먼저 중지·해지하세요. 운영 사이트·배포본·도메인은 프로젝트 폐기로 지워지지 않습니다` : `사이트 "${s.name}"(${s.lifecycle}) 의 제작 프로젝트 연결이 끊깁니다. 포털의 사이트·배포 기록(${n}건)·도메인(${domains.length}개)은 유지됩니다` };
|
||||
}
|
||||
/** Webmaker 가 프로젝트를 폐기한 뒤 연결 해제 */
|
||||
function onProjectDestroyed(projectId) { const s = row(store.get('SELECT * FROM sites WHERE webmaker_project_id=?', projectId)); if (s) save(s.id, { webmaker_project_id: null }); return !!s; }
|
||||
|
||||
// ------------------------------------------------------------------ 배포 전 확인(§11.1)
|
||||
function activeJobsFor(siteId) { return jobs.list({ subjectType: 'site', subjectId: siteId, status: ['queued', 'running', 'awaiting_external', 'verifying', 'reverting', 'needs_user'], limit: 20 }); }
|
||||
function externalAgentFor(siteId) { const a = store.get('SELECT * FROM external_agents WHERE site_id=? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1', siteId); return a ? { ...a, lastDeploy: parseJson(a.last_deploy_json, null), lastCheck: parseJson(a.last_check_json, null) } : null; }
|
||||
/**
|
||||
* 배포 전 확인. items[] 마다 { id, title, state: 'pass'|'block'|'warn'|'info', text, href }. ok 는 block 이 없을 때.
|
||||
*/
|
||||
async function precheck(site, { versionRef = null } = {}) {
|
||||
const items = []; const add = (id, title, state, text, href = null) => items.push({ id, title, state, text, href });
|
||||
// 1 저장본
|
||||
let project = null; let bundleBytes = 0;
|
||||
if (!site.webmaker_project_id) add('version', '배포할 저장본', 'block', 'Webmaker 프로젝트가 연결되어 있지 않습니다', `/sites/${site.id}?tab=build`);
|
||||
else { try { project = await wm().getProject(site.webmaker_project_id); const vlist = project.versionList || []; const v = versionRef && versionRef !== 'current' ? vlist.find((x) => String(x.no) === String(versionRef)) : null; if (versionRef && versionRef !== 'current' && !v) add('version', '배포할 저장본', 'block', `저장본 #${versionRef} 이(가) 없습니다`, `/sites/${site.id}?tab=deploy`); else if (!project.hasContent && !v) add('version', '배포할 저장본', 'block', '아직 제작된 페이지가 없습니다. Webmaker 에서 홈 페이지를 만들고 저장하세요', `/sites/${site.id}?tab=build`); else add('version', '배포할 저장본', 'pass', v ? `저장본 #${v.no} (${v.message || v.kind}, ${v.at})` : `현재 저장 상태(rev ${project.rev})`); } catch (e) { add('version', '배포할 저장본', 'block', e.code === 'service_unreachable' ? 'Webmaker 상태를 확인할 수 없습니다(정보 확인 지연). 잠시 후 다시 시도하세요' : `저장본 확인 실패: ${e.message}`); } }
|
||||
// 2 운영 사이트 수
|
||||
const f = quota.figures(store, site.group_id, 'sites');
|
||||
if (site.site_count_state === 'in_use') add('sites', '운영 사이트 수', 'pass', '이 사이트는 이미 운영 사이트 수를 사용 중입니다');
|
||||
else if (site.site_count_state === 'reserved') add('sites', '운영 사이트 수', 'pass', '예약됨(배포와 대표 도메인 확인 뒤 사용 중으로 전환)');
|
||||
else if (f.unallocated > 0) add('sites', '운영 사이트 수', 'pass', `사용 가능 ${f.unallocated}개 (구매 ${f.purchased} · 사용 중 ${f.allocated} · 예약 ${f.reserved}) — 배포 시작 때 1개를 예약합니다`);
|
||||
else add('sites', '운영 사이트 수', 'block', f.purchased === 0 ? '운영 사이트 수를 포함한 상품을 먼저 구매·승인받아야 합니다' : `운영 사이트 수가 모두 사용 중입니다(구매 ${f.purchased}). 상품을 추가하거나 다른 사이트의 사이트 수를 반환하세요`, '/billing');
|
||||
// 3 배포 위치
|
||||
if (!site.deploy_mode) add('target', '배포 위치', 'block', '배포 방식을 먼저 선택하세요', `/sites/${site.id}/deploy/mode`);
|
||||
else if (site.deploy_mode === 'managed') { try { const picked = site.server_id ? { serverId: site.server_id } : await hal().pickServer({ role: 'web', needBytes: bundleBytes }); if (picked) add('target', '배포 위치', 'pass', site.server_id ? '현재 운영 서버에 배포합니다' : '배치 가능한 관리형 서버가 있습니다'); else add('target', '배포 위치', 'block', '지금 신규 배치를 받을 수 있는 서버가 없습니다. 잠시 후 다시 시도하거나 문의하세요'); } catch (e) { add('target', '배포 위치', 'block', 'HAL(중앙 관리)에 연결할 수 없어 배포 위치를 확인하지 못했습니다(정보 확인 지연). 기존 서비스는 유지되며 신규 배포는 복구 후 가능합니다'); } }
|
||||
else { const a = externalAgentFor(site.id); if (a && a.status === 'connected') add('target', '배포 위치', 'pass', `외부 서버 Agent 연결됨 (${a.label || a.hal_agent_id})`); else add('target', '배포 위치', 'block', a ? `외부 서버 Agent 가 아직 연결되지 않았습니다(${a.status})` : '외부 서버 Agent 를 먼저 연결하세요', `/sites/${site.id}/external`);
|
||||
if (!site.consent_version || site.consent_version !== EXTERNAL_CONSENT_VERSION) add('consent', '외부 배포 확인 사항 동의', 'block', '외부 배포 확인 사항(고객 책임·미제공 기능·건별 비용)에 동의해야 합니다', `/sites/${site.id}/deploy/mode`); else add('consent', '외부 배포 확인 사항 동의', 'pass', `동의함 (${site.consent_version})`); }
|
||||
// 4 디스크·DB 용량(관리형만)
|
||||
if (site.deploy_mode === 'managed') { const alloc = quota.allocationOf(store, site.group_id, 'web_disk', 'site', site.id); const wf = quota.figures(store, site.group_id, 'web_disk'); if (alloc && alloc.amount > 0) add('capacity', '디스크와 데이터베이스 용량', 'pass', `배정 ${fmtBytes(alloc.amount)} (사용 ${fmtBytes(usageOf(site, 'web_disk'))})`); else if (wf.unallocated > 0) add('capacity', '디스크와 데이터베이스 용량', 'warn', `이 사이트에 배정된 웹 디스크가 없습니다. 배포 시작 때 미배정량(${fmtBytes(wf.unallocated)})에서 기본량을 배정합니다`, '/usage'); else add('capacity', '디스크와 데이터베이스 용량', 'block', '배정할 수 있는 웹 디스크가 없습니다. 상품을 추가하거나 다른 사이트의 배정을 줄이세요', '/usage'); if (site.runtime === 'node') add('runtime', '동적 사이트 실행 형태', 'info', 'Ariweb 가 승인한 Node 실행 형태입니다. 필요한 저장 공간: 사이트 파일 + 업로드. 데이터베이스는 SQLite 파일만 지원(외부 DB·cron·메일 발송은 이번 버전에서 지원하지 않음)'); else add('runtime', '실행 형태', 'info', '정적 사이트(HTML·CSS·JS·이미지). 서버 측 코드는 실행되지 않습니다'); }
|
||||
// 5 도메인 상태
|
||||
const pd = site.primary_domain ? store.get('SELECT * FROM domains WHERE name=? AND site_id=?', site.primary_domain, site.id) : null;
|
||||
if (pd && pd.dns_state === 'ok' && pd.cert_state === 'valid') add('domain', '도메인 상태', 'pass', `대표 도메인 ${pd.name} 연결·HTTPS 정상`); else if (pd) add('domain', '도메인 상태', 'warn', `대표 도메인 ${pd.name} 의 DNS/HTTPS 가 아직 완료되지 않았습니다(DNS ${pd.dns_state}, 인증서 ${pd.cert_state}). 배포는 진행되며 완료 상태는 "도메인 설정 필요" 가 됩니다`, `/domains/${pd.id}`); else add('domain', '도메인 상태', 'warn', '대표 도메인이 없습니다. 배포 후 "도메인 설정 필요" 상태가 되며 플랫폼 확인 주소에서만 볼 수 있습니다', `/sites/${site.id}?tab=domains`);
|
||||
// 6 필수 설정
|
||||
if (['cancel_scheduled', 'readonly_retention', 'pending_delete', 'deleted', 'delete_hold'].includes(site.lifecycle)) add('lifecycle', '사이트 상태', 'block', `해지·보존 중인 사이트(${site.lifecycle})에는 배포할 수 없습니다`, `/sites/${site.id}/lifecycle`);
|
||||
// 7 다른 작업과 충돌
|
||||
const act = activeJobsFor(site.id).filter((j) => ['site.deploy', 'site.rollback', 'site.mode_change', 'backup.restore', 'site.relocate', 'site.cleanup'].includes(j.kind));
|
||||
if (act.length) add('conflict', '다른 작업과의 충돌', 'block', `진행 중인 작업이 있습니다: ${act.map((j) => `${j.label}(${j.status})`).join(', ')}`, `/sites/${site.id}?tab=deploy`); else add('conflict', '다른 작업과의 충돌', 'pass', '진행 중인 배포·복원·이전 작업이 없습니다');
|
||||
return { ok: !items.some((i) => i.state === 'block'), items, project: project ? { id: project.id, title: project.title, rev: project.rev, hasContent: project.hasContent } : null };
|
||||
}
|
||||
function usageOf(site, resource) { const u = quota.usageOf(store, site.group_id, resource, 'site', site.id); return u ? u.used : 0; }
|
||||
|
||||
// ------------------------------------------------------------------ 배포 시작·작업
|
||||
/**
|
||||
* 배포 시작(§11). 사전 확인 통과 → (최초 공개 배포면) 사이트 수 예약 → 웹 디스크 기본 배정 → deployments 행 → 작업 enqueue(멱등).
|
||||
*/
|
||||
async function startDeploy({ user, group, site, versionRef = 'current', versionLabel = null, ip = '' }) {
|
||||
// 멱등: 같은 사이트·저장본·배포 순번의 요청(버튼 연타·새로 고침)은 기존 작업을 돌려준다 — 사전 확인보다 먼저 본다(진행 중 작업 충돌로 409 가 나지 않게)
|
||||
const seq = store.get('SELECT COUNT(*) AS n FROM deployments WHERE site_id=?', site.id).n;
|
||||
const idem = `site.deploy:${site.id}:${versionRef}:${seq}`;
|
||||
const existing = store.get('SELECT * FROM jobs WHERE idempotency_key=?', idem) || activeJobsFor(site.id).find((j) => j.kind === 'site.deploy' && String(j.subject.versionRef) === String(versionRef));
|
||||
if (existing) { const d = dep(store.get('SELECT * FROM deployments WHERE job_id=?', existing.id)); return { deployment: d, job: describeJob(jobs.get(existing.id)), created: false }; }
|
||||
const pc = await precheck(site, { versionRef });
|
||||
if (!pc.ok) throw new HttpError(409, 'precheck_failed', '배포 전 확인에서 차단된 항목이 있습니다', { items: pc.items });
|
||||
const now = nowIso(); const did = newId('d_');
|
||||
let reserved; try { reserved = store.tx(() => {
|
||||
let resId = site.site_count_reservation_id; let state = site.site_count_state;
|
||||
if (state === 'none') { const r = quota.reserve(store, { groupId: group.id, resource: 'sites', amount: 1, subjectType: 'site', subjectId: site.id, reason: '최초 공개 배포', ttlMs: 0 }); resId = r.id; state = 'reserved'; }
|
||||
if (site.deploy_mode === 'managed' && !quota.allocationOf(store, group.id, 'web_disk', 'site', site.id)) { const wf = quota.figures(store, group.id, 'web_disk'); const def = Math.min(wf.unallocated, defaultDiskBytes(group.id)); if (def > 0) quota.setAllocation(store, { groupId: group.id, resource: 'web_disk', subjectType: 'site', subjectId: site.id, amount: def }); }
|
||||
store.run('INSERT INTO deployments(id, site_id, group_id, version_ref, version_label, mode, target_id, status, previous_version, started_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,\'queued\',?,?,?,?)', did, site.id, group.id, String(versionRef), versionLabel, site.deploy_mode, site.deploy_mode === 'managed' ? site.server_id : (externalAgentFor(site.id) || {}).hal_agent_id, site.current_version, user.id, now, now);
|
||||
save(site.id, { site_count_state: state, site_count_reservation_id: resId, lifecycle: site.lifecycle === 'draft' || site.lifecycle === 'building' ? 'deploying' : site.lifecycle });
|
||||
return { resId, state };
|
||||
}); } catch (e) {
|
||||
// 동시에 다른 사이트가 마지막 단위를 가져갔다(§13.3): 사전 확인과 같은 모양으로 부족 사유를 돌려준다
|
||||
if (e instanceof quota.QuotaError) throw new HttpError(409, 'precheck_failed', '배포 전 확인에서 차단된 항목이 있습니다', { items: pc.items.map((i) => (i.id === 'sites' || (i.id === 'capacity' && e.detail && e.detail.resource === 'web_disk') ? { ...i, state: 'block', text: e.message + (e.detail && e.detail.shortage ? ` (부족 ${e.detail.shortage})` : ''), href: i.id === 'sites' ? '/billing' : '/usage' } : i)) });
|
||||
throw e;
|
||||
}
|
||||
const { job } = jobs.enqueue({ kind: 'site.deploy', subject: { siteId: site.id, deploymentId: did, versionRef: String(versionRef), versionLabel, mode: site.deploy_mode }, idempotencyKey: idem, createdBy: user.id, actorType: 'user', groupId: group.id, subjectType: 'site', subjectId: site.id, label: `사이트 배포 (${versionLabel || (versionRef === 'current' ? '현재 저장 상태' : '저장본 #' + versionRef)})`, maxAttempts: 3 });
|
||||
store.run('UPDATE deployments SET job_id=? WHERE id=?', job.id, did);
|
||||
audit(store, { actorType: 'user', actorId: user.id, groupId: group.id, subjectType: 'site', subjectId: site.id, action: 'deploy.start', detail: { deploymentId: did, versionRef, mode: site.deploy_mode, siteCount: reserved.state }, ip });
|
||||
return { deployment: dep(store.get('SELECT * FROM deployments WHERE id=?', did)), job: describeJob(job), created: true };
|
||||
}
|
||||
function defaultDiskBytes(groupId) { const s = store.get('SELECT value_json FROM settings WHERE key=\'sites.defaultDiskBytes\''); return s ? Number(parseJson(s.value_json, 0)) || 1024 ** 3 : 1024 ** 3; }
|
||||
/** 다운로드 토큰 만들기(Agent 가 저장본을 받아 갈 서명 URL). 1시간 유효 */
|
||||
function issueBundleToken(siteId, deploymentId, file, bytes) { const token = randomToken(24); store.run('INSERT INTO bundle_tokens(id, token_hash, site_id, deployment_id, file, bytes, created_at, expires_at) VALUES(?,?,?,?,?,?,?,?)', newId('bt_'), sha256(token), siteId, deploymentId, file, bytes, nowIso(), new Date(Date.now() + 3600000).toISOString()); return `${config.publicUrl.replace(/\/$/, '')}/internal/bundles/${token}`; }
|
||||
function bundleByToken(token) { const t = store.get('SELECT * FROM bundle_tokens WHERE token_hash=?', sha256(String(token || ''))); if (!t || Date.parse(t.expires_at) < Date.now()) return null; store.run('UPDATE bundle_tokens SET used_at=? WHERE id=?', nowIso(), t.id); return t; }
|
||||
/**
|
||||
* 단계에 묶인 HAL 작업 보장. 단계 결과(step.result.halJobId)에 HAL 작업 id 를 기억해 재진입(폴링 재개) 때 같은 작업을 보고,
|
||||
* 그 HAL 작업이 실패·취소로 끝났는데 다시 실행되는 경우(사용자 재시도)에만 새 멱등 키로 새 HAL 작업을 만든다.
|
||||
* @param {Function} factory (idemKey) => hal.createJob(...)
|
||||
*/
|
||||
async function halJobFor(ctx, job, stepId, factory, { fresh = false } = {}) {
|
||||
const st = ctx.steps().find((x) => x.id === stepId); const prev = st && st.result && st.result.halJobId ? st.result.halJobId : null;
|
||||
// fresh: 사용자 재시도(이전 실행이 실패로 끝남) — 끝난 HAL 작업의 결과를 다시 쓰지 않고 새 작업을 만든다. 아니면 진행 중·완료된 이전 작업을 그대로 본다
|
||||
if (prev && !fresh) { let hj; try { hj = await hal().getJob(prev); } catch (e) { if (e.code === 'service_unreachable') ctx.retryLater(15000, 'HAL(중앙 관리) 연결 대기 — 정보 확인 지연'); throw e; } if (!['failed', 'cancelled'].includes(hj.status)) return prev; }
|
||||
const r = await factory(`portal:${job.id}:${stepId}${prev ? ':r' + job.attempt : ''}`);
|
||||
if (st) { st.result = { ...(st.result || {}), halJobId: r.halJobId }; ctx.flush(); }
|
||||
return r.halJobId;
|
||||
}
|
||||
/** HAL 작업을 포털 작업 단계에 비추고, 끝날 때까지 5초 간격으로 다시 깨어난다(재진입). */
|
||||
async function awaitHal(ctx, halJobId, { mirror = [] } = {}) {
|
||||
let hj; try { hj = await hal().getJob(halJobId); } catch (e) { if (e.code === 'service_unreachable') ctx.retryLater(15000, 'HAL(중앙 관리) 연결 대기 — 정보 확인 지연'); throw e; }
|
||||
if (mirror.length) { const st = ctx.steps(); for (const hs of hj.steps || []) { const ps = st.find((x) => x.id === hs.id); if (ps && ps.state !== 'done') { ps.state = hs.state === 'done' ? 'done' : hs.state === 'failed' ? 'failed' : hs.state === 'running' ? 'running' : ps.state; if (hs.detail) ps.detail = hs.detail; if (hs.state === 'done') ps.endedAt = hs.endedAt || nowIso(); } } ctx.flush(); }
|
||||
if (typeof hj.progress === 'number') ctx.progress(Math.min(95, Math.max(10, hj.progress)));
|
||||
if (['done'].includes(hj.status)) return hj;
|
||||
if (['failed', 'cancelled', 'reverted'].includes(hj.status)) throw new JobError(hj.errorCode || 'hal_job_failed', hj.error || `서버 작업이 ${hj.status} 상태로 끝났습니다`);
|
||||
if (hj.status === 'needs_user') ctx.needsUser(hj.error || '서버 작업에 운영자 확인이 필요합니다');
|
||||
ctx.retryLater(5000, '배포 서버 작업 진행 중');
|
||||
}
|
||||
/** 배포 작업 종류 등록 */
|
||||
jobs.register('site.deploy', { title: '사이트 배포', steps: DEPLOY_STEPS, maxAttempts: 3, retryable: (e) => e && e.code === 'service_unreachable',
|
||||
async run(job, ctx) {
|
||||
const { siteId, deploymentId, versionRef } = job.subject; const site = get(siteId); if (!site) throw new JobError('site_missing', '사이트가 없습니다');
|
||||
store.run('UPDATE deployments SET status=?, updated_at=? WHERE id=?', 'running', nowIso(), deploymentId);
|
||||
await ctx.step('accept', () => ({ at: nowIso(), by: job.created_by }));
|
||||
const bundle = await ctx.step('prepare_version', async () => { mkdirSync(bundleDir, { recursive: true, mode: 0o700 }); const file = join(bundleDir, `${siteId}-${deploymentId}.tar.gz`); const r = await wm().exportVersion(site.webmaker_project_id, versionRef === 'current' ? null : versionRef, file); const url = issueBundleToken(siteId, deploymentId, file, r.bytes); return { file, bytes: r.bytes, version: r.version, commit: r.commit, url }; });
|
||||
const domains = store.all('SELECT name FROM domains WHERE site_id=? AND purpose IN (\'web\',\'both\') AND ownership_state=\'verified\'', siteId).map((d) => d.name);
|
||||
// 배포 위치 준비 = 서버(또는 외부 Agent) 결정 + HAL 작업 등록. HAL 작업 멱등 키는 포털 작업 id 라 재진입해도 하나만 만들어진다
|
||||
const target = await ctx.step('prepare_target', async () => {
|
||||
if (site.deploy_mode === 'managed') { let serverId = site.server_id; if (!serverId) { const p = await hal().pickServer({ role: 'web', needBytes: bundle.bytes * 3 }); if (!p) throw new JobError('no_server', '지금 신규 배치를 받을 수 있는 서버가 없습니다', { retryable: true }); serverId = p.serverId; save(siteId, { server_id: serverId }); } return { kind: 'server', id: serverId }; }
|
||||
const a = externalAgentFor(siteId); if (!a || a.status !== 'connected') throw new JobError('agent_not_connected', '외부 서버 Agent 가 연결되어 있지 않습니다'); return { kind: 'external', id: a.hal_agent_id };
|
||||
});
|
||||
const retrying = (job.steps || []).some((x) => x.state === 'failed'); // 이전 실행이 실패한 뒤의 재시도면 새 HAL 작업
|
||||
const halJobId = await halJobFor(ctx, job, 'prepare_target', async (key) => { const r = await hal().createJob({ kind: 'site.deploy', target: target.id, idempotencyKey: key, groupId: site.group_id, subjectType: 'site', subjectId: siteId, payload: { siteId, versionRef: bundle.version === 'current' ? `rev-${bundle.commit || nowIso()}` : `v${bundle.version}`, bundleUrl: bundle.url, bundleSha256: null, runtime: site.runtime, env: {}, domains, primaryDomain: site.primary_domain, previousVersion: site.current_version, external: site.deploy_mode === 'external', maintenance: !!site.maintenance } }); store.run('UPDATE deployments SET hal_job_id=?, target_id=?, updated_at=? WHERE id=?', r.halJobId, target.id, nowIso(), deploymentId); return r; }, { fresh: retrying });
|
||||
const hj = await awaitHal(ctx, halJobId, { mirror: ['apply', 'data_check', 'start', 'internal_check', 'external_check'] });
|
||||
for (const id of ['apply', 'data_check', 'start', 'internal_check', 'external_check']) { const s = ctx.steps().find((x) => x.id === id); if (s && s.state !== 'done') { s.state = 'done'; s.endedAt = nowIso(); } } ctx.flush();
|
||||
const res = hj.result || {};
|
||||
const verify = await ctx.step('domain_https', async () => { const pd = site.primary_domain ? store.get('SELECT * FROM domains WHERE name=? AND site_id=?', site.primary_domain, siteId) : null; const ext = res.externalCheck && res.externalCheck.ok; const publicOk = !!(pd && pd.dns_state === 'ok' && pd.cert_state === 'valid' && ext); return { state: publicOk ? DEPLOY_VERIFY.PUBLIC : DEPLOY_VERIFY.NEEDS_DOMAIN, primaryDomain: pd ? pd.name : null, dns: pd ? pd.dns_state : null, cert: pd ? pd.cert_state : null, externalOk: !!ext, detail: publicOk ? `${pd.name} 에서 외부 확인 완료` : pd ? `대표 도메인 ${pd.name} 의 DNS(${pd.dns_state})/HTTPS(${pd.cert_state}) 가 아직 완료되지 않았습니다` : '대표 도메인이 없어 플랫폼 확인 주소에서만 볼 수 있습니다' }; });
|
||||
await ctx.step('done', () => { finishDeployment(site, deploymentId, { ...res, bundleCommit: bundle.commit || null, bundleVersion: bundle.version }, verify, job); return { version: res.version, verify: verify.state }; });
|
||||
try { if (bundle.file && existsSync(bundle.file)) unlinkSync(bundle.file); } catch (_) {}
|
||||
return { version: res.version, verify: verify.state, siteUrl: res.siteUrl, externalCheck: res.externalCheck };
|
||||
},
|
||||
async revert(job, ctx) { const { siteId, deploymentId } = job.subject; const site = get(siteId); const d = dep(store.get('SELECT * FROM deployments WHERE id=?', deploymentId)); if (!d || !d.previous_version) throw new JobError('no_previous', '되돌릴 이전 버전이 없습니다'); await ctx.step('rollback', async () => { const id = await halJobFor(ctx, job, 'rollback', (key) => hal().createJob({ kind: 'site.rollback', target: d.target_id, idempotencyKey: key, payload: { siteId, toVersion: d.previous_version } }), { fresh: (job.steps || []).some((x) => x.id === 'rollback' && x.state === 'failed') }); const hj = await awaitHal(ctx, id); save(siteId, { current_version: d.previous_version }); store.run('UPDATE deployments SET status=\'reverted\', updated_at=? WHERE id=?', nowIso(), deploymentId); return hj.result; }); } });
|
||||
/** 배포 완료 처리: deployments·sites 갱신, 사이트 수 확정(public 일 때), 알림·감사 */
|
||||
function finishDeployment(site, deploymentId, res, verify, job) {
|
||||
const now = nowIso(); const version = res.version || null;
|
||||
store.run('UPDATE deployments SET status=\'done\', verify_state=?, result_json=?, updated_at=?, ended_at=? WHERE id=?', verify.state, toJson({ ...res, verify }), now, now, deploymentId);
|
||||
const patch = { current_version: version, current_deployment_id: deploymentId, deploy_verify: verify.state, last_deploy_at: now, health: res.externalCheck && res.externalCheck.ok ? HEALTH.OK : (res.internalCheck && res.internalCheck.ok ? HEALTH.OK : HEALTH.UNKNOWN), health_checked_at: now, last_external_check_json: res.externalCheck || null, pending_cleanup_json: null, return_due_at: null, lifecycle: verify.state === DEPLOY_VERIFY.PUBLIC ? 'live' : 'needs_domain' };
|
||||
if (verify.state === DEPLOY_VERIFY.PUBLIC && site.site_count_state !== 'in_use') { const fresh = get(site.id); if (fresh.site_count_reservation_id) quota.commit(store, fresh.site_count_reservation_id); patch.site_count_state = 'in_use'; }
|
||||
save(site.id, patch);
|
||||
notify(store, { groupId: site.group_id, category: 'site', severity: 'info', incidentKey: `deploy:${site.id}:${deploymentId}`, title: `사이트 "${site.name}" 배포 ${verify.state === 'public' ? '완료 — 공개 운영 중' : '검증 완료 — 도메인 설정 필요'}`, body: verify.detail, link: `/sites/${site.id}?tab=deploy`, subjectType: 'site', subjectId: site.id, actionRequired: verify.state === 'public' ? null : '대표 도메인 연결과 HTTPS 확인' });
|
||||
audit(store, { actorType: 'system', groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'deploy.done', detail: { deploymentId, version, verify: verify.state, jobId: job.id }, automatic: true });
|
||||
}
|
||||
/** 작업 실패 시 배포 기록 갱신(jobs.onChange 가 호출) */
|
||||
jobs.onChange((j) => {
|
||||
if (j.kind !== 'site.deploy' || !['failed', 'cancelled'].includes(j.status)) return;
|
||||
const { siteId, deploymentId } = j.subject; const site = get(siteId); if (!site) return;
|
||||
const d = store.get('SELECT status FROM deployments WHERE id=?', deploymentId); if (!d || ['failed', 'done', 'cancelled'].includes(d.status)) return;
|
||||
const now = nowIso(); store.run('UPDATE deployments SET status=?, result_json=?, updated_at=?, ended_at=? WHERE id=?', j.status, toJson({ error: j.error, errorCode: j.error_code, failedStep: (j.steps || []).find((s) => s.state === 'failed')?.id || null }), now, now, deploymentId);
|
||||
const patch = { lifecycle: site.current_version ? site.lifecycle : 'building' };
|
||||
// 최초 배포가 실패하면 예약은 남긴 채 '반환 예정 시각' 을 두고, 정리하지 않은 중간 결과를 표시한다(§11.2)
|
||||
if (site.site_count_state === 'reserved') { patch.return_due_at = new Date(Date.now() + RETURN_GRACE_MS).toISOString(); patch.pending_cleanup_json = { deploymentId, failedAt: now, leftovers: ['배포 위치의 중간 파일(있을 수 있음)'], message: '실패한 배포의 중간 결과가 남아 있을 수 있습니다. "지금 정리하고 반환" 을 누르면 정리 작업을 실행하고 사이트 수 예약을 반환합니다' }; }
|
||||
save(siteId, patch);
|
||||
notify(store, { groupId: site.group_id, category: 'site', severity: 'warning', incidentKey: `deploy:${siteId}:${deploymentId}`, title: `사이트 "${site.name}" 배포 ${j.status === 'failed' ? '실패' : '취소'}`, body: `${j.error || ''} (문의 번호 ${j.id}). 기존 버전${site.current_version ? '은 유지됩니다' : '이 없어 공개되지 않았습니다'}`, link: `/sites/${siteId}?tab=deploy`, subjectType: 'site', subjectId: siteId, actionRequired: '재시도 또는 되돌리기' });
|
||||
});
|
||||
/** 중간 결과 정리 + 예약 반환(§11.2 "지금 정리하고 반환") */
|
||||
function cleanupAndReturn(site, actor) {
|
||||
if (site.site_count_state !== 'reserved') throw new HttpError(409, 'nothing_to_return', '반환할 예약이 없습니다');
|
||||
if (activeJobsFor(site.id).some((j) => j.kind === 'site.deploy')) throw new HttpError(409, 'deploy_running', '배포가 진행 중입니다. 먼저 취소하세요');
|
||||
const { job } = jobs.enqueue({ kind: 'site.cleanup', subject: { siteId: site.id, reservationId: site.site_count_reservation_id, targetId: site.server_id || (externalAgentFor(site.id) || {}).hal_agent_id || null }, idempotencyKey: `site.cleanup:${site.id}:${site.site_count_reservation_id}`, createdBy: actor?.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, label: '중간 결과 정리와 사이트 수 반환' });
|
||||
return describeJob(job);
|
||||
}
|
||||
jobs.register('site.cleanup', { title: '중간 결과 정리', steps: [{ id: 'remove', title: '배포 위치의 중간 결과 제거' }, { id: 'return', title: '사이트 수 예약 반환' }], maxAttempts: 3, retryable: (e) => e && e.code === 'service_unreachable',
|
||||
async run(job, ctx) { const { siteId, reservationId, targetId } = job.subject; const site = get(siteId);
|
||||
const leftovers = await ctx.step('remove', async () => { if (!targetId) return { skipped: '배포 위치 없음' }; const id = await halJobFor(ctx, job, 'remove', (key) => hal().createJob({ kind: 'site.remove', target: targetId, idempotencyKey: key, payload: { siteId, keepData: false } }), { fresh: (job.steps || []).some((x) => x.id === 'remove' && x.state === 'failed') }); const hj = await awaitHal(ctx, id); return hj.result; });
|
||||
await ctx.step('return', () => { if (reservationId) quota.release(store, reservationId); save(siteId, { site_count_state: 'none', site_count_reservation_id: null, pending_cleanup_json: null, return_due_at: null, lifecycle: site.current_version ? site.lifecycle : 'building', server_id: site.current_version ? site.server_id : null }); audit(store, { actorType: 'user', actorId: job.created_by, groupId: site.group_id, subjectType: 'site', subjectId: siteId, action: 'site_count.reservation_returned', detail: { reservationId, leftovers } }); return { returned: true }; });
|
||||
return { leftovers };
|
||||
} });
|
||||
/** 유지보수 모드 전환(vhost 안내 페이지) */
|
||||
jobs.register('site.vhost', { title: 'vhost 적용', steps: [{ id: 'vhost', title: '웹서버 설정 적용' }], maxAttempts: 3, retryable: (e) => e && e.code === 'service_unreachable',
|
||||
async run(job, ctx) { const { siteId, maintenance } = job.subject; const site = get(siteId); const domains = store.all('SELECT name FROM domains WHERE site_id=? AND purpose IN (\'web\',\'both\') AND ownership_state=\'verified\'', siteId).map((d) => d.name); return ctx.step('vhost', async () => { const id = await halJobFor(ctx, job, 'vhost', (key) => hal().createJob({ kind: 'domain.vhost', target: site.server_id, idempotencyKey: key, payload: { siteId, domains, primaryDomain: site.primary_domain, redirectOld: false, maintenance: !!maintenance } }), { fresh: (job.steps || []).some((x) => x.id === 'vhost' && x.state === 'failed') }); return (await awaitHal(ctx, id)).result; }); } });
|
||||
/** 되돌리기(§11.3): 이전 배포 버전으로. 데이터 영향 안내는 화면이 보여 주고 확인을 받는다 */
|
||||
function rollback(site, actor) {
|
||||
const last = dep(store.get('SELECT * FROM deployments WHERE site_id=? AND status=\'done\' ORDER BY created_at DESC LIMIT 1', site.id));
|
||||
if (!last || !last.previous_version) throw new HttpError(409, 'no_previous', '되돌릴 이전 버전이 없습니다');
|
||||
if (activeJobsFor(site.id).some((j) => ['site.deploy', 'site.rollback'].includes(j.kind))) throw new HttpError(409, 'job_running', '배포 작업이 진행 중입니다');
|
||||
const { job } = jobs.enqueue({ kind: 'site.rollback', subject: { siteId: site.id, toVersion: last.previous_version, fromVersion: site.current_version, targetId: last.target_id }, idempotencyKey: `site.rollback:${site.id}:${site.current_version}:${last.previous_version}`, createdBy: actor?.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, label: `이전 버전(${last.previous_version})으로 되돌리기`, maxAttempts: 2 });
|
||||
audit(store, { actorType: 'user', actorId: actor?.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'deploy.rollback', detail: { to: last.previous_version, from: site.current_version } });
|
||||
return describeJob(job);
|
||||
}
|
||||
jobs.register('site.rollback', { title: '이전 버전으로 되돌리기', steps: [{ id: 'rollback', title: '이전 버전 전환' }, { id: 'external_check', title: '외부 접속 확인' }], maxAttempts: 2, retryable: (e) => e && e.code === 'service_unreachable',
|
||||
async run(job, ctx) { const { siteId, toVersion, targetId } = job.subject; const site = get(siteId);
|
||||
const r1 = await ctx.step('rollback', async () => { const id = await halJobFor(ctx, job, 'rollback', (key) => hal().createJob({ kind: 'site.rollback', target: targetId, idempotencyKey: key, payload: { siteId, toVersion } }), { fresh: (job.steps || []).some((x) => x.id === 'rollback' && x.state === 'failed') }); return (await awaitHal(ctx, id)).result; });
|
||||
const chk = await ctx.step('external_check', async () => { const urls = site.primary_domain ? [`https://${site.primary_domain}/`] : []; if (!urls.length) return { skipped: '대표 도메인 없음' }; const id = await halJobFor(ctx, job, 'external_check', (key) => hal().createJob({ kind: 'site.check', target: targetId, idempotencyKey: key, payload: { siteId, urls } }), { fresh: (job.steps || []).some((x) => x.id === 'external_check' && x.state === 'failed') }); return (await awaitHal(ctx, id)).result; });
|
||||
const now = nowIso(); save(siteId, { current_version: r1.version || toVersion, last_deploy_at: now, health_checked_at: now, last_external_check_json: chk && chk.checks ? chk.checks[0] : null });
|
||||
store.run('INSERT INTO deployments(id, site_id, group_id, version_ref, version_label, mode, target_id, job_id, status, verify_state, previous_version, result_json, started_by, created_at, updated_at, ended_at) VALUES(?,?,?,?,?,?,?,?,\'done\',?,?,?,?,?,?,?)', newId('d_'), siteId, site.group_id, String(toVersion), `되돌림 → ${toVersion}`, site.deploy_mode, targetId, job.id, site.deploy_verify, job.subject.fromVersion, toJson({ rollback: true, check: chk }), job.created_by, now, now, now);
|
||||
notify(store, { groupId: site.group_id, category: 'site', severity: 'info', incidentKey: `rollback:${siteId}:${job.id}`, title: `사이트 "${site.name}" 를 이전 버전(${toVersion})으로 되돌렸습니다`, link: `/sites/${siteId}?tab=deploy`, subjectType: 'site', subjectId: siteId });
|
||||
return { version: r1.version || toVersion, check: chk };
|
||||
} });
|
||||
/** 대표 도메인 확인 완료 훅(domains 서비스가 부른다): 검증 완료 배포가 있으면 공개 운영 중으로 전환하고 사이트 수를 확정 */
|
||||
function onPrimaryDomainVerified(siteId, domainName) {
|
||||
const s = get(siteId); if (!s) return null;
|
||||
const patch = { primary_domain: domainName };
|
||||
if (s.current_version && s.deploy_verify !== DEPLOY_VERIFY.PUBLIC) { patch.deploy_verify = DEPLOY_VERIFY.PUBLIC; if (['needs_domain', 'deploying'].includes(s.lifecycle)) patch.lifecycle = 'live'; if (s.site_count_state === 'reserved' && s.site_count_reservation_id) { quota.commit(store, s.site_count_reservation_id); patch.site_count_state = 'in_use'; } if (s.current_deployment_id) store.run('UPDATE deployments SET verify_state=?, updated_at=? WHERE id=?', DEPLOY_VERIFY.PUBLIC, nowIso(), s.current_deployment_id); notify(store, { groupId: s.group_id, category: 'site', severity: 'info', incidentKey: `public:${siteId}`, title: `사이트 "${s.name}" 공개 운영 중 — ${domainName}`, link: `/sites/${siteId}`, subjectType: 'site', subjectId: siteId }); }
|
||||
return save(siteId, patch);
|
||||
}
|
||||
/** 대표 도메인이 해제·실패했을 때 */
|
||||
function onPrimaryDomainLost(siteId) { const s = get(siteId); if (!s) return null; const patch = { primary_domain: null }; if (s.lifecycle === 'live') { patch.lifecycle = 'needs_domain'; patch.deploy_verify = s.current_version ? DEPLOY_VERIFY.NEEDS_DOMAIN : s.deploy_verify; } return save(siteId, patch); }
|
||||
|
||||
// ------------------------------------------------------------------ 외부 배포(§10.2)
|
||||
function recordConsent(site, user, { kind = 'external', version = EXTERNAL_CONSENT_VERSION, ip = '' } = {}) { store.run('INSERT INTO site_consents(id, site_id, group_id, user_id, kind, version, text_hash, agreed_at, ip) VALUES(?,?,?,?,?,?,?,?,?)', newId('c_'), site.id, site.group_id, user.id, kind, version, sha256(JSON.stringify(EXTERNAL_CONSENT.items)), nowIso(), ip); audit(store, { actorType: 'user', actorId: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'consent.external', detail: { version }, ip, immutable: true }); return save(site.id, { consent_version: version }); }
|
||||
async function createExternalAgent(site, user, label = '') {
|
||||
const prev = externalAgentFor(site.id); if (prev && prev.status === 'connected') throw new HttpError(409, 'agent_exists', '이미 연결된 외부 서버 Agent 가 있습니다. 먼저 해제하세요');
|
||||
const r = await hal().issueExternalAgentToken({ groupId: site.group_id, siteId: site.id, label });
|
||||
if (prev) store.run('UPDATE external_agents SET revoked_at=?, status=\'revoked\' WHERE id=?', nowIso(), prev.id);
|
||||
const id = newId('xa_'); const now = nowIso();
|
||||
store.run('INSERT INTO external_agents(id, site_id, group_id, hal_agent_id, label, status, token_expires_at, install_command, created_at, updated_at) VALUES(?,?,?,?,?,\'pending\',?,?,?,?)', id, site.id, site.group_id, r.agentId, label || null, r.expiresAt, r.installCommand, now, now);
|
||||
audit(store, { actorType: 'user', actorId: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'external_agent.issue', detail: { agentId: r.agentId, label } });
|
||||
// 토큰 원문은 이 응답에만 — 저장하지 않는다(§28.2 등록 정보 재표시 금지와 같은 원칙)
|
||||
return { agent: externalAgentFor(site.id), token: r.token, expiresAt: r.expiresAt, installCommand: r.installCommand.replace('<토큰>', r.token) };
|
||||
}
|
||||
async function refreshExternalAgent(site) { const a = externalAgentFor(site.id); if (!a) return null; try { const s = await hal().getExternalAgent(a.hal_agent_id); store.run('UPDATE external_agents SET status=?, last_seen_at=?, agent_version=?, last_deploy_json=COALESCE(?, last_deploy_json), updated_at=? WHERE id=?', s.status, s.lastSeenAt || a.last_seen_at, s.version || a.agent_version, s.lastDeploy ? toJson(s.lastDeploy) : null, nowIso(), a.id); return { ...externalAgentFor(site.id), stale: false }; } catch (e) { return { ...a, stale: true, staleReason: 'HAL 에 연결할 수 없습니다(정보 확인 지연)' }; } }
|
||||
async function revokeExternalAgent(site, user) { const a = externalAgentFor(site.id); if (!a) throw new HttpError(404, 'not_found', '외부 서버 Agent 가 없습니다'); try { await hal().revokeExternalAgent(a.hal_agent_id); } catch (e) { if (e.code !== 'service_unreachable') throw e; } store.run('UPDATE external_agents SET status=\'revoked\', revoked_at=?, updated_at=? WHERE id=?', nowIso(), nowIso(), a.id); audit(store, { actorType: 'user', actorId: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'external_agent.revoke', detail: { agentId: a.hal_agent_id } }); return true; }
|
||||
/** 외부 서버의 배포 제거(실패 잔여 파일 정리 포함) */
|
||||
function removeExternalDeploy(site, user) { const a = externalAgentFor(site.id); if (!a) throw new HttpError(409, 'agent_missing', '외부 서버 Agent 가 없습니다'); const { job } = jobs.enqueue({ kind: 'site.remove_external', subject: { siteId: site.id, agentId: a.hal_agent_id }, idempotencyKey: `site.remove_external:${site.id}:${Date.now() / 60000 | 0}`, createdBy: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, label: '외부 서버 배포 제거' }); return describeJob(job); }
|
||||
jobs.register('site.remove_external', { title: '외부 서버 배포 제거', steps: [{ id: 'remove', title: '외부 서버에서 파일·실행 상태 제거' }], maxAttempts: 2, retryable: (e) => e && e.code === 'service_unreachable', async run(job, ctx) { const { siteId, agentId } = job.subject; const r = await ctx.step('remove', async () => { const id = await halJobFor(ctx, job, 'remove', (key) => hal().createJob({ kind: 'site.remove', target: agentId, idempotencyKey: key, payload: { siteId, keepData: false } }), { fresh: (job.steps || []).some((x) => x.id === 'remove' && x.state === 'failed') }); return (await awaitHal(ctx, id)).result; }); const a = externalAgentFor(siteId); if (a) store.run('UPDATE external_agents SET last_deploy_json=?, updated_at=? WHERE id=?', toJson({ removedAt: nowIso(), leftovers: (r && r.leftovers) || [] }), nowIso(), a.id); save(siteId, { current_version: null, deploy_verify: null, lifecycle: 'building', pending_cleanup_json: null }); return r; } });
|
||||
/** 배포 방식 설정(처음 고르거나 바꿀 때). 운영 중 사이트의 변경은 작업(§10.3)으로 */
|
||||
async function setDeployMode(site, mode, user) {
|
||||
if (!['managed', 'external'].includes(mode)) throw new HttpError(400, 'bad_mode', '배포 방식은 managed 또는 external 입니다');
|
||||
if (site.deploy_mode === mode) return { site, changed: false };
|
||||
if (!site.current_version) { const s = save(site.id, { deploy_mode: mode, server_id: mode === 'external' ? null : site.server_id }); audit(store, { actorType: 'user', actorId: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'site.deploy_mode', detail: { from: site.deploy_mode, to: mode } }); return { site: s, changed: true }; }
|
||||
// 운영 중: 새 위치 준비 → 별도 배포 → 검증 → 도메인 전환 → 이전 위치 정리
|
||||
if (activeJobsFor(site.id).length) throw new HttpError(409, 'job_running', '진행 중인 작업이 끝난 뒤 배포 방식을 바꿀 수 있습니다');
|
||||
if (mode === 'external') { const a = await refreshExternalAgent(site); if (!a || a.status !== 'connected') throw new HttpError(409, 'agent_not_connected', '외부 서버 Agent 를 먼저 연결하세요', { href: `/sites/${site.id}/external` }); }
|
||||
const id = newId('mc_'); const now = nowIso();
|
||||
const { job } = jobs.enqueue({ kind: 'site.mode_change', subject: { siteId: site.id, changeId: id, fromMode: site.deploy_mode, toMode: mode, fromTarget: site.deploy_mode === 'managed' ? site.server_id : (externalAgentFor(site.id) || {}).hal_agent_id, versionRef: 'current' }, idempotencyKey: `site.mode_change:${site.id}:${mode}:${store.get('SELECT COUNT(*) AS n FROM deploy_mode_changes WHERE site_id=?', site.id).n}`, createdBy: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, label: `배포 방식 변경 (${site.deploy_mode} → ${mode})`, maxAttempts: 2 });
|
||||
store.run('INSERT INTO deploy_mode_changes(id, site_id, group_id, from_mode, to_mode, from_target, status, job_id, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,\'queued\',?,?,?,?)', id, site.id, site.group_id, site.deploy_mode, mode, site.deploy_mode === 'managed' ? site.server_id : null, job.id, user.id, now, now);
|
||||
audit(store, { actorType: 'user', actorId: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'site.deploy_mode.change', detail: { from: site.deploy_mode, to: mode, jobId: job.id } });
|
||||
return { site, changed: false, job: describeJob(job) };
|
||||
}
|
||||
jobs.register('site.mode_change', { title: '배포 방식 변경', steps: [{ id: 'prepare_new', title: '새 위치 준비' }, { id: 'deploy_new', title: '새 위치에 별도 배포' }, { id: 'verify_new', title: '새 위치 검증' }, { id: 'switch_domain', title: '도메인 전환' }, { id: 'cleanup_old', title: '이전 위치 정리' }], maxAttempts: 2, retryable: (e) => e && e.code === 'service_unreachable',
|
||||
async run(job, ctx) {
|
||||
const { siteId, changeId, toMode, fromTarget } = job.subject; const site = get(siteId);
|
||||
const newTarget = await ctx.step('prepare_new', async () => { if (toMode === 'managed') { const p = await hal().pickServer({ role: 'web', needBytes: 0 }); if (!p) throw new JobError('no_server', '신규 배치를 받을 수 있는 서버가 없습니다', { retryable: true }); return p.serverId; } const a = externalAgentFor(siteId); if (!a || a.status !== 'connected') throw new JobError('agent_not_connected', '외부 서버 Agent 가 연결되어 있지 않습니다'); return a.hal_agent_id; });
|
||||
const domains = store.all('SELECT name FROM domains WHERE site_id=? AND purpose IN (\'web\',\'both\') AND ownership_state=\'verified\'', siteId).map((d) => d.name);
|
||||
const bundle = await ctx.step('deploy_new', async () => { mkdirSync(bundleDir, { recursive: true, mode: 0o700 }); const file = join(bundleDir, `${siteId}-${changeId}.tar.gz`); const ex = await wm().exportVersion(site.webmaker_project_id, null, file); const url = issueBundleToken(siteId, null, file, ex.bytes); const id = await halJobFor(ctx, job, 'deploy_new', (key) => hal().createJob({ kind: 'site.deploy', target: newTarget, idempotencyKey: key, payload: { siteId, versionRef: site.current_version || `rev-${nowIso()}`, bundleUrl: url, runtime: site.runtime, env: {}, domains: [], primaryDomain: null, previousVersion: null, external: toMode === 'external' } }), { fresh: (job.steps || []).some((x) => x.id === 'deploy_new' && x.state === 'failed') }); return (await awaitHal(ctx, id)).result; });
|
||||
await ctx.step('verify_new', async () => { if (!(bundle.internalCheck && bundle.internalCheck.ok)) throw new JobError('verify_failed', '새 위치 내부 확인에 실패했습니다 — 현재 위치를 유지합니다'); return bundle.internalCheck; });
|
||||
await ctx.step('switch_domain', async () => { if (!domains.length) return { skipped: '연결된 도메인 없음' }; const id = await halJobFor(ctx, job, 'switch_domain', (key) => hal().createJob({ kind: 'domain.vhost', target: newTarget, idempotencyKey: key, payload: { siteId, domains, primaryDomain: site.primary_domain, redirectOld: false, maintenance: false } }), { fresh: (job.steps || []).some((x) => x.id === 'switch_domain' && x.state === 'failed') }); return (await awaitHal(ctx, id)).result; });
|
||||
save(siteId, { deploy_mode: toMode, server_id: toMode === 'managed' ? newTarget : null, external_agent_id: toMode === 'external' ? newTarget : null });
|
||||
await ctx.step('cleanup_old', async () => { if (!fromTarget) return { skipped: true }; const id = await halJobFor(ctx, job, 'cleanup_old', (key) => hal().createJob({ kind: 'site.remove', target: fromTarget, idempotencyKey: key, payload: { siteId, keepData: true } }), { fresh: (job.steps || []).some((x) => x.id === 'cleanup_old' && x.state === 'failed') }); return (await awaitHal(ctx, id)).result; });
|
||||
store.run('UPDATE deploy_mode_changes SET status=\'done\', to_target=?, updated_at=?, ended_at=? WHERE id=?', newTarget, nowIso(), nowIso(), changeId);
|
||||
notify(store, { groupId: site.group_id, category: 'site', severity: 'info', incidentKey: `modechange:${siteId}:${changeId}`, title: `사이트 "${site.name}" 배포 방식을 ${toMode === 'managed' ? '관리형' : '외부'} 으로 바꿨습니다`, link: `/sites/${siteId}?tab=deploy`, subjectType: 'site', subjectId: siteId });
|
||||
return { newTarget };
|
||||
},
|
||||
async revert(job, ctx) { const { siteId, changeId } = job.subject; const st = job.steps || []; const target = st.find((s) => s.id === 'prepare_new')?.result; await ctx.step('revert_new', async () => { if (!target) return { skipped: true }; const id = await halJobFor(ctx, job, 'revert_new', (key) => hal().createJob({ kind: 'site.remove', target, idempotencyKey: key, payload: { siteId, keepData: false } }), { fresh: (job.steps || []).some((x) => x.id === 'revert_new' && x.state === 'failed') }); return (await awaitHal(ctx, id)).result; }); store.run('UPDATE deploy_mode_changes SET status=\'reverted\', updated_at=?, ended_at=? WHERE id=?', nowIso(), nowIso(), changeId); } });
|
||||
jobs.onChange((j) => { if (j.kind === 'site.mode_change' && ['failed', 'cancelled'].includes(j.status)) { store.run('UPDATE deploy_mode_changes SET status=?, updated_at=? WHERE id=?', j.status, nowIso(), j.subject.changeId); const s = get(j.subject.siteId); if (s) notify(store, { groupId: s.group_id, category: 'site', severity: 'warning', incidentKey: `modechange:${s.id}:${j.subject.changeId}`, title: `사이트 "${s.name}" 배포 방식 변경 실패 — 현재 위치를 유지합니다`, body: j.error, link: `/sites/${s.id}?tab=deploy`, subjectType: 'site', subjectId: s.id, actionRequired: '되돌리기 또는 재시도' }); } });
|
||||
|
||||
// ------------------------------------------------------------------ 사용량·건강(§8.1·§13.1)
|
||||
/** 관리형 운영 사이트의 사용량·건강을 10분 단위 멱등 작업으로 모은다. 화면이 열릴 때와 주기 작업이 부른다 */
|
||||
function refresh(groupId, { force = false } = {}) {
|
||||
const sites = store.all('SELECT id, server_id, primary_domain FROM sites WHERE group_id=? AND deploy_mode=\'managed\' AND current_version IS NOT NULL AND lifecycle IN (\'live\',\'needs_domain\',\'paused\')', groupId);
|
||||
if (!sites.length) return null; const bucket = force ? Date.now() : Math.floor(Date.now() / USAGE_BUCKET_MS);
|
||||
const { job } = jobs.enqueue({ kind: 'site.usage', subject: { groupId, siteIds: sites.map((s) => s.id) }, idempotencyKey: `site.usage:${groupId}:${bucket}`, actorType: 'system', groupId, subjectType: 'group', subjectId: groupId, label: '사이트 사용량·건강 수집' });
|
||||
return describeJob(job);
|
||||
}
|
||||
jobs.register('site.usage', { title: '사용량 수집', steps: [{ id: 'usage', title: '서버 사용량 수집' }, { id: 'health', title: '외부 접속 확인' }], maxAttempts: 2, retryable: (e) => e && e.code === 'service_unreachable',
|
||||
async run(job, ctx) {
|
||||
const { groupId, siteIds } = job.subject; const bySrv = {}; for (const id of siteIds) { const s = get(id); if (s && s.server_id) (bySrv[s.server_id] = bySrv[s.server_id] || []).push(s); }
|
||||
await ctx.step('usage', async () => { const out = {}; for (const [srv, list] of Object.entries(bySrv)) { const r = await hal().createJob({ kind: 'site.usage', target: srv, idempotencyKey: `portal:${job.id}:${srv}`, payload: { siteIds: list.map((s) => s.id) } }); const hj = await awaitHal(ctx, r.halJobId); for (const [sid, u] of Object.entries((hj.result && hj.result.sites) || {})) { recordUsage(groupId, sid, u); out[sid] = true; } } return { collected: Object.keys(out).length }; });
|
||||
await ctx.step('health', async () => { let n = 0; for (const [srv, list] of Object.entries(bySrv)) { const urls = list.filter((s) => s.primary_domain).map((s) => `https://${s.primary_domain}/`); if (!urls.length) continue; const r = await hal().createJob({ kind: 'site.check', target: srv, idempotencyKey: `portal:${job.id}:check:${srv}`, payload: { urls } }); const hj = await awaitHal(ctx, r.halJobId); for (const c of (hj.result && hj.result.checks) || []) { const s = list.find((x) => c.url.includes(x.primary_domain)); if (!s) continue; const h = !c.ok ? HEALTH.DOWN : c.ms > 3000 ? HEALTH.DEGRADED : HEALTH.OK; save(s.id, { health: h, health_checked_at: nowIso(), last_external_check_json: c }); if (h !== HEALTH.OK) notify(store, { groupId, category: 'incident', severity: h === HEALTH.DOWN ? 'critical' : 'warning', incidentKey: `health:${s.id}`, title: `사이트 "${s.name}" 외부 접속 ${h === HEALTH.DOWN ? '중단' : '성능 저하'}`, body: `${c.url} 응답 ${c.status || '없음'} (${c.ms}ms)`, link: `/sites/${s.id}`, subjectType: 'site', subjectId: s.id, mandatory: h === HEALTH.DOWN }); else { const { resolveIncident } = await import('../../lib/notify.mjs'); resolveIncident(store, `health:${s.id}`, '외부 확인 정상 복구'); } n++; } } return { checked: n }; });
|
||||
return { sites: siteIds.length };
|
||||
} });
|
||||
/** HAL 사용량 결과 → usage_samples + quota 사용량 */
|
||||
function recordUsage(groupId, siteId, u) {
|
||||
const at = u.measuredAt || nowIso(); const ps = monthStart(at);
|
||||
const ins = (metric, value, period = null) => store.run('INSERT INTO usage_samples(id, group_id, subject_type, subject_id, metric, value, period, measured_at, source) VALUES(?,?,?,?,?,?,?,?,?)', newId('us_'), groupId, 'site', siteId, metric, Math.round(value || 0), period, at, 'hal');
|
||||
ins('disk_bytes', u.diskBytes); ins('db_bytes', u.dbBytes); ins('uploads_bytes', u.uploadsBytes); ins('out_bytes_day', u.outBytesDay, at.slice(0, 10)); ins('out_bytes_month', u.outBytesMonth, ps); ins('out_bytes_total', u.outBytesTotal); ins('in_bytes_day', u.inBytesDay, at.slice(0, 10)); ins('in_bytes_month', u.inBytesMonth, ps); ins('in_bytes_total', u.inBytesTotal); ins('req_24h', u.req24h); ins('pv_24h', u.pv24h); ins('error_rate_x1000', (u.errorRate || 0) * 1000); ins('p95_ms', u.p95ms);
|
||||
store.run('INSERT INTO usage_samples(id, group_id, subject_type, subject_id, metric, value, period, measured_at, source) VALUES(?,?,?,?,?,?,?,?,?)', newId('us_'), groupId, 'site', siteId, 'series_json', 0, toJson({ series7d: u.series7d || [], series30d: u.series30d || [] }), at, 'hal');
|
||||
quota.recordUsage(store, { groupId, resource: 'web_disk', subjectType: 'site', subjectId: siteId, used: (u.diskBytes || 0) + (u.dbBytes || 0) + (u.uploadsBytes || 0), source: 'hal', measuredAt: at });
|
||||
quota.recordUsage(store, { groupId, resource: 'web_out', subjectType: 'site', subjectId: siteId, used: u.outBytesMonth || 0, periodStart: ps, source: 'hal', measuredAt: at });
|
||||
}
|
||||
function latestMetric(siteId, metric) { return store.get('SELECT value, period, measured_at FROM usage_samples WHERE subject_type=\'site\' AND subject_id=? AND metric=? ORDER BY measured_at DESC LIMIT 1', siteId, metric) || null; }
|
||||
/** 사이트 개요 데이터(§8.1). 관리형/외부/미배포를 구분해 보일 값만 돌려준다 */
|
||||
function overview(site) {
|
||||
const lastDeploy = dep(store.get('SELECT * FROM deployments WHERE site_id=? ORDER BY created_at DESC LIMIT 1', site.id));
|
||||
const base = { id: site.id, name: site.name, lifecycle: site.lifecycle, deployMode: site.deploy_mode, runtime: site.runtime, primaryDomain: site.primary_domain, currentVersion: site.current_version, lastDeployAt: site.last_deploy_at, deployVerify: site.deploy_verify, siteCountState: site.site_count_state, returnDueAt: site.return_due_at, pendingCleanup: site.pendingCleanup, lastDeploy: lastDeploy ? { id: lastDeploy.id, status: lastDeploy.status, verify: lastDeploy.verify_state, at: lastDeploy.created_at, version: lastDeploy.version_label || lastDeploy.version_ref, jobId: lastDeploy.job_id } : null, nextJob: nextJobFor(site), actionNeeded: actionNeeded(site, lastDeploy) };
|
||||
if (!site.current_version) return { ...base, kind: 'not_deployed', readiness: nextSteps(site), message: '아직 정상 배포되지 않은 사이트입니다. 통계는 배포 뒤에 보입니다' };
|
||||
if (site.deploy_mode === 'external') { const a = externalAgentFor(site.id); return { ...base, kind: 'external', agent: a ? { status: a.status, label: a.label, lastSeenAt: a.last_seen_at, version: a.agent_version } : null, lastDeployResult: lastDeploy && lastDeploy.result ? { ok: lastDeploy.status === 'done', at: lastDeploy.ended_at, version: lastDeploy.result.version, externalCheck: lastDeploy.result.externalCheck || null } : null, lastExternalCheck: site.lastExternalCheck, customerResponsibility: EXTERNAL_CONSENT.items.slice(0, 2), notProvided: ['정상 여부(uptime)', '관리형 디스크·데이터베이스 사용량', '네트워크 트래픽', '접속 통계', '백업 보호 상태', '서버 이전'] }; }
|
||||
const disk = latestMetric(site.id, 'disk_bytes'); const db = latestMetric(site.id, 'db_bytes'); const up = latestMetric(site.id, 'uploads_bytes'); const alloc = quota.allocationOf(store, site.group_id, 'web_disk', 'site', site.id); const outAlloc = quota.allocationOf(store, site.group_id, 'web_out', 'site', site.id);
|
||||
const series = latestMetric(site.id, 'series_json'); const measuredAt = disk ? disk.measured_at : null; const stale = isStale(measuredAt, 30 * 60 * 1000);
|
||||
const m = (k) => { const r = latestMetric(site.id, k); return r ? r.value : null; };
|
||||
return { ...base, kind: 'managed', health: isStale(site.health_checked_at, 15 * 60 * 1000) ? HEALTH.STALE : site.health, healthCheckedAt: site.health_checked_at, measuredAt, stale, staleReason: stale ? '마지막 수집으로부터 30분이 지났습니다(정보 확인 지연)' : null,
|
||||
visits24h: m('pv_24h'), requests24h: m('req_24h'), series: series ? parseJson(series.period, { series7d: [], series30d: [] }) : { series7d: [], series30d: [] },
|
||||
disk: { used: disk ? disk.value : null, db: db ? db.value : null, uploads: up ? up.value : null, limit: alloc ? alloc.amount : null, percent: alloc && disk ? quota.percent((disk.value || 0) + (db ? db.value : 0) + (up ? up.value : 0), alloc.amount) : null },
|
||||
network: { out: { day: m('out_bytes_day'), month: m('out_bytes_month'), total: m('out_bytes_total'), limit: outAlloc ? outAlloc.amount : null }, in: { day: m('in_bytes_day'), month: m('in_bytes_month'), total: m('in_bytes_total') } },
|
||||
errors: { errorRate: m('error_rate_x1000') != null ? m('error_rate_x1000') / 1000 : null, p95ms: m('p95_ms') }, lastExternalCheck: site.lastExternalCheck };
|
||||
}
|
||||
function nextJobFor(site) { const j = jobs.list({ subjectType: 'site', subjectId: site.id, status: ['queued', 'awaiting_external'], limit: 1 })[0]; if (j) return { id: j.id, label: j.label, at: j.next_run_at, status: j.status }; const b = store.get('SELECT next_run_at FROM backup_options WHERE site_id=? AND status=\'active\'', site.id); return b && b.next_run_at ? { label: '정기 백업', at: b.next_run_at } : null; }
|
||||
function deployments(siteId, limit = 20) { return store.all('SELECT * FROM deployments WHERE site_id=? ORDER BY created_at DESC LIMIT ?', siteId, limit).map((d) => ({ ...dep(d), job: d.job_id ? describeJob(jobs.get(d.job_id)) : null })); }
|
||||
/** 주기 작업: 반환 예정 시각이 지난 예약 반환, 오래된 SSO 기록·번들 토큰 정리 */
|
||||
function sweep(now = Date.now()) {
|
||||
const due = store.all('SELECT * FROM sites WHERE site_count_state=\'reserved\' AND return_due_at IS NOT NULL AND return_due_at<=?', new Date(now).toISOString()).map(row); let returned = 0;
|
||||
for (const s of due) { if (activeJobsFor(s.id).length) continue; if (s.site_count_reservation_id) quota.release(store, s.site_count_reservation_id); save(s.id, { site_count_state: 'none', site_count_reservation_id: null, return_due_at: null, pending_cleanup_json: s.pendingCleanup ? { ...s.pendingCleanup, autoReturnedAt: nowIso() } : null }); notify(store, { groupId: s.group_id, category: 'site', severity: 'info', incidentKey: `return:${s.id}`, title: `사이트 "${s.name}" 의 사이트 수 예약이 반환되었습니다`, body: '정리하지 않은 중간 결과가 있으면 배포 화면에서 정리하세요', link: `/sites/${s.id}?tab=deploy`, subjectType: 'site', subjectId: s.id }); returned++; }
|
||||
const cut = new Date(now - 7 * 86400000).toISOString(); store.run('DELETE FROM sso_tickets WHERE expires_at<?', cut); for (const t of store.all('SELECT * FROM bundle_tokens WHERE expires_at<?', new Date(now - 86400000).toISOString())) { try { if (existsSync(t.file)) unlinkSync(t.file); } catch (_) {} store.run('DELETE FROM bundle_tokens WHERE id=?', t.id); }
|
||||
return { returned };
|
||||
}
|
||||
function fmtBytes(n) { if (n == null) return '-'; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; let v = Number(n); while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${u[i]}`; }
|
||||
return { DEPLOY_STEPS, EXTERNAL_CONSENT, get, getForGroup, list, summarize, create, update, setLifecycle, returnSiteCount, nextSteps, buildUrl, versionsFor, impactForProject, onProjectDestroyed, precheck, startDeploy, cleanupAndReturn, rollback, onPrimaryDomainVerified, onPrimaryDomainLost, recordConsent, createExternalAgent, refreshExternalAgent, revokeExternalAgent, removeExternalDeploy, externalAgentFor, setDeployMode, refresh, recordUsage, overview, deployments, activeJobsFor, bundleByToken, sweep, userLogin, fmtBytes, save };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* portal/sites/ui.mjs — 사이트 화면 HTML 뼈대(목록·생성·상세 탭·배포·배포 방식·외부 배포). 데이터는 /ui/sites/sites.js 가 /api/sites/* 로 채운다.
|
||||
* 각 화면은 data-page 속성으로 스크립트가 어떤 화면인지 알고, 모든 화면에 공통 상태 패널(불러오는 중·비어 있음·지연·부분 실패·전체 실패·권한 없음)이 있다.
|
||||
*/
|
||||
import { esc } from '../../lib/http.mjs';
|
||||
import { t } from '../../lib/i18n.mjs';
|
||||
|
||||
const TABS = ['overview', 'build', 'source', 'deploy', 'domains', 'mail', 'backup', 'usage', 'activity', 'settings'];
|
||||
export function listPage(ctx, deps, { filter = '' } = {}) {
|
||||
const filters = ['all', 'draft', 'building', 'deploying', 'live', 'paused', 'cancel_pending'].map((f) => `<a class="btn${(filter || 'all') === f ? ' primary' : ''}" href="/sites${f === 'all' ? '' : '?filter=' + f}">${esc(t('sites.filters.' + f))}</a>`).join('');
|
||||
return shell(ctx, deps, { title: t('sites.title'), nav: 'sites', crumbs: [{ label: t('nav.dashboard'), href: '/' }, { label: t('sites.title') }], actions: [{ label: t('sites.new'), href: '/sites/new', kind: 'primary' }], body: `<div class="actions" style="margin-bottom:12px">${filters}</div><div id="list" data-page="sites-list" data-filter="${esc(filter)}">${deps.statePanel('loading')}</div>` });
|
||||
}
|
||||
export function newPage(ctx, deps) {
|
||||
return shell(ctx, deps, { title: t('sites.new'), nav: 'sites', crumbs: [{ label: t('sites.title'), href: '/sites' }, { label: t('sites.new') }], body: `<form id="newSite" class="card" data-page="sites-new" data-dirty-guard>
|
||||
<p class="hint">${esc(t('sites.create.noSiteCount'))}</p>
|
||||
<div class="f"><label for="name">${esc(t('sites.create.name'))} <span class="muted">(${esc(t('common.required'))})</span></label><input id="name" name="name" required maxlength="120" placeholder="예: 우리 회사 홈페이지"><span class="hint">사이트 목록과 알림에 보이는 이름입니다</span><span class="err" data-err="name"></span></div>
|
||||
<div class="f"><label for="purpose">${esc(t('sites.create.purpose'))}</label><input id="purpose" name="purpose" maxlength="300" placeholder="예: 회사 소개와 문의 접수"><span class="hint">Webmaker 의 기획 단계에 참고 정보로 전달됩니다</span></div>
|
||||
<fieldset class="f"><legend>${esc(t('sites.create.project'))}</legend><label><input type="radio" name="projectMode" value="new" checked> ${esc(t('sites.create.projectNew'))}</label><label><input type="radio" name="projectMode" value="existing"> ${esc(t('sites.create.projectExisting'))}</label><label><input type="radio" name="projectMode" value="none"> ${esc(t('sites.create.projectNone'))}</label>
|
||||
<div id="projectPick" hidden><select name="projectId" id="projectId"><option value="">불러오는 중…</option></select><span class="hint">내 Webmaker 계정의 프로젝트만 보입니다. 이미 다른 사이트에 연결된 프로젝트는 고를 수 없습니다</span><span class="err" data-err="projectId"></span></div>
|
||||
<div id="buildModePick"><label for="buildMode">제작 방식</label><select id="buildMode" name="buildMode"><option value="manual">내가 고르기 (단계별 선택)</option><option value="ai">AI에게 맡기기</option><option value="quick">제목·내용 입력형</option></select><span class="hint">Webmaker 의 기존 제작 방식 그대로입니다</span></div></fieldset>
|
||||
<div class="f"><label for="template">${esc(t('sites.create.template'))}</label><select id="template" name="template"><option value="">선택 안 함 (Webmaker 에서 고르기)</option></select><span class="hint">템플릿 목록은 Webmaker 제작 화면에서 그대로 제공됩니다</span></div>
|
||||
<div class="f"><label for="lang">${esc(t('sites.create.lang'))}</label><select id="lang" name="lang"><option value="ko">한국어</option><option value="en">English</option><option value="ja">日本語</option><option value="zh">中文</option></select></div>
|
||||
<div class="f"><label for="timezone">${esc(t('sites.create.timezone'))}</label><select id="timezone" name="timezone"><option value="Asia/Seoul">Asia/Seoul (KST)</option><option value="UTC">UTC</option><option value="Asia/Tokyo">Asia/Tokyo</option><option value="America/New_York">America/New_York</option></select></div>
|
||||
<fieldset class="f"><legend>${esc(t('sites.create.deployMode'))}</legend><label><input type="radio" name="deployMode" value="" checked> ${esc(t('sites.create.modeLater'))}</label><label><input type="radio" name="deployMode" value="managed"> ${esc(t('sites.mode.managed'))} — ${esc(t('sites.mode.managedDesc'))}</label><label><input type="radio" name="deployMode" value="external"> ${esc(t('sites.mode.external'))} — ${esc(t('sites.mode.externalDesc'))}</label></fieldset>
|
||||
<div class="actions"><button class="btn primary" type="submit">${esc(t('common.create'))}</button><a class="btn" href="/sites">${esc(t('common.cancel'))}</a></div><div class="err" data-err="form"></div></form><div id="created" class="card" hidden></div>` });
|
||||
}
|
||||
export function detailPage(ctx, deps, site, { tab = 'overview' } = {}) {
|
||||
if (!TABS.includes(tab)) tab = 'overview';
|
||||
const tabs = TABS.map((k) => `<a class="${tab === k ? 'on' : ''}" href="/sites/${esc(site.id)}?tab=${k}">${esc(t('sites.tabs.' + k))}</a>`).join('');
|
||||
const bodies = {
|
||||
overview: `<div id="overview" data-page="site-overview">${deps.statePanel('loading')}</div>`,
|
||||
build: `<div class="card" data-page="site-build"><h2>Webmaker 제작</h2><p>제작·AI 생성·페이지 편집·미리보기·저장·이전 저장본 조회는 Webmaker 화면에서 그대로 합니다(별도 로그인 없음). 현재 그룹과 사이트 이름이 Webmaker 상단에 표시됩니다.</p><div class="actions"><a class="btn primary" href="/sso/webmaker?site=${esc(site.id)}" target="_blank" rel="noopener">제작 화면 열기</a><a class="btn" href="/sso/webmaker?site=${esc(site.id)}&path=/domains" target="_blank" rel="noopener">도메인·DNS 관리(Webmaker)</a></div><div id="buildInfo">${deps.statePanel('loading')}</div><p class="hint">이 화면의 "프로젝트" 는 운영 사이트와 다릅니다. 프로젝트를 지워도 운영 사이트·배포본·도메인은 지워지지 않습니다. 콘텐츠 가져오기(기존 사이트 복사·가져오기)는 이번 버전에서 지원하지 않습니다.</p><div id="wallet"></div></div>`,
|
||||
source: `<div id="source" data-page="site-source">${deps.statePanel('loading')}</div>`,
|
||||
deploy: `<div data-page="site-deploy"><div id="deployCurrent" class="card">${deps.statePanel('loading')}</div><div id="deployStart" class="card"></div><div id="deployHistory" class="card"></div></div>`,
|
||||
domains: `<div data-page="site-domains" class="card"><div class="actions" style="margin-bottom:10px"><a class="btn primary" href="/domains/connect?site=${esc(site.id)}">도메인 연결</a><a class="btn" href="/domains">도메인 목록</a></div><div id="siteDomains">${deps.statePanel('loading')}</div></div>`,
|
||||
mail: `<div class="card"><h2>메일</h2><p>사이트 대표 메일과 메일함은 메일 화면에서 관리합니다.</p><a class="btn primary" href="/mail?site=${esc(site.id)}">메일 화면으로</a></div>`,
|
||||
backup: `<div class="card"><h2>백업</h2><p>관리형 사이트 백업 옵션·보관 목록·복원 요청은 백업 화면에서 관리합니다.</p><a class="btn primary" href="/backup?site=${esc(site.id)}">백업 화면으로</a></div>`,
|
||||
usage: `<div id="usage" data-page="site-usage">${deps.statePanel('loading')}</div>`,
|
||||
activity: `<div id="activity" data-page="site-activity">${deps.statePanel('loading')}</div>`,
|
||||
settings: `<div data-page="site-settings"><form id="settings" class="card" data-dirty-guard><h2>사이트 설정</h2>
|
||||
<div class="f"><label for="s_name">사이트 이름</label><input id="s_name" name="name" value="${esc(site.name)}" maxlength="120"></div>
|
||||
<div class="f"><label for="s_desc">설명</label><textarea id="s_desc" name="description" rows="3">${esc(site.description || '')}</textarea></div>
|
||||
<div class="f"><label>대표 웹 도메인</label><div id="primaryDomain" class="mono">${esc(site.primary_domain || '없음')}</div><span class="hint">대표 도메인은 <a href="/sites/${esc(site.id)}?tab=domains">도메인 탭</a>에서 지정합니다</span></div>
|
||||
<div class="f"><label for="s_lang">기본 언어</label><select id="s_lang" name="lang">${['ko', 'en', 'ja', 'zh'].map((l) => `<option value="${l}"${site.lang === l ? ' selected' : ''}>${l}</option>`).join('')}</select></div>
|
||||
<div class="f"><label for="s_tz">시간대</label><input id="s_tz" name="timezone" value="${esc(site.timezone || 'Asia/Seoul')}"></div>
|
||||
<div class="f"><label for="s_runtime">실행 형태</label><select id="s_runtime" name="runtime"><option value="static"${site.runtime === 'static' ? ' selected' : ''}>정적 사이트</option><option value="node"${site.runtime === 'node' ? ' selected' : ''}>동적 사이트(승인된 Node 실행 형태)</option></select><span class="hint">배포 전 확인 화면에서 필요한 저장 공간과 지원하지 않는 기능을 안내합니다</span></div>
|
||||
<div class="f"><label><input type="checkbox" name="maintenance" ${site.maintenance ? 'checked' : ''}> 유지보수 모드</label><span class="hint">켜면 방문자에게 안내 페이지가 보입니다. 적용 전 영향과 예상 시간을 다시 보여 줍니다</span></div>
|
||||
<div class="f"><label><input type="checkbox" name="searchVisible" ${site.search_visible ? 'checked' : ''}> 검색 엔진 공개</label></div>
|
||||
<div class="actions"><button class="btn primary" type="submit">저장</button></div><div class="err" data-err="form"></div></form>
|
||||
<div class="card"><h2>사이트별 접근 권한</h2><div id="perms">${deps.statePanel('loading')}</div></div>
|
||||
<div class="card"><h2>중지 · 소유권 이전 · 해지</h2><p class="hint">서비스 중단이 예상되는 작업은 영향과 예상 시간을 적용 전에 보여 줍니다.</p><div class="actions"><a class="btn" href="/sites/${esc(site.id)}/lifecycle">생명주기 화면(중지·해지·보존)</a><a class="btn" href="/transfer/new?site=${esc(site.id)}">소유권 이전</a></div></div></div>`,
|
||||
};
|
||||
return shell(ctx, deps, { title: site.name, nav: 'sites', crumbs: [{ label: t('sites.title'), href: '/sites' }, { label: site.name }], actions: tab === 'overview' && site.primary_domain && site.lifecycle === 'live' ? [{ label: '사이트 열기', href: `https://${site.primary_domain}/`, kind: '' }, { label: '배포', href: `/sites/${site.id}?tab=deploy`, kind: 'primary' }] : [{ label: '배포', href: `/sites/${site.id}?tab=deploy`, kind: 'primary' }], body: `<div class="tabs">${tabs}</div><div data-site="${esc(site.id)}" data-lifecycle="${esc(site.lifecycle)}" data-mode="${esc(site.deploy_mode || '')}">${bodies[tab]}</div>` });
|
||||
}
|
||||
export function modePage(ctx, deps, site) {
|
||||
return shell(ctx, deps, { title: '배포 방식 선택', nav: 'sites', crumbs: [{ label: t('sites.title'), href: '/sites' }, { label: site.name, href: `/sites/${site.id}` }, { label: '배포 방식' }], body: `<div data-page="site-mode" data-site="${esc(site.id)}" data-current="${esc(site.deploy_mode || '')}"><div id="modeInfo">${deps.statePanel('loading')}</div></div>` });
|
||||
}
|
||||
export function externalPage(ctx, deps, site) {
|
||||
return shell(ctx, deps, { title: t('sites.external.title'), nav: 'sites', crumbs: [{ label: t('sites.title'), href: '/sites' }, { label: site.name, href: `/sites/${site.id}` }, { label: t('sites.external.title') }], body: `<div data-page="site-external" data-site="${esc(site.id)}"><div id="external">${deps.statePanel('loading')}</div></div>` });
|
||||
}
|
||||
export function deployPage(ctx, deps, site) { return detailPage(ctx, deps, site, { tab: 'deploy' }); }
|
||||
function shell(ctx, deps, o) { return deps.page({ ...o, user: ctx.user, group: ctx.group, groups: ctx.groups || [], unread: ctx.unread || 0, csrf: ctx.session ? ctx.session.csrf : '', scripts: ['/ui/sites/sites.js'] }); }
|
||||
@@ -0,0 +1,19 @@
|
||||
/** portal/source/api.mjs — Source 라우트. 화면 /sites/:id/source(사이트 상세 Source 탭) · API /api/source/:siteId (GET), /activate, /unlink, /export · 운영자 /internal/ops/source */
|
||||
import { requireGroup, requireSitePerm } from '../auth/guard.mjs';
|
||||
import { HttpError } from '../../lib/http.mjs';
|
||||
import { verifyCtx } from '../../lib/serviceauth.mjs';
|
||||
import { createSourceService } from './service.mjs';
|
||||
import * as siteUi from '../sites/ui.mjs';
|
||||
import { createReadStream, statSync, unlinkSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
export async function register(app, deps) {
|
||||
const source = createSourceService(deps); deps.source = source;
|
||||
const siteOf = (ctx, perm = 'view') => { requireGroup(ctx); const s = deps.sites.getForGroup(ctx.group.id, ctx.params.siteId || ctx.params.id); if (!s) throw new HttpError(404, 'not_found', '사이트를 찾을 수 없습니다'); if (perm !== 'view') requireSitePerm(ctx, s, perm); return s; };
|
||||
app.get('/sites/:id/source', (ctx) => ctx.html(siteUi.detailPage(ctx, deps, siteOf(ctx), { tab: 'source' })));
|
||||
app.get('/api/source/:siteId', async (ctx) => ({ ok: true, ...(await source.info(siteOf(ctx))) }));
|
||||
app.post('/api/source/:siteId/activate', (ctx) => ({ ok: true, option: source.activate(siteOf(ctx, 'admin'), ctx.user) }));
|
||||
app.post('/api/source/:siteId/unlink', async (ctx) => ({ ok: true, option: await source.unlink(siteOf(ctx, 'admin'), ctx.user) }));
|
||||
app.get('/api/source/:siteId/export', async (ctx) => { const s = siteOf(ctx); const o = source.get(s.id); if (!o || o.status === 'deleted') throw new HttpError(409, 'not_available', o ? '삭제 완료된 저장소는 내보낼 수 없습니다(복구 불가)' : 'Source 옵션이 없습니다'); if (!deps.webmaker) throw new HttpError(503, 'service_unreachable', 'Webmaker 연결 없음'); const dir = join(deps.config.dataDir, 'exports'); mkdirSync(dir, { recursive: true, mode: 0o700 }); const file = join(dir, `${s.id}-${Date.now()}.tar.gz`); await deps.webmaker.exportVersion(s.webmaker_project_id, ctx.query.version || null, file); ctx.ended = true; ctx.res.writeHead(200, { 'Content-Type': 'application/gzip', 'Content-Disposition': `attachment; filename="${s.id}-source.tar.gz"`, 'Content-Length': statSync(file).size, 'Cache-Control': 'no-store' }); createReadStream(file).on('close', () => { try { unlinkSync(file); } catch (_) {} }).pipe(ctx.res); });
|
||||
app.get('/internal/ops/source', async (ctx) => { const v = await verifyCtx(ctx, (id) => (['hal', 'portal'].includes(id) ? deps.vault.get('service:' + id) : null)); if (!v.ok) throw new HttpError(401, 'service_auth_failed', v.reason); return { ok: true, options: deps.store.all('SELECT o.*, s.name AS site_name, s.webmaker_project_id FROM source_options o LEFT JOIN sites s ON s.id=o.site_id ORDER BY o.updated_at DESC LIMIT 1000') }; });
|
||||
app.post('/internal/ops/source/sweep', async (ctx) => { const v = await verifyCtx(ctx, (id) => (['hal', 'portal'].includes(id) ? deps.vault.get('service:' + id) : null)); if (!v.ok) throw new HttpError(401, 'service_auth_failed', v.reason); return { ok: true, ...source.sweep() }; });
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** portal/source/model.mjs — Source 옵션(§8.3) 상태 표. status: active | readonly(만료 후 30일 read-only) | deleted */
|
||||
export const MIGRATIONS = [{ id: 'source_0001', sql: `
|
||||
CREATE TABLE IF NOT EXISTS source_options (
|
||||
id TEXT PRIMARY KEY, site_id TEXT NOT NULL UNIQUE, group_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', activated_at TEXT NOT NULL, expires_at TEXT, readonly_until TEXT, delete_after TEXT,
|
||||
deleted_at TEXT, reason TEXT, created_by TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
||||
);` }];
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* portal/source/service.mjs — Source(Gitea) 옵션 업무 규칙(개발 지시 §8.3·§30.5·§23.2).
|
||||
*
|
||||
* - 옵션은 사이트 단위이며 lib/quota 자원 'source' 를 1 배정한다(구매 총량 안에서만 활성화).
|
||||
* - 저장소·브랜치·변경 이력·저장소 크기는 Webmaker 의 Gitea 연동(modules/webmaker/lib/connectors/gitea.js·gitstore.js)을 내부 API 로 읽는다. 재구현하지 않는다.
|
||||
* - 만료·연결 해제 → readonly: push·새 변경은 막고 30일 동안 조회·clone·export 를 제공한다(만료일·삭제 예정일·재활성화 방법 표시).
|
||||
* - 삭제 완료(deleted) 뒤에는 복구 가능한 것처럼 표시하지 않는다.
|
||||
* 실제 push 차단은 Webmaker 쪽 gitstore 가 포털 연결 상태를 보고 하는 것이 아니라, 포털이 Webmaker 내부 API 로 연결을 해제(unlinkProject)하고 상태를 readonly 로 둔 뒤 export 만 허용하는 방식이다.
|
||||
*/
|
||||
import { newId } from '../../lib/crypto.mjs';
|
||||
import { HttpError } from '../../lib/http.mjs';
|
||||
import { nowIso, parseJson } from '../../lib/db.mjs';
|
||||
import * as quota from '../../lib/quota.mjs';
|
||||
import { notify } from '../../lib/notify.mjs';
|
||||
import { audit } from '../../lib/audit.mjs';
|
||||
|
||||
const READONLY_DAYS = 30;
|
||||
export function createSourceService(deps) {
|
||||
const { store, log = () => {} } = deps;
|
||||
const get = (siteId) => store.get('SELECT * FROM source_options WHERE site_id=?', siteId) || null;
|
||||
const save = (id, patch) => { const sets = []; const vals = []; for (const [k, v] of Object.entries(patch)) { sets.push(`${k}=?`); vals.push(v); } sets.push('updated_at=?'); vals.push(nowIso()); store.run(`UPDATE source_options SET ${sets.join(', ')} WHERE id=?`, ...vals, id); return store.get('SELECT * FROM source_options WHERE id=?', id); };
|
||||
/** 활성화: 권리 안에서 1 배정 */
|
||||
function activate(site, user) {
|
||||
const cur = get(site.id); if (cur && cur.status === 'active') return cur;
|
||||
try { quota.setAllocation(store, { groupId: site.group_id, resource: 'source', subjectType: 'site', subjectId: site.id, amount: 1 }); } catch (e) { throw new HttpError(409, e.code || 'quota_insufficient', e.code === 'quota_insufficient' ? 'Source 옵션 권리가 부족합니다. 상품 및 결제에서 옵션을 추가하세요' : e.message, { href: '/billing' }); }
|
||||
const now = nowIso();
|
||||
if (cur) { const r = save(cur.id, { status: 'active', activated_at: now, readonly_until: null, delete_after: null, reason: null }); audit(store, { actorType: 'user', actorId: user?.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'source.reactivate', detail: {} }); return r; }
|
||||
const id = newId('so_'); store.run('INSERT INTO source_options(id, site_id, group_id, status, activated_at, created_by, created_at, updated_at) VALUES(?,?,?,\'active\',?,?,?,?)', id, site.id, site.group_id, now, user?.id || null, now, now);
|
||||
audit(store, { actorType: 'user', actorId: user?.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'source.activate', detail: {} });
|
||||
return get(site.id);
|
||||
}
|
||||
/** 만료·해제 → readonly 30일. 배정 반환 */
|
||||
function expire(siteId, { reason = 'expired', at = Date.now(), actor = null } = {}) {
|
||||
const o = get(siteId); if (!o || o.status !== 'active') return o;
|
||||
const a = quota.allocationOf(store, o.group_id, 'source', 'site', siteId); if (a) quota.finishReturn(store, a.id);
|
||||
const until = new Date(at + READONLY_DAYS * 86400000).toISOString();
|
||||
const r = save(o.id, { status: 'readonly', readonly_until: until, delete_after: until, expires_at: new Date(at).toISOString(), reason });
|
||||
const site = store.get('SELECT name FROM sites WHERE id=?', siteId);
|
||||
notify(store, { groupId: o.group_id, category: 'site', severity: 'warning', mandatory: true, incidentKey: `source:${siteId}`, title: `사이트 "${site ? site.name : siteId}" Source 옵션 ${reason === 'unlinked' ? '연결 해제' : '만료'} — ${until.slice(0, 10)} 삭제 예정`, body: `30일 동안 read-only 조회·clone·export 가 가능합니다. 그 뒤 저장소가 삭제되며 복구할 수 없습니다. 재활성화하려면 Source 옵션을 다시 구매하세요`, link: `/sites/${siteId}/source`, subjectType: 'site', subjectId: siteId, actionRequired: '필요하면 export 또는 재활성화' });
|
||||
audit(store, { actorType: actor ? 'user' : 'system', actorId: actor?.id, groupId: o.group_id, subjectType: 'site', subjectId: siteId, action: `source.${reason}`, detail: { readonlyUntil: until }, automatic: !actor });
|
||||
return r;
|
||||
}
|
||||
/** 삭제 완료 처리(주기 작업). Gitea 브랜치 삭제는 운영자 작업(HAL 콘솔)으로 분리되어 있어 여기서는 상태만 둔다 */
|
||||
function sweep(now = Date.now()) {
|
||||
let deleted = 0; let expired = 0;
|
||||
for (const o of store.all('SELECT * FROM source_options WHERE status=\'readonly\' AND delete_after<=?', new Date(now).toISOString())) { save(o.id, { status: 'deleted', deleted_at: nowIso() }); const site = store.get('SELECT name FROM sites WHERE id=?', o.site_id); notify(store, { groupId: o.group_id, category: 'site', severity: 'info', incidentKey: `source:${o.site_id}`, title: `사이트 "${site ? site.name : o.site_id}" Source 저장소 삭제 완료`, body: '보존 기간이 끝나 저장소를 삭제했습니다. 복구할 수 없습니다', link: `/sites/${o.site_id}/source`, subjectType: 'site', subjectId: o.site_id }); deleted++; }
|
||||
// 권리가 줄어 배정이 구매량을 넘으면 가장 늦게 활성화한 옵션부터 만료(§6.4 축소는 정리 후 적용되므로 보통 발생하지 않음)
|
||||
for (const g of store.all('SELECT DISTINCT group_id FROM source_options WHERE status=\'active\'')) { const f = quota.figures(store, g.group_id, 'source'); let over = f.allocated - f.purchased; if (over <= 0) continue; for (const o of store.all('SELECT * FROM source_options WHERE group_id=? AND status=\'active\' ORDER BY activated_at DESC', g.group_id)) { if (over <= 0) break; expire(o.site_id, { reason: 'expired', at: now }); over--; expired++; } }
|
||||
return { deleted, expired };
|
||||
}
|
||||
/** 화면 데이터: 옵션 + Webmaker 저장소 정보(닿지 못하면 stale) + 배포 revision 대응 */
|
||||
async function info(site) {
|
||||
const o = get(site.id); const ent = quota.figures(store, site.group_id, 'source');
|
||||
let source = null; if (o && o.status !== 'deleted' && site.webmaker_project_id && deps.webmaker) { try { source = await deps.webmaker.projectSource(site.webmaker_project_id); } catch (e) { source = { stale: true, provider: '-', changes: [], error: e.code === 'service_unreachable' ? '정보 확인 지연' : e.message }; } }
|
||||
const deployedCommits = {}; for (const d of store.all('SELECT version_label, version_ref, result_json FROM deployments WHERE site_id=? AND status=\'done\' ORDER BY created_at DESC LIMIT 50', site.id)) { const r = parseJson(d.result_json, null); if (r && r.bundleCommit) deployedCommits[r.bundleCommit] = deployedCommits[r.bundleCommit] || (d.version_label || d.version_ref); }
|
||||
const lastDep = store.all('SELECT result_json, version_label, version_ref FROM deployments WHERE site_id=? AND status=\'done\' ORDER BY created_at DESC LIMIT 1', site.id)[0]; const lr = lastDep ? parseJson(lastDep.result_json, null) : null;
|
||||
const limits = (() => { const s = store.get('SELECT value_json FROM settings WHERE key=\'source.limits\''); return s ? parseJson(s.value_json, null) : null; })();
|
||||
return { option: o, entitlement: ent, source, selectedRevision: source && source.changes && source.changes.length ? source.changes[source.changes.length - 1].commit : null, lastDeployedRevision: lr && lr.bundleCommit ? lr.bundleCommit : null, lastDeployedVersion: lastDep ? lastDep.version_label || lastDep.version_ref : null, deployedCommits, limits, cloneInfo: source && source.remote && !source.stale ? `git clone --branch ${source.mainBranch} <Gitea 주소>/${source.remote}.git (접속 정보는 Webmaker › 소스 보관 설정)` : null, readonly: !!(o && o.status === 'readonly'), canExport: !!(o && o.status !== 'deleted' && site.webmaker_project_id) };
|
||||
}
|
||||
async function unlink(site, user) { const o = get(site.id); if (!o || o.status !== 'active') throw new HttpError(409, 'not_active', '활성 Source 옵션이 없습니다'); if (site.webmaker_project_id && deps.webmaker) { try { await deps.webmaker.unlinkProject(site.webmaker_project_id); } catch (e) { if (e.code === 'service_unreachable') throw new HttpError(503, 'service_unreachable', 'Webmaker 에 연결할 수 없어 지금은 해제할 수 없습니다'); } } return expire(site.id, { reason: 'unlinked', actor: user }); }
|
||||
return { get, activate, expire, unlink, sweep, info, READONLY_DAYS };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# Webmaker 기존 기능 적용 기록 (Ariweb Suite 0.1.1, 개발 지시 §0.1·§0.2·§0.5)
|
||||
|
||||
- 원본: https://source.ibit.kr/OZ-Group/WebHosting_AI_agent — revision `3b10859884ef2fd0f2af11934ab6c16370fd64b8` (v1.7.3), 도입 경로 `modules/webmaker/` (IMPORTED_SOURCES.md 참고)
|
||||
- 접근 위치: 포털 사이트 상세 › 제작 탭 › "제작 화면 열기" (`/sso/webmaker?site=<siteId>`) → Webmaker `/api/auth/sso` → 기존 빌더 화면. 포털 사용자당 Webmaker 계정 `aw-<그룹>-<사용자>` 가 자동으로 생기며 별도 로그인은 없다.
|
||||
- 회귀 테스트: `cd modules/webmaker && node --test test/*.test.js` (원본 6개 파일 + 신규 `test/ariweb.test.js`). 실제 화면 확인 결과는 출시 검증 기록(VERSION_HISTORY.md 검증 절)에 적는다.
|
||||
|
||||
| §0.1 기능 | 적용 구분 | 접근 위치 / 비고 |
|
||||
|---|---|---|
|
||||
| `AI에게 맡기기`·`내가 고르기` Site 제작 | 그대로 도입 | 제작 탭 → Webmaker 빌더. 포털에서 사이트를 만들 때 제작 방식(ai·manual·quick)을 골라 프로젝트를 만든다(내부 API `POST /api/internal/ariweb/projects`) |
|
||||
| Site Brief 작성과 확인 | 그대로 도입 | Webmaker 기획 단계 그대로 |
|
||||
| Template과 기존 제작 Module | 그대로 도입 | Webmaker 생성 탭 "템플릿에서 시작"·모듈 관리 그대로 |
|
||||
| Page 생성·편집·미리보기·저장 | 최소 연결 변경 | 저장(PATCH 기본 사항·PUT 소스 편집)에 `rev` 기반 동시 편집 충돌 감지(409 + 두 버전 요약)를 더함(§9.3). rev 를 보내지 않는 기존 화면은 그대로 동작 |
|
||||
| AI 생성·수정과 Wallet/Cache 차감 | 그대로 도입 | Webmaker 캐시(credits) 그대로. 포털은 `/api/internal/ariweb/credits/<login>` 으로 잔액을 읽어 AI Wallet 으로 표시(호스팅 사용량과 분리 §0.2) |
|
||||
| 이전 저장본과 배포할 저장본 선택 | 최소 연결 변경 | 저장본 목록은 Webmaker 버전 기록(git.changes) 그대로. 배포할 저장본 선택은 포털 배포 탭(`/api/internal/ariweb/projects/<id>/export?version=N`)에서 한다 |
|
||||
| Domain·DNS·TLS 관리 기능 | 그대로 도입 | 등록기관(Openprovider·Porkbun·Route 53)·DNS 공급자·네임서버 이전·레코드 관리는 Webmaker 도메인 탭 그대로(포털 도메인 화면의 "도메인 구매·DNS 자동화(Webmaker)" 링크). 포털은 연결 상태·소유 확인·HTTPS 검증만 가진다 |
|
||||
| Gitea Source 연결과 변경 이력 | 그대로 도입 | gitstore·connectors/gitea 그대로. 포털 Source 탭은 `/api/internal/ariweb/projects/<id>/source` 로 읽어 표시 |
|
||||
| 관리형 또는 외부 대상 배포에 이미 사용할 수 있는 기능 | 미사용(이번 버전) | Webmaker 의 배포 서버(deploy.js: local/ssh tar-over-ssh)는 포털 연결 프로젝트에서는 쓰지 않는다. 운영 배포는 HAL/Node Agent 가 맡는다(§32). 포털에 연결되지 않은 Webmaker 단독 프로젝트는 기존 기능 그대로 동작 |
|
||||
| 배포 결과 확인과 이전 Version 복구 기능 | 미사용(이번 버전) | 위와 같은 이유로 포털 배포 탭(되돌리기 = HAL site.rollback)이 담당. Webmaker 발행(plan/publish)은 포털 연결 프로젝트에서 409 로 막고 포털 배포 화면으로 안내 |
|
||||
| AriMail Server 연결과 Mail 사용량 표시 | 그대로 도입 | Webmaker 관리자 콘솔 메일 서버 등록부·사용량 연동 그대로. 포털 메일 화면은 별도(메일 영역) |
|
||||
| 사용자·관리자 화면의 설정·상태·통계 기능 | 그대로 도입 | Webmaker 관리자 콘솔·설정 페이지(8795) 그대로 |
|
||||
|
||||
## §0.2 수정·개선 항목
|
||||
|
||||
| 항목 | 구분 | 구현 |
|
||||
|---|---|---|
|
||||
| 별도 로그인 없이 현재 사용자·그룹으로 이용 | 정책 개선 | `GET /api/auth/sso?ticket=`(server.js) + `lib/auth.ensureSsoUser` + `lib/ariweb.verifyTicket`(60초·1회용·HMAC). 상단 배너(`public/index.html #ariwebBanner`, app.js)에 그룹·사이트 표시 |
|
||||
| Project 와 운영 Site 구분 | 정책 개선 | `lib/ariweb.js` 연결 등록부(data/ariweb-links.json), 배너 문구, 사이트 조회 응답 `ariweb` 필드 |
|
||||
| Draft Site 가 운영 사이트 수를 쓰지 않음 | 정책 개선(포털) | portal/sites: 초안 생성은 예약 없음, 최초 공개 배포 때 예약 |
|
||||
| 관리형/외부 배포의 비용·책임·상태 구분 | 정책 개선(포털) | portal/sites 배포 방식 화면·동의 기록 |
|
||||
| 상품 권리·Site 별 보장량 배포 전 확인 | 정책 개선(포털) | portal/sites precheck |
|
||||
| AI Wallet 과 Hosting·Mail 사용량 분리 | 정책 개선(포털) | 제작 탭 AI Wallet 카드 / 사용량 탭 별도 |
|
||||
| 운영 Version 을 바로 덮어쓰지 않고 Preview 후 배포 | 정책 개선 | Webmaker 발행 차단(연결 프로젝트) + 포털 배포 탭 저장본 선택 |
|
||||
| 진행 상태를 공통 상태·알림에 표시 | 정책 개선(포털) | lib/jobs + lib/notify |
|
||||
| Project 삭제가 운영 Site·Domain·Source 를 자동 삭제하지 않음 | 정책 개선 | `DELETE /api/sites/<id>` 가 포털 `/internal/webmaker/project-impact` 를 서명 호출해 영향을 보이고, 운영 중이면 거부·확인(acknowledgeImpact) 후에만 폐기. 포털 데이터는 건드리지 않음 |
|
||||
| 동시 편집 충돌 표시 | 오류 수정 | `lib/store.js` rev + `server.js revConflict` |
|
||||
| 기존 기능 오류 수정 | — | 이번 도입에서 Webmaker 1.7.3 회귀 테스트 전부 통과, 별도 오류 발견 없음 |
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* portal/webmaker/adapter.mjs — Webmaker(도입 모듈 modules/webmaker) 연동 어댑터. deps.webmaker 로 모든 영역이 쓴다.
|
||||
*
|
||||
* 역할(docs/ARCHITECTURE.md §7, 개발 지시 §0.2·§9)
|
||||
* - SSO: 포털 사용자·그룹마다 Webmaker 계정 `aw-<그룹 8자>-<사용자 8자>` 를 두고, 공유 비밀(보관소 'sso:webmaker')로 서명한 60초·1회용 티켓으로
|
||||
* Webmaker 의 /api/auth/sso 에 들어간다. 사용자는 Webmaker 비밀번호를 모른다(별도 로그인 없음). 발급 기록은 sso_tickets 표.
|
||||
* - 내부 API(/api/internal/ariweb/*, modules/webmaker/lib/ariweb.js): 프로젝트 목록·생성·연결·저장본·소스·export(tar.gz)·AI Wallet(캐시).
|
||||
* 서명 규약은 lib/serviceauth(키 id 'portal', 같은 공유 비밀).
|
||||
* - 장애 격리: Webmaker 에 닿지 못하면 code 'service_unreachable' 로 던져 화면이 '정보 확인 지연' 을 보이고 신규 작업을 막는다(§26.2).
|
||||
* 테스트는 createFake() 가 같은 모양의 메모리 구현을 준다.
|
||||
*/
|
||||
import { randomToken, hmac, newId } from '../../lib/crypto.mjs';
|
||||
import { serviceFetch, signHeaders } from '../../lib/serviceauth.mjs';
|
||||
import { nowIso } from '../../lib/db.mjs';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
export const SECRET_NAME = 'sso:webmaker';
|
||||
const TICKET_TTL_MS = 60 * 1000;
|
||||
/** Webmaker 로그인 ID 규칙(lib/auth.js ID_RE: 소문자·숫자로 시작, 3~32자) */
|
||||
export function loginFor(user, group) { const g = String(group.id).replace(/^g_/, '').slice(0, 8).toLowerCase(); const u = String(user.id).replace(/^u_/, '').slice(0, 8).toLowerCase(); return `aw-${g}-${u}`.replace(/[^a-z0-9._-]/g, ''); }
|
||||
|
||||
export function create(deps) {
|
||||
const { store, vault, config, log = () => {} } = deps;
|
||||
/** 공유 비밀: 없으면 만든다(운영자가 Webmaker 설정 ariweb.secret 에 같은 값을 넣어야 연동이 완성된다 — provisioningInfo 로 안내). */
|
||||
function secret() { let s = vault.get(SECRET_NAME); if (!s) { s = randomToken(32); vault.set(SECRET_NAME, s, { kind: 'shared-secret' }); log('[webmaker] 공유 비밀을 새로 만들었습니다 — Webmaker 설정 ariweb.secret 에 같은 값을 넣어야 합니다'); } return s; }
|
||||
const base = () => String(config.webmakerInternalUrl || '').replace(/\/$/, '');
|
||||
async function request(method, path, body) {
|
||||
const r = await serviceFetch(base(), 'portal', secret(), method, path, body, { timeoutMs: 20000 });
|
||||
if (!r.ok) { const e = new Error((r.json && (r.json.message || r.json.error)) || `Webmaker ${r.status}`); e.code = (r.json && r.json.error) || `webmaker_${r.status}`; e.status = r.status; throw e; }
|
||||
return r.json;
|
||||
}
|
||||
const api = {
|
||||
SECRET_NAME, loginFor, secret,
|
||||
/** 운영자 안내용: 공유 비밀 자체는 돌려주지 않고 존재 여부·힌트만 */
|
||||
provisioningInfo() { const items = vault.list(); const it = items.find((x) => x.name === SECRET_NAME); return { configured: !!it, hint: it ? it.hint : null, webmakerSettingKey: 'ariweb.secret', webmakerUrl: config.webmakerUrl }; },
|
||||
/** 공유 비밀 재발급(운영자). 새 값은 이 응답에 한 번만 보인다 — Webmaker 쪽도 같이 바꿔야 한다. */
|
||||
rotateSecret() { const s = randomToken(32); vault.set(SECRET_NAME, s, { kind: 'shared-secret' }); return { secret: s, note: 'Webmaker 설정 ariweb.secret 에 같은 값을 넣으세요. 이 값은 다시 표시되지 않습니다' }; },
|
||||
/**
|
||||
* SSO 진입 주소 만들기. 티켓은 60초·1회용. 기록(sso_tickets)에는 티켓 원문이 아니라 nonce 만 남긴다.
|
||||
* @param {{user, group, site?, projectId?, path?}} o
|
||||
*/
|
||||
ssoUrl({ user, group, site = null, projectId = null, path = null }) {
|
||||
const nonce = randomToken(12); const exp = Date.now() + TICKET_TTL_MS;
|
||||
const payload = Buffer.from(JSON.stringify({ v: 1, login: loginFor(user, group), name: user.display_name || user.email, groupId: group.id, groupName: group.name, siteId: site ? site.id : '', siteName: site ? site.name : '', projectId: projectId || (site ? site.webmaker_project_id : '') || '', path: path || '', portalUrl: config.publicUrl, exp, nonce })).toString('base64url');
|
||||
const ticket = `${payload}.${hmac(secret(), payload)}`;
|
||||
store.run('INSERT INTO sso_tickets(id, nonce, user_id, group_id, site_id, project_id, issued_at, expires_at) VALUES(?,?,?,?,?,?,?,?)', newId('t_'), nonce, user.id, group.id, site ? site.id : null, projectId || (site ? site.webmaker_project_id : null), nowIso(), new Date(exp).toISOString());
|
||||
return `${String(config.webmakerUrl).replace(/\/$/, '')}/api/auth/sso?ticket=${encodeURIComponent(ticket)}`;
|
||||
},
|
||||
health: () => request('GET', '/api/internal/ariweb/health'),
|
||||
listProjects: async (login) => (await request('GET', `/api/internal/ariweb/projects?owner=${encodeURIComponent(login)}`)).projects,
|
||||
getProject: async (id) => (await request('GET', `/api/internal/ariweb/projects/${encodeURIComponent(id)}`)).project,
|
||||
/** 프로젝트 만들기. mode: quick(제목·내용 입력형) | ai(AI에게 맡기기) | manual(내가 고르기). link 를 주면 바로 연결 */
|
||||
createProject: async ({ login, ownerName, title, mode = 'manual', purpose = '', link = null }) => (await request('POST', '/api/internal/ariweb/projects', { owner: login, ownerName, title, mode, purpose, link })).project,
|
||||
linkProject: async (id, link) => (await request('POST', `/api/internal/ariweb/projects/${encodeURIComponent(id)}/link`, link)).link,
|
||||
unlinkProject: async (id) => request('DELETE', `/api/internal/ariweb/projects/${encodeURIComponent(id)}/link`),
|
||||
projectSource: async (id) => (await request('GET', `/api/internal/ariweb/projects/${encodeURIComponent(id)}/source`)).source,
|
||||
credits: async (login) => request('GET', `/api/internal/ariweb/credits/${encodeURIComponent(login)}`),
|
||||
/** 저장본 tar.gz 를 파일로 받는다. versionNo 가 없으면 현재 작업 트리. @returns {{file, version, commit, bytes}} */
|
||||
async exportVersion(id, versionNo, destFile) {
|
||||
const path = `/api/internal/ariweb/projects/${encodeURIComponent(id)}/export${versionNo ? `?version=${encodeURIComponent(versionNo)}` : ''}`;
|
||||
let r; try { r = await fetch(base() + path, { method: 'GET', headers: signHeaders('portal', secret(), 'GET', path, undefined), signal: AbortSignal.timeout(120000) }); }
|
||||
catch (e) { const err = new Error('Webmaker 에 연결할 수 없습니다'); err.code = 'service_unreachable'; throw err; }
|
||||
if (!r.ok) { const e = new Error(`저장본 내보내기 실패 (${r.status})`); e.code = r.status === 404 ? 'version_not_found' : 'export_failed'; throw e; }
|
||||
await pipeline(Readable.fromWeb(r.body), createWriteStream(destFile, { mode: 0o600 }));
|
||||
const { statSync } = await import('node:fs');
|
||||
return { file: destFile, version: r.headers.get('x-ariweb-version') || 'current', commit: r.headers.get('x-ariweb-commit') || '', bytes: statSync(destFile).size };
|
||||
},
|
||||
};
|
||||
return api;
|
||||
}
|
||||
/** 테스트용 가짜 어댑터: 프로젝트를 메모리에 두고 같은 인터페이스를 제공한다. */
|
||||
export function createFake(deps = {}) {
|
||||
const projects = new Map(); let seq = 0; const calls = [];
|
||||
const summary = (p) => ({ ...p, versions: p.versionList.length, hasContent: p.hasContent });
|
||||
return {
|
||||
SECRET_NAME, loginFor, secret: () => 'fake-secret', calls, _projects: projects, unreachable: false,
|
||||
_guard() { if (this.unreachable) { const e = new Error('Webmaker 에 연결할 수 없습니다'); e.code = 'service_unreachable'; throw e; } },
|
||||
provisioningInfo: () => ({ configured: true, hint: 'fake', webmakerSettingKey: 'ariweb.secret', webmakerUrl: 'https://webmaker.example.test' }),
|
||||
rotateSecret: () => ({ secret: 'new-fake', note: '' }),
|
||||
ssoUrl({ user, group, site = null, projectId = null, path = null }) { calls.push(['sso', loginFor(user, group), site?.id]); if (deps.store) deps.store.run('INSERT INTO sso_tickets(id, nonce, user_id, group_id, site_id, project_id, issued_at, expires_at) VALUES(?,?,?,?,?,?,?,?)', newId('t_'), randomToken(8), user.id, group.id, site ? site.id : null, projectId || (site ? site.webmaker_project_id : null), nowIso(), new Date(Date.now() + 60000).toISOString()); return `https://webmaker.example.test/api/auth/sso?ticket=fake.${randomToken(6)}`; },
|
||||
async health() { this._guard(); return { ok: true, version: '1.7.3' }; },
|
||||
async listProjects(login) { this._guard(); return [...projects.values()].filter((p) => p.ownerId === login).map(summary); },
|
||||
async getProject(id) { this._guard(); const p = projects.get(id); if (!p) { const e = new Error('프로젝트가 없습니다'); e.code = 'webmaker_404'; e.status = 404; throw e; } return summary(p); },
|
||||
async createProject({ login, title, mode = 'manual', link = null }) { this._guard(); const id = (++seq).toString(16).padStart(32, '0'); const p = { id, title, ownerId: login, status: 'idle', mode, stage: mode === 'ai' ? 'input' : 'manual', rev: 1, createdAt: nowIso(), updatedAt: nowIso(), lastChange: null, versionList: [], link: link || null, hasContent: false, git: { provider: 'gitea', enabled: true, remote: 'webmaker/sites', mainBranch: `sites/${id}/main`, mainUrl: `https://gitea.example.test/webmaker/sites/src/branch/sites/${id}/main` }, liveUrl: null, deployState: null, domains: [] }; projects.set(id, p); return summary(p); },
|
||||
async linkProject(id, link) { this._guard(); const p = projects.get(id); if (!p) throw Object.assign(new Error('없음'), { code: 'webmaker_404', status: 404 }); p.link = { ...link, linkedAt: nowIso() }; return p.link; },
|
||||
async unlinkProject(id) { this._guard(); const p = projects.get(id); if (p) p.link = null; return { ok: true }; },
|
||||
async projectSource(id) { this._guard(); const p = projects.get(id); return { ...p.git, repoBytes: 2048, siteBytes: 4096, uploadBytes: 0, lfsBytes: 0, lfsSupported: false, changes: p.versionList.map((v) => ({ ...v, pushed: true, branch: `sites/${id}/changes/${String(v.no).padStart(4, '0')}-${v.kind}` })), remoteStatus: { ok: true } }; },
|
||||
async credits(login) { this._guard(); return { ok: true, credit: { userId: login, balance: 12000, used: 3000, granted: 15000, updatedAt: nowIso(), entries: 2 }, unit: '원' }; },
|
||||
async exportVersion(id, versionNo, destFile) { this._guard(); const p = projects.get(id); if (versionNo && !p.versionList.find((v) => String(v.no) === String(versionNo))) throw Object.assign(new Error('없음'), { code: 'version_not_found' }); const { writeFileSync } = await import('node:fs'); writeFileSync(destFile, Buffer.from([0x1f, 0x8b, 0x08, 0, 0, 0, 0, 0, 0, 3])); return { file: destFile, version: versionNo || 'current', commit: versionNo ? 'c' + versionNo : '', bytes: 10 }; },
|
||||
/** 테스트 도우미: 저장본 추가 */
|
||||
_addVersion(id, kind = 'update', message = '수정') { const p = projects.get(id); const no = p.versionList.length + 1; p.versionList.push({ no, kind, message, at: nowIso(), commit: 'c' + no, files: 1 }); p.hasContent = true; p.rev++; p.lastChange = { no, kind, at: nowIso(), message }; return no; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** portal/webmaker/api.mjs — Webmaker 연동 라우트: 내 프로젝트 목록(/api/webmaker/projects), AI Wallet(/api/webmaker/wallet), 연동 상태(운영자 /internal/ops/webmaker). SSO 진입은 portal/sites/api.mjs 의 /sso/webmaker. */
|
||||
import { requireGroup } from '../auth/guard.mjs';
|
||||
import { HttpError } from '../../lib/http.mjs';
|
||||
import { verifyCtx } from '../../lib/serviceauth.mjs';
|
||||
import { loginFor } from './adapter.mjs';
|
||||
export async function register(app, deps) {
|
||||
const wm = () => { if (!deps.webmaker) throw new HttpError(503, 'service_unreachable', 'Webmaker 연동이 설정되지 않았습니다'); return deps.webmaker; };
|
||||
app.get('/api/webmaker/projects', async (ctx) => { requireGroup(ctx); try { const projects = await wm().listProjects(loginFor(ctx.user, ctx.group)); const linked = new Set(deps.store.all('SELECT webmaker_project_id FROM sites WHERE group_id=? AND webmaker_project_id IS NOT NULL AND lifecycle<>\'deleted\'', ctx.group.id).map((r) => r.webmaker_project_id)); return { ok: true, projects: projects.map((p) => ({ ...p, link: p.link || (linked.has(p.id) ? { siteId: deps.store.get('SELECT id FROM sites WHERE webmaker_project_id=?', p.id)?.id } : null) })) }; } catch (e) { if (e.code === 'service_unreachable') return ctx.json({ ok: false, error: 'service_unreachable', message: 'Webmaker 에 연결할 수 없습니다(정보 확인 지연)' }, 503); throw e; } });
|
||||
app.get('/api/webmaker/wallet', async (ctx) => { requireGroup(ctx); try { const w = await wm().credits(loginFor(ctx.user, ctx.group)); return { ok: true, wallet: w.credit, unit: w.unit, note: 'AI Wallet 은 AI 생성·수정 비용에만 쓰이며 호스팅 디스크·트래픽·메일 사용량과 분리됩니다' }; } catch (e) { if (e.code === 'service_unreachable') return ctx.json({ ok: false, error: 'service_unreachable', message: 'Webmaker 에 연결할 수 없습니다(정보 확인 지연)' }, 503); throw e; } });
|
||||
app.get('/internal/ops/webmaker', async (ctx) => { const v = await verifyCtx(ctx, (id) => (['hal', 'portal'].includes(id) ? deps.vault.get('service:' + id) : null)); if (!v.ok) throw new HttpError(401, 'service_auth_failed', v.reason); let health = null; try { health = await wm().health(); } catch (e) { health = { ok: false, error: e.code || e.message }; } return { ok: true, health, provisioning: deps.webmaker ? deps.webmaker.provisioningInfo() : null, ssoIssued24h: deps.store.get('SELECT COUNT(*) AS n FROM sso_tickets WHERE issued_at>?', new Date(Date.now() - 86400000).toISOString()).n }; });
|
||||
}
|
||||
Reference in New Issue
Block a user