사이트 목록·생성·상세, 관리형/외부 배포, 배포 방식 변경, 도메인·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
+36
View File
@@ -0,0 +1,36 @@
# Webmaker 기존 기능 적용 기록 (Ariweb Suite 0.1.1, 개발 지시 §0.1·§0.2·§0.5)
- 원본: https://source.ibit.kr/OZ-Group/WebHosting_AI_agent — revision `3b10859884ef2fd0f2af11934ab6c16370fd64b8` (v1.7.3), 도입 경로 `modules/webmaker/` (IMPORTED_SOURCES.md 참고)
- 접근 위치: 포털 사이트 상세 › 제작 탭 › "제작 화면 열기" (`/sso/webmaker?site=<siteId>`) → Webmaker `/api/auth/sso` → 기존 빌더 화면. 포털 사용자당 Webmaker 계정 `aw-<그룹>-<사용자>` 가 자동으로 생기며 별도 로그인은 없다.
- 회귀 테스트: `cd modules/webmaker && node --test test/*.test.js` (원본 6개 파일 + 신규 `test/ariweb.test.js`). 실제 화면 확인 결과는 출시 검증 기록(VERSION_HISTORY.md 검증 절)에 적는다.
| §0.1 기능 | 적용 구분 | 접근 위치 / 비고 |
|---|---|---|
| `AI에게 맡기기`·`내가 고르기` Site 제작 | 그대로 도입 | 제작 탭 → Webmaker 빌더. 포털에서 사이트를 만들 때 제작 방식(ai·manual·quick)을 골라 프로젝트를 만든다(내부 API `POST /api/internal/ariweb/projects`) |
| Site Brief 작성과 확인 | 그대로 도입 | Webmaker 기획 단계 그대로 |
| Template과 기존 제작 Module | 그대로 도입 | Webmaker 생성 탭 "템플릿에서 시작"·모듈 관리 그대로 |
| Page 생성·편집·미리보기·저장 | 최소 연결 변경 | 저장(PATCH 기본 사항·PUT 소스 편집)에 `rev` 기반 동시 편집 충돌 감지(409 + 두 버전 요약)를 더함(§9.3). rev 를 보내지 않는 기존 화면은 그대로 동작 |
| AI 생성·수정과 Wallet/Cache 차감 | 그대로 도입 | Webmaker 캐시(credits) 그대로. 포털은 `/api/internal/ariweb/credits/<login>` 으로 잔액을 읽어 AI Wallet 으로 표시(호스팅 사용량과 분리 §0.2) |
| 이전 저장본과 배포할 저장본 선택 | 최소 연결 변경 | 저장본 목록은 Webmaker 버전 기록(git.changes) 그대로. 배포할 저장본 선택은 포털 배포 탭(`/api/internal/ariweb/projects/<id>/export?version=N`)에서 한다 |
| Domain·DNS·TLS 관리 기능 | 그대로 도입 | 등록기관(Openprovider·Porkbun·Route 53)·DNS 공급자·네임서버 이전·레코드 관리는 Webmaker 도메인 탭 그대로(포털 도메인 화면의 "도메인 구매·DNS 자동화(Webmaker)" 링크). 포털은 연결 상태·소유 확인·HTTPS 검증만 가진다 |
| Gitea Source 연결과 변경 이력 | 그대로 도입 | gitstore·connectors/gitea 그대로. 포털 Source 탭은 `/api/internal/ariweb/projects/<id>/source` 로 읽어 표시 |
| 관리형 또는 외부 대상 배포에 이미 사용할 수 있는 기능 | 미사용(이번 버전) | Webmaker 의 배포 서버(deploy.js: local/ssh tar-over-ssh)는 포털 연결 프로젝트에서는 쓰지 않는다. 운영 배포는 HAL/Node Agent 가 맡는다(§32). 포털에 연결되지 않은 Webmaker 단독 프로젝트는 기존 기능 그대로 동작 |
| 배포 결과 확인과 이전 Version 복구 기능 | 미사용(이번 버전) | 위와 같은 이유로 포털 배포 탭(되돌리기 = HAL site.rollback)이 담당. Webmaker 발행(plan/publish)은 포털 연결 프로젝트에서 409 로 막고 포털 배포 화면으로 안내 |
| AriMail Server 연결과 Mail 사용량 표시 | 그대로 도입 | Webmaker 관리자 콘솔 메일 서버 등록부·사용량 연동 그대로. 포털 메일 화면은 별도(메일 영역) |
| 사용자·관리자 화면의 설정·상태·통계 기능 | 그대로 도입 | Webmaker 관리자 콘솔·설정 페이지(8795) 그대로 |
## §0.2 수정·개선 항목
| 항목 | 구분 | 구현 |
|---|---|---|
| 별도 로그인 없이 현재 사용자·그룹으로 이용 | 정책 개선 | `GET /api/auth/sso?ticket=`(server.js) + `lib/auth.ensureSsoUser` + `lib/ariweb.verifyTicket`(60초·1회용·HMAC). 상단 배너(`public/index.html #ariwebBanner`, app.js)에 그룹·사이트 표시 |
| Project 와 운영 Site 구분 | 정책 개선 | `lib/ariweb.js` 연결 등록부(data/ariweb-links.json), 배너 문구, 사이트 조회 응답 `ariweb` 필드 |
| Draft Site 가 운영 사이트 수를 쓰지 않음 | 정책 개선(포털) | portal/sites: 초안 생성은 예약 없음, 최초 공개 배포 때 예약 |
| 관리형/외부 배포의 비용·책임·상태 구분 | 정책 개선(포털) | portal/sites 배포 방식 화면·동의 기록 |
| 상품 권리·Site 별 보장량 배포 전 확인 | 정책 개선(포털) | portal/sites precheck |
| AI Wallet 과 Hosting·Mail 사용량 분리 | 정책 개선(포털) | 제작 탭 AI Wallet 카드 / 사용량 탭 별도 |
| 운영 Version 을 바로 덮어쓰지 않고 Preview 후 배포 | 정책 개선 | Webmaker 발행 차단(연결 프로젝트) + 포털 배포 탭 저장본 선택 |
| 진행 상태를 공통 상태·알림에 표시 | 정책 개선(포털) | lib/jobs + lib/notify |
| Project 삭제가 운영 Site·Domain·Source 를 자동 삭제하지 않음 | 정책 개선 | `DELETE /api/sites/<id>` 가 포털 `/internal/webmaker/project-impact` 를 서명 호출해 영향을 보이고, 운영 중이면 거부·확인(acknowledgeImpact) 후에만 폐기. 포털 데이터는 건드리지 않음 |
| 동시 편집 충돌 표시 | 오류 수정 | `lib/store.js` rev + `server.js revConflict` |
| 기존 기능 오류 수정 | — | 이번 도입에서 Webmaker 1.7.3 회귀 테스트 전부 통과, 별도 오류 발견 없음 |
+95
View File
@@ -0,0 +1,95 @@
/**
* portal/webmaker/adapter.mjs — Webmaker(도입 모듈 modules/webmaker) 연동 어댑터. deps.webmaker 로 모든 영역이 쓴다.
*
* 역할(docs/ARCHITECTURE.md §7, 개발 지시 §0.2·§9)
* - SSO: 포털 사용자·그룹마다 Webmaker 계정 `aw-<그룹 8자>-<사용자 8자>` 를 두고, 공유 비밀(보관소 'sso:webmaker')로 서명한 60초·1회용 티켓으로
* Webmaker 의 /api/auth/sso 에 들어간다. 사용자는 Webmaker 비밀번호를 모른다(별도 로그인 없음). 발급 기록은 sso_tickets 표.
* - 내부 API(/api/internal/ariweb/*, modules/webmaker/lib/ariweb.js): 프로젝트 목록·생성·연결·저장본·소스·export(tar.gz)·AI Wallet(캐시).
* 서명 규약은 lib/serviceauth(키 id 'portal', 같은 공유 비밀).
* - 장애 격리: Webmaker 에 닿지 못하면 code 'service_unreachable' 로 던져 화면이 '정보 확인 지연' 을 보이고 신규 작업을 막는다(§26.2).
* 테스트는 createFake() 가 같은 모양의 메모리 구현을 준다.
*/
import { randomToken, hmac, newId } from '../../lib/crypto.mjs';
import { serviceFetch, signHeaders } from '../../lib/serviceauth.mjs';
import { nowIso } from '../../lib/db.mjs';
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';
export const SECRET_NAME = 'sso:webmaker';
const TICKET_TTL_MS = 60 * 1000;
/** Webmaker 로그인 ID 규칙(lib/auth.js ID_RE: 소문자·숫자로 시작, 3~32자) */
export function loginFor(user, group) { const g = String(group.id).replace(/^g_/, '').slice(0, 8).toLowerCase(); const u = String(user.id).replace(/^u_/, '').slice(0, 8).toLowerCase(); return `aw-${g}-${u}`.replace(/[^a-z0-9._-]/g, ''); }
export function create(deps) {
const { store, vault, config, log = () => {} } = deps;
/** 공유 비밀: 없으면 만든다(운영자가 Webmaker 설정 ariweb.secret 에 같은 값을 넣어야 연동이 완성된다 — provisioningInfo 로 안내). */
function secret() { let s = vault.get(SECRET_NAME); if (!s) { s = randomToken(32); vault.set(SECRET_NAME, s, { kind: 'shared-secret' }); log('[webmaker] 공유 비밀을 새로 만들었습니다 — Webmaker 설정 ariweb.secret 에 같은 값을 넣어야 합니다'); } return s; }
const base = () => String(config.webmakerInternalUrl || '').replace(/\/$/, '');
async function request(method, path, body) {
const r = await serviceFetch(base(), 'portal', secret(), method, path, body, { timeoutMs: 20000 });
if (!r.ok) { const e = new Error((r.json && (r.json.message || r.json.error)) || `Webmaker ${r.status}`); e.code = (r.json && r.json.error) || `webmaker_${r.status}`; e.status = r.status; throw e; }
return r.json;
}
const api = {
SECRET_NAME, loginFor, secret,
/** 운영자 안내용: 공유 비밀 자체는 돌려주지 않고 존재 여부·힌트만 */
provisioningInfo() { const items = vault.list(); const it = items.find((x) => x.name === SECRET_NAME); return { configured: !!it, hint: it ? it.hint : null, webmakerSettingKey: 'ariweb.secret', webmakerUrl: config.webmakerUrl }; },
/** 공유 비밀 재발급(운영자). 새 값은 이 응답에 한 번만 보인다 — Webmaker 쪽도 같이 바꿔야 한다. */
rotateSecret() { const s = randomToken(32); vault.set(SECRET_NAME, s, { kind: 'shared-secret' }); return { secret: s, note: 'Webmaker 설정 ariweb.secret 에 같은 값을 넣으세요. 이 값은 다시 표시되지 않습니다' }; },
/**
* SSO 진입 주소 만들기. 티켓은 60초·1회용. 기록(sso_tickets)에는 티켓 원문이 아니라 nonce 만 남긴다.
* @param {{user, group, site?, projectId?, path?}} o
*/
ssoUrl({ user, group, site = null, projectId = null, path = null }) {
const nonce = randomToken(12); const exp = Date.now() + TICKET_TTL_MS;
const payload = Buffer.from(JSON.stringify({ v: 1, login: loginFor(user, group), name: user.display_name || user.email, groupId: group.id, groupName: group.name, siteId: site ? site.id : '', siteName: site ? site.name : '', projectId: projectId || (site ? site.webmaker_project_id : '') || '', path: path || '', portalUrl: config.publicUrl, exp, nonce })).toString('base64url');
const ticket = `${payload}.${hmac(secret(), payload)}`;
store.run('INSERT INTO sso_tickets(id, nonce, user_id, group_id, site_id, project_id, issued_at, expires_at) VALUES(?,?,?,?,?,?,?,?)', newId('t_'), nonce, user.id, group.id, site ? site.id : null, projectId || (site ? site.webmaker_project_id : null), nowIso(), new Date(exp).toISOString());
return `${String(config.webmakerUrl).replace(/\/$/, '')}/api/auth/sso?ticket=${encodeURIComponent(ticket)}`;
},
health: () => request('GET', '/api/internal/ariweb/health'),
listProjects: async (login) => (await request('GET', `/api/internal/ariweb/projects?owner=${encodeURIComponent(login)}`)).projects,
getProject: async (id) => (await request('GET', `/api/internal/ariweb/projects/${encodeURIComponent(id)}`)).project,
/** 프로젝트 만들기. mode: quick(제목·내용 입력형) | ai(AI에게 맡기기) | manual(내가 고르기). link 를 주면 바로 연결 */
createProject: async ({ login, ownerName, title, mode = 'manual', purpose = '', link = null }) => (await request('POST', '/api/internal/ariweb/projects', { owner: login, ownerName, title, mode, purpose, link })).project,
linkProject: async (id, link) => (await request('POST', `/api/internal/ariweb/projects/${encodeURIComponent(id)}/link`, link)).link,
unlinkProject: async (id) => request('DELETE', `/api/internal/ariweb/projects/${encodeURIComponent(id)}/link`),
projectSource: async (id) => (await request('GET', `/api/internal/ariweb/projects/${encodeURIComponent(id)}/source`)).source,
credits: async (login) => request('GET', `/api/internal/ariweb/credits/${encodeURIComponent(login)}`),
/** 저장본 tar.gz 를 파일로 받는다. versionNo 가 없으면 현재 작업 트리. @returns {{file, version, commit, bytes}} */
async exportVersion(id, versionNo, destFile) {
const path = `/api/internal/ariweb/projects/${encodeURIComponent(id)}/export${versionNo ? `?version=${encodeURIComponent(versionNo)}` : ''}`;
let r; try { r = await fetch(base() + path, { method: 'GET', headers: signHeaders('portal', secret(), 'GET', path, undefined), signal: AbortSignal.timeout(120000) }); }
catch (e) { const err = new Error('Webmaker 에 연결할 수 없습니다'); err.code = 'service_unreachable'; throw err; }
if (!r.ok) { const e = new Error(`저장본 내보내기 실패 (${r.status})`); e.code = r.status === 404 ? 'version_not_found' : 'export_failed'; throw e; }
await pipeline(Readable.fromWeb(r.body), createWriteStream(destFile, { mode: 0o600 }));
const { statSync } = await import('node:fs');
return { file: destFile, version: r.headers.get('x-ariweb-version') || 'current', commit: r.headers.get('x-ariweb-commit') || '', bytes: statSync(destFile).size };
},
};
return api;
}
/** 테스트용 가짜 어댑터: 프로젝트를 메모리에 두고 같은 인터페이스를 제공한다. */
export function createFake(deps = {}) {
const projects = new Map(); let seq = 0; const calls = [];
const summary = (p) => ({ ...p, versions: p.versionList.length, hasContent: p.hasContent });
return {
SECRET_NAME, loginFor, secret: () => 'fake-secret', calls, _projects: projects, unreachable: false,
_guard() { if (this.unreachable) { const e = new Error('Webmaker 에 연결할 수 없습니다'); e.code = 'service_unreachable'; throw e; } },
provisioningInfo: () => ({ configured: true, hint: 'fake', webmakerSettingKey: 'ariweb.secret', webmakerUrl: 'https://webmaker.example.test' }),
rotateSecret: () => ({ secret: 'new-fake', note: '' }),
ssoUrl({ user, group, site = null, projectId = null, path = null }) { calls.push(['sso', loginFor(user, group), site?.id]); if (deps.store) deps.store.run('INSERT INTO sso_tickets(id, nonce, user_id, group_id, site_id, project_id, issued_at, expires_at) VALUES(?,?,?,?,?,?,?,?)', newId('t_'), randomToken(8), user.id, group.id, site ? site.id : null, projectId || (site ? site.webmaker_project_id : null), nowIso(), new Date(Date.now() + 60000).toISOString()); return `https://webmaker.example.test/api/auth/sso?ticket=fake.${randomToken(6)}`; },
async health() { this._guard(); return { ok: true, version: '1.7.3' }; },
async listProjects(login) { this._guard(); return [...projects.values()].filter((p) => p.ownerId === login).map(summary); },
async getProject(id) { this._guard(); const p = projects.get(id); if (!p) { const e = new Error('프로젝트가 없습니다'); e.code = 'webmaker_404'; e.status = 404; throw e; } return summary(p); },
async createProject({ login, title, mode = 'manual', link = null }) { this._guard(); const id = (++seq).toString(16).padStart(32, '0'); const p = { id, title, ownerId: login, status: 'idle', mode, stage: mode === 'ai' ? 'input' : 'manual', rev: 1, createdAt: nowIso(), updatedAt: nowIso(), lastChange: null, versionList: [], link: link || null, hasContent: false, git: { provider: 'gitea', enabled: true, remote: 'webmaker/sites', mainBranch: `sites/${id}/main`, mainUrl: `https://gitea.example.test/webmaker/sites/src/branch/sites/${id}/main` }, liveUrl: null, deployState: null, domains: [] }; projects.set(id, p); return summary(p); },
async linkProject(id, link) { this._guard(); const p = projects.get(id); if (!p) throw Object.assign(new Error('없음'), { code: 'webmaker_404', status: 404 }); p.link = { ...link, linkedAt: nowIso() }; return p.link; },
async unlinkProject(id) { this._guard(); const p = projects.get(id); if (p) p.link = null; return { ok: true }; },
async projectSource(id) { this._guard(); const p = projects.get(id); return { ...p.git, repoBytes: 2048, siteBytes: 4096, uploadBytes: 0, lfsBytes: 0, lfsSupported: false, changes: p.versionList.map((v) => ({ ...v, pushed: true, branch: `sites/${id}/changes/${String(v.no).padStart(4, '0')}-${v.kind}` })), remoteStatus: { ok: true } }; },
async credits(login) { this._guard(); return { ok: true, credit: { userId: login, balance: 12000, used: 3000, granted: 15000, updatedAt: nowIso(), entries: 2 }, unit: '원' }; },
async exportVersion(id, versionNo, destFile) { this._guard(); const p = projects.get(id); if (versionNo && !p.versionList.find((v) => String(v.no) === String(versionNo))) throw Object.assign(new Error('없음'), { code: 'version_not_found' }); const { writeFileSync } = await import('node:fs'); writeFileSync(destFile, Buffer.from([0x1f, 0x8b, 0x08, 0, 0, 0, 0, 0, 0, 3])); return { file: destFile, version: versionNo || 'current', commit: versionNo ? 'c' + versionNo : '', bytes: 10 }; },
/** 테스트 도우미: 저장본 추가 */
_addVersion(id, kind = 'update', message = '수정') { const p = projects.get(id); const no = p.versionList.length + 1; p.versionList.push({ no, kind, message, at: nowIso(), commit: 'c' + no, files: 1 }); p.hasContent = true; p.rev++; p.lastChange = { no, kind, at: nowIso(), message }; return no; },
};
}
+11
View File
@@ -0,0 +1,11 @@
/** portal/webmaker/api.mjs — Webmaker 연동 라우트: 내 프로젝트 목록(/api/webmaker/projects), AI Wallet(/api/webmaker/wallet), 연동 상태(운영자 /internal/ops/webmaker). SSO 진입은 portal/sites/api.mjs 의 /sso/webmaker. */
import { requireGroup } from '../auth/guard.mjs';
import { HttpError } from '../../lib/http.mjs';
import { verifyCtx } from '../../lib/serviceauth.mjs';
import { loginFor } from './adapter.mjs';
export async function register(app, deps) {
const wm = () => { if (!deps.webmaker) throw new HttpError(503, 'service_unreachable', 'Webmaker 연동이 설정되지 않았습니다'); return deps.webmaker; };
app.get('/api/webmaker/projects', async (ctx) => { requireGroup(ctx); try { const projects = await wm().listProjects(loginFor(ctx.user, ctx.group)); const linked = new Set(deps.store.all('SELECT webmaker_project_id FROM sites WHERE group_id=? AND webmaker_project_id IS NOT NULL AND lifecycle<>\'deleted\'', ctx.group.id).map((r) => r.webmaker_project_id)); return { ok: true, projects: projects.map((p) => ({ ...p, link: p.link || (linked.has(p.id) ? { siteId: deps.store.get('SELECT id FROM sites WHERE webmaker_project_id=?', p.id)?.id } : null) })) }; } catch (e) { if (e.code === 'service_unreachable') return ctx.json({ ok: false, error: 'service_unreachable', message: 'Webmaker 에 연결할 수 없습니다(정보 확인 지연)' }, 503); throw e; } });
app.get('/api/webmaker/wallet', async (ctx) => { requireGroup(ctx); try { const w = await wm().credits(loginFor(ctx.user, ctx.group)); return { ok: true, wallet: w.credit, unit: w.unit, note: 'AI Wallet 은 AI 생성·수정 비용에만 쓰이며 호스팅 디스크·트래픽·메일 사용량과 분리됩니다' }; } catch (e) { if (e.code === 'service_unreachable') return ctx.json({ ok: false, error: 'service_unreachable', message: 'Webmaker 에 연결할 수 없습니다(정보 확인 지연)' }, 503); throw e; } });
app.get('/internal/ops/webmaker', async (ctx) => { const v = await verifyCtx(ctx, (id) => (['hal', 'portal'].includes(id) ? deps.vault.get('service:' + id) : null)); if (!v.ok) throw new HttpError(401, 'service_auth_failed', v.reason); let health = null; try { health = await wm().health(); } catch (e) { health = { ok: false, error: e.code || e.message }; } return { ok: true, health, provisioning: deps.webmaker ? deps.webmaker.provisioningInfo() : null, ssoIssued24h: deps.store.get('SELECT COUNT(*) AS n FROM sso_tickets WHERE issued_at>?', new Date(Date.now() - 86400000).toISOString()).n }; });
}