사이트 목록·생성·상세, 관리형/외부 배포, 배포 방식 변경, 도메인·HTTPS, Source 옵션과 Webmaker 도입 수정(SSO, 프로젝트↔사이트 구분, 삭제 영향 확인, 동시 편집 충돌, 발행 경로) (§7~§12·§0.2)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:56:38 +00:00
co-authored by Claude Fable 5.1
parent a1cde81332
commit b1c06a0304
32 changed files with 1900 additions and 3 deletions
+4
View File
@@ -0,0 +1,4 @@
{ "domains": { "title": "도메인", "connect": "도메인 연결", "detail": "도메인 상세", "empty": "연결된 도메인이 없습니다. 도메인을 연결하면 소유 확인 → DNS → HTTPS → 외부 확인 순서로 진행합니다",
"purpose": { "web": "웹", "mail": "메일", "both": "웹 + 메일" },
"ownership": { "pending": "확인 전", "verified": "확인됨" }, "dns": { "pending": "미반영", "wrong": "잘못된 값", "ok": "정상", "customer": "고객 관리", "n/a": "해당 없음" },
"cert": { "none": "없음", "issuing": "발급 중", "valid": "정상", "expiring_soon": "만료 임박", "failed": "실패", "customer_managed": "고객 관리" } } }
+11
View File
@@ -0,0 +1,11 @@
{
"sites": {
"title": "사이트", "new": "새 사이트", "detail": "사이트 상세", "empty": "아직 사이트가 없습니다. 새 사이트를 만들어 Webmaker 에서 제작을 시작하세요",
"filters": { "all": "전체", "draft": "초안", "building": "제작 중", "deploying": "배포 중", "live": "운영 중", "paused": "중지", "cancel_pending": "해지 대기" },
"tabs": { "overview": "개요", "build": "제작", "source": "Source", "deploy": "배포", "domains": "도메인", "mail": "메일", "backup": "백업", "usage": "사용량 및 통계", "activity": "활동 기록", "settings": "설정" },
"create": { "name": "사이트 이름", "purpose": "용도", "project": "Webmaker 프로젝트", "projectNew": "새 프로젝트 만들기", "projectExisting": "기존 프로젝트 연결", "projectNone": "나중에 연결", "template": "시작 템플릿", "lang": "기본 언어", "timezone": "시간대", "deployMode": "배포 방식", "modeLater": "나중에 선택", "noSiteCount": "초안 사이트를 만드는 것만으로는 운영 사이트 수를 쓰지 않습니다. 최초 공개 배포 직전에 사이트 수를 확보합니다" },
"deploy": { "title": "배포", "start": "배포 시작", "precheck": "배포 전 확인", "version": "배포할 저장본", "current": "현재 저장 상태", "rollback": "이전 버전으로 되돌리기", "cleanup": "지금 정리하고 반환", "verified": "배포 검증 완료", "needs_domain": "도메인 설정 필요", "public": "공개 운영 중", "rollbackImpact": "되돌리면 지금 배포된 파일이 이전 배포본으로 바뀝니다. 업로드 데이터와 데이터베이스는 그대로 유지되며, 되돌린 뒤 실제 외부 접속을 확인한 다음 완료로 표시합니다" },
"mode": { "managed": "관리형 배포", "external": "외부 배포", "managedDesc": "Ariweb 가 서버 배치와 운영을 관리합니다", "externalDesc": "고객이 준비한 Linux 서버에 External Pull Agent 를 설치해 배포합니다" },
"external": { "title": "외부 서버 배포", "issue": "연결 토큰 발급", "connected": "연결됨", "pending": "연결 대기", "offline": "응답 없음", "revoked": "해제됨" }
}
}
+121
View File
@@ -0,0 +1,121 @@
'use strict';
// ============================================================================
// [Ariweb Suite 변경] Ariweb Suite 연동 모듈 (Ariweb Suite 0.1.1, 개발 지시 §0.2·§7·§9)
//
// 왜 — Webmaker 는 Ariweb Suite 의 "제작 모듈" 로 도입된다. 고객은 포털(my.ariweb.net)에서 로그인한 상태 그대로 제작 화면으로
// 들어와야 하고(별도 로그인 없음), Webmaker 의 프로젝트(data/sites/<id>.json)와 포털의 운영 사이트는 서로 다른 것으로 구분되어야 한다.
// 이 모듈은 그 연결에 필요한 최소 기능만 담는다. 기존 Webmaker 동작(제작·AI·도메인·배포)은 바꾸지 않는다.
//
// 기능
// 1) SSO 티켓 검증(verifyTicket): 포털이 공유 비밀(보관소 'ariweb')로 서명한 티켓. 60초 유효, 한 번만 사용(nonce 기억).
// 티켓 내용 { v:1, login, name, groupId, groupName, siteId, siteName, projectId, path, exp, nonce } — 비밀번호 없음.
// 2) 서비스 호출 검증(verifyService): 포털 → Webmaker 내부 API(/api/internal/ariweb/*). lib/serviceauth(포털) 와 같은 서명 규약
// X-Service-Key-Id('portal') · X-Service-Ts · X-Service-Sign = HMAC(secret, `${METHOD}|${path}|${ts}|${sha256(body)}`), ±5분.
// 3) 연결 등록부(data/ariweb-links.json): 프로젝트 id → { siteId, groupId, portalUrl, siteName, linkedAt }.
// 연결된 프로젝트는 "운영 사이트가 포털에 있다" 는 뜻이라, Webmaker 의 발행 대신 포털 배포 화면으로 안내하고(§0.2 "운영 Version 을 바로 덮어쓰지 않음"),
// 폐기 전에는 포털에 영향(운영 사이트·배포·도메인)을 물어 표시한다(§9.1 "자동으로 함께 삭제하지 않는다").
// 4) 화면 컨텍스트 쿠키(wm_ariweb): SSO 로 들어온 세션에 그룹·사이트 이름을 붙여 상단 배너에 보인다. 서명된 값이라 위조할 수 없다.
// 5) 포털 영향 조회(impactFromPortal): POST <portalUrl>/internal/webmaker/project-impact (같은 서명 규약, 키 id 'webmaker').
//
// 비밀값은 보관소(lib/vault.js, 종류 'ariweb')에만 있고 설정 파일·로그·응답에 쓰지 않는다.
// ============================================================================
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const js = require('./jsonstore');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const LINKS_FILE = path.join(DATA_ROOT, 'ariweb-links.json');
const CTX_COOKIE = 'wm_ariweb';
const TICKET_TTL_MS = 60 * 1000;
const SKEW_MS = 5 * 60 * 1000;
const usedNonces = new Map(); // nonce → 만료 시각 (티켓 재사용 차단; 티켓이 60초만 살므로 메모리로 충분)
/** 공유 비밀(보관소 'ariweb'). 없으면 '' — 연동이 설정되지 않은 서버에서는 모든 검증이 실패한다(안전한 기본값). */
function secret() { try { return require('./vault').secretOf('ariweb') || ''; } catch (_) { return ''; } }
/** 포털 공개 주소(설정 ariweb.portalUrl) */
function portalUrl() { try { return String(require('./config').get('ariweb.portalUrl') || '').replace(/\/$/, ''); } catch (_) { return ''; } }
function hmac(key, data) { return crypto.createHmac('sha256', key).update(data).digest('base64url'); }
function safeEqual(a, b) { const x = Buffer.from(String(a || '')); const y = Buffer.from(String(b || '')); return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y); }
const sha256 = (s) => crypto.createHash('sha256').update(String(s || '')).digest('hex');
/** 사용한 nonce 정리 */
function sweep() { const now = Date.now(); for (const [k, v] of usedNonces) if (v < now) usedNonces.delete(k); }
/**
* SSO 티켓 검증. 성공하면 티켓 내용을, 실패하면 { error } 를 돌려준다(이유는 사용자에게 보여도 되는 문장).
* 한 번 쓴 티켓(nonce)은 만료 전에 다시 쓸 수 없다(링크 복사·뒤로 가기 재사용 차단).
*/
function verifyTicket(ticket, { now = Date.now(), key = secret() } = {}) {
if (!key) return { error: 'Ariweb Suite 연동이 설정되지 않았습니다(공유 비밀 없음)' };
const parts = String(ticket || '').split('.'); if (parts.length !== 2) return { error: '티켓 형식이 올바르지 않습니다' };
const [payload, sig] = parts;
if (!safeEqual(hmac(key, payload), sig)) return { error: '티켓 서명이 맞지 않습니다' };
let d; try { d = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); } catch (_) { return { error: '티켓 내용을 읽을 수 없습니다' }; }
if (!d || d.v !== 1 || typeof d.login !== 'string' || !Number.isFinite(d.exp)) return { error: '티켓 내용이 올바르지 않습니다' };
if (d.exp < now) return { error: '티켓이 만료되었습니다. 포털에서 다시 열어 주세요' };
if (d.exp - now > TICKET_TTL_MS + SKEW_MS) return { error: '티켓 유효 시간이 비정상입니다' };
sweep(); if (!d.nonce || usedNonces.has(d.nonce)) return { error: '이미 사용한 티켓입니다. 포털에서 다시 열어 주세요' };
usedNonces.set(d.nonce, d.exp);
return d;
}
/** (테스트·포털용) 티켓 만들기 — 포털이 하는 일과 같은 형식 */
function makeTicket(data, { key = secret(), now = Date.now() } = {}) {
const payload = Buffer.from(JSON.stringify({ v: 1, ...data, exp: data.exp || now + TICKET_TTL_MS, nonce: data.nonce || crypto.randomBytes(12).toString('base64url') })).toString('base64url');
return `${payload}.${hmac(key, payload)}`;
}
/**
* 서비스 호출 검증(포털 → Webmaker). rawBody 는 본문 원문 문자열.
* @returns {{ok:boolean, reason?:string}}
*/
function verifyService(req, pathWithQuery, rawBody, { key = secret(), now = Date.now() } = {}) {
const keyId = String(req.headers['x-service-key-id'] || ''); const ts = Number(req.headers['x-service-ts'] || 0); const sign = String(req.headers['x-service-sign'] || '');
if (!key) return { ok: false, reason: 'not_configured' };
if (keyId !== 'portal' || !ts || !sign) return { ok: false, reason: 'missing_headers' };
if (Math.abs(now - ts) > SKEW_MS) return { ok: false, reason: 'clock_skew' };
return safeEqual(hmac(key, `${req.method.toUpperCase()}|${pathWithQuery}|${ts}|${sha256(rawBody)}`), sign) ? { ok: true } : { ok: false, reason: 'bad_signature' };
}
/** 포털로 보내는 서비스 호출 헤더(키 id 'webmaker') */
function signHeaders(method, p, body, { key = secret(), now = Date.now() } = {}) { const ts = String(now); return { 'X-Service-Key-Id': 'webmaker', 'X-Service-Ts': ts, 'X-Service-Sign': hmac(key, `${method.toUpperCase()}|${p}|${ts}|${sha256(body == null ? '' : typeof body === 'string' ? body : JSON.stringify(body))}`), 'Content-Type': 'application/json' }; }
// ---------- 연결 등록부 ----------
function loadLinks() { const d = js.readJson(LINKS_FILE, null); return d && typeof d === 'object' && d.links ? d : { version: 1, links: {} }; }
function saveLinks(d) { js.writeJsonAtomic(LINKS_FILE, d, { mode: 0o600, keepBackup: true }); }
/** 프로젝트의 포털 연결 정보(없으면 null) */
function linkOf(projectId) { return loadLinks().links[projectId] || null; }
/** 연결 등록(포털이 사이트를 만들거나 기존 프로젝트를 연결할 때) */
function link(projectId, { siteId, groupId, portalUrl: pu, siteName, groupName }) {
const d = loadLinks(); d.links[projectId] = { siteId: String(siteId), groupId: String(groupId), portalUrl: String(pu || portalUrl()), siteName: String(siteName || ''), groupName: String(groupName || ''), linkedAt: new Date().toISOString() }; saveLinks(d); return d.links[projectId];
}
function unlink(projectId) { const d = loadLinks(); const had = !!d.links[projectId]; delete d.links[projectId]; saveLinks(d); return had; }
/** 연결된 프로젝트 전체(포털 사이트 id → 프로젝트 id 역참조용) */
function allLinks() { return loadLinks().links; }
// ---------- 화면 컨텍스트 쿠키 ----------
/** SSO 로 들어온 세션의 그룹·사이트 표시 정보를 서명 쿠키로 둔다(값: base64url(JSON).sig). 세션과 같은 수명(30일)으로 두되 비밀 없음. */
function ctxCookie(ctx, { key = secret(), secure = false } = {}) {
const payload = Buffer.from(JSON.stringify({ v: 1, groupId: ctx.groupId || '', groupName: ctx.groupName || '', siteId: ctx.siteId || '', siteName: ctx.siteName || '', projectId: ctx.projectId || '', portalUrl: ctx.portalUrl || portalUrl() })).toString('base64url');
return `${CTX_COOKIE}=${payload}.${hmac(key, payload)}; Path=/; HttpOnly; SameSite=Lax${secure ? '; Secure' : ''}; Max-Age=${30 * 86400}`;
}
function clearCtxCookie() { return `${CTX_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`; }
/** 요청의 컨텍스트 쿠키 읽기(서명 확인). 없거나 위조면 null */
function readCtx(req, { key = secret() } = {}) {
const raw = (req.headers.cookie || '').split(';').map((s) => s.trim()).find((s) => s.startsWith(CTX_COOKIE + '='));
if (!raw || !key) return null; const v = raw.slice(CTX_COOKIE.length + 1); const [payload, sig] = v.split('.'); if (!payload || !sig || !safeEqual(hmac(key, payload), sig)) return null;
try { const d = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); return d && d.v === 1 ? d : null; } catch (_) { return null; }
}
/**
* 포털에 프로젝트 삭제 영향을 묻는다. 포털에 닿지 못하면 { reachable:false } — 호출자는 "영향을 확인할 수 없어 폐기를 보류" 한다(모르면 지우지 않는다).
* @returns {Promise<{reachable:boolean, impact?:{liveSite:boolean, lifecycle:string, deployments:number, domains:string[], message:string}}>}
*/
async function impactFromPortal(projectId, { fetchImpl = fetch } = {}) {
const l = linkOf(projectId); if (!l) return { reachable: true, impact: null };
const base = l.portalUrl || portalUrl(); if (!base || !secret()) return { reachable: false, reason: 'not_configured' };
const p = '/internal/webmaker/project-impact'; const body = JSON.stringify({ projectId, siteId: l.siteId, groupId: l.groupId });
try {
const r = await fetchImpl(base + p, { method: 'POST', headers: signHeaders('POST', p, body), body, signal: AbortSignal.timeout(8000) });
const j = await r.json().catch(() => null); if (!r.ok || !j) return { reachable: false, reason: `portal_${r.status}` };
return { reachable: true, impact: j.impact || null };
} catch (e) { return { reachable: false, reason: e.name === 'TimeoutError' ? 'timeout' : 'unreachable' }; }
}
module.exports = { CTX_COOKIE, TICKET_TTL_MS, secret, portalUrl, verifyTicket, makeTicket, verifyService, signHeaders, linkOf, link, unlink, allLinks, ctxCookie, clearCtxCookie, readCtx, impactFromPortal, _usedNonces: usedNonces };
+13 -1
View File
@@ -211,4 +211,16 @@ function updateProfile(id, patch) { const users = loadUsers(); const u = users[i
function setUiLang(id, lang) { const users = loadUsers(); const u = users[id]; if (!u) throw httpError('사용자가 없습니다', 404); u.uiLang = String(lang || '').slice(0, 20); u.uiLangAt = new Date().toISOString(); saveUsers(users); return { uiLang: u.uiLang, uiLangAt: u.uiLangAt }; }
/** 사용자의 화면 언어 선택 (없으면 null) */
function uiLangOf(id) { const u = id ? loadUsers()[id] : null; return u && u.uiLang ? { lang: u.uiLang, at: u.uiLangAt || '' } : null; }
module.exports = { passwordFingerprint, csrfToken, csrfOk, approve, reject, pendingCount, setAccountType, setUiLang, uiLangOf, parseCookies, updateProfile, signup, login, getUser, createUser, setRole, listUsers, changePassword, resetPassword, makeSessionCookie, clearSessionCookie, userFromRequest, bootstrapAdmin, ensureBootstrapAdmin, adminState, BOOTSTRAP_ID, BOOTSTRAP_PASSWORD }; // v1.5.11 기본 관리자·관리자 상태
/**
* [Ariweb Suite 변경] SSO 계정 보장(Ariweb Suite 0.1.1, §0.2 "별도 로그인 없이"). 포털의 서명 티켓이 검증된 뒤 호출된다.
* 아이디가 없으면 무작위 비밀번호(아무도 모름 — 포털 SSO 로만 들어온다)로 만들고, 있으면 이름만 맞춘다. 역할은 user, 계정 종류는 domain(포털이 도메인을 관리).
* 비밀번호 규칙·가입 승인·가입 허용 설정을 거치지 않는다: 이 계정은 사람이 가입한 것이 아니라 포털 사용자와 1:1 로 대응하는 내부 계정이기 때문이다.
*/
function ensureSsoUser(id, name) {
id = String(id || '').trim().toLowerCase(); if (!ID_RE.test(id)) throw httpError('SSO 아이디 형식이 올바르지 않습니다');
const users = loadUsers();
if (!users[id]) { const salt = crypto.randomBytes(16).toString('hex'); users[id] = { id, name: String(name || id).trim().slice(0, 80) || id, salt, passHash: hashPassword(crypto.randomBytes(24).toString('base64url'), salt), role: 'user', accountType: 'domain', createdAt: new Date().toISOString(), sso: 'ariweb' }; }
else if (name && users[id].name !== name) users[id].name = String(name).trim().slice(0, 80);
users[id].lastLoginAt = new Date().toISOString(); saveUsers(users); return publicUser(users[id]);
}
module.exports = { ensureSsoUser, passwordFingerprint, csrfToken, csrfOk, approve, reject, pendingCount, setAccountType, setUiLang, uiLangOf, parseCookies, updateProfile, signup, login, getUser, createUser, setRole, listUsers, changePassword, resetPassword, makeSessionCookie, clearSessionCookie, userFromRequest, bootstrapAdmin, ensureBootstrapAdmin, adminState, BOOTSTRAP_ID, BOOTSTRAP_PASSWORD }; // v1.5.11 기본 관리자·관리자 상태
+5
View File
@@ -96,6 +96,11 @@ const SCHEMA = [
{ key: 'source.repo', env: 'GITEA_REPO', type: 'text', label: '저장소', desc: '소유자/이름 (기본 webmaker/sites)', default: 'webmaker/sites' },
{ key: 'source.token', type: 'secret', vaultKind: 'gitea', label: 'Gitea API 토큰', desc: '위 계정의 접근 토큰(repo·user 권한). install-gitea.sh 가 만들어 넣는다. 없으면 API 기능(저장소 만들기·키 등록)만 꺼지고 푸시는 SSH 키로 그대로 된다' },
] },
// [Ariweb Suite 변경] Ariweb Suite 포털 연동: 포털 주소(비밀 아님)와 공유 비밀(보관소 'ariweb'). lib/ariweb.js 참고.
{ group: 'ariweb', label: 'Ariweb Suite 연동', desc: 'Ariweb Suite 고객 포털과의 연동(별도 로그인 없는 제작 화면 진입, 프로젝트 ↔ 운영 사이트 연결). 포털의 보관소 sso:webmaker 와 같은 공유 비밀을 넣습니다.', keys: [
{ key: 'ariweb.portalUrl', env: 'ARIWEB_PORTAL_URL', type: 'url', label: '포털 공개 주소', desc: '예: https://my.ariweb.net — 발행 안내·폐기 영향 조회에 쓴다', default: '' },
{ key: 'ariweb.secret', type: 'secret', vaultKind: 'ariweb', label: '공유 비밀', desc: 'SSO 티켓·서비스 호출 서명에 쓰는 공유 비밀(암호화 보관). 포털이 발급한 값을 그대로 넣는다' },
] },
{ group: 'mail', label: '메일 연동 (Arimail)', desc: 'Arimail(구 mail-gateway)은 받는 메일·웹메일·IMAP/POP/SMTP 를 맡는 메일 서버입니다. 연동 모듈 lib/connectors/arimail.js 가 관리자 API 로 사이트 관리자 계정의 메일함을 만들고 지운다. 보내기(Resend 키)는 관리자 콘솔 › 설정 › 메일 발송 그대로.', keys: [
{ key: 'mail.provider', env: 'MAIL_PROVIDER', type: 'enum', options: [['none', '연동 안 함 (보내기만)'], ['arimail', 'Arimail']], label: '연동 모듈', desc: '다른 메일 서버를 쓰려면 lib/connectors/ 에 모듈을 추가하고 여기서 고른다', default: 'none' },
{ key: 'mail.url', env: 'ARIMAIL_URL', type: 'url', label: 'Arimail 주소 (공개)', desc: '사용자에게 안내하는 웹메일 주소(예: https://mail.example.com). deploy/install-arimail.sh 가 설치하면 자동으로 채운다', default: '' },
+2
View File
@@ -29,8 +29,10 @@ function recomputeTotals(site) {
// v1.6.31 (요구 10): 원자적 쓰기(임시 파일 + fsync + rename) + 직전 정상본 .bak 보존. 읽기 실패는 손상으로 기록해 관리자 운영 복구 화면에 보인다(빈 값으로 숨기지 않음)
const js = require('./jsonstore');
// [Ariweb Suite 변경] rev: 저장할 때마다 1씩 오르는 개정 번호. 여러 사용자가 같은 프로젝트를 편집할 때 화면이 알고 있던 rev 와 다르면 조용히 덮어쓰지 않고 409 로 알린다(server.js PATCH·PUT /files, Ariweb Suite §9.3).
function save(site) {
recomputeTotals(site);
site.rev = (Number(site.rev) || 0) + 1;
site.updatedAt = new Date().toISOString();
js.writeJsonAtomic(fileOf(site.id), JSON.stringify(site, null, 2), { mode: 0o600, keepBackup: true });
return site;
+2
View File
@@ -44,6 +44,8 @@ const KINDS = {
porkbun: { label: '도메인 등록·DNS (Porkbun)', fixedId: 'porkbun', defaultUrl: 'https://api.porkbun.com/api/json/v3', keyNames: ['PORKBUN_SECRET_KEY', 'SECRET_API_KEY', 'SECRET'], urlNames: ['PORKBUN_URL'], settingNames: ['API_KEY', 'PORKBUN_API_KEY'], secretLabel: 'Secret API Key (설정 칸에 API_KEY=공개 API 키)' },
route53: { label: 'DNS (Amazon Route 53)', fixedId: 'route53', defaultUrl: 'https://route53.amazonaws.com', keyNames: ['AWS_SECRET_ACCESS_KEY', 'SECRET_ACCESS_KEY', 'SECRET'], urlNames: [], settingNames: ['ACCESS_KEY_ID', 'AWS_ACCESS_KEY_ID', 'SESSION_TOKEN', 'REGION'], secretLabel: 'Secret Access Key (설정 칸에 ACCESS_KEY_ID=액세스 키 ID, 선택 REGION=us-east-1)' },
'gcloud-dns': { label: 'DNS (Google Cloud DNS, 서비스 계정 키 JSON)', fixedId: 'gcloud-dns', defaultUrl: 'https://dns.googleapis.com', keyNames: ['private_key'], urlNames: [], settingNames: ['PROJECT', 'PROJECT_ID'], secretLabel: '서비스 계정 키 JSON 전체 (roles/dns.admin; 설정 PROJECT 는 키의 project_id 와 다를 때만)' },
// [Ariweb Suite 변경] 포털 ↔ Webmaker 공유 비밀(SSO 티켓·서비스 호출 서명). lib/ariweb.js 가 읽는다. 설정 파일 ariweb.secret(secret) 이 이 항목에 저장된다.
ariweb: { label: 'Ariweb Suite 연동 공유 비밀', fixedId: 'ariweb', defaultUrl: '', keyNames: ['ARIWEB_SHARED_SECRET', 'SHARED_SECRET', 'SECRET'], urlNames: ['ARIWEB_PORTAL_URL'], settingNames: [], secretLabel: '공유 비밀 (포털 보관소 sso:webmaker 와 같은 값)' },
http: { label: '기타 외부 서비스 (HTTP API)', fixedId: null, defaultUrl: '', keyNames: ['API_KEY', 'TOKEN', 'API_TOKEN', 'KEY'], urlNames: ['URL', 'BASE_URL', 'API_URL'], settingNames: [], secretLabel: 'API 키 또는 토큰' },
};
+4
View File
@@ -108,6 +108,8 @@ let activeTab = 'home';
* 탭을 바꾸면 그 탭의 사이트를 다시 열어(`openSite`) 화면과 진행 상태 스트림을 그 사이트로 맞춘다.
*/
let createId = null, manageId = null; try { createId = localStorage.getItem('createSiteId') || null; manageId = localStorage.getItem('currentSiteId') || null; } catch (_) {}
// [Ariweb Suite 변경] 포털 SSO 진입(/api/auth/sso → /?site=<프로젝트 id>): 지정한 프로젝트를 바로 연다
try { const sp = new URLSearchParams(location.search).get('site'); if (sp && /^[a-f0-9]{32}$/.test(sp)) { manageId = sp; localStorage.setItem('currentSiteId', sp); history.replaceState(null, '', location.pathname); } } catch (_) {}
// 가져온(import)·복사한(copy) 사이트는 기획 단계가 없으므로 "생성 중" 으로 보지 않는다 (복사 중에는 관리 탭의 진행 상태 상자로 표시)
const inProgress = (site) => !!(site && site.plan && !['generated', 'published'].includes(site.plan.stage) && !['import', 'copy', 'template'].includes(site.mode));
let switching = false;
@@ -1155,6 +1157,8 @@ $('btnMobile').onclick = () => { const on = !$('previewArea').classList.contains
if (!current) renderEmptyReason(null, false); // v1.4.5 설정을 받은 뒤 첫 화면 페이지 표시
fillTiers(); fillLangOpts(); if (config.credit) renderCredit(config.credit); $('verBadge').textContent = config.version ? `v${config.version}` : ''; document.title = (config.product || 'AI Webmaker') + (config.version ? ` v${config.version}` : ''); fillEditTier(null);
loadMyFeedback();
// [Ariweb Suite 변경] 포털 컨텍스트 배너: 그룹·운영 사이트 이름과 "이 화면의 사이트 = Webmaker 프로젝트" 구분 안내, 포털로 돌아가는 링크
try { const a = config.ariweb; const bn = $('ariwebBanner'); if (a && bn) { const pu = a.portalUrl || ''; bn.innerHTML = `<b>Ariweb Suite</b><span>그룹: ${esc(a.groupName || a.groupId || '-')}</span>${a.siteName ? `<span>사이트: ${esc(a.siteName)}</span>` : ''}<span class="muted" style="color:#dbe6fb">이 화면의 "사이트" 는 Webmaker 프로젝트입니다. 운영 사이트·배포·도메인·메일은 포털에서 관리합니다.</span>${pu ? `<a href="${esc(pu)}${a.siteId ? '/sites/' + encodeURIComponent(a.siteId) : ''}">포털로 돌아가기</a>` : ''}`; bn.classList.remove('hidden'); } } catch (_) {}
if (config.user) { $('userName').textContent = config.user.name; $('userId').textContent = `(${config.user.id})`; if (config.user.role === 'admin') $('adminLink').classList.remove('hidden'); $('pfId').value = config.user.id; $('pfName').value = config.user.name || ''; }
loadLedger();
$('btnLogout').onclick = async () => { await fetch('/api/auth/logout', { method: 'POST' }); localStorage.removeItem('currentSiteId'); location.href = '/login'; };
+2
View File
@@ -8,6 +8,8 @@
<link rel="stylesheet" href="/theme.css">
</head>
<body>
<!-- [Ariweb Suite 변경] 포털 SSO 로 들어온 세션의 배너: 어느 그룹·어느 운영 사이트의 "프로젝트" 를 편집 중인지 항상 보인다(§9.1). public/app.js 가 /api/config 의 ariweb 값으로 채운다 -->
<div id="ariwebBanner" class="ariweb-banner hidden" role="status"></div>
<!-- =====================================================================================================
v1.6.40 (요구 1) 사용자 화면 전면 재구성 — "작업 흐름" 중심의 세 칸 구조
① 왼쪽 레일(.rail) : 제품명 · 작업 중인 사이트 선택 · 작업 흐름 내비게이션(홈 / 사이트: 만들기·수정·배포·운영 / 주소·메일: 도메인·DNS·메일 / 계정: 캐시·충전·설정)
+5
View File
@@ -755,3 +755,8 @@ details.dom-rec-details > summary { cursor: pointer; font-size: 13px; font-weigh
/* ===== v1.7.3: 메일 사용량 (메일 화면 한눈에 보기 · 사이트 정보) ===== */
.mg-usage .git-list { margin: 6px 0 4px; } .mg-usage .git-list .st { white-space: normal; text-align: right; }
.mg-usage > div + div { margin-top: 3px; }
/* [Ariweb Suite 변경] 포털 연동 배너 */
.ariweb-banner { position: sticky; top: 0; z-index: 60; background: #1b5fd1; color: #fff; font-size: 13px; padding: 6px 14px; display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }
.ariweb-banner a { color: #fff; text-decoration: underline; }
.ariweb-banner.hidden { display: none; }
+101 -2
View File
@@ -65,6 +65,7 @@ const i18n = require('./lib/i18n');
const feedback = require('./lib/feedback');
const costcal = require('./lib/costcal');
const crawler = require('./lib/crawler');
const ariweb = require('./lib/ariweb'); // [Ariweb Suite 변경] 포털 연동(SSO 티켓·내부 API·프로젝트↔사이트 연결)
process.umask(0o022); // 생성 파일을 nginx(www-data)가 읽을 수 있도록
@@ -1513,6 +1514,71 @@ function sessionUser(req) {
function isSecureReq(req) { const xf = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim(); if (xf) return xf === 'https'; return /^https:/i.test(PUBLIC_BASE_URL); }
/** v1.6.31 (요구 3·25): 서버 전체 동시 AI 작업 수 제한 — 넘으면 429 */
function assertJobCapacity() { const max = Number(config.get('ai.maxConcurrentJobs') || 4); const running = [...jobs.values()].filter((j) => j.running).length; if (running >= max) throw Object.assign(new Error(`지금 서버에서 실행 중인 AI 작업이 많습니다(${running}/${max}). 잠시 후 다시 시도하세요`), { status: 429 }); }
/**
* [Ariweb Suite 변경] 동시 편집 충돌 검사(§9.3). 화면이 보낸 rev(본문 rev 또는 If-Match 헤더)가 저장된 site.rev 와 다르면
* "마지막 저장으로 조용히 덮어쓰지 않고" 409 와 함께 두 버전의 요약(현재 rev·갱신 시각·마지막 변경)을 돌려준다. rev 를 보내지 않은 옛 화면은 예전처럼 동작한다.
*/
function revConflict(site, b, req) {
const given = b && b.rev !== undefined && b.rev !== null && b.rev !== '' ? Number(b.rev) : (req.headers['if-match'] ? Number(String(req.headers['if-match']).replace(/"/g, '')) : null);
if (given === null || !Number.isFinite(given)) return null;
const cur = Number(site.rev) || 0; if (given === cur) return null;
const last = (site.git && site.git.changes || []).slice(-1)[0] || null;
return { error: '다른 사용자(또는 다른 창)가 먼저 저장해 내용이 달라졌습니다. 최신 내용을 불러온 뒤 다시 적용하거나, 내 변경을 그대로 덮어쓰려면 최신 rev 로 다시 저장하세요', conflict: { yourRev: given, currentRev: cur, updatedAt: site.updatedAt || null, lastChange: last ? { no: last.no, kind: last.kind, message: last.message, at: last.at } : null, manualEdits: (site.manualEdits || []).slice(-3) } };
}
/**
* [Ariweb Suite 변경] 포털 내부 API. 서명 검증은 호출자가 끝냈다. 경로 조각 parts = ['projects', id, ...].
* GET projects?owner=<login> 소유자의 프로젝트 목록(제목·상태·rev·갱신 시각·연결)
* POST projects {owner, ownerName, title, purpose, link?} 빈 프로젝트 만들기(캐시 확인 없음 — 포털이 상품으로 관리) + 선택 연결
* GET projects/<id> 프로젝트 요약·저장본(versions)·연결·git 정보
* POST projects/<id>/link {siteId, groupId, siteName, groupName, portalUrl} / DELETE projects/<id>/link
* GET projects/<id>/export?version=N 저장본 N(없으면 현재 작업 트리)의 tar.gz
* GET projects/<id>/source Source 화면용: 저장소·브랜치·변경 이력·저장소 크기
* GET credits/<login> AI Wallet(캐시) 잔액·사용 — 호스팅 사용량과 분리해 보이기 위한 값
* GET health
*/
async function handleAriwebInternal(req, res, parts, url, b) {
const [kind, id, action] = parts;
if (kind === 'health') return send(res, 200, { ok: true, version: VERSION, product: 'AI Webmaker', portalUrl: ariweb.portalUrl() || null });
if (kind === 'credits' && id && req.method === 'GET') return send(res, 200, { ok: true, credit: credits.info(String(id).toLowerCase()), unit: credits.unit() });
if (kind !== 'projects') return send(res, 404, { error: 'not_found' });
const summary = (s1) => { const l = ariweb.linkOf(s1.id); const last = (s1.git && s1.git.changes || []).slice(-1)[0] || null; return { id: s1.id, title: s1.title, ownerId: s1.ownerId, status: s1.status, mode: s1.mode || 'quick', stage: s1.plan ? s1.plan.stage : null, rev: Number(s1.rev) || 0, createdAt: s1.createdAt, updatedAt: s1.updatedAt, lastChange: last ? { no: last.no, kind: last.kind, at: last.at, message: last.message } : null, versions: (s1.git && s1.git.changes || []).length, link: l, hasContent: fs.existsSync(path.join(SITES_ROOT, s1.id, 'index.html')) || (multilang.isMulti(s1) && fs.existsSync(path.join(SITES_ROOT, s1.id, s1.langs.default, 'index.html'))) }; };
if (!id && req.method === 'GET') { const owner = String(url.searchParams.get('owner') || '').toLowerCase(); if (!owner) return send(res, 400, { error: 'owner 가 필요합니다' }); return send(res, 200, { ok: true, projects: store.list(owner).map((s0) => summary(store.load(s0.id))) }); }
if (!id && req.method === 'POST') {
const owner = String(b.owner || '').trim().toLowerCase(); if (!owner) return send(res, 400, { error: 'owner 가 필요합니다' });
const u = auth.getUser(owner) || auth.ensureSsoUser(owner, b.ownerName || owner);
const title = String(b.title || '').trim().slice(0, 120); if (!title) return send(res, 400, { error: 'title 이 필요합니다' });
const pid = crypto.randomBytes(16).toString('hex'); fs.mkdirSync(path.join(SITES_ROOT, pid), { recursive: true, mode: 0o755 });
const site = store.create({ id: pid, ownerId: u.id, ownerName: u.name, title, purpose: String(b.purpose || ''), content: String(b.content || ''), design: String(b.design || ''), extra: String(b.extra || ''), sessionId: crypto.randomUUID() });
site.mode = 'quick'; site.uiLang = 'ko'; site.tier = settings.validTier(b.tier); site.titleCustom = true;
// 포털이 "AI에게 맡기기(ai)·내가 고르기(manual)" 로 시작하려 하면 기획(plan) 모드 프로젝트로 만든다(사용자가 Webmaker 에서 입력을 이어서 채운다)
if (b.mode === 'ai' || b.mode === 'manual') { site.mode = b.mode; site.plan = newPlan(b.mode); site.plan.input = { text: String(b.text || ''), existingUrl: '', references: [], answers: [] }; }
store.save(site);
if (b.link && b.link.siteId) ariweb.link(pid, b.link);
audit.log({ by: 'portal', action: 'ariweb.project.create', targetType: 'site', target: pid, detail: { owner: u.id, siteId: b.link ? b.link.siteId : null } });
return send(res, 200, { ok: true, project: summary(store.load(pid)) });
}
const site = store.load(id); if (!site) return send(res, 404, { error: '프로젝트가 없습니다' });
if (!action && req.method === 'GET') return send(res, 200, { ok: true, project: { ...summary(site), versionList: (site.git && site.git.changes || []).map((c) => ({ no: c.no, kind: c.kind, message: c.message, at: c.at, commit: c.commit, files: (c.files || []).length })), git: gitstore.info(site.id), liveUrl: liveUrlOf(site), deployState: deployState(site), domains: site.domains || [] } });
if (action === 'link' && req.method === 'POST') { if (!b.siteId || !b.groupId) return send(res, 400, { error: 'siteId·groupId 가 필요합니다' }); const l = ariweb.link(site.id, b); audit.log({ by: 'portal', action: 'ariweb.link', targetType: 'site', target: site.id, detail: { siteId: l.siteId, groupId: l.groupId } }); return send(res, 200, { ok: true, link: l }); }
if (action === 'link' && req.method === 'DELETE') { const had = ariweb.unlink(site.id); audit.log({ by: 'portal', action: 'ariweb.unlink', targetType: 'site', target: site.id, detail: {} }); return send(res, 200, { ok: true, removed: had }); }
if (action === 'source' && req.method === 'GET') {
const gitDir = path.join(DATA_ROOT_FOR_GIT, 'git', site.id); let bytes = 0; const walk = (d) => { let st; try { st = fs.statSync(d); } catch (_) { return; } if (st.isDirectory()) { for (const f of fs.readdirSync(d)) walk(path.join(d, f)); } else bytes += st.size; }; walk(gitDir);
const siteDir = path.join(SITES_ROOT, site.id); let siteBytes = 0; let uploadBytes = 0; const walk2 = (d, up) => { let st; try { st = fs.statSync(d); } catch (_) { return; } if (st.isDirectory()) { for (const f of fs.readdirSync(d)) walk2(path.join(d, f), up || f === 'uploads'); } else { siteBytes += st.size; if (up) uploadBytes += st.size; } }; walk2(siteDir, false);
return send(res, 200, { ok: true, source: { ...gitstore.info(site.id), repoBytes: bytes, siteBytes, uploadBytes, lfsBytes: 0, lfsSupported: false, changes: (site.git && site.git.changes || []).map((c) => ({ no: c.no, kind: c.kind, message: c.message, at: c.at, commit: c.commit, pushed: !!c.pushed, branch: c.branch || null, url: c.url || null })), remoteStatus: gitstore.remoteStatus() } });
}
if (action === 'export' && req.method === 'GET') {
const { spawn } = require('child_process'); const siteDir = path.join(SITES_ROOT, site.id);
const vno = url.searchParams.get('version'); const change = vno ? (site.git && site.git.changes || []).find((c) => String(c.no) === String(vno)) : null;
if (vno && !(change && change.commit)) return send(res, 404, { error: '해당 저장본이 없습니다' });
res.writeHead(200, { 'Content-Type': 'application/gzip', 'Cache-Control': 'no-store', 'X-Ariweb-Version': vno || 'current', 'X-Ariweb-Commit': change ? change.commit : '' });
const child = change
? spawn('git', ['archive', '--format=tar.gz', change.commit], { env: { ...process.env, GIT_DIR: path.join(DATA_ROOT_FOR_GIT, 'git', site.id), GIT_WORK_TREE: siteDir }, cwd: siteDir })
: spawn('tar', ['-C', siteDir, '--exclude=./.refs', '-czf', '-', '.']);
child.stdout.pipe(res); child.on('error', () => { try { res.end(); } catch (_) {} }); return;
}
return send(res, 404, { error: 'not_found' });
}
const DATA_ROOT_FOR_GIT = process.env.DATA_ROOT || path.join(__dirname, 'data'); // lib/gitstore.js 의 GIT_ROOT 와 같은 기준
async function handle(req, res) {
const url = new URL(req.url, 'http://localhost');
const p = url.pathname;
@@ -1555,6 +1621,29 @@ async function handle(req, res) {
// 생성 사이트 페이지는 Referer 가 /<사이트ID>/… 이거나(거부) Referer 를 없애면 (3)이 필요한데 토큰은 (2)를 만족하는 요청에만 주므로 얻을 수 없다.
const user = sessionUser(req);
// ---------- [Ariweb Suite 변경] 포털 SSO 진입: GET /api/auth/sso?ticket=… ----------
// 포털이 서명한 60초짜리 1회용 티켓을 검증해 그 사용자의 Webmaker 계정(없으면 생성)으로 세션 쿠키를 주고, 지정한 프로젝트 화면으로 보낸다.
// 비밀번호는 오가지 않는다. 티켓에 든 그룹·사이트 이름은 서명 쿠키(wm_ariweb)에 두어 상단 배너에 보인다.
if (req.method === 'GET' && p === '/api/auth/sso') {
const cip = ratelimit.clientIp(req); if (ratelimit.limited(`sso:${cip}`, 60, 600000)) return send(res, 429, { error: '요청이 너무 많습니다' });
const t = ariweb.verifyTicket(url.searchParams.get('ticket'));
if (t.error) { audit.log({ by: 'sso', action: 'auth.sso.denied', targetType: 'user', target: '-', detail: { reason: t.error } }); return send(res, 403, { error: t.error }); }
let u; try { u = auth.ensureSsoUser(t.login, t.name); } catch (e) { return send(res, 400, { error: e.message }); }
audit.log({ by: u.id, action: 'auth.sso', targetType: 'user', target: u.id, detail: { groupId: t.groupId || null, siteId: t.siteId || null, projectId: t.projectId || null } });
const secure = isSecureReq(req);
const dest = t.path && /^\/[^\s]*$/.test(String(t.path)) ? String(t.path) : (t.projectId ? `/?site=${encodeURIComponent(t.projectId)}` : '/');
res.writeHead(302, { Location: dest, 'Cache-Control': 'no-store', 'Set-Cookie': [auth.makeSessionCookie(u.id, { secure }), ariweb.ctxCookie(t, { secure })] }); return res.end();
}
// ---------- [Ariweb Suite 변경] 포털 → Webmaker 내부 API (/api/internal/ariweb/*) — 서비스 서명(lib/ariweb.verifyService)으로만 ----------
if (p.startsWith('/api/internal/ariweb/')) {
const raw = await new Promise((resolve, reject) => { let d = ''; req.on('data', (c) => { d += c; if (d.length > 6e6) { reject(new Error('본문이 너무 큽니다')); req.destroy(); } }); req.on('end', () => resolve(d)); req.on('error', reject); });
const v = ariweb.verifyService(req, p + url.search, raw);
if (!v.ok) { console.log(`[ariweb] 내부 API 거부 ${req.method} ${p} (${v.reason}) from ${ratelimit.clientIp(req)}`); return send(res, 401, { error: 'service_auth_failed', reason: v.reason }); }
let b = {}; try { b = raw ? JSON.parse(raw) : {}; } catch (_) { return send(res, 400, { error: 'JSON 파싱 실패' }); }
try { return await handleAriwebInternal(req, res, p.slice('/api/internal/ariweb/'.length).split('/').filter(Boolean).map(decodeURIComponent), url, b); }
catch (e) { return send(res, e.status || 500, { error: e.message }); }
}
// ---------- v1.5.11 (요구 2): 설정 페이지 API 를 본 서비스에서도 — 토큰(data/setup.token) 또는 관리자 로그인 ----------
// 설치 직후 첫 화면이 /setup 으로 넘어오는데, 그때는 관리자 비밀번호를 모를 수 있으므로 별도 포트의 setup-server.js 와 같은 토큰 로그인을 받는다.
// 세션은 wm_setup 쿠키(lib/setupauth.js). 이미 관리자로 로그인한 사람(wh_session, role=admin)은 그대로 같은 권한이다.
@@ -2251,7 +2340,7 @@ async function handle(req, res) {
if (req.method === 'POST') { const oid = p.split('/')[4]; try { const o = prepaid.cancelOrder(oid, user.id, { userId: user.id, reason: '사용자 취소' }); audit.log({ by: user.id, action: 'prepaid.order.cancel', targetType: 'prepaid-order', target: oid, userId: user.id, detail: { name: o.name, amount: o.amount } }); return send(res, 200, { ok: true, order: o, orders: prepaid.myOrders(user.id) }); } catch (e) { return send(res, e.status || 500, { error: e.message }); } }
return send(res, 405, { error: 'method not allowed' });
}
if (req.method === 'GET' && p === '/api/config') return send(res, 200, { version: VERSION, product: 'AI Webmaker', cashUnit: credits.unit(), welcomePage: welcomePage(), tiers: settings.TIERS, tierGuide: await tierGuideKrw(), maxEdits: settings.load().maxEdits || 0, defaultTier: settings.load().defaultTier || 'standard', credit: credits.info(user.id), publicBaseUrl: PUBLIC_BASE_URL, user, git: gitstore.info('<siteId>'), upload: { maxFileBytes: uploads.MAX_FILE_BYTES, extensions: uploads.allowedExtensions() }, siteLocales: Object.entries(multilang.LOCALES).map(([code, l]) => ({ code, name: l.name, label: l.label, dir: l.dir })) });
if (req.method === 'GET' && p === '/api/config') return send(res, 200, { version: VERSION, product: 'AI Webmaker', ariweb: ariweb.readCtx(req), ariwebLinks: ariweb.allLinks(), cashUnit: credits.unit(), welcomePage: welcomePage(), tiers: settings.TIERS, tierGuide: await tierGuideKrw(), maxEdits: settings.load().maxEdits || 0, defaultTier: settings.load().defaultTier || 'standard', credit: credits.info(user.id), publicBaseUrl: PUBLIC_BASE_URL, user, git: gitstore.info('<siteId>'), upload: { maxFileBytes: uploads.MAX_FILE_BYTES, extensions: uploads.allowedExtensions() }, siteLocales: Object.entries(multilang.LOCALES).map(([code, l]) => ({ code, name: l.name, label: l.label, dir: l.dir })) });
if (req.method === 'GET' && p === '/api/catalog') return send(res, 200, briefLib.catalog());
if (p === '/api/models' || p === '/api/me/connections' || p.startsWith('/api/me/connections/')) return send(res, 410, { error: '모델 선택 기능은 종료되었습니다. 모델 등급(기본/중급/고급)을 선택하세요.' });
if (req.method === 'GET' && p === '/api/sites') return send(res, 200, store.list(user.id));
@@ -2376,9 +2465,10 @@ async function handle(req, res) {
if (!site || site.ownerId !== user.id) return send(res, 404, { error: '사이트를 찾을 수 없습니다' });
{ const ul = String(req.headers['x-ui-lang'] || ''); if (/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(ul) && site.uiLang !== ul) { site.uiLang = ul; store.save(site); } } // 생성 코드 주석 언어
const sub = m[2] || '';
if (req.method === 'GET' && sub === '') return send(res, 200, { ...publicView(site), running: jobOf(site.id).running, deploying: jobOf(site.id).deploying || null, status: jobOf(site.id).running ? 'running' : (site.status === 'running' ? 'idle' : site.status) });
if (req.method === 'GET' && sub === '') return send(res, 200, { ...publicView(site), ariweb: ariweb.linkOf(site.id), running: jobOf(site.id).running, deploying: jobOf(site.id).deploying || null, status: jobOf(site.id).running ? 'running' : (site.status === 'running' ? 'idle' : site.status) });
if (req.method === 'PATCH' && sub === '') {
const { fields: b } = await readBody(req);
{ const c = revConflict(site, b, req); if (c) return send(res, 409, c); } // [Ariweb Suite 변경] 동시 편집 충돌(§9.3)
const changed = [];
const labels = { title: '페이지 제목', purpose: '목적', content: '주요 컨텐츠', design: '디자인 방향', extra: '추가 요구사항' };
if (typeof b.contactEmail === 'string') { const ce = b.contactEmail.trim(); if (ce && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(ce)) return send(res, 400, { error: '문의 수신 이메일 형식이 올바르지 않습니다' }); if (ce !== (site.contactEmail || '')) { site.contactEmail = ce; changed.push('문의 수신 이메일'); if (site.plan && site.plan.brief && site.plan.brief.facts) site.plan.brief.facts.email = { value: ce, source: 'user_text', status: ce ? 'provided' : 'missing', mode: 'keep' }; } }
@@ -2442,6 +2532,7 @@ async function handle(req, res) {
if (jobOf(site.id).running) return send(res, 409, { error: 'AI 작업 실행 중에는 저장할 수 없습니다' });
const { fields: b } = await readBody(req);
if (typeof b.content !== 'string') return send(res, 400, { error: 'content 가 필요합니다' });
{ const c = revConflict(site, b, req); if (c) return send(res, 409, c); } // [Ariweb Suite 변경] 동시 편집 충돌(§9.3)
if (Buffer.byteLength(b.content) > MAX_TEXT_BYTES) return send(res, 413, { error: '내용이 너무 큽니다' });
fs.mkdirSync(path.dirname(abs), { recursive: true, mode: 0o755 });
fs.writeFileSync(abs, b.content, { mode: 0o644 });
@@ -2458,6 +2549,12 @@ async function handle(req, res) {
const { fields: b } = await readBody(req);
if (jobOf(site.id).running || jobOf(site.id).deploying) return send(res, 409, { error: '작업이 진행 중입니다. 끝난 뒤 폐기하세요' });
if (String(b.confirm || '').trim() !== String(site.title || '').trim()) return send(res, 400, { error: '확인을 위해 사이트 제목을 정확히 입력하세요' });
// [Ariweb Suite 변경] 포털에 연결된 프로젝트: 운영 사이트·배포·도메인에 미치는 영향을 포털에 물어 보이고, 사용자가 영향을 확인(acknowledgeImpact)했을 때만 프로젝트를 폐기한다.
// 포털의 운영 사이트·도메인·Source 는 여기서 절대 지우지 않는다(§9.1). 포털에 닿지 못하면 영향을 모르므로 폐기를 보류한다.
{ const l = ariweb.linkOf(site.id); if (l) { const r = await ariweb.impactFromPortal(site.id); if (!r.reachable) return send(res, 503, { error: '포털에서 운영 사이트 영향을 확인하지 못해 폐기를 보류합니다. 잠시 후 다시 시도하세요', ariweb: { reason: r.reason } });
if (r.impact && r.impact.blocked) return send(res, 409, { error: r.impact.message || '운영 중인 사이트가 이 프로젝트를 쓰고 있어 폐기할 수 없습니다. 포털에서 사이트를 먼저 중지·해지하세요', ariweb: { impact: r.impact } });
if (!b.acknowledgeImpact) return send(res, 409, { error: '이 프로젝트는 Ariweb Suite 사이트에 연결되어 있습니다. 아래 영향을 확인한 뒤 다시 요청하세요', ariweb: { impact: r.impact, needAcknowledge: true } });
ariweb.unlink(site.id); audit.log({ by: user.id, action: 'ariweb.unlink', targetType: 'site', target: site.id, detail: { siteId: l.siteId, reason: 'project destroyed' } }); } }
return send(res, 200, await destroySite(site, user.id));
}
if (site.source && req.method === 'POST' && sub === '/deploy') {
@@ -2647,6 +2744,8 @@ async function handle(req, res) {
}
// 9/13단계: 발행
if (req.method === 'POST' && step === 'publish') {
// [Ariweb Suite 변경] 포털에 연결된 프로젝트는 Webmaker 발행이 운영 버전을 바로 덮어쓰지 않도록 막고, 포털의 "배포" 화면(저장본 선택 → 미리보기 → 배포)으로 안내한다(§0.2).
{ const l = ariweb.linkOf(site.id); if (l) return send(res, 409, { error: '이 프로젝트는 Ariweb Suite 운영 사이트에 연결되어 있습니다. 배포는 포털의 사이트 › 배포 화면에서 저장본을 골라 진행합니다(운영 중인 버전을 바로 덮어쓰지 않습니다).', ariweb: { siteId: l.siteId, deployUrl: `${l.portalUrl || ariweb.portalUrl()}/sites/${l.siteId}/deploy` } }); }
if (body.domains !== undefined) { const nd = deploy.normalizeDomains(body.domains); const bad = subdomainOnlyViolation(user, nd); if (bad) return send(res, 403, { error: bad }); site.domains = nd; store.save(site); }
// v1.4.0 (요구 10): 연결한 도메인의 DNS 가 아직 전파 중이면 발행(웹서버·SSL 설정)을 미루고 예상 시간을 알린다
{ const wait = await domainsLib.gate(site).catch(() => null); if (wait) return send(res, 409, { error: wait, dnsPending: true }); }
+93
View File
@@ -0,0 +1,93 @@
'use strict';
// [Ariweb Suite 변경] Ariweb Suite 연동 회귀 테스트 — lib/ariweb(티켓·서비스 서명·연결 등록부), store.rev, 서버 단위(SSO 진입·내부 API·동시 편집 충돌·발행 안내·폐기 영향).
const test = require('node:test'); const assert = require('node:assert/strict');
const fs = require('fs'); const path = require('path'); const os = require('os'); const http = require('http'); const crypto = require('crypto'); const { spawn } = require('child_process');
const ROOT = path.join(__dirname, '..'); const PORT = 4100 + Math.floor(Math.random() * 90);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-ariweb-')); process.env.DATA_ROOT = tmp; process.env.DATA_DIR = path.join(tmp, 'sites'); process.env.SITES_ROOT = path.join(tmp, 'www');
fs.mkdirSync(path.join(tmp, 'sites'), { recursive: true }); fs.mkdirSync(path.join(tmp, 'www'), { recursive: true });
const SECRET = 'test-shared-secret-' + crypto.randomBytes(8).toString('hex');
const B = `http://127.0.0.1:${PORT}`;
const sha256 = (s) => crypto.createHash('sha256').update(String(s || '')).digest('hex');
const sign = (key, d) => crypto.createHmac('sha256', key).update(d).digest('base64url');
/** 포털이 하는 서비스 서명 호출 */
const svc = async (method, p, body) => { const text = body === undefined ? undefined : JSON.stringify(body); const ts = String(Date.now()); const r = await fetch(B + p, { method, headers: { 'Content-Type': 'application/json', 'X-Service-Key-Id': 'portal', 'X-Service-Ts': ts, 'X-Service-Sign': sign(SECRET, `${method}|${p}|${ts}|${sha256(text)}`) }, body: text }); const ct = r.headers.get('content-type') || ''; return { status: r.status, b: ct.includes('json') ? await r.json() : null, r }; };
const ticket = (data) => { const payload = Buffer.from(JSON.stringify({ v: 1, exp: Date.now() + 60000, nonce: crypto.randomBytes(8).toString('hex'), ...data })).toString('base64url'); return `${payload}.${sign(SECRET, payload)}`; };
let child; let portal; let portalImpact = { impact: { blocked: false, liveSite: true, lifecycle: 'live', deployments: 2, domains: ['example.test'], message: '운영 사이트가 연결되어 있습니다' } }; let portalCalls = [];
test('lib/ariweb: 티켓 검증(서명·만료·1회용)과 서비스 서명 검증', () => {
const a = require(ROOT + '/lib/ariweb');
const t = a.makeTicket({ login: 'aw-g1-u1', name: 'U', groupId: 'g1' }, { key: SECRET }); const ok = a.verifyTicket(t, { key: SECRET }); assert.equal(ok.login, 'aw-g1-u1');
assert.match(a.verifyTicket(t, { key: SECRET }).error, /이미 사용/); assert.match(a.verifyTicket(t + 'x', { key: SECRET }).error, /서명/);
assert.match(a.verifyTicket(a.makeTicket({ login: 'x', exp: Date.now() - 1 }, { key: SECRET }), { key: SECRET }).error, /만료/);
assert.match(a.verifyTicket(a.makeTicket({ login: 'x' }, { key: 'other' }), { key: SECRET }).error, /서명/);
assert.match(a.verifyTicket(t, { key: '' }).error, /설정되지/);
const req = (h) => ({ method: 'POST', headers: h }); const ts = String(Date.now()); const body = '{"a":1}';
assert.equal(a.verifyService(req({ 'x-service-key-id': 'portal', 'x-service-ts': ts, 'x-service-sign': sign(SECRET, `POST|/api/internal/ariweb/health|${ts}|${sha256(body)}`) }), '/api/internal/ariweb/health', body, { key: SECRET }).ok, true);
assert.equal(a.verifyService(req({ 'x-service-key-id': 'portal', 'x-service-ts': ts, 'x-service-sign': 'bad' }), '/x', body, { key: SECRET }).ok, false);
assert.equal(a.verifyService(req({ 'x-service-key-id': 'portal', 'x-service-ts': String(Date.now() - 10 * 60000), 'x-service-sign': 'x' }), '/x', body, { key: SECRET }).reason, 'clock_skew');
assert.equal(a.linkOf('p1'), null); a.link('p1', { siteId: 's1', groupId: 'g1', portalUrl: 'https://my.example.test' }); assert.equal(a.linkOf('p1').siteId, 's1'); assert.equal(a.unlink('p1'), true); assert.equal(a.linkOf('p1'), null);
});
test('store.save: rev 가 저장마다 1 오른다', () => {
const store = require(ROOT + '/lib/store'); const id = crypto.randomBytes(16).toString('hex'); const s = store.create({ id, title: 't' }); assert.equal(s.rev, 1); store.save(s); assert.equal(store.load(id).rev, 2);
});
test.before(async () => {
// 가짜 포털(프로젝트 삭제 영향 응답) — Webmaker 가 서명한 요청만 받는다
portal = http.createServer((req, res) => { let d = ''; req.on('data', (c) => { d += c; }); req.on('end', () => { portalCalls.push({ url: req.url, keyId: req.headers['x-service-key-id'], hasSign: !!req.headers['x-service-sign'], body: d }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(portalImpact)); }); });
await new Promise((r) => portal.listen(0, '127.0.0.1', r));
// 보관소에 공유 비밀·설정에 포털 주소를 넣고 서버를 띄운다
const vault = require(ROOT + '/lib/vault'); vault.set({ kind: 'ariweb', id: 'ariweb', label: 'test', url: '', secret: SECRET, settings: {} });
const config = require(ROOT + '/lib/config'); config.set({ 'ariweb.portalUrl': `http://127.0.0.1:${portal.address().port}` }, 'test');
child = spawn('node', [path.join(ROOT, 'server.js')], { env: { ...process.env, PORT: String(PORT), HOST: '127.0.0.1', DATA_ROOT: tmp, DATA_DIR: path.join(tmp, 'sites'), SITES_ROOT: path.join(tmp, 'www'), PUBLIC_BASE_URL: B, SOURCE_PROVIDER: 'none' } });
let out = ''; child.stdout.on('data', (c) => { out += c; }); child.stderr.on('data', (c) => { out += c; });
for (let i = 0; i < 150; i++) { if (/listening on/.test(out)) break; await new Promise((r) => setTimeout(r, 100)); }
assert.match(out, /listening on/);
});
test.after(() => { try { child.kill('SIGTERM'); } catch (_) {} try { portal.close(); } catch (_) {} });
let projectId; let cookie;
test('내부 API: 서명 없는 호출 거부 · 프로젝트 만들기·연결·조회·소스·export', async () => {
assert.equal((await fetch(B + '/api/internal/ariweb/health')).status, 401);
assert.equal((await svc('GET', '/api/internal/ariweb/health')).b.ok, true);
const c = await svc('POST', '/api/internal/ariweb/projects', { owner: 'aw-g1-u1', ownerName: '테스트', title: '포털 사이트', mode: 'manual', link: { siteId: 's_1', groupId: 'g_1', siteName: '우리 사이트', groupName: '우리 그룹', portalUrl: 'http://127.0.0.1:' + portal.address().port } });
if (c.status !== 200) console.log('DEBUG create', c.status, JSON.stringify(c.b), await c.r.text().catch(() => '')); assert.equal(c.status, 200, JSON.stringify(c.b)); projectId = c.b.project.id; assert.equal(c.b.project.link.siteId, 's_1'); assert.equal(c.b.project.hasContent, false);
fs.writeFileSync(path.join(tmp, 'www', projectId, 'index.html'), '<h1>hello</h1>');
const g = await svc('GET', `/api/internal/ariweb/projects/${projectId}`); assert.equal(g.b.project.hasContent, true); assert.ok(Array.isArray(g.b.project.versionList)); assert.equal(g.b.project.git.provider, 'none');
const l = await svc('GET', '/api/internal/ariweb/projects?owner=aw-g1-u1'); assert.equal(l.b.projects.length, 1);
const src = await svc('GET', `/api/internal/ariweb/projects/${projectId}/source`); assert.equal(src.b.ok, true); assert.ok(src.b.source.siteBytes > 0); assert.equal(src.b.source.lfsSupported, false);
const ex = await svc('GET', `/api/internal/ariweb/projects/${projectId}/export`); assert.equal(ex.status, 200); assert.equal(ex.r.headers.get('x-ariweb-version'), 'current'); const buf = Buffer.from(await ex.r.arrayBuffer()); assert.ok(buf.length > 20 && buf[0] === 0x1f && buf[1] === 0x8b, 'gzip');
assert.equal((await svc('GET', `/api/internal/ariweb/projects/${projectId}/export?version=99`)).status, 404);
assert.equal((await svc('GET', '/api/internal/ariweb/credits/aw-g1-u1')).b.credit.balance, 0);
});
test('SSO: 티켓으로 세션 발급·프로젝트로 이동·배너 컨텍스트, 재사용·위조 거부', async () => {
const t = ticket({ login: 'aw-g1-u1', name: '테스트', groupId: 'g_1', groupName: '우리 그룹', siteId: 's_1', siteName: '우리 사이트', projectId });
const r = await fetch(B + '/api/auth/sso?ticket=' + encodeURIComponent(t), { redirect: 'manual' }); assert.equal(r.status, 302); assert.equal(r.headers.get('location'), `/?site=${projectId}`);
const sc = r.headers.getSetCookie(); assert.ok(sc.some((c) => c.startsWith('wh_session=')) && sc.some((c) => c.startsWith('wm_ariweb=')));
cookie = sc.map((c) => c.split(';')[0]).join('; ');
const meR = await fetch(B + '/api/auth/me', { headers: { cookie, referer: B + '/' } }); const me = await meR.json(); if (!me.user) console.log('DEBUG me', meR.status, JSON.stringify(me), cookie); assert.equal(me.user.id, 'aw-g1-u1');
const cfg = await (await fetch(B + '/api/config', { headers: { cookie, referer: B + '/' } })).json(); assert.equal(cfg.ariweb.groupName, '우리 그룹'); assert.equal(cfg.ariweb.siteName, '우리 사이트');
assert.equal((await fetch(B + '/api/auth/sso?ticket=' + encodeURIComponent(t), { redirect: 'manual' })).status, 403, '1회용');
assert.equal((await fetch(B + '/api/auth/sso?ticket=' + encodeURIComponent(t.slice(0, -2) + 'zz'), { redirect: 'manual' })).status, 403, '위조');
});
test('동시 편집 충돌: 옛 rev 로 저장하면 409 + 두 버전 요약, 최신 rev 는 저장', async () => {
const H = { cookie, 'Content-Type': 'application/json', referer: B + '/' };
const s0 = await (await fetch(B + `/api/sites/${projectId}`, { headers: H })).json(); const rev = s0.rev; assert.ok(rev >= 1); assert.equal(s0.ariweb.siteId, 's_1');
const ok = await fetch(B + `/api/sites/${projectId}`, { method: 'PATCH', headers: H, body: JSON.stringify({ title: '바뀐 제목', rev }) }); assert.equal(ok.status, 200);
const stale = await fetch(B + `/api/sites/${projectId}`, { method: 'PATCH', headers: H, body: JSON.stringify({ title: '다른 창', rev }) }); assert.equal(stale.status, 409); const j = await stale.json(); assert.equal(j.conflict.yourRev, rev); assert.ok(j.conflict.currentRev > rev);
const stale2 = await fetch(B + `/api/sites/${projectId}/files/index.html`, { method: 'PUT', headers: { ...H, 'If-Match': String(rev) }, body: JSON.stringify({ content: '<h1>x</h1>' }) }); assert.equal(stale2.status, 409);
const noRev = await fetch(B + `/api/sites/${projectId}`, { method: 'PATCH', headers: H, body: JSON.stringify({ purpose: '옛 화면' }) }); assert.equal(noRev.status, 200, 'rev 없는 옛 화면은 예전처럼');
});
test('발행: 포털 연결 프로젝트는 409 + 포털 배포 화면 안내', async () => {
const r = await fetch(B + `/api/sites/${projectId}/plan/publish`, { method: 'POST', headers: { cookie, 'Content-Type': 'application/json', referer: B + '/' }, body: '{}' });
assert.equal(r.status, 409); const j = await r.json(); assert.match(j.error, /포털/); assert.match(j.ariweb.deployUrl, /\/sites\/s_1\/deploy$/);
});
test('폐기: 포털 영향 확인 → 확인 전 409(영향 표시) → 운영 중이면 차단 → 확인 후 폐기(포털 사이트는 건드리지 않음)', async () => {
const H = { cookie, 'Content-Type': 'application/json', referer: B + '/' };
const r1 = await fetch(B + `/api/sites/${projectId}`, { method: 'DELETE', headers: H, body: JSON.stringify({ confirm: '바뀐 제목' }) }); assert.equal(r1.status, 409); const j1 = await r1.json(); assert.equal(j1.ariweb.needAcknowledge, true); assert.equal(j1.ariweb.impact.deployments, 2);
assert.equal(portalCalls[0].keyId, 'webmaker'); assert.ok(portalCalls[0].hasSign); assert.equal(JSON.parse(portalCalls[0].body).siteId, 's_1');
portalImpact = { impact: { blocked: true, liveSite: true, lifecycle: 'live', deployments: 2, domains: [], message: '운영 중' } };
const r2 = await fetch(B + `/api/sites/${projectId}`, { method: 'DELETE', headers: H, body: JSON.stringify({ confirm: '바뀐 제목', acknowledgeImpact: true }) }); assert.equal(r2.status, 409);
portalImpact = { impact: { blocked: false, liveSite: false, lifecycle: 'draft', deployments: 0, domains: [], message: '초안' } };
const r3 = await fetch(B + `/api/sites/${projectId}`, { method: 'DELETE', headers: H, body: JSON.stringify({ confirm: '바뀐 제목', acknowledgeImpact: true }) }); assert.equal(r3.status, 200, await r3.text());
assert.equal(require(ROOT + '/lib/ariweb').linkOf(projectId), null);
});
+33
View File
@@ -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() }; });
}
+15
View File
@@ -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;
` }];
+22
View File
@@ -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] || (() => {}))();
})();
+167
View File
@@ -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 };
}
+12
View File
@@ -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>` });
+92
View File
@@ -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; }
}
+50
View File
@@ -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;
}
+27
View File
@@ -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;
` }];
+103
View File
@@ -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] || (() => {}))();
})();
+459
View File
@@ -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 };
}
+62
View File
@@ -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'] }); }
+19
View File
@@ -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() }; });
}
+6
View File
@@ -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
);` }];
+62
View File
@@ -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 };
}
+36
View File
@@ -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 회귀 테스트 전부 통과, 별도 오류 발견 없음 |
+95
View File
@@ -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; },
};
}
+11
View File
@@ -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 }; });
}
+46
View File
@@ -0,0 +1,46 @@
/** test/domains.test.mjs — 도메인 영역 단위 테스트(서비스 직접 호출): 이름 정규화·중복·필요 DNS 값·메일 레코드 연동·해제·운영자 목록. 연결 작업 흐름은 test/sites.test.mjs 가 함께 검증한다. */
import { test, eq, ok, rejects, done } from '../lib/test.mjs';
import { makeDeps, seedGroup, appFor, call } from './helpers.mjs';
import { createFakeHal } from '../portal/sites/fake-hal.mjs';
import { createFake as fakeWebmaker } from '../portal/webmaker/adapter.mjs';
import { signHeaders } from '../lib/serviceauth.mjs';
import * as quota from '../lib/quota.mjs';
const setup = async () => { const deps = await makeDeps(); deps.hal = createFakeHal(); deps.webmaker = fakeWebmaker({ store: deps.store }); const who = seedGroup(deps); quota.grantEntitlement(deps.store, { groupId: who.group.id, resource: 'sites', amount: 1 }); const app = await appFor(deps, ['sites', 'domains'], who); return { deps, who, app, D: deps.domains }; };
await test('이름 정규화·형식 검사·용도·필요 DNS 값(복사용)·메일 레코드는 메일 영역에서 제공', async () => {
const { deps, who, app, D } = await setup();
eq(D.normalize(' HTTPS://Example.COM/path '), 'example.com');
await rejects(() => Promise.resolve().then(() => D.add({ group: who.group, user: who.user }, { name: 'bad_domain' })), 'bad_domain');
await rejects(() => Promise.resolve().then(() => D.add({ group: who.group, user: who.user }, { name: 'a.com', purpose: 'x' })), 'bad_purpose');
const d = D.add({ group: who.group, user: who.user }, { name: 'Mail.Example.org', purpose: 'both' }); eq(d.name, 'mail.example.org');
let recs = await D.requiredRecords(d); ok(recs.find((r) => r.type === 'TXT' && r.name === '_ariweb-verify.mail.example.org')); const a = recs.find((r) => r.type === 'A'); eq(a.value, null); ok(/관리형 배포/.test(a.note)); ok(recs.find((r) => /MX/.test(r.type) && /메일 서비스 화면/.test(r.note)));
deps.mailDns = { recordsFor: async (name) => [{ type: 'MX', name, value: `mail.ariweb.net`, prio: 10, state: 'pending' }, { type: 'TXT', name, value: 'v=spf1 include:_spf.ariweb.net ~all', purpose: 'SPF', state: 'pending' }] };
recs = await D.requiredRecords(d); ok(recs.find((r) => r.type === 'MX' && r.value === 'mail.ariweb.net')); ok(recs.find((r) => r.purpose === 'SPF'));
eq(D.setMailDnsState('mail.example.org', 'warn', { dkim: 'missing' }).mail_dns_state, 'warn'); eq(D.list(who.group.id)[0].mailDnsState, 'warn');
// 사이트에 연결된 관리형 도메인은 서버 공인 IP 를 A 값으로
const s = (await call(app, 'POST', '/api/sites', { name: 'S', projectMode: 'none', deployMode: 'managed' })).json.site; deps.sites.save(s.id, { server_id: 'srv_web2' });
const d2 = D.add({ group: who.group, user: who.user }, { name: 'web.example.org', purpose: 'web', siteId: s.id }); eq((await D.requiredRecords(d2)).find((r) => r.type === 'A').value, '203.0.113.20');
eq(D.detail(D.get(d2.id)).actionNeeded.code, 'ownership');
});
await test('중복·소속 검사·연결 대상 변경·해제·API 권한', async () => {
const { deps, who, app, D } = await setup();
const d = D.add({ group: who.group, user: who.user }, { name: 'dup.example', purpose: 'web' });
const e = await rejects(() => Promise.resolve().then(() => D.add({ group: who.group, user: who.user }, { name: 'dup.example' })), 'domain_taken'); ok(/이미 등록한/.test(e.message)); eq(e.detail.href, `/domains/${d.id}`);
const other = seedGroup(deps, { email: 'o@example.test' }); const e2 = await rejects(() => Promise.resolve().then(() => D.add({ group: other.group, user: other.user }, { name: 'dup.example' })), 'domain_taken'); ok(/다른 고객/.test(e2.message)); eq(e2.detail.href, null);
await rejects(() => Promise.resolve().then(() => D.add({ group: other.group, user: other.user }, { name: 'x.example', siteId: 'nope' })), 'not_found');
const s = (await call(app, 'POST', '/api/sites', { name: 'S', projectMode: 'none' })).json.site; const up = D.update(d, { siteId: s.id, purpose: 'both' }, who.user); eq(up.site_id, s.id); eq(up.purpose, 'both'); eq(up.dns_state, 'pending');
const r = await call(app, 'PATCH', `/api/domains/${d.id}`, { purpose: 'web' }); eq(r.status, 200); eq(r.json.domain.purpose, 'web');
// viewer 는 변경 불가
const now = new Date().toISOString(); deps.store.run('INSERT INTO users(id, email, display_name, created_at, updated_at) VALUES(?,?,?,?,?)', 'u_v', 'v@example.test', 'V', now, now); deps.store.run('INSERT INTO memberships(group_id, user_id, role, joined_at) VALUES(?,?,\'viewer\',?)', who.group.id, 'u_v', now);
const appV = await appFor(deps, ['sites', 'domains'], { user: deps.store.get('SELECT * FROM users WHERE id=\'u_v\''), group: who.group, membership: deps.store.get('SELECT * FROM memberships WHERE user_id=\'u_v\'') }); eq((await call(appV, 'DELETE', `/api/domains/${d.id}`)).status, 403); eq((await call(appV, 'GET', `/api/domains/${d.id}`)).status, 200);
eq((await call(app, 'DELETE', `/api/domains/${d.id}`)).json.released, true); eq(D.get(d.id), null); eq(deps.store.get('SELECT COUNT(*) AS n FROM audit_log WHERE action=\'domain.release\'').n, 1);
});
await test('운영자 목록: 서명 필요·반복 실패·만료 임박', async () => {
const { deps, who, app, D } = await setup(); deps.vault.set('service:hal', 'hal-secret-example');
const d = D.add({ group: who.group, user: who.user }, { name: 'ops.example', purpose: 'web' }); deps.store.run('UPDATE domains SET cert_state=\'valid\', cert_expires_at=? WHERE id=?', new Date(Date.now() + 3 * 86400000).toISOString(), d.id);
for (let i = 0; i < 3; i++) deps.store.run('INSERT INTO domain_checks(id, domain_id, kind, ok, checked_at) VALUES(?,?,\'dns\',0,?)', 'dc' + i, d.id, new Date().toISOString());
eq((await call(app, 'GET', '/internal/ops/domains')).status, 401);
const h = Object.fromEntries(Object.entries(signHeaders('hal', 'hal-secret-example', 'GET', '/internal/ops/domains', undefined)).map(([k, v]) => [k.toLowerCase(), v]));
const r = await call(app, 'GET', '/internal/ops/domains', undefined, h); eq(r.status, 200, r.text); eq(r.json.expiring.length, 1); eq(r.json.repeatedFailures.length, 1); eq(r.json.domains[0].recentFailures, 3);
});
done();
+181
View File
@@ -0,0 +1,181 @@
/** test/sites.test.mjs — 사이트·배포 영역 회귀 테스트(가짜 HAL·가짜 Webmaker). */
import { test, eq, ok, rejects, done } from '../lib/test.mjs';
import { makeDeps, seedGroup, appFor, call } from './helpers.mjs';
import { createFakeHal } from '../portal/sites/fake-hal.mjs';
import { createFake as fakeWebmaker } from '../portal/webmaker/adapter.mjs';
import * as quota from '../lib/quota.mjs';
import { signHeaders } from '../lib/serviceauth.mjs';
async function setup({ sites = 2, disk = 10e9 } = {}) {
const deps = await makeDeps(); deps.hal = createFakeHal(); deps.webmaker = fakeWebmaker({ store: deps.store });
const who = seedGroup(deps); if (sites) quota.grantEntitlement(deps.store, { groupId: who.group.id, resource: 'sites', amount: sites }); if (disk) quota.grantEntitlement(deps.store, { groupId: who.group.id, resource: 'web_disk', amount: disk });
const app = await appFor(deps, ['sites', 'domains', 'source', 'webmaker'], who); return { deps, who, app, S: deps.sites, D: deps.domains };
}
await test('초안 생성은 사이트 수를 쓰지 않고 프로젝트를 만들어 연결한다 · 다음 단계 안내 · 목록 필터', async () => {
const { deps, who, app } = await setup();
const r = await call(app, 'POST', '/api/sites', { name: '우리 회사', purpose: '소개', projectMode: 'new', buildMode: 'ai' }); eq(r.status, 200, r.text); eq(r.json.site.lifecycle, 'draft'); eq(r.json.site.site_count_state, 'none'); ok(r.json.site.webmaker_project_id); ok(r.json.nextSteps.find((s) => s.id === 'import').unsupported);
eq(quota.figures(deps.store, who.group.id, 'sites').reserved, 0);
const p = deps.webmaker._projects.get(r.json.site.webmaker_project_id); eq(p.link.siteId, r.json.site.id); eq(p.mode, 'ai');
const l = await call(app, 'GET', '/api/sites?filter=draft'); eq(l.json.sites.length, 1); eq((await call(app, 'GET', '/api/sites?filter=live')).json.sites.length, 0);
// 기존 프로젝트 연결: 다른 사이트에 이미 연결된 프로젝트는 거부, 내 것이 아니면 거부
const other = await deps.webmaker.createProject({ login: 'someone-else', title: 'x' }); eq((await call(app, 'POST', '/api/sites', { name: 'B', projectMode: 'existing', projectId: other.id })).json.error, 'project_not_owned');
eq((await call(app, 'POST', '/api/sites', { name: 'C', projectMode: 'existing', projectId: r.json.site.webmaker_project_id })).json.error, 'project_linked');
eq((await call(app, 'POST', '/api/sites', { name: '' })).json.detail.field, 'name');
});
await test('배포 전 확인 → 최초 공개 배포는 사이트 수를 예약하고, 10단계 작업이 HAL 결과로 완료되며 대표 도메인 확인 뒤 사용 중으로 확정된다', async () => {
const { deps, who, app, S, D } = await setup();
const s = (await call(app, 'POST', '/api/sites', { name: 'A', projectMode: 'new' })).json.site;
let pc = (await call(app, 'GET', `/api/sites/${s.id}/precheck`)).json.precheck; ok(!pc.ok); ok(pc.items.find((i) => i.id === 'version' && i.state === 'block')); ok(pc.items.find((i) => i.id === 'target' && i.state === 'block' && i.href.endsWith('/deploy/mode')));
deps.webmaker._addVersion(s.webmaker_project_id, 'create', '생성'); await call(app, 'POST', `/api/sites/${s.id}/mode`, { mode: 'managed' });
pc = (await call(app, 'GET', `/api/sites/${s.id}/precheck`)).json.precheck; eq(pc.ok, true, JSON.stringify(pc.items.filter((i) => i.state === 'block')));
const d = await call(app, 'POST', `/api/sites/${s.id}/deploy`, { versionRef: 'current' }); eq(d.status, 200, d.text); ok(d.json.created); eq(d.json.job.steps.length, 10);
eq(S.get(s.id).site_count_state, 'reserved'); eq(quota.figures(deps.store, who.group.id, 'sites').reserved, 1); ok(quota.allocationOf(deps.store, who.group.id, 'web_disk', 'site', s.id), '웹 디스크 기본 배정');
const again = await call(app, 'POST', `/api/sites/${s.id}/deploy`, { versionRef: 'current' }); eq(again.json.created, false, '같은 배포 요청은 하나');
await deps.jobs.drain(); const j = deps.jobs.get(d.json.job.id); eq(j.status, 'done', j.error); eq(j.steps.filter((x) => x.state === 'done').length, 10); eq(j.result.verify, 'needs_domain');
const site = S.get(s.id); eq(site.lifecycle, 'needs_domain'); eq(site.deploy_verify, 'needs_domain'); ok(site.current_version); eq(site.site_count_state, 'reserved', '대표 도메인 전에는 예약 유지'); eq(site.server_id, 'srv_web1');
const hc = deps.hal.calls.find((c) => c.kind === 'site.deploy'); ok(hc.payload.bundleUrl.includes('/internal/bundles/')); eq(hc.payload.external, false);
// 번들 다운로드 토큰
const tok = hc.payload.bundleUrl.split('/internal/bundles/')[1]; eq((await call(app, 'GET', `/internal/bundles/${tok}`)).status, 404, '배포가 끝나면 번들 파일은 지워진다'); eq((await call(app, 'GET', '/internal/bundles/bogus')).status, 404);
// 도메인 연결 → 소유 TXT 반영 → DNS 반영 → 인증서 → 외부 확인 → 대표 지정 → 공개 운영 중 + 사이트 수 사용 중
const dm = await call(app, 'POST', '/api/domains', { name: 'Example.com', purpose: 'web', siteId: s.id, makePrimary: true }); eq(dm.status, 200, dm.text); const dom = dm.json.domain; ok(dm.json.records.find((r) => r.type === 'TXT' && r.value.startsWith('ariweb-verify=')));
await deps.jobs.drain(); let dj = deps.jobs.get(dm.json.job.id); eq(dj.status, 'awaiting_external'); ok(/TXT/.test(dj.wait_reason));
deps.hal.script.dns.txt[`_ariweb-verify.example.com`] = `ariweb-verify=${dm.json.domain.ownershipToken}`; await call(app, 'POST', `/api/domains/${dom.id}/check`, {}); await deps.jobs.drain(); dj = deps.jobs.get(dm.json.job.id); eq(dj.status, 'awaiting_external'); ok(/A 레코드가 아직/.test(dj.wait_reason), dj.wait_reason); eq(D.get(dom.id).ownership_state, 'verified');
deps.hal.script.dns.a = ['198.51.100.9']; await call(app, 'POST', `/api/domains/${dom.id}/check`, {}); await deps.jobs.drain(); dj = deps.jobs.get(dm.json.job.id); ok(/값이 다릅니다/.test(dj.wait_reason), '잘못된 값 구분'); eq(D.get(dom.id).dns_state, 'wrong');
deps.hal.script.dns.a = ['203.0.113.10']; await call(app, 'POST', `/api/domains/${dom.id}/check`, {}); await deps.jobs.drain(); dj = deps.jobs.get(dm.json.job.id); eq(dj.status, 'done', dj.error);
const fin = D.get(dom.id); eq([fin.dns_state, fin.cert_state, !!fin.is_primary], ['ok', 'valid', true]);
const site2 = S.get(s.id); eq(site2.lifecycle, 'live'); eq(site2.deploy_verify, 'public'); eq(site2.primary_domain, 'example.com'); eq(site2.site_count_state, 'in_use'); eq(quota.figures(deps.store, who.group.id, 'sites').allocated, 1);
// 개요: 관리형 지표
const ov = (await call(app, 'GET', `/api/sites/${s.id}/overview`)).json; eq(ov.overview.kind, 'managed'); await deps.jobs.drain(); const ov2 = (await call(app, 'GET', `/api/sites/${s.id}/overview`)).json.overview; eq(ov2.visits24h, 800); eq(ov2.disk.limit, 1024 ** 3); ok(ov2.disk.used > 0); eq(ov2.health, 'ok');
eq((await call(app, 'GET', `/api/sites/${s.id}/usage`)).json.allocations.find((a) => a.resource === 'web_disk').usage.used, 145e6);
// 두 번째 도메인은 사이트 수를 더 쓰지 않는다 · 중복 도메인은 다른 그룹에서 거부
await call(app, 'POST', '/api/domains', { name: 'www2.example.com', purpose: 'web', siteId: s.id, startCheck: false }); eq(quota.figures(deps.store, who.group.id, 'sites').allocated, 1);
const other = seedGroup(deps, { email: 'b@example.test', name: 'B' }); const app2 = await appFor(deps, ['sites', 'domains'], other); const dup = await call(app2, 'POST', '/api/domains', { name: 'example.com', purpose: 'web' }); eq(dup.status, 409); eq(dup.json.error, 'domain_taken'); ok(!dup.text.includes('A'), '다른 고객 정보 비노출'); eq((await call(app2, 'GET', `/api/domains/${dom.id}`)).status, 404, '다른 그룹 도메인은 404');
});
await test('마지막 사이트 수 1개를 두 사이트가 동시에 배포 요청하면 하나만 성공하고 다른 쪽은 부족 사유', async () => {
const { deps, app } = await setup({ sites: 1 });
const mk = async (n) => { const s = (await call(app, 'POST', '/api/sites', { name: n, projectMode: 'new', deployMode: 'managed' })).json.site; deps.webmaker._addVersion(s.webmaker_project_id); return s; };
const a = await mk('A'); const b = await mk('B');
const [ra, rb] = await Promise.all([call(app, 'POST', `/api/sites/${a.id}/deploy`, {}), call(app, 'POST', `/api/sites/${b.id}/deploy`, {})]);
const okc = [ra, rb].filter((r) => r.status === 200); const bad = [ra, rb].find((r) => r.status !== 200); eq(okc.length, 1); eq(bad.status, 409); eq(bad.json.error, 'precheck_failed'); ok(bad.json.detail.items.find((i) => i.id === 'sites' && i.state === 'block' && i.href === '/billing'), JSON.stringify(bad.json.detail.items));
});
await test('배포 실패: 이전 버전 유지 · 실패 단계·문의 번호 · 재시도 · 되돌리기 · 중간 결과 정리와 예약 반환', async () => {
const { deps, who, app, S } = await setup();
const s = (await call(app, 'POST', '/api/sites', { name: 'A', projectMode: 'new', deployMode: 'managed' })).json.site; deps.webmaker._addVersion(s.webmaker_project_id, 'create', 'v1');
await call(app, 'POST', `/api/sites/${s.id}/deploy`, { versionRef: '1' }); await deps.jobs.drain(); eq(S.get(s.id).current_version, 'v1');
// 두 번째 배포 실패 → 이전 버전 유지, 실패 단계 표시
deps.webmaker._addVersion(s.webmaker_project_id, 'update', 'v2'); deps.hal.script.failKinds.add('site.deploy');
const d2 = await call(app, 'POST', `/api/sites/${s.id}/deploy`, { versionRef: '2' }); await deps.jobs.drain(); let j = deps.jobs.get(d2.json.job.id); eq(j.status, 'failed'); ok(/내부 확인 실패/.test(j.error)); eq(S.get(s.id).current_version, 'v1', '이전 버전 유지');
const deps2 = (await call(app, 'GET', `/api/sites/${s.id}/deployments`)).json; eq(deps2.deployments[0].status, 'failed'); eq(deps2.deployments[0].result.failedStep, 'internal_check'); ok(deps2.deployments[0].job.canRetry);
const n = deps.store.all('SELECT * FROM notifications WHERE group_id=?', who.group.id); ok(n.find((x) => /배포 실패/.test(x.title) && x.body.includes(j.id)), '문의 번호 포함 알림');
// 재시도 성공
deps.hal.script.failKinds.delete('site.deploy'); const rt = await call(app, 'POST', `/api/sites/${s.id}/jobs/${j.id}/retry`); eq(rt.status, 200); await deps.jobs.drain(); j = deps.jobs.get(j.id); eq(j.status, 'done', j.error); eq(S.get(s.id).current_version, 'v2');
// 되돌리기 → v1, 외부 확인 포함
const rb = await call(app, 'POST', `/api/sites/${s.id}/rollback`); eq(rb.status, 200, rb.text); await deps.jobs.drain(); eq(deps.jobs.get(rb.json.job.id).status, 'done'); eq(S.get(s.id).current_version, 'v1'); ok(deps.hal.calls.find((c) => c.kind === 'site.rollback' && c.payload.toVersion === 'v1'));
// 최초 배포 실패 → 예약 반환 예정 + 정리하고 반환
const t = (await call(app, 'POST', '/api/sites', { name: 'T', projectMode: 'new', deployMode: 'managed' })).json.site; deps.webmaker._addVersion(t.webmaker_project_id); deps.hal.script.failKinds.add('site.deploy');
await call(app, 'POST', `/api/sites/${t.id}/deploy`, {}); await deps.jobs.drain(); let ts = S.get(t.id); eq(ts.site_count_state, 'reserved'); ok(ts.return_due_at); ok(ts.pendingCleanup); ok(!ts.current_version);
deps.hal.script.failKinds.delete('site.deploy'); const cl = await call(app, 'POST', `/api/sites/${t.id}/cleanup`); eq(cl.status, 200, cl.text); await deps.jobs.drain(); ts = S.get(t.id); eq(ts.site_count_state, 'none'); eq(ts.lifecycle, 'building'); eq(deps.store.get('SELECT state FROM quota_reservations WHERE subject_id=?', t.id).state, 'returned'); ok(deps.hal.calls.find((c) => c.kind === 'site.remove' && c.payload.siteId === t.id));
// 자동 반환(sweep): 반환 예정 시각이 지난 예약
const u = (await call(app, 'POST', '/api/sites', { name: 'U', projectMode: 'new', deployMode: 'managed' })).json.site; deps.webmaker._addVersion(u.webmaker_project_id); deps.hal.script.failKinds.add('site.deploy'); await call(app, 'POST', `/api/sites/${u.id}/deploy`, {}); await deps.jobs.drain(); deps.hal.script.failKinds.delete('site.deploy');
eq(S.sweep(Date.now() + 25 * 3600 * 1000).returned, 1); eq(S.get(u.id).site_count_state, 'none');
// 프로세스 재시작 복구: 진행 중 작업이 이어진다
deps.hal.script.pendingKinds.add('site.deploy'); const d3 = await call(app, 'POST', `/api/sites/${s.id}/deploy`, { versionRef: '2' }); await deps.jobs.drain(); j = deps.jobs.get(d3.json.job.id); eq(j.status, 'queued', 'HAL 진행 중이면 다시 깨어날 예정'); ok(/진행 중/.test(j.wait_reason));
{ const hc3 = deps.hal.calls.filter((c) => c.kind === 'site.deploy').pop(); const tok3 = hc3.payload.bundleUrl.split('/internal/bundles/')[1]; const b3 = await call(app, 'GET', `/internal/bundles/${tok3}`); eq(b3.status, 200, '진행 중에는 Agent 가 번들을 받을 수 있다'); }
deps.store.run('UPDATE jobs SET status=\'running\' WHERE id=?', j.id); eq(deps.jobs.recover(), 1); const hid = deps.hal.calls.filter((c) => c.kind === 'site.deploy').pop(); deps.hal.script.pendingKinds.delete('site.deploy'); deps.hal._finish([...deps.hal.jobs.values()].find((x) => x.idempotencyKey.startsWith(`portal:${j.id}:`)).id);
deps.store.run('UPDATE jobs SET next_run_at=? WHERE id=?', new Date(0).toISOString(), j.id); await deps.jobs.drain(); eq(deps.jobs.get(j.id).status, 'done'); eq(deps.hal.calls.filter((c) => c.kind === "site.deploy" && c.idempotencyKey.startsWith(`portal:${j.id}:`)).length, 1, "재시작 뒤에도 HAL 작업은 하나");
});
await test('외부 배포: 동의 기록 → Agent 토큰 발급(1회 표시) → 연결 → 배포 → 개요에 관리형 지표 없음 · 제거 · 메일 미지원 안내', async () => {
const { deps, app, S } = await setup();
const s = (await call(app, 'POST', '/api/sites', { name: 'X', projectMode: 'new', deployMode: 'external' })).json.site; deps.webmaker._addVersion(s.webmaker_project_id);
const mi = (await call(app, 'GET', `/api/sites/${s.id}/mode`)).json; ok(mi.external.notProvided.includes('메일(외부 메일 배포 미지원)')); eq(mi.consent.agreed, false);
eq((await call(app, 'POST', `/api/sites/${s.id}/external`, {})).json.error, 'consent_required');
eq((await call(app, 'POST', `/api/sites/${s.id}/consent`, { version: mi.consent.version, agree: true })).status, 200); eq(S.get(s.id).consent_version, mi.consent.version);
const tk = await call(app, 'POST', `/api/sites/${s.id}/external`, { label: '고객 서버' }); eq(tk.status, 200, tk.text); ok(tk.json.token.startsWith('xat_')); ok(tk.json.installCommand.includes(tk.json.token)); ok(!JSON.stringify(deps.store.all('SELECT * FROM external_agents')).includes(tk.json.token), '토큰 저장 안 함');
let pc = (await call(app, 'GET', `/api/sites/${s.id}/precheck`)).json.precheck; ok(pc.items.find((i) => i.id === 'target' && i.state === 'block'));
deps.hal._connectAgent(tk.json.agent.hal_agent_id); const ex = (await call(app, 'GET', `/api/sites/${s.id}/external`)).json; eq(ex.agent.status, 'connected');
pc = (await call(app, 'GET', `/api/sites/${s.id}/precheck`)).json.precheck; eq(pc.ok, true, JSON.stringify(pc.items.filter((i) => i.state === 'block'))); ok(!pc.items.find((i) => i.id === 'capacity'), '외부는 관리형 용량 확인 없음');
const d = await call(app, 'POST', `/api/sites/${s.id}/deploy`, {}); eq(d.status, 200, d.text); await deps.jobs.drain(); eq(deps.jobs.get(d.json.job.id).status, 'done'); const hc = deps.hal.calls.find((c) => c.kind === 'site.deploy'); eq(hc.target, tk.json.agent.hal_agent_id); eq(hc.payload.external, true);
const ov = (await call(app, 'GET', `/api/sites/${s.id}/overview`)).json.overview; eq(ov.kind, 'external'); eq(ov.disk, undefined); eq(ov.health, undefined); ok(ov.notProvided.includes('백업 보호 상태')); eq(ov.agent.status, 'connected'); ok(ov.lastDeployResult.ok);
eq((await call(app, 'GET', `/api/sites`)).json.sites[0].health, 'not_provided');
const rm = await call(app, 'POST', `/api/sites/${s.id}/external/remove`); eq(rm.status, 200); await deps.jobs.drain(); eq(S.get(s.id).current_version, null); ok(deps.hal.calls.find((c) => c.kind === 'site.remove' && c.target === tk.json.agent.hal_agent_id));
await call(app, 'DELETE', `/api/sites/${s.id}/external`); eq(S.externalAgentFor(s.id), null);
});
await test('배포 방식 변경(운영 중)은 새 위치 준비→별도 배포→검증→도메인 전환→이전 위치 정리 작업이며 실패하면 현재 위치 유지', async () => {
const { deps, app, S } = await setup();
const s = (await call(app, 'POST', '/api/sites', { name: 'M', projectMode: 'new', deployMode: 'managed' })).json.site; deps.webmaker._addVersion(s.webmaker_project_id);
await call(app, 'POST', `/api/sites/${s.id}/deploy`, {}); await deps.jobs.drain(); eq(S.get(s.id).server_id, 'srv_web1');
await call(app, 'POST', `/api/sites/${s.id}/consent`, { version: S.EXTERNAL_CONSENT.version, agree: true }); const tk = await call(app, 'POST', `/api/sites/${s.id}/external`, {}); deps.hal._connectAgent(tk.json.agent.hal_agent_id);
deps.hal.script.failKinds.add('site.deploy'); let r = await call(app, 'POST', `/api/sites/${s.id}/mode`, { mode: 'external' }); eq(r.status, 200, r.text); ok(r.json.job); await deps.jobs.drain(); eq(deps.jobs.get(r.json.job.id).status, 'failed'); eq(S.get(s.id).deploy_mode, 'managed', '실패하면 현재 위치 유지'); eq(deps.store.get('SELECT status FROM deploy_mode_changes').status, 'failed');
deps.hal.script.failKinds.delete('site.deploy'); r = await call(app, 'POST', `/api/sites/${s.id}/mode`, { mode: 'external' }); await deps.jobs.drain(); const j = deps.jobs.get(r.json.job.id); eq(j.status, 'done', j.error); eq(j.steps.map((x) => x.id), ['prepare_new', 'deploy_new', 'verify_new', 'switch_domain', 'cleanup_old']); eq(S.get(s.id).deploy_mode, 'external'); ok(deps.hal.calls.find((c) => c.kind === 'site.remove' && c.target === 'srv_web1' && c.payload.keepData === true));
});
await test('설정 변경: 이름 필수·유지보수 모드는 영향 안내 + vhost 작업 · 다른 그룹은 404 · 권한 없는 구성원 403 · 생명주기 훅·사이트 수 반환', async () => {
const { deps, who, app, S } = await setup();
const s = (await call(app, 'POST', '/api/sites', { name: 'S', projectMode: 'none' })).json.site;
eq((await call(app, 'PATCH', `/api/sites/${s.id}`, { name: '' })).json.detail.field, 'name');
const up = await call(app, 'PATCH', `/api/sites/${s.id}`, { name: '새 이름', maintenance: true, searchVisible: false }); eq(up.status, 200); eq(up.json.site.name, '새 이름'); eq(up.json.site.search_visible, 0);
// viewer 는 조회만
const now = new Date().toISOString(); deps.store.run('INSERT INTO users(id, email, display_name, created_at, updated_at) VALUES(?,?,?,?,?)', 'u_v', 'v@example.test', 'V', now, now); deps.store.run('INSERT INTO memberships(group_id, user_id, role, joined_at) VALUES(?,?,\'viewer\',?)', who.group.id, 'u_v', now);
const appV = await appFor(deps, ['sites'], { user: deps.store.get('SELECT * FROM users WHERE id=\'u_v\''), group: who.group, membership: deps.store.get('SELECT * FROM memberships WHERE user_id=\'u_v\'') });
eq((await call(appV, 'PATCH', `/api/sites/${s.id}`, { name: 'x' })).status, 403); eq((await call(appV, 'POST', `/api/sites/${s.id}/deploy`, {})).status, 403); eq((await call(appV, 'POST', '/api/sites', { name: 'n' })).status, 403);
// 사이트별 권한으로 배포 허용
deps.store.run('INSERT INTO site_permissions(site_id, user_id, perms_json, updated_at) VALUES(?,?,?,?)', s.id, 'u_v', JSON.stringify(['view', 'deploy']), now); eq((await call(appV, 'GET', `/api/sites/${s.id}/precheck`)).status, 200);
S.setLifecycle(s.id, 'paused', { actor: who.user }); eq(S.get(s.id).lifecycle, 'paused'); eq((await call(app, 'PATCH', `/api/sites/${s.id}`, { name: 'y' })).status, 200); S.setLifecycle(s.id, 'cancel_scheduled'); eq((await call(app, 'PATCH', `/api/sites/${s.id}`, { name: 'z' })).json.error, 'site_not_modifiable');
ok(deps.store.get('SELECT immutable FROM audit_log WHERE action=\'site.lifecycle.paused\'').immutable);
await rejects(() => Promise.resolve().then(() => S.setLifecycle(s.id, 'bogus')), 'bad_lifecycle');
});
await test('Webmaker 프로젝트 삭제 영향(서명 호출): 운영 중이면 차단, 초안이면 연결 해제 안내 · 서명 없으면 401', async () => {
const { deps, app, S } = await setup();
const s = (await call(app, 'POST', '/api/sites', { name: 'P', projectMode: 'new', deployMode: 'managed' })).json.site; const pid = s.webmaker_project_id;
const secret = deps.webmaker.secret(); deps.vault.set('service:webmaker', secret);
const signed = (body) => call(app, 'POST', '/internal/webmaker/project-impact', body, Object.fromEntries(Object.entries(signHeaders('webmaker', secret, 'POST', '/internal/webmaker/project-impact', JSON.stringify(body))).map(([k, v]) => [k.toLowerCase(), v])));
eq((await call(app, 'POST', '/internal/webmaker/project-impact', { projectId: pid })).status, 401);
let r = await signed({ projectId: pid, siteId: s.id }); eq(r.status, 200, r.text); eq(r.json.impact.blocked, false); eq(r.json.impact.lifecycle, 'draft');
deps.webmaker._addVersion(pid); await call(app, 'POST', `/api/sites/${s.id}/deploy`, {}); await deps.jobs.drain();
r = await signed({ projectId: pid }); eq(r.json.impact.blocked, true); eq(r.json.impact.deployments, 1); ok(/운영/.test(r.json.impact.message));
eq(S.impactForProject('nope').blocked, false);
});
await test('Source 옵션: 권리 안에서 활성화 → 저장소 정보·배포 revision 대응 → 해제/만료는 30일 read-only → 삭제 완료(복구 불가) · export', async () => {
const { deps, who, app, S } = await setup();
const s = (await call(app, 'POST', '/api/sites', { name: 'G', projectMode: 'new', deployMode: 'managed' })).json.site; deps.webmaker._addVersion(s.webmaker_project_id, 'create', '생성');
let r = await call(app, 'GET', `/api/source/${s.id}`); eq(r.json.option, null); eq(r.json.entitlement.purchased, 0);
eq((await call(app, 'POST', `/api/source/${s.id}/activate`)).status, 409, '권리 없음');
quota.grantEntitlement(deps.store, { groupId: who.group.id, resource: 'source', amount: 1 }); eq((await call(app, 'POST', `/api/source/${s.id}/activate`)).json.option.status, 'active');
await call(app, 'POST', `/api/sites/${s.id}/deploy`, { versionRef: '1' }); await deps.jobs.drain();
r = (await call(app, 'GET', `/api/source/${s.id}`)).json; eq(r.source.provider, 'gitea'); eq(r.source.changes.length, 1); eq(r.lastDeployedRevision, 'c1'); eq(r.deployedCommits.c1, '저장본 #1 생성 · 1'.slice(0, 0) || r.deployedCommits.c1); ok(r.cloneInfo.includes('git clone'));
const ex = await call(app, 'GET', `/api/source/${s.id}/export`); eq(ex.status, 200);
// 두 번째 사이트는 권리 부족
const s2 = (await call(app, 'POST', '/api/sites', { name: 'H', projectMode: 'new' })).json.site; eq((await call(app, 'POST', `/api/source/${s2.id}/activate`)).json.error, 'quota_insufficient');
// 해제 → read-only 30일 → sweep 로 삭제
const un = await call(app, 'POST', `/api/source/${s.id}/unlink`); eq(un.json.option.status, 'readonly'); ok(un.json.option.delete_after); eq(quota.figures(deps.store, who.group.id, 'source').allocated, 0); eq(deps.webmaker._projects.get(s.webmaker_project_id).link, null);
eq((await call(app, 'POST', `/api/source/${s2.id}/activate`)).status, 200, '반환된 권리로 다른 사이트 활성화');
eq(deps.source.sweep(Date.now() + 31 * 86400000).deleted, 1); eq(deps.source.get(s.id).status, 'deleted'); eq((await call(app, 'GET', `/api/source/${s.id}/export`)).status, 409);
ok(deps.store.all('SELECT title FROM notifications').some((n) => /삭제 완료/.test(n.title)));
});
await test('도메인: 대표 지정은 DNS·HTTPS 정상일 때만 · 인증서 실패는 정상 HTTPS 로 표시하지 않음 · 해제하면 도메인 설정 필요 · 만료 임박 갱신', async () => {
const { deps, app, S, D } = await setup();
const s = (await call(app, 'POST', '/api/sites', { name: 'D', projectMode: 'new', deployMode: 'managed' })).json.site; deps.webmaker._addVersion(s.webmaker_project_id); await call(app, 'POST', `/api/sites/${s.id}/deploy`, {}); await deps.jobs.drain();
const dm = (await call(app, 'POST', '/api/domains', { name: 'bad-cert.test.example', purpose: 'web', siteId: s.id, startCheck: false })).json.domain;
eq((await call(app, 'POST', `/api/domains/${dm.id}/primary`, {})).json.error, 'domain_not_ready');
deps.hal.script.dns.txt[`_ariweb-verify.bad-cert.test.example`] = `ariweb-verify=${dm.ownershipToken}`; deps.hal.script.dns.a = ['203.0.113.10']; deps.hal.script.results['domain.cert'] = { issued: [], failed: [{ domain: 'bad-cert.test.example', reason: 'DNS-01 challenge failed' }] };
const ck = await call(app, 'POST', `/api/domains/${dm.id}/check`, {}); await deps.jobs.drain(); const j = deps.jobs.get(ck.json.job.id); eq(j.status, 'failed'); eq(D.get(dm.id).cert_state, 'failed'); ok(/challenge/.test(D.get(dm.id).cert_error));
eq((await call(app, 'POST', `/api/domains/${dm.id}/primary`, {})).json.error, 'domain_not_ready');
delete deps.hal.script.results['domain.cert']; await call(app, 'POST', `/api/domains/${dm.id}/jobs/${j.id}/retry`, {}); await deps.jobs.drain(); eq(deps.jobs.get(j.id).status, 'done', deps.jobs.get(j.id).error); eq(D.get(dm.id).cert_state, 'valid');
// 외부 확인에서 TLS 무효 → failed
deps.hal.script.results['site.check'] = { checks: [{ url: 'https://bad-cert.test.example/', status: 200, ok: true, ms: 10, tls: { valid: false } }] }; const ck2 = await call(app, 'POST', `/api/domains/${dm.id}/check`, {}); await deps.jobs.drain(); eq(deps.jobs.get(ck2.json.job.id).status, 'failed'); eq(D.get(dm.id).cert_state, 'failed'); delete deps.hal.script.results['site.check'];
const ck3 = await call(app, 'POST', `/api/domains/${dm.id}/check`, { makePrimary: true }); await deps.jobs.drain(); eq(deps.jobs.get(ck3.json.job.id).status, 'done'); eq(S.get(s.id).primary_domain, 'bad-cert.test.example'); eq(S.get(s.id).lifecycle, 'live');
const lst = (await call(app, 'GET', `/api/domains?site=${s.id}`)).json.domains; eq(lst[0].isPrimary, true); eq(lst[0].certState, 'valid');
// 만료 임박 → expiring_soon + 갱신 작업
deps.store.run('UPDATE domains SET cert_expires_at=? WHERE id=?', new Date(Date.now() + 5 * 86400000).toISOString(), dm.id); eq(D.sweep().renew, 1); eq(D.get(dm.id).cert_state, 'expiring_soon');
// 해제 → 사이트는 도메인 설정 필요
const rel = await call(app, 'DELETE', `/api/domains/${dm.id}`); eq(rel.status, 409, '확인 작업 진행 중'); await deps.jobs.drain(); eq((await call(app, 'DELETE', `/api/domains/${dm.id}`)).json.wasPrimary, true); eq(S.get(s.id).lifecycle, 'needs_domain'); eq(S.get(s.id).primary_domain, null);
eq((await call(app, 'POST', '/api/domains', { name: 'not a domain' })).json.detail.field, 'name');
});
await test('HAL 장애: 사전 확인은 "정보 확인 지연" 으로 신규 배포를 막고 기존 상태는 유지 · 작업은 재시도 대기', async () => {
const { deps, app, S } = await setup();
const s = (await call(app, 'POST', '/api/sites', { name: 'H', projectMode: 'new', deployMode: 'managed' })).json.site; deps.webmaker._addVersion(s.webmaker_project_id);
deps.hal.script.unreachable = true; const pc = (await call(app, 'GET', `/api/sites/${s.id}/precheck`)).json.precheck; ok(!pc.ok); ok(/정보 확인 지연/.test(pc.items.find((i) => i.id === 'target').text));
deps.hal.script.unreachable = false; const d = await call(app, 'POST', `/api/sites/${s.id}/deploy`, {}); deps.hal.script.unreachable = true; await deps.jobs.drain(); const j = deps.jobs.get(d.json.job.id); eq(j.status, 'queued'); ok(j.attempt >= 1); ok(Date.parse(j.next_run_at) > Date.now());
deps.hal.script.unreachable = false; deps.store.run('UPDATE jobs SET next_run_at=? WHERE id=?', new Date(0).toISOString(), j.id); await deps.jobs.drain(); eq(deps.jobs.get(j.id).status, 'done');
const mi = (await call(app, 'GET', `/api/sites/${s.id}/mode`)).json; eq(mi.managed.halStale, false);
});
done();
+39
View File
@@ -0,0 +1,39 @@
/** test/webmaker-adapter.test.mjs — 실제 어댑터(portal/webmaker/adapter.mjs)와 Webmaker 의 서명·티켓 규약(modules/webmaker/lib/ariweb.js) 호환 검증. 가짜 Webmaker HTTP 서버가 lib/ariweb.verifyService 로 서명을 검사한다. */
import { test, eq, ok, rejects, tmpDir, done } from '../lib/test.mjs';
import { makeDeps, seedGroup } from './helpers.mjs';
import { create as createAdapter, loginFor } from '../portal/webmaker/adapter.mjs';
import { createRequire } from 'node:module';
import http from 'node:http';
import { join } from 'node:path';
import { readFileSync } from 'node:fs';
const require = createRequire(import.meta.url);
process.env.DATA_ROOT = tmpDir(); // modules/webmaker/lib/ariweb 가 쓰는 데이터 경로(연결 등록부)
const ariweb = require('../modules/webmaker/lib/ariweb.js');
const deps = await makeDeps(); const who = seedGroup(deps);
const wm = createAdapter(deps); const SECRET = wm.secret();
// 가짜 Webmaker 내부 API
const server = http.createServer((req, res) => { let d = ''; req.on('data', (c) => { d += c; }); req.on('end', () => { const v = ariweb.verifyService(req, req.url, d, { key: SECRET }); if (!v.ok) { res.writeHead(401, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: 'service_auth_failed', reason: v.reason })); }
if (req.url.startsWith('/api/internal/ariweb/projects/p1/export')) { res.writeHead(200, { 'Content-Type': 'application/gzip', 'X-Ariweb-Version': '2', 'X-Ariweb-Commit': 'abc' }); return res.end(Buffer.from([0x1f, 0x8b, 0, 0])); }
if (req.url === '/api/internal/ariweb/projects/p9/export') { res.writeHead(404, { 'Content-Type': 'application/json' }); return res.end('{"error":"nope"}'); }
res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, echo: { method: req.method, url: req.url, body: d ? JSON.parse(d) : null }, projects: [{ id: 'p1', title: 't' }], project: { id: 'p1', title: 't', versionList: [] }, link: { siteId: 's' }, source: { provider: 'gitea' }, credit: { balance: 1 } })); }); });
await new Promise((r) => server.listen(0, '127.0.0.1', r)); deps.config.webmakerInternalUrl = `http://127.0.0.1:${server.address().port}`; deps.config.webmakerUrl = 'https://webmaker.example.test';
await test('로그인 ID 규칙과 SSO 티켓: 포털 서명이 Webmaker 검증을 통과하고 60초 뒤 만료·1회용', () => {
const login = loginFor(who.user, who.group); ok(/^aw-[a-z0-9]{1,8}-[a-z0-9]{1,8}$/.test(login), login);
const url = wm.ssoUrl({ user: who.user, group: who.group, site: { id: 's_1', name: '사이트', webmaker_project_id: 'p1' } }); ok(url.startsWith('https://webmaker.example.test/api/auth/sso?ticket='));
const ticket = decodeURIComponent(url.split('ticket=')[1]); const t = ariweb.verifyTicket(ticket, { key: SECRET }); eq(t.login, login); eq(t.groupName, who.group.name); eq(t.siteName, '사이트'); eq(t.projectId, 'p1'); ok(t.exp - Date.now() <= 60000);
ok(/이미 사용/.test(ariweb.verifyTicket(ticket, { key: SECRET }).error), '1회용'); ok(/만료/.test(ariweb.verifyTicket(ticket, { key: SECRET, now: Date.now() + 61000 }).error) || /이미 사용/.test(ariweb.verifyTicket(ticket, { key: SECRET, now: Date.now() + 61000 }).error));
ok(/서명/.test(ariweb.verifyTicket(ticket, { key: 'other' }).error), '다른 비밀은 거부');
eq(deps.store.get('SELECT COUNT(*) AS n FROM sso_tickets').n, 1); ok(!JSON.stringify(deps.store.all('SELECT * FROM sso_tickets')).includes(ticket.split('.')[1]), '서명 원문 저장 안 함');
const info = wm.provisioningInfo(); eq(info.configured, true); ok(!JSON.stringify(info).includes(SECRET));
});
await test('내부 API 호출: 서명 헤더로 통과, 경로·본문이 그대로 전달, 404/연결 실패 코드', async () => {
const p = await wm.listProjects('aw-x-y'); eq(p[0].id, 'p1');
const c = await wm.createProject({ login: 'aw-x-y', ownerName: 'N', title: 'T', mode: 'ai', link: { siteId: 's' } }); eq(c.id, 'p1');
const l = await wm.linkProject('p1', { siteId: 's', groupId: 'g' }); eq(l.siteId, 's'); eq((await wm.projectSource('p1')).provider, 'gitea'); eq((await wm.credits('aw-x-y')).credit.balance, 1); ok((await wm.health()).ok);
const file = join(tmpDir(), 'b.tar.gz'); const ex = await wm.exportVersion('p1', 2, file); eq(ex.version, '2'); eq(ex.commit, 'abc'); eq(readFileSync(file).length, 4);
eq((await rejects(() => wm.exportVersion('p9', null, file))).code, 'version_not_found');
deps.config.webmakerInternalUrl = 'http://127.0.0.1:1'; eq((await rejects(() => wm.listProjects('x'))).code, 'service_unreachable'); eq((await rejects(() => wm.exportVersion('p1', null, file))).code, 'service_unreachable');
});
server.close(); done();