Files
AriWeb-Suite/portal/webmaker/adapter.mjs
T

96 lines
11 KiB
JavaScript

/**
* 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; },
};
}