From b1c06a03045ef8f3f872d68c809cbe532b9c4028 Mon Sep 17 00:00:00 2001 From: zzabisagent-lab Date: Mon, 26 Oct 2026 11:56:38 +0000 Subject: [PATCH] =?UTF-8?q?=EC=82=AC=EC=9D=B4=ED=8A=B8=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=C2=B7=EC=83=9D=EC=84=B1=C2=B7=EC=83=81=EC=84=B8,=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=ED=98=95/=EC=99=B8=EB=B6=80=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC,=20=EB=B0=B0=ED=8F=AC=20=EB=B0=A9=EC=8B=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD,=20=EB=8F=84=EB=A9=94=EC=9D=B8=C2=B7HTTPS,?= =?UTF-8?q?=20Source=20=EC=98=B5=EC=85=98=EA=B3=BC=20Webmaker=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=20=EC=88=98=EC=A0=95(SSO,=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=E2=86=94=EC=82=AC=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84,=20=EC=82=AD=EC=A0=9C=20=EC=98=81=ED=96=A5?= =?UTF-8?q?=20=ED=99=95=EC=9D=B8,=20=EB=8F=99=EC=8B=9C=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=EC=B6=A9=EB=8F=8C,=20=EB=B0=9C=ED=96=89=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C)=20(=C2=A77~=C2=A712=C2=B7=C2=A70.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- lang/ko/domains.json | 4 + lang/ko/sites.json | 11 + modules/webmaker/lib/ariweb.js | 121 +++++++ modules/webmaker/lib/auth.js | 14 +- modules/webmaker/lib/config.js | 5 + modules/webmaker/lib/store.js | 2 + modules/webmaker/lib/vault.js | 2 + modules/webmaker/public/app.js | 4 + modules/webmaker/public/index.html | 2 + modules/webmaker/public/style.css | 5 + modules/webmaker/server.js | 103 +++++- modules/webmaker/test/ariweb.test.js | 93 ++++++ portal/domains/api.mjs | 33 ++ portal/domains/model.mjs | 15 + portal/domains/public/domains.js | 22 ++ portal/domains/service.mjs | 167 ++++++++++ portal/domains/ui.mjs | 12 + portal/sites/api.mjs | 92 ++++++ portal/sites/fake-hal.mjs | 50 +++ portal/sites/model.mjs | 27 ++ portal/sites/public/sites.js | 103 ++++++ portal/sites/service.mjs | 459 +++++++++++++++++++++++++++ portal/sites/ui.mjs | 62 ++++ portal/source/api.mjs | 19 ++ portal/source/model.mjs | 6 + portal/source/service.mjs | 62 ++++ portal/webmaker/REUSE.md | 36 +++ portal/webmaker/adapter.mjs | 95 ++++++ portal/webmaker/api.mjs | 11 + test/domains.test.mjs | 46 +++ test/sites.test.mjs | 181 +++++++++++ test/webmaker-adapter.test.mjs | 39 +++ 32 files changed, 1900 insertions(+), 3 deletions(-) create mode 100644 lang/ko/domains.json create mode 100644 lang/ko/sites.json create mode 100644 modules/webmaker/lib/ariweb.js create mode 100644 modules/webmaker/test/ariweb.test.js create mode 100644 portal/domains/api.mjs create mode 100644 portal/domains/model.mjs create mode 100644 portal/domains/public/domains.js create mode 100644 portal/domains/service.mjs create mode 100644 portal/domains/ui.mjs create mode 100644 portal/sites/api.mjs create mode 100644 portal/sites/fake-hal.mjs create mode 100644 portal/sites/model.mjs create mode 100644 portal/sites/public/sites.js create mode 100644 portal/sites/service.mjs create mode 100644 portal/sites/ui.mjs create mode 100644 portal/source/api.mjs create mode 100644 portal/source/model.mjs create mode 100644 portal/source/service.mjs create mode 100644 portal/webmaker/REUSE.md create mode 100644 portal/webmaker/adapter.mjs create mode 100644 portal/webmaker/api.mjs create mode 100644 test/domains.test.mjs create mode 100644 test/sites.test.mjs create mode 100644 test/webmaker-adapter.test.mjs diff --git a/lang/ko/domains.json b/lang/ko/domains.json new file mode 100644 index 0000000..b9f0437 --- /dev/null +++ b/lang/ko/domains.json @@ -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": "고객 관리" } } } diff --git a/lang/ko/sites.json b/lang/ko/sites.json new file mode 100644 index 0000000..6dff8fb --- /dev/null +++ b/lang/ko/sites.json @@ -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": "해제됨" } + } +} diff --git a/modules/webmaker/lib/ariweb.js b/modules/webmaker/lib/ariweb.js new file mode 100644 index 0000000..62af968 --- /dev/null +++ b/modules/webmaker/lib/ariweb.js @@ -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/.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 /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 }; diff --git a/modules/webmaker/lib/auth.js b/modules/webmaker/lib/auth.js index 96969e8..80b9a51 100644 --- a/modules/webmaker/lib/auth.js +++ b/modules/webmaker/lib/auth.js @@ -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 기본 관리자·관리자 상태 diff --git a/modules/webmaker/lib/config.js b/modules/webmaker/lib/config.js index b243c2c..c365ba9 100644 --- a/modules/webmaker/lib/config.js +++ b/modules/webmaker/lib/config.js @@ -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: '' }, diff --git a/modules/webmaker/lib/store.js b/modules/webmaker/lib/store.js index 16f26e9..90116b0 100644 --- a/modules/webmaker/lib/store.js +++ b/modules/webmaker/lib/store.js @@ -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; diff --git a/modules/webmaker/lib/vault.js b/modules/webmaker/lib/vault.js index be82bc3..f943703 100644 --- a/modules/webmaker/lib/vault.js +++ b/modules/webmaker/lib/vault.js @@ -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 키 또는 토큰' }, }; diff --git a/modules/webmaker/public/app.js b/modules/webmaker/public/app.js index 0a72147..f124d7c 100644 --- a/modules/webmaker/public/app.js +++ b/modules/webmaker/public/app.js @@ -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 = `Ariweb Suite그룹: ${esc(a.groupName || a.groupId || '-')}${a.siteName ? `사이트: ${esc(a.siteName)}` : ''}이 화면의 "사이트" 는 Webmaker 프로젝트입니다. 운영 사이트·배포·도메인·메일은 포털에서 관리합니다.${pu ? `포털로 돌아가기` : ''}`; 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'; }; diff --git a/modules/webmaker/public/index.html b/modules/webmaker/public/index.html index 115b7b5..cc23af9 100644 --- a/modules/webmaker/public/index.html +++ b/modules/webmaker/public/index.html @@ -8,6 +8,8 @@ + +