사이트 목록·생성·상세, 관리형/외부 배포, 배포 방식 변경, 도메인·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
+101
-2
@@ -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 }); }
|
||||
|
||||
Reference in New Issue
Block a user