Files
AriWeb-Suite/portal/backup/api.mjs

73 lines
16 KiB
JavaScript

/**
* portal/backup/api.mjs — 백업·복원 화면과 API(§19·§20), 운영자 내부 API(§30.3).
*
* 고객 화면
* GET /backup 그룹의 백업 현황(옵션 활성 사이트·최근 결과·복원 요청)
* GET /sites/:id/backup 사이트 백업 탭(옵션·보관 목록·복원 요청·실행 상태)
* GET /sites/:id/restore/:backupId 복원 요청 화면(§20.1)
* 고객 API (/api/backup/…)
* GET sites/:id siteView POST sites/:id/option 옵션 구매(권리 필요)
* POST sites/:id/option/resume 재개(confirm 방식·저장 공간 정리 후) POST sites/:id/option/end 옵션 해지(확인 필요)
* POST sites/:id/run 지금 백업(수동, 옵션 활성 때) GET backups/:id 백업 하나
* POST sites/:id/restore { backupId, completionMode } 복원 요청 GET restores/:id 복원 상태(작업 포함)
* POST restores/:id/decision { action: confirm|revert } POST restores/:id/cancel
* 운영자 내부 API (/internal/ops/backup/…, lib/serviceauth 키 hal)
* GET options · GET backups?site= · GET restores · GET restores/:id · POST restores/:id/approve {operator, window, confirm:{groupId,siteId,domain,backupAt}}
* POST restores/:id/reject · POST options/:id/override-same-server · POST options/:id/retry · POST options/:id/end · POST run-due
*/
import { HttpError, esc } from '../../lib/http.mjs';
import { requireGroup } from '../auth/guard.mjs';
import * as svc from './service.mjs';
import { getSite, opsGuard, operatorOf, jobView, humanBytes, fmtKst, nowIso, parseJson, requireConfirm, inProgress } from '../lifecycle/common.mjs';
import { audit } from '../../lib/audit.mjs';
import { notify } from '../../lib/notify.mjs';
import { QuotaError } from '../../lib/quota.mjs';
export async function register(app, deps) {
svc.registerJobs(deps);
const { page, t } = deps;
const shell = (ctx, o) => page({ user: ctx.user, group: ctx.group, groups: ctx.groups || (ctx.group ? [ctx.group] : []), csrf: ctx.session?.csrf, unread: ctx.unread || 0, ...o });
const quotaErr = (e) => { if (e instanceof QuotaError) throw new HttpError(409, e.code, e.message, { ...(e.detail || {}), action: 'buy_product', link: '/billing/products?product=backup' }); throw e; };
// ---------- 고객 화면 ----------
app.get('/backup', (ctx) => { requireGroup(ctx); return shell(ctx, { title: t('backup.title'), nav: 'backup', crumbs: [{ label: t('nav.backup') }], body: `<div id="bk-list">${deps.statePanel('loading')}</div>`, scripts: ['/ui/backup/backup.js'] }); });
app.get('/sites/:id/backup', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'view'); return shell(ctx, { title: `${site.name} · ${t('backup.title')}`, nav: 'sites', crumbs: [{ label: t('nav.sites'), href: '/sites' }, { label: site.name, href: `/sites/${site.id}` }, { label: t('backup.title') }], body: `${siteTabs(site.id, 'backup')}<div id="bk-site" data-site="${esc(site.id)}">${deps.statePanel('loading')}</div>`, scripts: ['/ui/backup/backup.js'] }); });
app.get('/sites/:id/restore/:backupId', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'deploy'); const b = backupOf(ctx.params.backupId, site); return shell(ctx, { title: t('backup.restore.title'), nav: 'sites', crumbs: [{ label: t('nav.sites'), href: '/sites' }, { label: site.name, href: `/sites/${site.id}` }, { label: t('backup.title'), href: `/sites/${site.id}/backup` }, { label: t('backup.restore.title') }], body: `<div id="bk-restore" data-site="${esc(site.id)}" data-backup="${esc(b.id)}">${deps.statePanel('loading')}</div>`, scripts: ['/ui/backup/backup.js'] }); });
app.static('/ui/backup', new URL('./public', import.meta.url).pathname);
function backupOf(id, site) { const b = deps.store.get('SELECT * FROM backups WHERE id=? AND site_id=?', id, site.id); if (!b) throw new HttpError(404, 'not_found', t('error.notFound')); return b; }
function siteTabs(id, on) { const tabs = [['overview', '개요', ''], ['deploy', '배포', '/deploy'], ['domains', '도메인', '/domains'], ['mail', '메일', '/mail'], ['backup', '백업', '/backup'], ['usage', '사용량 및 통계', '/usage'], ['lifecycle', '설정·생명주기', '/lifecycle']]; return `<nav class="tabs" aria-label="사이트 탭">${tabs.map(([k, l, p]) => `<a href="/sites/${id}${p}" class="${k === on ? 'on' : ''}">${l}</a>`).join('')}</nav>`; }
// ---------- 고객 API ----------
app.get('/api/backup/overview', (ctx) => {
requireGroup(ctx); const gid = ctx.group.id;
const sites = deps.store.all('SELECT * FROM sites WHERE group_id=? AND deleted_at IS NULL ORDER BY name', gid).map((s) => { const o = svc.optionOf(deps, s.id); const last = deps.store.get('SELECT created_at, status, verify_state FROM backups WHERE site_id=? AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1', s.id); return { id: s.id, name: s.name, deployMode: s.deploy_mode, lifecycle: s.lifecycle, eligible: svc.eligibility(deps, s).ok, option: o ? { status: o.status, pausedReason: o.paused_reason, nextRunAt: o.next_run_at, lastSuccessAt: o.last_success_at, lastFailureAt: o.last_failure_at, consecutiveFailures: o.consecutive_failures, endsAt: o.ends_at, deleteAfter: o.delete_after } : null, lastBackup: last || null }; });
const restores = deps.store.all('SELECT r.*, s.name AS site_name FROM restores r JOIN sites s ON s.id=r.site_id WHERE r.group_id=? ORDER BY r.created_at DESC LIMIT 20', gid).map((r) => ({ id: r.id, siteId: r.site_id, siteName: r.site_name, status: r.status, createdAt: r.created_at, cost: r.cost, job: jobView(deps, r.job_id) }));
return { ok: true, sites, restores, mailBackupNotice: '메일 원문은 고객용 백업·복원 대상이 아닙니다', measuredAt: nowIso() };
});
app.get('/api/backup/sites/:id', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'view'); const v = svc.siteView(deps, site); return { ok: true, ...v, lastRun: v.lastRun ? jobView(deps, v.lastRun.id) : null, restores: v.restores.map((r) => ({ ...r, job: r.job ? jobView(deps, r.job.id) : null })) }; });
app.post('/api/backup/sites/:id/option', async (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'billing'); const body = await ctx.body(); requireConfirm(body); try { const o = svc.purchaseOption(deps, site, { by: ctx.user.id, orderId: body.orderId || null }); return { ok: true, option: o }; } catch (e) { quotaErr(e); } });
app.post('/api/backup/sites/:id/option/resume', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'deploy'); const o = svc.resumeForSite(deps, site.id, { by: ctx.user.id, userConfirmed: true }); audit(deps.store, { actorType: 'user', actorId: ctx.user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'backup.option.resume' }); return { ok: true, option: o }; });
app.post('/api/backup/sites/:id/option/end', async (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'billing'); const body = await ctx.body(); requireConfirm(body); const o = svc.optionOf(deps, site.id); if (!o || o.status === 'ended') throw new HttpError(404, 'not_found', '활성 백업 옵션이 없습니다'); return { ok: true, option: svc.expireOption(deps, o.id, { by: ctx.user.id, reason: 'option_ended' }) }; });
app.post('/api/backup/sites/:id/run', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'deploy'); const o = svc.optionOf(deps, site.id); if (!o || o.status !== 'active') throw new HttpError(409, 'backup_option_inactive', '활성 백업 옵션이 있어야 수동 백업을 할 수 있습니다'); const conflicts = inProgress(deps, 'site', site.id).filter((c) => c.kind !== 'backup.scheduled'); if (conflicts.length) throw new HttpError(409, 'conflict', '진행 중인 배포·복원·이전이 있어 지금은 백업할 수 없습니다', { conflicts }); const slot = 'manual:' + nowIso().slice(0, 16); const { job, created } = deps.jobs.enqueue({ kind: 'backup.scheduled', subject: { optionId: o.id, siteId: site.id, groupId: site.group_id, slot, manual: true }, idempotencyKey: `backup:${o.id}:${slot}`, actorType: 'user', createdBy: ctx.user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, label: `수동 백업 (${site.name})` }); return { ok: true, job: jobView(deps, job.id), created }; });
app.get('/api/backup/backups/:id', (ctx) => { const b = deps.store.get('SELECT * FROM backups WHERE id=?', ctx.params.id); if (!b) throw new HttpError(404, 'not_found', t('error.notFound')); const site = getSite(deps, ctx, b.site_id, 'view'); return { ok: true, backup: { ...b, scope: parseJson(b.scope_json, {}) }, restoreInfo: svc.restoreInfo(deps, site, b) }; });
app.post('/api/backup/sites/:id/restore', async (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'deploy'); const body = await ctx.body(); const b = backupOf(String(body.backupId || ''), site); requireConfirm(body); const r = await svc.requestRestore(deps, site, b, { by: ctx.user.id, completionMode: body.completionMode || null }); return { ok: true, restore: r.restore, created: r.created, info: svc.restoreInfo(deps, site, b), finalConfirmation: { group: ctx.group.name, site: site.name, domain: site.primary_domain } }; });
app.get('/api/backup/restores/:id', (ctx) => { const r = deps.store.get('SELECT * FROM restores WHERE id=?', ctx.params.id); if (!r) throw new HttpError(404, 'not_found', t('error.notFound')); const site = getSite(deps, ctx, r.site_id, 'view'); return { ok: true, restore: { ...r, policy: parseJson(r.policy_json, {}) }, job: jobView(deps, r.job_id), site: { id: site.id, name: site.name, domain: site.primary_domain }, group: ctx.group.name }; });
app.post('/api/backup/restores/:id/decision', async (ctx) => { const r = deps.store.get('SELECT * FROM restores WHERE id=?', ctx.params.id); if (!r) throw new HttpError(404, 'not_found', t('error.notFound')); getSite(deps, ctx, r.site_id, 'deploy'); const body = await ctx.body(); if (body.action === 'revert') requireConfirm(body); const out = await svc.userDecision(deps, r, body.action, ctx.user.id); return { ok: true, ...out, job: jobView(deps, r.job_id) }; });
app.post('/api/backup/restores/:id/cancel', (ctx) => { const r = deps.store.get('SELECT * FROM restores WHERE id=?', ctx.params.id); if (!r) throw new HttpError(404, 'not_found', t('error.notFound')); getSite(deps, ctx, r.site_id, 'deploy'); if (!['awaiting_payment', 'awaiting_approval'].includes(r.status)) throw new HttpError(409, 'restore_not_cancellable', '승인 뒤에는 취소할 수 없습니다. 복원이 끝난 뒤 되돌리기를 사용하세요'); deps.store.run('UPDATE restores SET status=\'cancelled\', updated_at=?, ended_at=? WHERE id=?', nowIso(), nowIso(), r.id); audit(deps.store, { actorType: 'user', actorId: ctx.user.id, groupId: r.group_id, subjectType: 'site', subjectId: r.site_id, action: 'restore.cancel', detail: { restoreId: r.id } }); return { ok: true }; });
// ---------- 운영자 내부 API ----------
const ops = (fn) => async (ctx) => { await opsGuard(ctx, deps); return fn(ctx); };
app.get('/internal/ops/backup/options', ops((ctx) => ({ ok: true, options: deps.store.all('SELECT o.*, s.name AS site_name, s.server_id FROM backup_options o JOIN sites s ON s.id=o.site_id ORDER BY o.updated_at DESC LIMIT 500').map((o) => ({ ...o, storedBytes: svc.storedBytes(deps, o.site_id) })) })));
app.get('/internal/ops/backup/backups', ops((ctx) => ({ ok: true, backups: deps.store.all(`SELECT * FROM backups ${ctx.query.site ? 'WHERE site_id=?' : ''} ORDER BY created_at DESC LIMIT 500`, ...(ctx.query.site ? [ctx.query.site] : [])) })));
app.get('/internal/ops/backup/restores', ops((ctx) => ({ ok: true, restores: deps.store.all(`SELECT r.*, s.name AS site_name, s.primary_domain, g.name AS group_name FROM restores r JOIN sites s ON s.id=r.site_id JOIN groups g ON g.id=r.group_id ${ctx.query.status ? 'WHERE r.status=?' : ''} ORDER BY r.created_at DESC LIMIT 300`, ...(ctx.query.status ? [ctx.query.status] : [])).map((r) => ({ ...r, job: jobView(deps, r.job_id) })) })));
app.get('/internal/ops/backup/restores/:id', ops((ctx) => { const r = deps.store.get('SELECT r.*, s.name AS site_name, s.primary_domain, g.name AS group_name FROM restores r JOIN sites s ON s.id=r.site_id JOIN groups g ON g.id=r.group_id WHERE r.id=?', ctx.params.id); if (!r) throw new HttpError(404, 'not_found', '복원 요청이 없습니다'); const b = deps.store.get('SELECT * FROM backups WHERE id=?', r.backup_id); return { ok: true, restore: r, backup: b, job: jobView(deps, r.job_id), finalConfirmation: { customerGroup: r.group_name, groupId: r.group_id, site: r.site_name, siteId: r.site_id, domain: r.primary_domain, backupAt: b ? b.created_at : null } }; }));
app.post('/internal/ops/backup/restores/:id/approve', ops(async (ctx) => { const body = await ctx.body(); const r = deps.store.get('SELECT r.*, s.primary_domain FROM restores r JOIN sites s ON s.id=r.site_id WHERE r.id=?', ctx.params.id); if (!r) throw new HttpError(404, 'not_found', '복원 요청이 없습니다'); const b = deps.store.get('SELECT created_at FROM backups WHERE id=?', r.backup_id); const c = body.confirm || {}; if (c.groupId !== r.group_id || c.siteId !== r.site_id || (c.backupAt || null) !== (b ? b.created_at : null)) throw new HttpError(400, 'final_confirmation_mismatch', '최종 확인 내용(고객 그룹·사이트·백업 시각)이 요청과 다릅니다', { expected: { groupId: r.group_id, siteId: r.site_id, domain: r.primary_domain, backupAt: b ? b.created_at : null } }); if (body.paymentConfirmed) svc.markRestorePaid(deps, r.id, operatorOf(body)); const out = svc.approveRestore(deps, r.id, { by: operatorOf(body), window: body.window || null }); return { ok: true, restore: out.restore, job: jobView(deps, out.job.id), created: out.created }; }));
app.post('/internal/ops/backup/restores/:id/reject', ops(async (ctx) => { const body = await ctx.body(); return { ok: true, restore: svc.rejectRestore(deps, ctx.params.id, { by: operatorOf(body), reason: body.reason }) }; }));
app.post('/internal/ops/backup/restores/:id/retry', ops(async (ctx) => { const r = deps.store.get('SELECT * FROM restores WHERE id=?', ctx.params.id); if (!r || !r.job_id) throw new HttpError(404, 'not_found', '복원 작업이 없습니다'); const body = await ctx.body(); const j = deps.jobs.retry(r.job_id, operatorOf(body)); deps.store.run('UPDATE restores SET status=\'queued\', error=NULL, updated_at=? WHERE id=?', nowIso(), r.id); return { ok: true, job: jobView(deps, j.id) }; }));
app.post('/internal/ops/backup/options/:id/override-same-server', ops(async (ctx) => { const body = await ctx.body(); const o = svc.optionById(deps, ctx.params.id); if (!o) throw new HttpError(404, 'not_found', '옵션이 없습니다'); deps.store.run('UPDATE backup_options SET same_server_override=?, updated_at=? WHERE id=?', body.allow ? 1 : 0, nowIso(), o.id); audit(deps.store, { actorType: 'operator', actorId: operatorOf(body), groupId: o.group_id, subjectType: 'site', subjectId: o.site_id, action: 'backup.option.same_server_override', detail: { allow: !!body.allow, reason: body.reason } }); return { ok: true }; }));
app.post('/internal/ops/backup/options/:id/retry', ops(async (ctx) => { const body = await ctx.body(); const o = svc.optionById(deps, ctx.params.id); if (!o) throw new HttpError(404, 'not_found', '옵션이 없습니다'); const last = deps.store.get('SELECT id, status FROM jobs WHERE kind=\'backup.scheduled\' AND subject_id=? ORDER BY created_at DESC LIMIT 1', o.site_id); if (last && ['failed', 'needs_user'].includes(last.status)) { const j = deps.jobs.retry(last.id, operatorOf(body)); return { ok: true, job: jobView(deps, j.id) }; } svc.resumeForSite(deps, o.site_id, { by: operatorOf(body), userConfirmed: true, clearStorageFull: !!body.clearStorageFull }); deps.store.run('UPDATE backup_options SET next_run_at=?, updated_at=? WHERE id=?', nowIso(), nowIso(), o.id); return { ok: true, jobs: svc.runDue(deps) }; }));
app.post('/internal/ops/backup/options/:id/end', ops(async (ctx) => { const body = await ctx.body(); return { ok: true, option: svc.expireOption(deps, ctx.params.id, { by: operatorOf(body), reason: body.reason || 'operator' }) }; }));
app.post('/internal/ops/backup/run-due', ops(() => ({ ok: true, started: svc.runDue(deps) })));
}