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