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

40 lines
7.7 KiB
JavaScript

/**
* portal/transfer/api.mjs — 소유권 이전 화면·API(§22)와 운영자 내부 API.
* GET /transfers · /transfers/new · /transfers/:id
* GET /api/transfer 목록(보낸·받은) POST /api/transfer { toEmail|toGroupId, siteIds[], mailServiceIds[], confirm:true }
* POST /api/transfer/preview 범위 미리보기 GET /api/transfer/:id
* POST /api/transfer/:id/accept {token?} · /decline {reason} · /cancel · /dispute {note} · /recheck
* 운영자: GET /internal/ops/transfer · GET /internal/ops/transfer/:id · POST /internal/ops/transfer/:id/approve {operator, review:{ownershipProof, domainChecks}, confirm:{fromGroupId,toGroupId}} · /reject · /resolve-dispute {outcome}
*/
import { HttpError, esc } from '../../lib/http.mjs';
import { requireGroup, requireRole, requireUser } from '../auth/guard.mjs';
import * as svc from './service.mjs';
import { opsGuard, operatorOf, requireConfirm } from '../lifecycle/common.mjs';
export async function register(app, 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 });
app.static('/ui/transfer', new URL('./public', import.meta.url).pathname);
app.get('/transfers', (ctx) => { requireGroup(ctx); return shell(ctx, { title: '소유권 이전', nav: 'group', crumbs: [{ label: t('nav.group'), href: '/group' }, { label: '소유권 이전' }], actions: [{ label: '새 이전 요청', href: '/transfers/new', kind: 'primary' }], body: `<div id="tr-list">${deps.statePanel('loading')}</div>`, scripts: ['/ui/transfer/transfer.js'] }); });
app.get('/transfers/new', (ctx) => { requireRole(ctx, 'owner'); return shell(ctx, { title: '새 소유권 이전 요청', nav: 'group', crumbs: [{ label: '소유권 이전', href: '/transfers' }, { label: '새 요청' }], body: `<div id="tr-new">${deps.statePanel('loading')}</div>`, scripts: ['/ui/transfer/transfer.js'] }); });
app.get('/transfers/:id', (ctx) => { requireUser(ctx); return shell(ctx, { title: '소유권 이전 상세', nav: 'group', crumbs: [{ label: '소유권 이전', href: '/transfers' }, { label: ctx.params.id }], body: `<div id="tr-detail" data-id="${esc(ctx.params.id)}">${deps.statePanel('loading')}</div>`, scripts: ['/ui/transfer/transfer.js'] }); });
const visible = (ctx, id) => { requireUser(ctx); const tr = svc.get(deps, id); if (!tr) throw new HttpError(404, 'not_found', t('error.notFound')); const gid = ctx.group ? ctx.group.id : null; const byEmail = tr.to_email && ctx.user.email && ctx.user.email.toLowerCase() === tr.to_email; if (tr.from_group_id !== gid && tr.to_group_id !== gid && !byEmail && !ctx.query.token) throw new HttpError(404, 'not_found', t('error.notFound')); return tr; };
app.get('/api/transfer', (ctx) => { requireGroup(ctx); const gid = ctx.group.id; const rows = deps.store.all('SELECT * FROM ownership_transfers WHERE from_group_id=? OR to_group_id=? OR to_email=? ORDER BY created_at DESC LIMIT 100', gid, gid, (ctx.user.email || '').toLowerCase()); return { ok: true, transfers: rows.map((r) => svc.view(deps, svc.get(deps, r.id), gid)), excluded: svc.EXCLUDED }; });
app.get('/api/transfer/candidates', (ctx) => { requireRole(ctx, 'owner'); const gid = ctx.group.id; return { ok: true, sites: deps.store.all('SELECT id, name, lifecycle, primary_domain FROM sites WHERE group_id=? AND deleted_at IS NULL ORDER BY name', gid), mailServices: deps.store.all('SELECT id, name, domain, status FROM mail_services WHERE group_id=? AND kind=\'standalone\' AND deleted_at IS NULL ORDER BY name', gid), excluded: svc.EXCLUDED }; });
app.post('/api/transfer/preview', async (ctx) => { requireRole(ctx, 'owner'); const b = await ctx.body(); const scope = svc.scopeOf(deps, ctx.group.id, { siteIds: b.siteIds || [], mailServiceIds: b.mailServiceIds || [] }); return { ok: true, scope }; });
app.post('/api/transfer', async (ctx) => { requireRole(ctx, 'owner'); const b = await ctx.body(); requireConfirm(b); const r = svc.request(deps, { fromGroup: ctx.group, by: ctx.user.id, toEmail: b.toEmail || null, toGroupId: b.toGroupId || null, siteIds: b.siteIds || [], mailServiceIds: b.mailServiceIds || [] }); return { ok: true, transfer: svc.view(deps, r.transfer, ctx.group.id) }; });
app.get('/api/transfer/:id', (ctx) => { const tr = visible(ctx, ctx.params.id); return { ok: true, transfer: svc.view(deps, tr, ctx.group ? ctx.group.id : null) }; });
app.post('/api/transfer/:id/accept', async (ctx) => { requireRole(ctx, 'owner'); const b = await ctx.body(); requireConfirm(b); const tr = svc.accept(deps, ctx.params.id, { user: ctx.user, group: ctx.group, token: b.token || null }); return { ok: true, transfer: svc.view(deps, tr, ctx.group.id) }; });
app.post('/api/transfer/:id/recheck', (ctx) => { requireRole(ctx, 'owner'); const tr = svc.get(deps, ctx.params.id); if (!tr || tr.to_group_id !== ctx.group.id) throw new HttpError(404, 'not_found', t('error.notFound')); return { ok: true, transfer: svc.view(deps, svc.recheck(deps, tr.id), ctx.group.id) }; });
app.post('/api/transfer/:id/decline', async (ctx) => { requireRole(ctx, 'owner'); const b = await ctx.body(); const tr = visible(ctx, ctx.params.id); if (tr.from_group_id === ctx.group.id) throw new HttpError(403, 'forbidden', '보내는 쪽은 취소를 사용하세요'); return { ok: true, transfer: svc.view(deps, svc.decline(deps, tr.id, { by: ctx.user.id, groupId: ctx.group.id, reason: b.reason }), ctx.group.id) }; });
app.post('/api/transfer/:id/cancel', (ctx) => { requireRole(ctx, 'owner'); return { ok: true, transfer: svc.view(deps, svc.cancel(deps, ctx.params.id, { by: ctx.user.id, groupId: ctx.group.id }), ctx.group.id) }; });
app.post('/api/transfer/:id/dispute', async (ctx) => { requireRole(ctx, 'owner'); const b = await ctx.body(); requireConfirm(b); return { ok: true, transfer: svc.view(deps, svc.dispute(deps, ctx.params.id, { by: ctx.user.id, groupId: ctx.group.id, note: b.note, email: ctx.user.email }), ctx.group.id) }; });
const ops = (fn) => async (ctx) => { await opsGuard(ctx, deps); return fn(ctx); };
app.get('/internal/ops/transfer', ops((ctx) => ({ ok: true, transfers: deps.store.all(`SELECT id FROM ownership_transfers ${ctx.query.status ? 'WHERE status=?' : ''} ORDER BY created_at DESC LIMIT 300`, ...(ctx.query.status ? [ctx.query.status] : [])).map((r) => svc.view(deps, svc.get(deps, r.id), null)) })));
app.get('/internal/ops/transfer/:id', ops((ctx) => { const tr = svc.get(deps, ctx.params.id); if (!tr) throw new HttpError(404, 'not_found', '이전 요청이 없습니다'); return { ok: true, transfer: svc.view(deps, tr, null), finalConfirmation: { fromGroupId: tr.from_group_id, toGroupId: tr.to_group_id, siteIds: tr.scope.siteIds, mailServiceIds: tr.scope.mailServiceIds }, domains: (tr.scope.siteIds || []).flatMap((s) => deps.store.all('SELECT name, ownership_state, dns_state, cert_state FROM domains WHERE site_id=?', s)) }; }));
app.post('/internal/ops/transfer/:id/approve', ops(async (ctx) => { const b = await ctx.body(); const out = svc.approve(deps, ctx.params.id, { by: operatorOf(b), review: b.review || {}, confirm: b.confirm || {} }); return { ok: true, transfer: out.transfer, created: out.created }; }));
app.post('/internal/ops/transfer/:id/reject', ops(async (ctx) => { const b = await ctx.body(); return { ok: true, transfer: svc.reject(deps, ctx.params.id, { by: operatorOf(b), reason: b.reason }) }; }));
app.post('/internal/ops/transfer/:id/resolve-dispute', ops(async (ctx) => { const b = await ctx.body(); return { ok: true, transfer: svc.resolveDispute(deps, ctx.params.id, { by: operatorOf(b), outcome: b.outcome === 'resume' ? 'resume' : 'cancel', note: b.note }) }; }));
app.post('/internal/ops/transfer/run-due', ops(() => ({ ok: true, expired: svc.runDue(deps) })));
}