사이트 목록·생성·상세, 관리형/외부 배포, 배포 방식 변경, 도메인·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,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 키 또는 토큰' },
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user