Files

93 lines
16 KiB
JavaScript

/**
* portal/sites/api.mjs — 사이트 영역 라우트(화면 + JSON API + 내부/운영자 API).
*
* 화면: /sites, /sites/new, /sites/:id(?tab=), /sites/:id/deploy, /sites/:id/deploy/mode, /sites/:id/external
* API : /api/sites (GET 목록, POST 생성) · /api/sites/:id (GET, PATCH 설정) · /api/sites/:id/overview · /versions · /precheck · /deploy(POST) · /deployments · /rollback · /cleanup
* /api/sites/:id/mode (GET 안내, POST 선택) · /consent(POST) · /external (GET 상태, POST 토큰 발급, DELETE 해제) · /external/remove(POST) · /jobs/:jobId/(retry|cancel|resume|revert)
* /api/sites/:id/usage · /activity
* 내부: /internal/bundles/:token (Agent 가 저장본을 받아 감) · /internal/webmaker/project-impact (Webmaker 가 폐기 전 영향 조회, 키 id 'webmaker')
* 운영자: /internal/ops/sites, /internal/ops/sites/:id (키 id 'hal')
* 권한: 조회 view · 설정 edit/admin · 배포 deploy · 외부 Agent deploy · 해지 billing(다른 영역)
*/
import { requireGroup, requireSitePerm, requireRole } from '../auth/guard.mjs';
import { HttpError } from '../../lib/http.mjs';
import { verifyCtx } from '../../lib/serviceauth.mjs';
import { listAudit } from '../../lib/audit.mjs';
import { describe as describeJob } from '../../lib/jobs.mjs';
import * as quota from '../../lib/quota.mjs';
import { createSitesService } from './service.mjs';
import * as ui from './ui.mjs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createReadStream, existsSync, statSync } from 'node:fs';
const HERE = dirname(fileURLToPath(import.meta.url));
export async function register(app, deps) {
const sites = createSitesService(deps); deps.sites = sites;
app.static('/ui/sites', join(HERE, 'public'));
const secretOf = (keyId) => deps.vault.get('service:' + keyId) || (keyId === 'webmaker' && deps.webmaker ? deps.webmaker.secret() : null);
async function requireService(ctx, keyIds) { const v = await verifyCtx(ctx, (id) => (keyIds.includes(id) ? secretOf(id) : null)); if (!v.ok) throw new HttpError(401, 'service_auth_failed', `서비스 인증 실패(${v.reason})`); return v.keyId; }
/** 현재 그룹의 사이트를 권한과 함께 가져온다(다른 그룹 사이트는 404) */
/** 조회(view)는 그룹 구성원 누구나(viewer 포함 §4.3 '조회'), 그 밖의 권한은 guard.requireSitePerm */
const siteOf = (ctx, perm = 'view') => { requireGroup(ctx); const s = sites.getForGroup(ctx.group.id, ctx.params.id); if (!s) throw new HttpError(404, 'not_found', '사이트를 찾을 수 없습니다'); if (perm !== 'view') requireSitePerm(ctx, s, perm); return s; };
const pageCtx = (ctx) => { requireGroup(ctx); return ctx; };
// ---------- 화면 ----------
app.get('/sites', (ctx) => ctx.html(ui.listPage(pageCtx(ctx), deps, { filter: ctx.query.filter || '' })));
app.get('/sites/new', (ctx) => { pageCtx(ctx); requireRole(ctx, 'member'); return ctx.html(ui.newPage(ctx, deps)); });
app.get('/sites/:id', (ctx) => ctx.html(ui.detailPage(ctx, deps, siteOf(ctx, 'view'), { tab: ctx.query.tab || 'overview' })));
app.get('/sites/:id/deploy', (ctx) => ctx.html(ui.deployPage(ctx, deps, siteOf(ctx, 'view'))));
app.get('/sites/:id/deploy/mode', (ctx) => ctx.html(ui.modePage(ctx, deps, siteOf(ctx, 'view'))));
app.get('/sites/:id/external', (ctx) => ctx.html(ui.externalPage(ctx, deps, siteOf(ctx, 'view'))));
/** Webmaker SSO 진입(별도 로그인 없음). site 를 주면 그 프로젝트로, path 로 Webmaker 안의 특정 화면 */
app.get('/sso/webmaker', (ctx) => { requireGroup(ctx); let site = null; if (ctx.query.site) { site = sites.getForGroup(ctx.group.id, ctx.query.site); if (!site) throw new HttpError(404, 'not_found', '사이트를 찾을 수 없습니다'); requireSitePerm(ctx, site, 'edit'); } if (!deps.webmaker) throw new HttpError(503, 'service_unreachable', 'Webmaker 연동이 설정되지 않았습니다'); return ctx.redirect(deps.webmaker.ssoUrl({ user: ctx.user, group: ctx.group, site, projectId: ctx.query.project || null, path: ctx.query.path || null })); });
// ---------- JSON API ----------
app.get('/api/sites', (ctx) => { requireGroup(ctx); return { ok: true, sites: sites.list(ctx.group.id, { lifecycle: ctx.query.filter || null }), siteCount: quota.figures(deps.store, ctx.group.id, 'sites') }; });
app.post('/api/sites', async (ctx) => { requireRole(ctx, 'member'); if (!ctx.user.email_verified_at) throw new HttpError(403, 'email_unverified', '이메일 확인이 끝나야 사이트를 만들 수 있습니다'); const r = await sites.create({ user: ctx.user, group: ctx.group, ip: ctx.ip }, await ctx.body()); return { ok: true, ...r }; });
app.get('/api/sites/:id', (ctx) => { const s = siteOf(ctx); return { ok: true, site: pub(s), summary: sites.summarize(s) }; });
app.patch('/api/sites/:id', async (ctx) => { const s = siteOf(ctx, 'admin'); const r = sites.update(s, await ctx.body(), ctx.user); return { ok: true, site: pub(r.site), impact: r.impact, jobId: r.jobId || null }; });
app.get('/api/sites/:id/overview', (ctx) => { const s = siteOf(ctx); let refreshed = null; try { refreshed = sites.refresh(s.group_id); } catch (_) {} return { ok: true, overview: sites.overview(s), refreshJob: refreshed ? refreshed.id : null }; });
/** 제작 탭: Webmaker 프로젝트 요약·저장본·AI Wallet(호스팅 사용량과 분리) */
app.get('/api/sites/:id/versions', async (ctx) => { const s = siteOf(ctx); try { const v = await sites.versionsFor(s); let wallet = null; try { wallet = deps.webmaker ? await deps.webmaker.credits(sites.userLogin(ctx.user, ctx.group)) : null; } catch (_) { wallet = { stale: true }; } return { ok: true, ...v, wallet, webmakerUrl: `/sso/webmaker?site=${s.id}` }; } catch (e) { if (e.code === 'service_unreachable') return ctx.json({ ok: false, error: 'service_unreachable', message: 'Webmaker 에 연결할 수 없습니다(정보 확인 지연)', stale: true }, 503); throw e; } });
app.get('/api/sites/:id/precheck', async (ctx) => { const s = siteOf(ctx, 'deploy'); return { ok: true, precheck: await sites.precheck(s, { versionRef: ctx.query.version || 'current' }) }; });
app.post('/api/sites/:id/deploy', async (ctx) => { const s = siteOf(ctx, 'deploy'); const b = await ctx.body(); const r = await sites.startDeploy({ user: ctx.user, group: ctx.group, site: s, versionRef: b.versionRef || 'current', versionLabel: b.versionLabel || null, ip: ctx.ip }); return { ok: true, ...r }; });
app.get('/api/sites/:id/deployments', (ctx) => { const s = siteOf(ctx); return { ok: true, deployments: sites.deployments(s.id), activeJobs: sites.activeJobsFor(s.id).map(describeJob), site: pub(sites.get(s.id)) }; });
app.post('/api/sites/:id/rollback', (ctx) => { const s = siteOf(ctx, 'deploy'); return { ok: true, job: sites.rollback(s, ctx.user) }; });
app.post('/api/sites/:id/cleanup', (ctx) => { const s = siteOf(ctx, 'deploy'); return { ok: true, job: sites.cleanupAndReturn(s, ctx.user) }; });
app.get('/api/sites/:id/mode', async (ctx) => { const s = siteOf(ctx); return { ok: true, ...(await modeInfo(s, ctx)) }; });
app.post('/api/sites/:id/mode', async (ctx) => { const s = siteOf(ctx, 'deploy'); const b = await ctx.body(); const r = await sites.setDeployMode(s, b.mode, ctx.user); return { ok: true, site: pub(r.site), changed: r.changed, job: r.job || null }; });
app.post('/api/sites/:id/consent', async (ctx) => { const s = siteOf(ctx, 'deploy'); const b = await ctx.body(); if (b.version !== sites.EXTERNAL_CONSENT.version || !b.agree) throw new HttpError(400, 'consent_required', '확인 사항에 동의해야 합니다'); return { ok: true, site: pub(sites.recordConsent(s, ctx.user, { ip: ctx.ip })) }; });
app.get('/api/sites/:id/external', async (ctx) => { const s = siteOf(ctx); const a = await sites.refreshExternalAgent(s); return { ok: true, agent: a, consent: { version: sites.EXTERNAL_CONSENT.version, agreed: s.consent_version === sites.EXTERNAL_CONSENT.version }, lastDeploy: sites.deployments(s.id, 1)[0] || null }; });
app.post('/api/sites/:id/external', async (ctx) => { const s = siteOf(ctx, 'deploy'); if (s.consent_version !== sites.EXTERNAL_CONSENT.version) throw new HttpError(409, 'consent_required', '외부 배포 확인 사항에 먼저 동의하세요', { href: `/sites/${s.id}/deploy/mode` }); const b = await ctx.body(); return { ok: true, ...(await sites.createExternalAgent(s, ctx.user, String(b.label || '').slice(0, 80))) }; });
app.delete('/api/sites/:id/external', async (ctx) => { const s = siteOf(ctx, 'deploy'); await sites.revokeExternalAgent(s, ctx.user); return { ok: true }; });
app.post('/api/sites/:id/external/remove', (ctx) => { const s = siteOf(ctx, 'deploy'); return { ok: true, job: sites.removeExternalDeploy(s, ctx.user) }; });
app.post('/api/sites/:id/jobs/:jobId/:action', async (ctx) => { const s = siteOf(ctx, 'deploy'); const j = deps.jobs.get(ctx.params.jobId); if (!j || j.subject_id !== s.id) throw new HttpError(404, 'not_found', '작업을 찾을 수 없습니다'); const a = ctx.params.action; let r; if (a === 'retry') r = deps.jobs.retry(j.id, ctx.user.id); else if (a === 'cancel') r = deps.jobs.cancel(j.id, ctx.user.id); else if (a === 'resume') r = deps.jobs.resume(j.id, ctx.user.id); else if (a === 'revert') r = await deps.jobs.revert(j.id, ctx.user.id); else throw new HttpError(400, 'bad_action', '알 수 없는 작업 동작'); return { ok: true, job: describeJob(r) }; });
app.get('/api/sites/:id/jobs/:jobId', (ctx) => { const s = siteOf(ctx); const j = deps.jobs.get(ctx.params.jobId); if (!j || j.subject_id !== s.id) throw new HttpError(404, 'not_found', '작업을 찾을 수 없습니다'); return { ok: true, job: describeJob(j) }; });
app.get('/api/sites/:id/usage', (ctx) => { const s = siteOf(ctx); const o = sites.overview(s); const alloc = ['web_disk', 'web_out'].map((r) => ({ resource: r, ...quota.RESOURCES[r], allocation: quota.allocationOf(deps.store, s.group_id, r, 'site', s.id), usage: quota.usageOf(deps.store, s.group_id, r, 'site', s.id) })); return { ok: true, overview: o, allocations: alloc, samples: deps.store.all('SELECT metric, value, period, measured_at FROM usage_samples WHERE subject_type=\'site\' AND subject_id=? AND metric<>\'series_json\' ORDER BY measured_at DESC LIMIT 200', s.id) }; });
app.get('/api/sites/:id/activity', (ctx) => { const s = siteOf(ctx); return { ok: true, items: listAudit(deps.store, { subjectType: 'site', subjectId: s.id, limit: 100 }), jobs: deps.jobs.list({ subjectType: 'site', subjectId: s.id, limit: 50 }).map(describeJob) }; });
// ---------- 내부 ----------
/** 저장본 다운로드(Agent). 토큰은 1시간 유효. 비밀값 없는 tar.gz */
app.get('/internal/bundles/:token', (ctx) => { const t = sites.bundleByToken(ctx.params.token); if (!t || !existsSync(t.file)) throw new HttpError(404, 'not_found', '번들을 찾을 수 없거나 만료되었습니다'); ctx.ended = true; ctx.res.writeHead(200, { 'Content-Type': 'application/gzip', 'Content-Length': statSync(t.file).size, 'Cache-Control': 'no-store' }); createReadStream(t.file).pipe(ctx.res); return undefined; });
app.post('/internal/webmaker/project-impact', async (ctx) => { await requireService(ctx, ['webmaker']); const b = await ctx.body(); return { ok: true, impact: sites.impactForProject(String(b.projectId || ''), b.siteId || null) }; });
app.post('/internal/webmaker/project-destroyed', async (ctx) => { await requireService(ctx, ['webmaker']); const b = await ctx.body(); return { ok: true, unlinked: sites.onProjectDestroyed(String(b.projectId || '')) }; });
// ---------- 운영자(HAL 콘솔이 부른다) ----------
app.get('/internal/ops/sites', async (ctx) => { await requireService(ctx, ['hal', 'portal']); const { groupId, lifecycle, q } = ctx.query; const w = ['lifecycle<>\'deleted\'']; const p = []; if (groupId) { w.push('group_id=?'); p.push(groupId); } if (lifecycle) { w.push('lifecycle=?'); p.push(lifecycle); } if (q) { w.push('(name LIKE ? OR primary_domain LIKE ? OR id=?)'); p.push(`%${q}%`, `%${q}%`, q); } return { ok: true, sites: deps.store.all(`SELECT * FROM sites WHERE ${w.join(' AND ')} ORDER BY updated_at DESC LIMIT 500`, ...p).map((r) => sites.summarize(sites.get(r.id))).map((s) => ({ ...s, groupId: sites.get(s.id).group_id, serverId: sites.get(s.id).server_id })) }; });
app.get('/internal/ops/sites/:id', async (ctx) => { await requireService(ctx, ['hal', 'portal']); const s = sites.get(ctx.params.id); if (!s) throw new HttpError(404, 'not_found', '사이트 없음'); return { ok: true, site: s, overview: sites.overview(s), deployments: sites.deployments(s.id, 50), jobs: deps.jobs.list({ subjectType: 'site', subjectId: s.id, limit: 100 }).map(describeJob), externalAgent: sites.externalAgentFor(s.id), domains: deps.store.all('SELECT * FROM domains WHERE site_id=?', s.id), audit: listAudit(deps.store, { subjectType: 'site', subjectId: s.id, limit: 100 }) }; });
app.post('/internal/ops/sites/:id/refresh', async (ctx) => { await requireService(ctx, ['hal', 'portal']); const s = sites.get(ctx.params.id); if (!s) throw new HttpError(404, 'not_found', '사이트 없음'); return { ok: true, job: sites.refresh(s.group_id, { force: true }) }; });
app.post('/internal/ops/sites/sweep', async (ctx) => { await requireService(ctx, ['hal', 'portal']); return { ok: true, ...sites.sweep() }; });
/** 배포 방식 안내 데이터(§10.1·§10.2): 권리·보장량·백업 가능 여부·비용(활성 상품정책에서) */
async function modeInfo(s, ctx) {
const ent = ['sites', 'web_disk', 'web_out', 'backup'].map((r) => quota.figures(deps.store, s.group_id, r));
const policy = deps.productPolicy ? await deps.productPolicy.activeFor(s.group_id).catch(() => null) : null; // 상품 영역(fork C)이 제공하면 비용 표시
let servers = null; let halStale = false; try { servers = (await deps.hal.listServers({ role: 'web' })).filter((x) => x.acceptsNew).length; } catch (_) { halStale = true; }
const agent = sites.externalAgentFor(s.id);
return { current: s.deploy_mode, runtime: s.runtime, consent: { ...sites.EXTERNAL_CONSENT, agreed: s.consent_version === sites.EXTERNAL_CONSENT.version }, agent: agent ? { status: agent.status, label: agent.label } : null,
managed: { description: 'Ariweb 가 서버 배치와 운영(모니터링·자동 재시작·용량 조정 이전)을 관리합니다', monitoring: ['외부 접속 확인(대표 도메인, 10분 주기)', '디스크·데이터베이스·네트워크 사용량 수집', '오류율·응답 시간'], limits: ent.filter((e) => ['web_disk', 'web_out'].includes(e.resource)), backupAvailable: ent.find((e) => e.resource === 'backup').purchased > 0 || true, backupNote: '백업은 관리형 사이트 단위 유료 옵션입니다(백업 화면에서 구매)', relocation: '정적 사이트는 조건이 맞으면 자동 이전, 동적 사이트는 운영자 승인 후 이전', estimatedMinutes: s.runtime === 'node' ? 10 : 3, cost: policy ? policy.managedDeployCost || null : null, costNote: policy ? null : '승인된 비용은 활성 상품정책에서 표시됩니다(상품 영역 연결 전)', supportedRuntimes: ['static', 'node'], runtimeNotes: { static: 'HTML·CSS·JS·이미지. 서버 코드 실행 없음', node: 'Ariweb 가 승인한 Node 실행 형태. 데이터베이스: SQLite 파일만. 외부 DB·cron·메일 발송·소켓 서버는 이번 버전에서 지원하지 않음' }, serversAvailable: servers, halStale },
external: { targets: ['External Pull Agent 를 설치할 수 있는 지원 Linux 서버(Ubuntu 22.04/24.04, Debian 12, systemd, sudo, 외부 HTTPS 접속)'], prepare: ['root 또는 sudo 가능한 계정', '서버에서 HAL(hal.ariweb.net) 로 나가는 HTTPS 연결', '웹서버(nginx) 또는 Agent 가 설치할 nginx 허용', '도메인 DNS 를 고객이 직접 관리'], perDeployCost: policy ? policy.externalDeployCost || null : null, costNote: policy ? null : '건별 비용은 활성 상품정책에서 표시됩니다(상품 영역 연결 전)', notProvided: ['관리형 모니터링', '백업·복원', '서버 이전', 'SLA', '메일(외부 메일 배포 미지원)'], isolation: '외부 환경 장애는 Ariweb 정상 상태와 분리되어 상태 페이지·알림에 Ariweb 장애로 표시되지 않습니다', siteCountNote: '외부 배포도 공개 사이트라면 운영 사이트 수를 소비합니다. 관리형 디스크·트래픽·메일·백업 권리는 자동 할당되지 않습니다' } };
}
function pub(s) { if (!s) return null; const { settings_json, pending_cleanup_json, last_external_check_json, next_job_json, ...rest } = s; return rest; }
}