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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:56:38 +00:00
co-authored by Claude Fable 5.1
parent a1cde81332
commit b1c06a0304
32 changed files with 1900 additions and 3 deletions
+121
View File
@@ -0,0 +1,121 @@
'use strict';
// ============================================================================
// [Ariweb Suite 변경] Ariweb Suite 연동 모듈 (Ariweb Suite 0.1.1, 개발 지시 §0.2·§7·§9)
//
// 왜 — Webmaker 는 Ariweb Suite 의 "제작 모듈" 로 도입된다. 고객은 포털(my.ariweb.net)에서 로그인한 상태 그대로 제작 화면으로
// 들어와야 하고(별도 로그인 없음), Webmaker 의 프로젝트(data/sites/<id>.json)와 포털의 운영 사이트는 서로 다른 것으로 구분되어야 한다.
// 이 모듈은 그 연결에 필요한 최소 기능만 담는다. 기존 Webmaker 동작(제작·AI·도메인·배포)은 바꾸지 않는다.
//
// 기능
// 1) SSO 티켓 검증(verifyTicket): 포털이 공유 비밀(보관소 'ariweb')로 서명한 티켓. 60초 유효, 한 번만 사용(nonce 기억).
// 티켓 내용 { v:1, login, name, groupId, groupName, siteId, siteName, projectId, path, exp, nonce } — 비밀번호 없음.
// 2) 서비스 호출 검증(verifyService): 포털 → Webmaker 내부 API(/api/internal/ariweb/*). lib/serviceauth(포털) 와 같은 서명 규약
// X-Service-Key-Id('portal') · X-Service-Ts · X-Service-Sign = HMAC(secret, `${METHOD}|${path}|${ts}|${sha256(body)}`), ±5분.
// 3) 연결 등록부(data/ariweb-links.json): 프로젝트 id → { siteId, groupId, portalUrl, siteName, linkedAt }.
// 연결된 프로젝트는 "운영 사이트가 포털에 있다" 는 뜻이라, Webmaker 의 발행 대신 포털 배포 화면으로 안내하고(§0.2 "운영 Version 을 바로 덮어쓰지 않음"),
// 폐기 전에는 포털에 영향(운영 사이트·배포·도메인)을 물어 표시한다(§9.1 "자동으로 함께 삭제하지 않는다").
// 4) 화면 컨텍스트 쿠키(wm_ariweb): SSO 로 들어온 세션에 그룹·사이트 이름을 붙여 상단 배너에 보인다. 서명된 값이라 위조할 수 없다.
// 5) 포털 영향 조회(impactFromPortal): POST <portalUrl>/internal/webmaker/project-impact (같은 서명 규약, 키 id 'webmaker').
//
// 비밀값은 보관소(lib/vault.js, 종류 'ariweb')에만 있고 설정 파일·로그·응답에 쓰지 않는다.
// ============================================================================
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const js = require('./jsonstore');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const LINKS_FILE = path.join(DATA_ROOT, 'ariweb-links.json');
const CTX_COOKIE = 'wm_ariweb';
const TICKET_TTL_MS = 60 * 1000;
const SKEW_MS = 5 * 60 * 1000;
const usedNonces = new Map(); // nonce → 만료 시각 (티켓 재사용 차단; 티켓이 60초만 살므로 메모리로 충분)
/** 공유 비밀(보관소 'ariweb'). 없으면 '' — 연동이 설정되지 않은 서버에서는 모든 검증이 실패한다(안전한 기본값). */
function secret() { try { return require('./vault').secretOf('ariweb') || ''; } catch (_) { return ''; } }
/** 포털 공개 주소(설정 ariweb.portalUrl) */
function portalUrl() { try { return String(require('./config').get('ariweb.portalUrl') || '').replace(/\/$/, ''); } catch (_) { return ''; } }
function hmac(key, data) { return crypto.createHmac('sha256', key).update(data).digest('base64url'); }
function safeEqual(a, b) { const x = Buffer.from(String(a || '')); const y = Buffer.from(String(b || '')); return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y); }
const sha256 = (s) => crypto.createHash('sha256').update(String(s || '')).digest('hex');
/** 사용한 nonce 정리 */
function sweep() { const now = Date.now(); for (const [k, v] of usedNonces) if (v < now) usedNonces.delete(k); }
/**
* SSO 티켓 검증. 성공하면 티켓 내용을, 실패하면 { error } 를 돌려준다(이유는 사용자에게 보여도 되는 문장).
* 한 번 쓴 티켓(nonce)은 만료 전에 다시 쓸 수 없다(링크 복사·뒤로 가기 재사용 차단).
*/
function verifyTicket(ticket, { now = Date.now(), key = secret() } = {}) {
if (!key) return { error: 'Ariweb Suite 연동이 설정되지 않았습니다(공유 비밀 없음)' };
const parts = String(ticket || '').split('.'); if (parts.length !== 2) return { error: '티켓 형식이 올바르지 않습니다' };
const [payload, sig] = parts;
if (!safeEqual(hmac(key, payload), sig)) return { error: '티켓 서명이 맞지 않습니다' };
let d; try { d = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); } catch (_) { return { error: '티켓 내용을 읽을 수 없습니다' }; }
if (!d || d.v !== 1 || typeof d.login !== 'string' || !Number.isFinite(d.exp)) return { error: '티켓 내용이 올바르지 않습니다' };
if (d.exp < now) return { error: '티켓이 만료되었습니다. 포털에서 다시 열어 주세요' };
if (d.exp - now > TICKET_TTL_MS + SKEW_MS) return { error: '티켓 유효 시간이 비정상입니다' };
sweep(); if (!d.nonce || usedNonces.has(d.nonce)) return { error: '이미 사용한 티켓입니다. 포털에서 다시 열어 주세요' };
usedNonces.set(d.nonce, d.exp);
return d;
}
/** (테스트·포털용) 티켓 만들기 — 포털이 하는 일과 같은 형식 */
function makeTicket(data, { key = secret(), now = Date.now() } = {}) {
const payload = Buffer.from(JSON.stringify({ v: 1, ...data, exp: data.exp || now + TICKET_TTL_MS, nonce: data.nonce || crypto.randomBytes(12).toString('base64url') })).toString('base64url');
return `${payload}.${hmac(key, payload)}`;
}
/**
* 서비스 호출 검증(포털 → Webmaker). rawBody 는 본문 원문 문자열.
* @returns {{ok:boolean, reason?:string}}
*/
function verifyService(req, pathWithQuery, rawBody, { key = secret(), now = Date.now() } = {}) {
const keyId = String(req.headers['x-service-key-id'] || ''); const ts = Number(req.headers['x-service-ts'] || 0); const sign = String(req.headers['x-service-sign'] || '');
if (!key) return { ok: false, reason: 'not_configured' };
if (keyId !== 'portal' || !ts || !sign) return { ok: false, reason: 'missing_headers' };
if (Math.abs(now - ts) > SKEW_MS) return { ok: false, reason: 'clock_skew' };
return safeEqual(hmac(key, `${req.method.toUpperCase()}|${pathWithQuery}|${ts}|${sha256(rawBody)}`), sign) ? { ok: true } : { ok: false, reason: 'bad_signature' };
}
/** 포털로 보내는 서비스 호출 헤더(키 id 'webmaker') */
function signHeaders(method, p, body, { key = secret(), now = Date.now() } = {}) { const ts = String(now); return { 'X-Service-Key-Id': 'webmaker', 'X-Service-Ts': ts, 'X-Service-Sign': hmac(key, `${method.toUpperCase()}|${p}|${ts}|${sha256(body == null ? '' : typeof body === 'string' ? body : JSON.stringify(body))}`), 'Content-Type': 'application/json' }; }
// ---------- 연결 등록부 ----------
function loadLinks() { const d = js.readJson(LINKS_FILE, null); return d && typeof d === 'object' && d.links ? d : { version: 1, links: {} }; }
function saveLinks(d) { js.writeJsonAtomic(LINKS_FILE, d, { mode: 0o600, keepBackup: true }); }
/** 프로젝트의 포털 연결 정보(없으면 null) */
function linkOf(projectId) { return loadLinks().links[projectId] || null; }
/** 연결 등록(포털이 사이트를 만들거나 기존 프로젝트를 연결할 때) */
function link(projectId, { siteId, groupId, portalUrl: pu, siteName, groupName }) {
const d = loadLinks(); d.links[projectId] = { siteId: String(siteId), groupId: String(groupId), portalUrl: String(pu || portalUrl()), siteName: String(siteName || ''), groupName: String(groupName || ''), linkedAt: new Date().toISOString() }; saveLinks(d); return d.links[projectId];
}
function unlink(projectId) { const d = loadLinks(); const had = !!d.links[projectId]; delete d.links[projectId]; saveLinks(d); return had; }
/** 연결된 프로젝트 전체(포털 사이트 id → 프로젝트 id 역참조용) */
function allLinks() { return loadLinks().links; }
// ---------- 화면 컨텍스트 쿠키 ----------
/** SSO 로 들어온 세션의 그룹·사이트 표시 정보를 서명 쿠키로 둔다(값: base64url(JSON).sig). 세션과 같은 수명(30일)으로 두되 비밀 없음. */
function ctxCookie(ctx, { key = secret(), secure = false } = {}) {
const payload = Buffer.from(JSON.stringify({ v: 1, groupId: ctx.groupId || '', groupName: ctx.groupName || '', siteId: ctx.siteId || '', siteName: ctx.siteName || '', projectId: ctx.projectId || '', portalUrl: ctx.portalUrl || portalUrl() })).toString('base64url');
return `${CTX_COOKIE}=${payload}.${hmac(key, payload)}; Path=/; HttpOnly; SameSite=Lax${secure ? '; Secure' : ''}; Max-Age=${30 * 86400}`;
}
function clearCtxCookie() { return `${CTX_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`; }
/** 요청의 컨텍스트 쿠키 읽기(서명 확인). 없거나 위조면 null */
function readCtx(req, { key = secret() } = {}) {
const raw = (req.headers.cookie || '').split(';').map((s) => s.trim()).find((s) => s.startsWith(CTX_COOKIE + '='));
if (!raw || !key) return null; const v = raw.slice(CTX_COOKIE.length + 1); const [payload, sig] = v.split('.'); if (!payload || !sig || !safeEqual(hmac(key, payload), sig)) return null;
try { const d = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); return d && d.v === 1 ? d : null; } catch (_) { return null; }
}
/**
* 포털에 프로젝트 삭제 영향을 묻는다. 포털에 닿지 못하면 { reachable:false } — 호출자는 "영향을 확인할 수 없어 폐기를 보류" 한다(모르면 지우지 않는다).
* @returns {Promise<{reachable:boolean, impact?:{liveSite:boolean, lifecycle:string, deployments:number, domains:string[], message:string}}>}
*/
async function impactFromPortal(projectId, { fetchImpl = fetch } = {}) {
const l = linkOf(projectId); if (!l) return { reachable: true, impact: null };
const base = l.portalUrl || portalUrl(); if (!base || !secret()) return { reachable: false, reason: 'not_configured' };
const p = '/internal/webmaker/project-impact'; const body = JSON.stringify({ projectId, siteId: l.siteId, groupId: l.groupId });
try {
const r = await fetchImpl(base + p, { method: 'POST', headers: signHeaders('POST', p, body), body, signal: AbortSignal.timeout(8000) });
const j = await r.json().catch(() => null); if (!r.ok || !j) return { reachable: false, reason: `portal_${r.status}` };
return { reachable: true, impact: j.impact || null };
} catch (e) { return { reachable: false, reason: e.name === 'TimeoutError' ? 'timeout' : 'unreachable' }; }
}
module.exports = { CTX_COOKIE, TICKET_TTL_MS, secret, portalUrl, verifyTicket, makeTicket, verifyService, signHeaders, linkOf, link, unlink, allLinks, ctxCookie, clearCtxCookie, readCtx, impactFromPortal, _usedNonces: usedNonces };
+13 -1
View File
@@ -211,4 +211,16 @@ function updateProfile(id, patch) { const users = loadUsers(); const u = users[i
function setUiLang(id, lang) { const users = loadUsers(); const u = users[id]; if (!u) throw httpError('사용자가 없습니다', 404); u.uiLang = String(lang || '').slice(0, 20); u.uiLangAt = new Date().toISOString(); saveUsers(users); return { uiLang: u.uiLang, uiLangAt: u.uiLangAt }; }
/** 사용자의 화면 언어 선택 (없으면 null) */
function uiLangOf(id) { const u = id ? loadUsers()[id] : null; return u && u.uiLang ? { lang: u.uiLang, at: u.uiLangAt || '' } : null; }
module.exports = { passwordFingerprint, csrfToken, csrfOk, approve, reject, pendingCount, setAccountType, setUiLang, uiLangOf, parseCookies, updateProfile, signup, login, getUser, createUser, setRole, listUsers, changePassword, resetPassword, makeSessionCookie, clearSessionCookie, userFromRequest, bootstrapAdmin, ensureBootstrapAdmin, adminState, BOOTSTRAP_ID, BOOTSTRAP_PASSWORD }; // v1.5.11 기본 관리자·관리자 상태
/**
* [Ariweb Suite 변경] SSO 계정 보장(Ariweb Suite 0.1.1, §0.2 "별도 로그인 없이"). 포털의 서명 티켓이 검증된 뒤 호출된다.
* 아이디가 없으면 무작위 비밀번호(아무도 모름 — 포털 SSO 로만 들어온다)로 만들고, 있으면 이름만 맞춘다. 역할은 user, 계정 종류는 domain(포털이 도메인을 관리).
* 비밀번호 규칙·가입 승인·가입 허용 설정을 거치지 않는다: 이 계정은 사람이 가입한 것이 아니라 포털 사용자와 1:1 로 대응하는 내부 계정이기 때문이다.
*/
function ensureSsoUser(id, name) {
id = String(id || '').trim().toLowerCase(); if (!ID_RE.test(id)) throw httpError('SSO 아이디 형식이 올바르지 않습니다');
const users = loadUsers();
if (!users[id]) { const salt = crypto.randomBytes(16).toString('hex'); users[id] = { id, name: String(name || id).trim().slice(0, 80) || id, salt, passHash: hashPassword(crypto.randomBytes(24).toString('base64url'), salt), role: 'user', accountType: 'domain', createdAt: new Date().toISOString(), sso: 'ariweb' }; }
else if (name && users[id].name !== name) users[id].name = String(name).trim().slice(0, 80);
users[id].lastLoginAt = new Date().toISOString(); saveUsers(users); return publicUser(users[id]);
}
module.exports = { ensureSsoUser, passwordFingerprint, csrfToken, csrfOk, approve, reject, pendingCount, setAccountType, setUiLang, uiLangOf, parseCookies, updateProfile, signup, login, getUser, createUser, setRole, listUsers, changePassword, resetPassword, makeSessionCookie, clearSessionCookie, userFromRequest, bootstrapAdmin, ensureBootstrapAdmin, adminState, BOOTSTRAP_ID, BOOTSTRAP_PASSWORD }; // v1.5.11 기본 관리자·관리자 상태
+5
View File
@@ -96,6 +96,11 @@ const SCHEMA = [
{ key: 'source.repo', env: 'GITEA_REPO', type: 'text', label: '저장소', desc: '소유자/이름 (기본 webmaker/sites)', default: 'webmaker/sites' },
{ key: 'source.token', type: 'secret', vaultKind: 'gitea', label: 'Gitea API 토큰', desc: '위 계정의 접근 토큰(repo·user 권한). install-gitea.sh 가 만들어 넣는다. 없으면 API 기능(저장소 만들기·키 등록)만 꺼지고 푸시는 SSH 키로 그대로 된다' },
] },
// [Ariweb Suite 변경] Ariweb Suite 포털 연동: 포털 주소(비밀 아님)와 공유 비밀(보관소 'ariweb'). lib/ariweb.js 참고.
{ group: 'ariweb', label: 'Ariweb Suite 연동', desc: 'Ariweb Suite 고객 포털과의 연동(별도 로그인 없는 제작 화면 진입, 프로젝트 ↔ 운영 사이트 연결). 포털의 보관소 sso:webmaker 와 같은 공유 비밀을 넣습니다.', keys: [
{ key: 'ariweb.portalUrl', env: 'ARIWEB_PORTAL_URL', type: 'url', label: '포털 공개 주소', desc: '예: https://my.ariweb.net — 발행 안내·폐기 영향 조회에 쓴다', default: '' },
{ key: 'ariweb.secret', type: 'secret', vaultKind: 'ariweb', label: '공유 비밀', desc: 'SSO 티켓·서비스 호출 서명에 쓰는 공유 비밀(암호화 보관). 포털이 발급한 값을 그대로 넣는다' },
] },
{ group: 'mail', label: '메일 연동 (Arimail)', desc: 'Arimail(구 mail-gateway)은 받는 메일·웹메일·IMAP/POP/SMTP 를 맡는 메일 서버입니다. 연동 모듈 lib/connectors/arimail.js 가 관리자 API 로 사이트 관리자 계정의 메일함을 만들고 지운다. 보내기(Resend 키)는 관리자 콘솔 › 설정 › 메일 발송 그대로.', keys: [
{ key: 'mail.provider', env: 'MAIL_PROVIDER', type: 'enum', options: [['none', '연동 안 함 (보내기만)'], ['arimail', 'Arimail']], label: '연동 모듈', desc: '다른 메일 서버를 쓰려면 lib/connectors/ 에 모듈을 추가하고 여기서 고른다', default: 'none' },
{ key: 'mail.url', env: 'ARIMAIL_URL', type: 'url', label: 'Arimail 주소 (공개)', desc: '사용자에게 안내하는 웹메일 주소(예: https://mail.example.com). deploy/install-arimail.sh 가 설치하면 자동으로 채운다', default: '' },
+2
View File
@@ -29,8 +29,10 @@ function recomputeTotals(site) {
// v1.6.31 (요구 10): 원자적 쓰기(임시 파일 + fsync + rename) + 직전 정상본 .bak 보존. 읽기 실패는 손상으로 기록해 관리자 운영 복구 화면에 보인다(빈 값으로 숨기지 않음)
const js = require('./jsonstore');
// [Ariweb Suite 변경] rev: 저장할 때마다 1씩 오르는 개정 번호. 여러 사용자가 같은 프로젝트를 편집할 때 화면이 알고 있던 rev 와 다르면 조용히 덮어쓰지 않고 409 로 알린다(server.js PATCH·PUT /files, Ariweb Suite §9.3).
function save(site) {
recomputeTotals(site);
site.rev = (Number(site.rev) || 0) + 1;
site.updatedAt = new Date().toISOString();
js.writeJsonAtomic(fileOf(site.id), JSON.stringify(site, null, 2), { mode: 0o600, keepBackup: true });
return site;
+2
View File
@@ -44,6 +44,8 @@ const KINDS = {
porkbun: { label: '도메인 등록·DNS (Porkbun)', fixedId: 'porkbun', defaultUrl: 'https://api.porkbun.com/api/json/v3', keyNames: ['PORKBUN_SECRET_KEY', 'SECRET_API_KEY', 'SECRET'], urlNames: ['PORKBUN_URL'], settingNames: ['API_KEY', 'PORKBUN_API_KEY'], secretLabel: 'Secret API Key (설정 칸에 API_KEY=공개 API 키)' },
route53: { label: 'DNS (Amazon Route 53)', fixedId: 'route53', defaultUrl: 'https://route53.amazonaws.com', keyNames: ['AWS_SECRET_ACCESS_KEY', 'SECRET_ACCESS_KEY', 'SECRET'], urlNames: [], settingNames: ['ACCESS_KEY_ID', 'AWS_ACCESS_KEY_ID', 'SESSION_TOKEN', 'REGION'], secretLabel: 'Secret Access Key (설정 칸에 ACCESS_KEY_ID=액세스 키 ID, 선택 REGION=us-east-1)' },
'gcloud-dns': { label: 'DNS (Google Cloud DNS, 서비스 계정 키 JSON)', fixedId: 'gcloud-dns', defaultUrl: 'https://dns.googleapis.com', keyNames: ['private_key'], urlNames: [], settingNames: ['PROJECT', 'PROJECT_ID'], secretLabel: '서비스 계정 키 JSON 전체 (roles/dns.admin; 설정 PROJECT 는 키의 project_id 와 다를 때만)' },
// [Ariweb Suite 변경] 포털 ↔ Webmaker 공유 비밀(SSO 티켓·서비스 호출 서명). lib/ariweb.js 가 읽는다. 설정 파일 ariweb.secret(secret) 이 이 항목에 저장된다.
ariweb: { label: 'Ariweb Suite 연동 공유 비밀', fixedId: 'ariweb', defaultUrl: '', keyNames: ['ARIWEB_SHARED_SECRET', 'SHARED_SECRET', 'SECRET'], urlNames: ['ARIWEB_PORTAL_URL'], settingNames: [], secretLabel: '공유 비밀 (포털 보관소 sso:webmaker 와 같은 값)' },
http: { label: '기타 외부 서비스 (HTTP API)', fixedId: null, defaultUrl: '', keyNames: ['API_KEY', 'TOKEN', 'API_TOKEN', 'KEY'], urlNames: ['URL', 'BASE_URL', 'API_URL'], settingNames: [], secretLabel: 'API 키 또는 토큰' },
};
+4
View File
@@ -108,6 +108,8 @@ let activeTab = 'home';
* 탭을 바꾸면 그 탭의 사이트를 다시 열어(`openSite`) 화면과 진행 상태 스트림을 그 사이트로 맞춘다.
*/
let createId = null, manageId = null; try { createId = localStorage.getItem('createSiteId') || null; manageId = localStorage.getItem('currentSiteId') || null; } catch (_) {}
// [Ariweb Suite 변경] 포털 SSO 진입(/api/auth/sso → /?site=<프로젝트 id>): 지정한 프로젝트를 바로 연다
try { const sp = new URLSearchParams(location.search).get('site'); if (sp && /^[a-f0-9]{32}$/.test(sp)) { manageId = sp; localStorage.setItem('currentSiteId', sp); history.replaceState(null, '', location.pathname); } } catch (_) {}
// 가져온(import)·복사한(copy) 사이트는 기획 단계가 없으므로 "생성 중" 으로 보지 않는다 (복사 중에는 관리 탭의 진행 상태 상자로 표시)
const inProgress = (site) => !!(site && site.plan && !['generated', 'published'].includes(site.plan.stage) && !['import', 'copy', 'template'].includes(site.mode));
let switching = false;
@@ -1155,6 +1157,8 @@ $('btnMobile').onclick = () => { const on = !$('previewArea').classList.contains
if (!current) renderEmptyReason(null, false); // v1.4.5 설정을 받은 뒤 첫 화면 페이지 표시
fillTiers(); fillLangOpts(); if (config.credit) renderCredit(config.credit); $('verBadge').textContent = config.version ? `v${config.version}` : ''; document.title = (config.product || 'AI Webmaker') + (config.version ? ` v${config.version}` : ''); fillEditTier(null);
loadMyFeedback();
// [Ariweb Suite 변경] 포털 컨텍스트 배너: 그룹·운영 사이트 이름과 "이 화면의 사이트 = Webmaker 프로젝트" 구분 안내, 포털로 돌아가는 링크
try { const a = config.ariweb; const bn = $('ariwebBanner'); if (a && bn) { const pu = a.portalUrl || ''; bn.innerHTML = `<b>Ariweb Suite</b><span>그룹: ${esc(a.groupName || a.groupId || '-')}</span>${a.siteName ? `<span>사이트: ${esc(a.siteName)}</span>` : ''}<span class="muted" style="color:#dbe6fb">이 화면의 "사이트" 는 Webmaker 프로젝트입니다. 운영 사이트·배포·도메인·메일은 포털에서 관리합니다.</span>${pu ? `<a href="${esc(pu)}${a.siteId ? '/sites/' + encodeURIComponent(a.siteId) : ''}">포털로 돌아가기</a>` : ''}`; bn.classList.remove('hidden'); } } catch (_) {}
if (config.user) { $('userName').textContent = config.user.name; $('userId').textContent = `(${config.user.id})`; if (config.user.role === 'admin') $('adminLink').classList.remove('hidden'); $('pfId').value = config.user.id; $('pfName').value = config.user.name || ''; }
loadLedger();
$('btnLogout').onclick = async () => { await fetch('/api/auth/logout', { method: 'POST' }); localStorage.removeItem('currentSiteId'); location.href = '/login'; };
+2
View File
@@ -8,6 +8,8 @@
<link rel="stylesheet" href="/theme.css">
</head>
<body>
<!-- [Ariweb Suite 변경] 포털 SSO 로 들어온 세션의 배너: 어느 그룹·어느 운영 사이트의 "프로젝트" 를 편집 중인지 항상 보인다(§9.1). public/app.js 가 /api/config 의 ariweb 값으로 채운다 -->
<div id="ariwebBanner" class="ariweb-banner hidden" role="status"></div>
<!-- =====================================================================================================
v1.6.40 (요구 1) 사용자 화면 전면 재구성 — "작업 흐름" 중심의 세 칸 구조
① 왼쪽 레일(.rail) : 제품명 · 작업 중인 사이트 선택 · 작업 흐름 내비게이션(홈 / 사이트: 만들기·수정·배포·운영 / 주소·메일: 도메인·DNS·메일 / 계정: 캐시·충전·설정)
+5
View File
@@ -755,3 +755,8 @@ details.dom-rec-details > summary { cursor: pointer; font-size: 13px; font-weigh
/* ===== v1.7.3: 메일 사용량 (메일 화면 한눈에 보기 · 사이트 정보) ===== */
.mg-usage .git-list { margin: 6px 0 4px; } .mg-usage .git-list .st { white-space: normal; text-align: right; }
.mg-usage > div + div { margin-top: 3px; }
/* [Ariweb Suite 변경] 포털 연동 배너 */
.ariweb-banner { position: sticky; top: 0; z-index: 60; background: #1b5fd1; color: #fff; font-size: 13px; padding: 6px 14px; display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }
.ariweb-banner a { color: #fff; text-decoration: underline; }
.ariweb-banner.hidden { display: none; }
+101 -2
View File
@@ -65,6 +65,7 @@ const i18n = require('./lib/i18n');
const feedback = require('./lib/feedback');
const costcal = require('./lib/costcal');
const crawler = require('./lib/crawler');
const ariweb = require('./lib/ariweb'); // [Ariweb Suite 변경] 포털 연동(SSO 티켓·내부 API·프로젝트↔사이트 연결)
process.umask(0o022); // 생성 파일을 nginx(www-data)가 읽을 수 있도록
@@ -1513,6 +1514,71 @@ function sessionUser(req) {
function isSecureReq(req) { const xf = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim(); if (xf) return xf === 'https'; return /^https:/i.test(PUBLIC_BASE_URL); }
/** v1.6.31 (요구 3·25): 서버 전체 동시 AI 작업 수 제한 — 넘으면 429 */
function assertJobCapacity() { const max = Number(config.get('ai.maxConcurrentJobs') || 4); const running = [...jobs.values()].filter((j) => j.running).length; if (running >= max) throw Object.assign(new Error(`지금 서버에서 실행 중인 AI 작업이 많습니다(${running}/${max}). 잠시 후 다시 시도하세요`), { status: 429 }); }
/**
* [Ariweb Suite 변경] 동시 편집 충돌 검사(§9.3). 화면이 보낸 rev(본문 rev 또는 If-Match 헤더)가 저장된 site.rev 와 다르면
* "마지막 저장으로 조용히 덮어쓰지 않고" 409 와 함께 두 버전의 요약(현재 rev·갱신 시각·마지막 변경)을 돌려준다. rev 를 보내지 않은 옛 화면은 예전처럼 동작한다.
*/
function revConflict(site, b, req) {
const given = b && b.rev !== undefined && b.rev !== null && b.rev !== '' ? Number(b.rev) : (req.headers['if-match'] ? Number(String(req.headers['if-match']).replace(/"/g, '')) : null);
if (given === null || !Number.isFinite(given)) return null;
const cur = Number(site.rev) || 0; if (given === cur) return null;
const last = (site.git && site.git.changes || []).slice(-1)[0] || null;
return { error: '다른 사용자(또는 다른 창)가 먼저 저장해 내용이 달라졌습니다. 최신 내용을 불러온 뒤 다시 적용하거나, 내 변경을 그대로 덮어쓰려면 최신 rev 로 다시 저장하세요', conflict: { yourRev: given, currentRev: cur, updatedAt: site.updatedAt || null, lastChange: last ? { no: last.no, kind: last.kind, message: last.message, at: last.at } : null, manualEdits: (site.manualEdits || []).slice(-3) } };
}
/**
* [Ariweb Suite 변경] 포털 내부 API. 서명 검증은 호출자가 끝냈다. 경로 조각 parts = ['projects', id, ...].
* GET projects?owner=<login> 소유자의 프로젝트 목록(제목·상태·rev·갱신 시각·연결)
* POST projects {owner, ownerName, title, purpose, link?} 빈 프로젝트 만들기(캐시 확인 없음 — 포털이 상품으로 관리) + 선택 연결
* GET projects/<id> 프로젝트 요약·저장본(versions)·연결·git 정보
* POST projects/<id>/link {siteId, groupId, siteName, groupName, portalUrl} / DELETE projects/<id>/link
* GET projects/<id>/export?version=N 저장본 N(없으면 현재 작업 트리)의 tar.gz
* GET projects/<id>/source Source 화면용: 저장소·브랜치·변경 이력·저장소 크기
* GET credits/<login> AI Wallet(캐시) 잔액·사용 — 호스팅 사용량과 분리해 보이기 위한 값
* GET health
*/
async function handleAriwebInternal(req, res, parts, url, b) {
const [kind, id, action] = parts;
if (kind === 'health') return send(res, 200, { ok: true, version: VERSION, product: 'AI Webmaker', portalUrl: ariweb.portalUrl() || null });
if (kind === 'credits' && id && req.method === 'GET') return send(res, 200, { ok: true, credit: credits.info(String(id).toLowerCase()), unit: credits.unit() });
if (kind !== 'projects') return send(res, 404, { error: 'not_found' });
const summary = (s1) => { const l = ariweb.linkOf(s1.id); const last = (s1.git && s1.git.changes || []).slice(-1)[0] || null; return { id: s1.id, title: s1.title, ownerId: s1.ownerId, status: s1.status, mode: s1.mode || 'quick', stage: s1.plan ? s1.plan.stage : null, rev: Number(s1.rev) || 0, createdAt: s1.createdAt, updatedAt: s1.updatedAt, lastChange: last ? { no: last.no, kind: last.kind, at: last.at, message: last.message } : null, versions: (s1.git && s1.git.changes || []).length, link: l, hasContent: fs.existsSync(path.join(SITES_ROOT, s1.id, 'index.html')) || (multilang.isMulti(s1) && fs.existsSync(path.join(SITES_ROOT, s1.id, s1.langs.default, 'index.html'))) }; };
if (!id && req.method === 'GET') { const owner = String(url.searchParams.get('owner') || '').toLowerCase(); if (!owner) return send(res, 400, { error: 'owner 가 필요합니다' }); return send(res, 200, { ok: true, projects: store.list(owner).map((s0) => summary(store.load(s0.id))) }); }
if (!id && req.method === 'POST') {
const owner = String(b.owner || '').trim().toLowerCase(); if (!owner) return send(res, 400, { error: 'owner 가 필요합니다' });
const u = auth.getUser(owner) || auth.ensureSsoUser(owner, b.ownerName || owner);
const title = String(b.title || '').trim().slice(0, 120); if (!title) return send(res, 400, { error: 'title 이 필요합니다' });
const pid = crypto.randomBytes(16).toString('hex'); fs.mkdirSync(path.join(SITES_ROOT, pid), { recursive: true, mode: 0o755 });
const site = store.create({ id: pid, ownerId: u.id, ownerName: u.name, title, purpose: String(b.purpose || ''), content: String(b.content || ''), design: String(b.design || ''), extra: String(b.extra || ''), sessionId: crypto.randomUUID() });
site.mode = 'quick'; site.uiLang = 'ko'; site.tier = settings.validTier(b.tier); site.titleCustom = true;
// 포털이 "AI에게 맡기기(ai)·내가 고르기(manual)" 로 시작하려 하면 기획(plan) 모드 프로젝트로 만든다(사용자가 Webmaker 에서 입력을 이어서 채운다)
if (b.mode === 'ai' || b.mode === 'manual') { site.mode = b.mode; site.plan = newPlan(b.mode); site.plan.input = { text: String(b.text || ''), existingUrl: '', references: [], answers: [] }; }
store.save(site);
if (b.link && b.link.siteId) ariweb.link(pid, b.link);
audit.log({ by: 'portal', action: 'ariweb.project.create', targetType: 'site', target: pid, detail: { owner: u.id, siteId: b.link ? b.link.siteId : null } });
return send(res, 200, { ok: true, project: summary(store.load(pid)) });
}
const site = store.load(id); if (!site) return send(res, 404, { error: '프로젝트가 없습니다' });
if (!action && req.method === 'GET') return send(res, 200, { ok: true, project: { ...summary(site), versionList: (site.git && site.git.changes || []).map((c) => ({ no: c.no, kind: c.kind, message: c.message, at: c.at, commit: c.commit, files: (c.files || []).length })), git: gitstore.info(site.id), liveUrl: liveUrlOf(site), deployState: deployState(site), domains: site.domains || [] } });
if (action === 'link' && req.method === 'POST') { if (!b.siteId || !b.groupId) return send(res, 400, { error: 'siteId·groupId 가 필요합니다' }); const l = ariweb.link(site.id, b); audit.log({ by: 'portal', action: 'ariweb.link', targetType: 'site', target: site.id, detail: { siteId: l.siteId, groupId: l.groupId } }); return send(res, 200, { ok: true, link: l }); }
if (action === 'link' && req.method === 'DELETE') { const had = ariweb.unlink(site.id); audit.log({ by: 'portal', action: 'ariweb.unlink', targetType: 'site', target: site.id, detail: {} }); return send(res, 200, { ok: true, removed: had }); }
if (action === 'source' && req.method === 'GET') {
const gitDir = path.join(DATA_ROOT_FOR_GIT, 'git', site.id); let bytes = 0; const walk = (d) => { let st; try { st = fs.statSync(d); } catch (_) { return; } if (st.isDirectory()) { for (const f of fs.readdirSync(d)) walk(path.join(d, f)); } else bytes += st.size; }; walk(gitDir);
const siteDir = path.join(SITES_ROOT, site.id); let siteBytes = 0; let uploadBytes = 0; const walk2 = (d, up) => { let st; try { st = fs.statSync(d); } catch (_) { return; } if (st.isDirectory()) { for (const f of fs.readdirSync(d)) walk2(path.join(d, f), up || f === 'uploads'); } else { siteBytes += st.size; if (up) uploadBytes += st.size; } }; walk2(siteDir, false);
return send(res, 200, { ok: true, source: { ...gitstore.info(site.id), repoBytes: bytes, siteBytes, uploadBytes, lfsBytes: 0, lfsSupported: false, changes: (site.git && site.git.changes || []).map((c) => ({ no: c.no, kind: c.kind, message: c.message, at: c.at, commit: c.commit, pushed: !!c.pushed, branch: c.branch || null, url: c.url || null })), remoteStatus: gitstore.remoteStatus() } });
}
if (action === 'export' && req.method === 'GET') {
const { spawn } = require('child_process'); const siteDir = path.join(SITES_ROOT, site.id);
const vno = url.searchParams.get('version'); const change = vno ? (site.git && site.git.changes || []).find((c) => String(c.no) === String(vno)) : null;
if (vno && !(change && change.commit)) return send(res, 404, { error: '해당 저장본이 없습니다' });
res.writeHead(200, { 'Content-Type': 'application/gzip', 'Cache-Control': 'no-store', 'X-Ariweb-Version': vno || 'current', 'X-Ariweb-Commit': change ? change.commit : '' });
const child = change
? spawn('git', ['archive', '--format=tar.gz', change.commit], { env: { ...process.env, GIT_DIR: path.join(DATA_ROOT_FOR_GIT, 'git', site.id), GIT_WORK_TREE: siteDir }, cwd: siteDir })
: spawn('tar', ['-C', siteDir, '--exclude=./.refs', '-czf', '-', '.']);
child.stdout.pipe(res); child.on('error', () => { try { res.end(); } catch (_) {} }); return;
}
return send(res, 404, { error: 'not_found' });
}
const DATA_ROOT_FOR_GIT = process.env.DATA_ROOT || path.join(__dirname, 'data'); // lib/gitstore.js 의 GIT_ROOT 와 같은 기준
async function handle(req, res) {
const url = new URL(req.url, 'http://localhost');
const p = url.pathname;
@@ -1555,6 +1621,29 @@ async function handle(req, res) {
// 생성 사이트 페이지는 Referer 가 /<사이트ID>/… 이거나(거부) Referer 를 없애면 (3)이 필요한데 토큰은 (2)를 만족하는 요청에만 주므로 얻을 수 없다.
const user = sessionUser(req);
// ---------- [Ariweb Suite 변경] 포털 SSO 진입: GET /api/auth/sso?ticket=… ----------
// 포털이 서명한 60초짜리 1회용 티켓을 검증해 그 사용자의 Webmaker 계정(없으면 생성)으로 세션 쿠키를 주고, 지정한 프로젝트 화면으로 보낸다.
// 비밀번호는 오가지 않는다. 티켓에 든 그룹·사이트 이름은 서명 쿠키(wm_ariweb)에 두어 상단 배너에 보인다.
if (req.method === 'GET' && p === '/api/auth/sso') {
const cip = ratelimit.clientIp(req); if (ratelimit.limited(`sso:${cip}`, 60, 600000)) return send(res, 429, { error: '요청이 너무 많습니다' });
const t = ariweb.verifyTicket(url.searchParams.get('ticket'));
if (t.error) { audit.log({ by: 'sso', action: 'auth.sso.denied', targetType: 'user', target: '-', detail: { reason: t.error } }); return send(res, 403, { error: t.error }); }
let u; try { u = auth.ensureSsoUser(t.login, t.name); } catch (e) { return send(res, 400, { error: e.message }); }
audit.log({ by: u.id, action: 'auth.sso', targetType: 'user', target: u.id, detail: { groupId: t.groupId || null, siteId: t.siteId || null, projectId: t.projectId || null } });
const secure = isSecureReq(req);
const dest = t.path && /^\/[^\s]*$/.test(String(t.path)) ? String(t.path) : (t.projectId ? `/?site=${encodeURIComponent(t.projectId)}` : '/');
res.writeHead(302, { Location: dest, 'Cache-Control': 'no-store', 'Set-Cookie': [auth.makeSessionCookie(u.id, { secure }), ariweb.ctxCookie(t, { secure })] }); return res.end();
}
// ---------- [Ariweb Suite 변경] 포털 → Webmaker 내부 API (/api/internal/ariweb/*) — 서비스 서명(lib/ariweb.verifyService)으로만 ----------
if (p.startsWith('/api/internal/ariweb/')) {
const raw = await new Promise((resolve, reject) => { let d = ''; req.on('data', (c) => { d += c; if (d.length > 6e6) { reject(new Error('본문이 너무 큽니다')); req.destroy(); } }); req.on('end', () => resolve(d)); req.on('error', reject); });
const v = ariweb.verifyService(req, p + url.search, raw);
if (!v.ok) { console.log(`[ariweb] 내부 API 거부 ${req.method} ${p} (${v.reason}) from ${ratelimit.clientIp(req)}`); return send(res, 401, { error: 'service_auth_failed', reason: v.reason }); }
let b = {}; try { b = raw ? JSON.parse(raw) : {}; } catch (_) { return send(res, 400, { error: 'JSON 파싱 실패' }); }
try { return await handleAriwebInternal(req, res, p.slice('/api/internal/ariweb/'.length).split('/').filter(Boolean).map(decodeURIComponent), url, b); }
catch (e) { return send(res, e.status || 500, { error: e.message }); }
}
// ---------- v1.5.11 (요구 2): 설정 페이지 API 를 본 서비스에서도 — 토큰(data/setup.token) 또는 관리자 로그인 ----------
// 설치 직후 첫 화면이 /setup 으로 넘어오는데, 그때는 관리자 비밀번호를 모를 수 있으므로 별도 포트의 setup-server.js 와 같은 토큰 로그인을 받는다.
// 세션은 wm_setup 쿠키(lib/setupauth.js). 이미 관리자로 로그인한 사람(wh_session, role=admin)은 그대로 같은 권한이다.
@@ -2251,7 +2340,7 @@ async function handle(req, res) {
if (req.method === 'POST') { const oid = p.split('/')[4]; try { const o = prepaid.cancelOrder(oid, user.id, { userId: user.id, reason: '사용자 취소' }); audit.log({ by: user.id, action: 'prepaid.order.cancel', targetType: 'prepaid-order', target: oid, userId: user.id, detail: { name: o.name, amount: o.amount } }); return send(res, 200, { ok: true, order: o, orders: prepaid.myOrders(user.id) }); } catch (e) { return send(res, e.status || 500, { error: e.message }); } }
return send(res, 405, { error: 'method not allowed' });
}
if (req.method === 'GET' && p === '/api/config') return send(res, 200, { version: VERSION, product: 'AI Webmaker', cashUnit: credits.unit(), welcomePage: welcomePage(), tiers: settings.TIERS, tierGuide: await tierGuideKrw(), maxEdits: settings.load().maxEdits || 0, defaultTier: settings.load().defaultTier || 'standard', credit: credits.info(user.id), publicBaseUrl: PUBLIC_BASE_URL, user, git: gitstore.info('<siteId>'), upload: { maxFileBytes: uploads.MAX_FILE_BYTES, extensions: uploads.allowedExtensions() }, siteLocales: Object.entries(multilang.LOCALES).map(([code, l]) => ({ code, name: l.name, label: l.label, dir: l.dir })) });
if (req.method === 'GET' && p === '/api/config') return send(res, 200, { version: VERSION, product: 'AI Webmaker', ariweb: ariweb.readCtx(req), ariwebLinks: ariweb.allLinks(), cashUnit: credits.unit(), welcomePage: welcomePage(), tiers: settings.TIERS, tierGuide: await tierGuideKrw(), maxEdits: settings.load().maxEdits || 0, defaultTier: settings.load().defaultTier || 'standard', credit: credits.info(user.id), publicBaseUrl: PUBLIC_BASE_URL, user, git: gitstore.info('<siteId>'), upload: { maxFileBytes: uploads.MAX_FILE_BYTES, extensions: uploads.allowedExtensions() }, siteLocales: Object.entries(multilang.LOCALES).map(([code, l]) => ({ code, name: l.name, label: l.label, dir: l.dir })) });
if (req.method === 'GET' && p === '/api/catalog') return send(res, 200, briefLib.catalog());
if (p === '/api/models' || p === '/api/me/connections' || p.startsWith('/api/me/connections/')) return send(res, 410, { error: '모델 선택 기능은 종료되었습니다. 모델 등급(기본/중급/고급)을 선택하세요.' });
if (req.method === 'GET' && p === '/api/sites') return send(res, 200, store.list(user.id));
@@ -2376,9 +2465,10 @@ async function handle(req, res) {
if (!site || site.ownerId !== user.id) return send(res, 404, { error: '사이트를 찾을 수 없습니다' });
{ const ul = String(req.headers['x-ui-lang'] || ''); if (/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(ul) && site.uiLang !== ul) { site.uiLang = ul; store.save(site); } } // 생성 코드 주석 언어
const sub = m[2] || '';
if (req.method === 'GET' && sub === '') return send(res, 200, { ...publicView(site), running: jobOf(site.id).running, deploying: jobOf(site.id).deploying || null, status: jobOf(site.id).running ? 'running' : (site.status === 'running' ? 'idle' : site.status) });
if (req.method === 'GET' && sub === '') return send(res, 200, { ...publicView(site), ariweb: ariweb.linkOf(site.id), running: jobOf(site.id).running, deploying: jobOf(site.id).deploying || null, status: jobOf(site.id).running ? 'running' : (site.status === 'running' ? 'idle' : site.status) });
if (req.method === 'PATCH' && sub === '') {
const { fields: b } = await readBody(req);
{ const c = revConflict(site, b, req); if (c) return send(res, 409, c); } // [Ariweb Suite 변경] 동시 편집 충돌(§9.3)
const changed = [];
const labels = { title: '페이지 제목', purpose: '목적', content: '주요 컨텐츠', design: '디자인 방향', extra: '추가 요구사항' };
if (typeof b.contactEmail === 'string') { const ce = b.contactEmail.trim(); if (ce && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(ce)) return send(res, 400, { error: '문의 수신 이메일 형식이 올바르지 않습니다' }); if (ce !== (site.contactEmail || '')) { site.contactEmail = ce; changed.push('문의 수신 이메일'); if (site.plan && site.plan.brief && site.plan.brief.facts) site.plan.brief.facts.email = { value: ce, source: 'user_text', status: ce ? 'provided' : 'missing', mode: 'keep' }; } }
@@ -2442,6 +2532,7 @@ async function handle(req, res) {
if (jobOf(site.id).running) return send(res, 409, { error: 'AI 작업 실행 중에는 저장할 수 없습니다' });
const { fields: b } = await readBody(req);
if (typeof b.content !== 'string') return send(res, 400, { error: 'content 가 필요합니다' });
{ const c = revConflict(site, b, req); if (c) return send(res, 409, c); } // [Ariweb Suite 변경] 동시 편집 충돌(§9.3)
if (Buffer.byteLength(b.content) > MAX_TEXT_BYTES) return send(res, 413, { error: '내용이 너무 큽니다' });
fs.mkdirSync(path.dirname(abs), { recursive: true, mode: 0o755 });
fs.writeFileSync(abs, b.content, { mode: 0o644 });
@@ -2458,6 +2549,12 @@ async function handle(req, res) {
const { fields: b } = await readBody(req);
if (jobOf(site.id).running || jobOf(site.id).deploying) return send(res, 409, { error: '작업이 진행 중입니다. 끝난 뒤 폐기하세요' });
if (String(b.confirm || '').trim() !== String(site.title || '').trim()) return send(res, 400, { error: '확인을 위해 사이트 제목을 정확히 입력하세요' });
// [Ariweb Suite 변경] 포털에 연결된 프로젝트: 운영 사이트·배포·도메인에 미치는 영향을 포털에 물어 보이고, 사용자가 영향을 확인(acknowledgeImpact)했을 때만 프로젝트를 폐기한다.
// 포털의 운영 사이트·도메인·Source 는 여기서 절대 지우지 않는다(§9.1). 포털에 닿지 못하면 영향을 모르므로 폐기를 보류한다.
{ const l = ariweb.linkOf(site.id); if (l) { const r = await ariweb.impactFromPortal(site.id); if (!r.reachable) return send(res, 503, { error: '포털에서 운영 사이트 영향을 확인하지 못해 폐기를 보류합니다. 잠시 후 다시 시도하세요', ariweb: { reason: r.reason } });
if (r.impact && r.impact.blocked) return send(res, 409, { error: r.impact.message || '운영 중인 사이트가 이 프로젝트를 쓰고 있어 폐기할 수 없습니다. 포털에서 사이트를 먼저 중지·해지하세요', ariweb: { impact: r.impact } });
if (!b.acknowledgeImpact) return send(res, 409, { error: '이 프로젝트는 Ariweb Suite 사이트에 연결되어 있습니다. 아래 영향을 확인한 뒤 다시 요청하세요', ariweb: { impact: r.impact, needAcknowledge: true } });
ariweb.unlink(site.id); audit.log({ by: user.id, action: 'ariweb.unlink', targetType: 'site', target: site.id, detail: { siteId: l.siteId, reason: 'project destroyed' } }); } }
return send(res, 200, await destroySite(site, user.id));
}
if (site.source && req.method === 'POST' && sub === '/deploy') {
@@ -2647,6 +2744,8 @@ async function handle(req, res) {
}
// 9/13단계: 발행
if (req.method === 'POST' && step === 'publish') {
// [Ariweb Suite 변경] 포털에 연결된 프로젝트는 Webmaker 발행이 운영 버전을 바로 덮어쓰지 않도록 막고, 포털의 "배포" 화면(저장본 선택 → 미리보기 → 배포)으로 안내한다(§0.2).
{ const l = ariweb.linkOf(site.id); if (l) return send(res, 409, { error: '이 프로젝트는 Ariweb Suite 운영 사이트에 연결되어 있습니다. 배포는 포털의 사이트 › 배포 화면에서 저장본을 골라 진행합니다(운영 중인 버전을 바로 덮어쓰지 않습니다).', ariweb: { siteId: l.siteId, deployUrl: `${l.portalUrl || ariweb.portalUrl()}/sites/${l.siteId}/deploy` } }); }
if (body.domains !== undefined) { const nd = deploy.normalizeDomains(body.domains); const bad = subdomainOnlyViolation(user, nd); if (bad) return send(res, 403, { error: bad }); site.domains = nd; store.save(site); }
// v1.4.0 (요구 10): 연결한 도메인의 DNS 가 아직 전파 중이면 발행(웹서버·SSL 설정)을 미루고 예상 시간을 알린다
{ const wait = await domainsLib.gate(site).catch(() => null); if (wait) return send(res, 409, { error: wait, dnsPending: true }); }
+93
View File
@@ -0,0 +1,93 @@
'use strict';
// [Ariweb Suite 변경] Ariweb Suite 연동 회귀 테스트 — lib/ariweb(티켓·서비스 서명·연결 등록부), store.rev, 서버 단위(SSO 진입·내부 API·동시 편집 충돌·발행 안내·폐기 영향).
const test = require('node:test'); const assert = require('node:assert/strict');
const fs = require('fs'); const path = require('path'); const os = require('os'); const http = require('http'); const crypto = require('crypto'); const { spawn } = require('child_process');
const ROOT = path.join(__dirname, '..'); const PORT = 4100 + Math.floor(Math.random() * 90);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-ariweb-')); process.env.DATA_ROOT = tmp; process.env.DATA_DIR = path.join(tmp, 'sites'); process.env.SITES_ROOT = path.join(tmp, 'www');
fs.mkdirSync(path.join(tmp, 'sites'), { recursive: true }); fs.mkdirSync(path.join(tmp, 'www'), { recursive: true });
const SECRET = 'test-shared-secret-' + crypto.randomBytes(8).toString('hex');
const B = `http://127.0.0.1:${PORT}`;
const sha256 = (s) => crypto.createHash('sha256').update(String(s || '')).digest('hex');
const sign = (key, d) => crypto.createHmac('sha256', key).update(d).digest('base64url');
/** 포털이 하는 서비스 서명 호출 */
const svc = async (method, p, body) => { const text = body === undefined ? undefined : JSON.stringify(body); const ts = String(Date.now()); const r = await fetch(B + p, { method, headers: { 'Content-Type': 'application/json', 'X-Service-Key-Id': 'portal', 'X-Service-Ts': ts, 'X-Service-Sign': sign(SECRET, `${method}|${p}|${ts}|${sha256(text)}`) }, body: text }); const ct = r.headers.get('content-type') || ''; return { status: r.status, b: ct.includes('json') ? await r.json() : null, r }; };
const ticket = (data) => { const payload = Buffer.from(JSON.stringify({ v: 1, exp: Date.now() + 60000, nonce: crypto.randomBytes(8).toString('hex'), ...data })).toString('base64url'); return `${payload}.${sign(SECRET, payload)}`; };
let child; let portal; let portalImpact = { impact: { blocked: false, liveSite: true, lifecycle: 'live', deployments: 2, domains: ['example.test'], message: '운영 사이트가 연결되어 있습니다' } }; let portalCalls = [];
test('lib/ariweb: 티켓 검증(서명·만료·1회용)과 서비스 서명 검증', () => {
const a = require(ROOT + '/lib/ariweb');
const t = a.makeTicket({ login: 'aw-g1-u1', name: 'U', groupId: 'g1' }, { key: SECRET }); const ok = a.verifyTicket(t, { key: SECRET }); assert.equal(ok.login, 'aw-g1-u1');
assert.match(a.verifyTicket(t, { key: SECRET }).error, /이미 사용/); assert.match(a.verifyTicket(t + 'x', { key: SECRET }).error, /서명/);
assert.match(a.verifyTicket(a.makeTicket({ login: 'x', exp: Date.now() - 1 }, { key: SECRET }), { key: SECRET }).error, /만료/);
assert.match(a.verifyTicket(a.makeTicket({ login: 'x' }, { key: 'other' }), { key: SECRET }).error, /서명/);
assert.match(a.verifyTicket(t, { key: '' }).error, /설정되지/);
const req = (h) => ({ method: 'POST', headers: h }); const ts = String(Date.now()); const body = '{"a":1}';
assert.equal(a.verifyService(req({ 'x-service-key-id': 'portal', 'x-service-ts': ts, 'x-service-sign': sign(SECRET, `POST|/api/internal/ariweb/health|${ts}|${sha256(body)}`) }), '/api/internal/ariweb/health', body, { key: SECRET }).ok, true);
assert.equal(a.verifyService(req({ 'x-service-key-id': 'portal', 'x-service-ts': ts, 'x-service-sign': 'bad' }), '/x', body, { key: SECRET }).ok, false);
assert.equal(a.verifyService(req({ 'x-service-key-id': 'portal', 'x-service-ts': String(Date.now() - 10 * 60000), 'x-service-sign': 'x' }), '/x', body, { key: SECRET }).reason, 'clock_skew');
assert.equal(a.linkOf('p1'), null); a.link('p1', { siteId: 's1', groupId: 'g1', portalUrl: 'https://my.example.test' }); assert.equal(a.linkOf('p1').siteId, 's1'); assert.equal(a.unlink('p1'), true); assert.equal(a.linkOf('p1'), null);
});
test('store.save: rev 가 저장마다 1 오른다', () => {
const store = require(ROOT + '/lib/store'); const id = crypto.randomBytes(16).toString('hex'); const s = store.create({ id, title: 't' }); assert.equal(s.rev, 1); store.save(s); assert.equal(store.load(id).rev, 2);
});
test.before(async () => {
// 가짜 포털(프로젝트 삭제 영향 응답) — Webmaker 가 서명한 요청만 받는다
portal = http.createServer((req, res) => { let d = ''; req.on('data', (c) => { d += c; }); req.on('end', () => { portalCalls.push({ url: req.url, keyId: req.headers['x-service-key-id'], hasSign: !!req.headers['x-service-sign'], body: d }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(portalImpact)); }); });
await new Promise((r) => portal.listen(0, '127.0.0.1', r));
// 보관소에 공유 비밀·설정에 포털 주소를 넣고 서버를 띄운다
const vault = require(ROOT + '/lib/vault'); vault.set({ kind: 'ariweb', id: 'ariweb', label: 'test', url: '', secret: SECRET, settings: {} });
const config = require(ROOT + '/lib/config'); config.set({ 'ariweb.portalUrl': `http://127.0.0.1:${portal.address().port}` }, 'test');
child = spawn('node', [path.join(ROOT, 'server.js')], { env: { ...process.env, PORT: String(PORT), HOST: '127.0.0.1', DATA_ROOT: tmp, DATA_DIR: path.join(tmp, 'sites'), SITES_ROOT: path.join(tmp, 'www'), PUBLIC_BASE_URL: B, SOURCE_PROVIDER: 'none' } });
let out = ''; child.stdout.on('data', (c) => { out += c; }); child.stderr.on('data', (c) => { out += c; });
for (let i = 0; i < 150; i++) { if (/listening on/.test(out)) break; await new Promise((r) => setTimeout(r, 100)); }
assert.match(out, /listening on/);
});
test.after(() => { try { child.kill('SIGTERM'); } catch (_) {} try { portal.close(); } catch (_) {} });
let projectId; let cookie;
test('내부 API: 서명 없는 호출 거부 · 프로젝트 만들기·연결·조회·소스·export', async () => {
assert.equal((await fetch(B + '/api/internal/ariweb/health')).status, 401);
assert.equal((await svc('GET', '/api/internal/ariweb/health')).b.ok, true);
const c = await svc('POST', '/api/internal/ariweb/projects', { owner: 'aw-g1-u1', ownerName: '테스트', title: '포털 사이트', mode: 'manual', link: { siteId: 's_1', groupId: 'g_1', siteName: '우리 사이트', groupName: '우리 그룹', portalUrl: 'http://127.0.0.1:' + portal.address().port } });
if (c.status !== 200) console.log('DEBUG create', c.status, JSON.stringify(c.b), await c.r.text().catch(() => '')); assert.equal(c.status, 200, JSON.stringify(c.b)); projectId = c.b.project.id; assert.equal(c.b.project.link.siteId, 's_1'); assert.equal(c.b.project.hasContent, false);
fs.writeFileSync(path.join(tmp, 'www', projectId, 'index.html'), '<h1>hello</h1>');
const g = await svc('GET', `/api/internal/ariweb/projects/${projectId}`); assert.equal(g.b.project.hasContent, true); assert.ok(Array.isArray(g.b.project.versionList)); assert.equal(g.b.project.git.provider, 'none');
const l = await svc('GET', '/api/internal/ariweb/projects?owner=aw-g1-u1'); assert.equal(l.b.projects.length, 1);
const src = await svc('GET', `/api/internal/ariweb/projects/${projectId}/source`); assert.equal(src.b.ok, true); assert.ok(src.b.source.siteBytes > 0); assert.equal(src.b.source.lfsSupported, false);
const ex = await svc('GET', `/api/internal/ariweb/projects/${projectId}/export`); assert.equal(ex.status, 200); assert.equal(ex.r.headers.get('x-ariweb-version'), 'current'); const buf = Buffer.from(await ex.r.arrayBuffer()); assert.ok(buf.length > 20 && buf[0] === 0x1f && buf[1] === 0x8b, 'gzip');
assert.equal((await svc('GET', `/api/internal/ariweb/projects/${projectId}/export?version=99`)).status, 404);
assert.equal((await svc('GET', '/api/internal/ariweb/credits/aw-g1-u1')).b.credit.balance, 0);
});
test('SSO: 티켓으로 세션 발급·프로젝트로 이동·배너 컨텍스트, 재사용·위조 거부', async () => {
const t = ticket({ login: 'aw-g1-u1', name: '테스트', groupId: 'g_1', groupName: '우리 그룹', siteId: 's_1', siteName: '우리 사이트', projectId });
const r = await fetch(B + '/api/auth/sso?ticket=' + encodeURIComponent(t), { redirect: 'manual' }); assert.equal(r.status, 302); assert.equal(r.headers.get('location'), `/?site=${projectId}`);
const sc = r.headers.getSetCookie(); assert.ok(sc.some((c) => c.startsWith('wh_session=')) && sc.some((c) => c.startsWith('wm_ariweb=')));
cookie = sc.map((c) => c.split(';')[0]).join('; ');
const meR = await fetch(B + '/api/auth/me', { headers: { cookie, referer: B + '/' } }); const me = await meR.json(); if (!me.user) console.log('DEBUG me', meR.status, JSON.stringify(me), cookie); assert.equal(me.user.id, 'aw-g1-u1');
const cfg = await (await fetch(B + '/api/config', { headers: { cookie, referer: B + '/' } })).json(); assert.equal(cfg.ariweb.groupName, '우리 그룹'); assert.equal(cfg.ariweb.siteName, '우리 사이트');
assert.equal((await fetch(B + '/api/auth/sso?ticket=' + encodeURIComponent(t), { redirect: 'manual' })).status, 403, '1회용');
assert.equal((await fetch(B + '/api/auth/sso?ticket=' + encodeURIComponent(t.slice(0, -2) + 'zz'), { redirect: 'manual' })).status, 403, '위조');
});
test('동시 편집 충돌: 옛 rev 로 저장하면 409 + 두 버전 요약, 최신 rev 는 저장', async () => {
const H = { cookie, 'Content-Type': 'application/json', referer: B + '/' };
const s0 = await (await fetch(B + `/api/sites/${projectId}`, { headers: H })).json(); const rev = s0.rev; assert.ok(rev >= 1); assert.equal(s0.ariweb.siteId, 's_1');
const ok = await fetch(B + `/api/sites/${projectId}`, { method: 'PATCH', headers: H, body: JSON.stringify({ title: '바뀐 제목', rev }) }); assert.equal(ok.status, 200);
const stale = await fetch(B + `/api/sites/${projectId}`, { method: 'PATCH', headers: H, body: JSON.stringify({ title: '다른 창', rev }) }); assert.equal(stale.status, 409); const j = await stale.json(); assert.equal(j.conflict.yourRev, rev); assert.ok(j.conflict.currentRev > rev);
const stale2 = await fetch(B + `/api/sites/${projectId}/files/index.html`, { method: 'PUT', headers: { ...H, 'If-Match': String(rev) }, body: JSON.stringify({ content: '<h1>x</h1>' }) }); assert.equal(stale2.status, 409);
const noRev = await fetch(B + `/api/sites/${projectId}`, { method: 'PATCH', headers: H, body: JSON.stringify({ purpose: '옛 화면' }) }); assert.equal(noRev.status, 200, 'rev 없는 옛 화면은 예전처럼');
});
test('발행: 포털 연결 프로젝트는 409 + 포털 배포 화면 안내', async () => {
const r = await fetch(B + `/api/sites/${projectId}/plan/publish`, { method: 'POST', headers: { cookie, 'Content-Type': 'application/json', referer: B + '/' }, body: '{}' });
assert.equal(r.status, 409); const j = await r.json(); assert.match(j.error, /포털/); assert.match(j.ariweb.deployUrl, /\/sites\/s_1\/deploy$/);
});
test('폐기: 포털 영향 확인 → 확인 전 409(영향 표시) → 운영 중이면 차단 → 확인 후 폐기(포털 사이트는 건드리지 않음)', async () => {
const H = { cookie, 'Content-Type': 'application/json', referer: B + '/' };
const r1 = await fetch(B + `/api/sites/${projectId}`, { method: 'DELETE', headers: H, body: JSON.stringify({ confirm: '바뀐 제목' }) }); assert.equal(r1.status, 409); const j1 = await r1.json(); assert.equal(j1.ariweb.needAcknowledge, true); assert.equal(j1.ariweb.impact.deployments, 2);
assert.equal(portalCalls[0].keyId, 'webmaker'); assert.ok(portalCalls[0].hasSign); assert.equal(JSON.parse(portalCalls[0].body).siteId, 's_1');
portalImpact = { impact: { blocked: true, liveSite: true, lifecycle: 'live', deployments: 2, domains: [], message: '운영 중' } };
const r2 = await fetch(B + `/api/sites/${projectId}`, { method: 'DELETE', headers: H, body: JSON.stringify({ confirm: '바뀐 제목', acknowledgeImpact: true }) }); assert.equal(r2.status, 409);
portalImpact = { impact: { blocked: false, liveSite: false, lifecycle: 'draft', deployments: 0, domains: [], message: '초안' } };
const r3 = await fetch(B + `/api/sites/${projectId}`, { method: 'DELETE', headers: H, body: JSON.stringify({ confirm: '바뀐 제목', acknowledgeImpact: true }) }); assert.equal(r3.status, 200, await r3.text());
assert.equal(require(ROOT + '/lib/ariweb').linkOf(projectId), null);
});