Files

104 lines
10 KiB
JavaScript

/**
* portal/lifecycle/common.mjs — 백업·복원·이전·소유권 이전·생명주기 영역(fork F)이 함께 쓰는 도우미.
*
* - getSite / getMailService : 그룹 경계와 사이트별 권한(§4.3)을 검사해 행을 돌려준다(다른 그룹 것은 404 — 존재를 드러내지 않음 §37).
* - opsGuard : 운영자 콘솔(HAL)에서 오는 내부 호출을 lib/serviceauth 로 검증한다(보관소의 'service:<keyId>').
* - productPolicy : 활성 상품정책 버전(product_versions)에서 limits/policy 를 읽는다. 값이 없으면 deferred 로 표시해 화면이 "미확정" 을 솔직히 보이게 한다(§19.1·§27.2).
* - halOf : HAL 어댑터가 없거나 응답하지 않으면 '정보 확인 지연' 오류(503)로 바꿔 신규 증가를 막는다(§13.4·§26.2).
* - inProgress : 사이트·메일에 진행 중인 배포·복원·이전·소유권 이전이 있는지(충돌 확인 §11.1·§21·§22).
* - 설정 표(settings) 읽기/쓰기, KST 표시, 작업 상태 요약.
*/
import { HttpError } from '../../lib/http.mjs';
import { requireSitePerm, requireGroup } from '../auth/guard.mjs';
import { verifyCtx } from '../../lib/serviceauth.mjs';
import { parseJson, toJson, nowIso } from '../../lib/db.mjs';
import { TERMINAL } from '../../lib/status.mjs';
import { describe } from '../../lib/jobs.mjs';
import { fmtKst } from '../../lib/kst.mjs';
export { fmtKst, nowIso, parseJson, toJson, describe };
/** ms 단위 상수 */
export const DAY = 86400000, HOUR = 3600000;
export const addMs = (iso, ms) => new Date(Date.parse(iso || nowIso()) + ms).toISOString();
/** 사이트 행 + 권한 검사 */
export function getSite(deps, ctx, id, perm = 'view') {
const s = deps.store.get('SELECT * FROM sites WHERE id=?', id);
requireSitePerm(ctx, s, perm); // 없거나 다른 그룹이면 404
return s;
}
/** 메일 서비스 행 + 권한 검사(사이트 대표 메일은 사이트 권한 domain_mail, 독립 메일은 그룹 역할) */
export function getMailService(deps, ctx, id, perm = 'view') {
requireGroup(ctx);
const m = deps.store.get('SELECT * FROM mail_services WHERE id=? AND group_id=?', id, ctx.group.id);
if (!m) throw new HttpError(404, 'not_found', '요청한 항목을 찾을 수 없습니다');
if (m.site_id) { const s = deps.store.get('SELECT * FROM sites WHERE id=?', m.site_id); if (s) requireSitePerm(ctx, s, perm === 'view' ? 'view' : 'domain_mail'); }
return m;
}
/** 운영자 콘솔(HAL) 내부 호출 검증. 통과하면 keyId. */
export async function opsGuard(ctx, deps) {
const r = await verifyCtx(ctx, (keyId) => { try { return deps.vault.get('service:' + keyId); } catch (_) { return null; } });
if (!r.ok) throw new HttpError(401, 'service_auth_failed', `서비스 인증에 실패했습니다 (${r.reason})`);
return r.keyId;
}
/** 운영자 식별(내부 호출 본문의 operator 필드) */
export const operatorOf = (body) => String((body && body.operator) || 'operator').slice(0, 80);
/**
* 활성 상품정책. productId 의 published 버전 중 effective_from ≤ now < effective_to 인 가장 높은 버전.
* @returns {{version:object|null, limits:object, policy:object, deferred:string[]}}
*/
export function productPolicy(deps, productId, required = []) {
const now = nowIso();
let v = deps.store.get('SELECT * FROM product_versions WHERE product_id=? AND published=1 AND effective_from<=? AND (effective_to IS NULL OR effective_to>?) ORDER BY version DESC LIMIT 1', productId, now, now) || null;
let limits = parseJson(v?.limits_json, {}) || {}; let policy = parseJson(v?.policy_json, {}) || {}; let derivedPrice = null;
// [통합 보완] 상거래 영역의 Catalog 는 백업·이전·보존 정책을 "별도 상품" 이 아니라 기본 상품(basic/team)의 옵션(backup)과 policy(relocation·retention_days·lifecycle) 안에 둔다.
// 전용 product_id 행이 없으면 활성 기본 상품에서 같은 뜻의 값을 유도한다(키 이름 대응은 아래). 전용 행이 있으면 그것이 우선한다(운영자가 나중에 분리 등록할 수 있음).
if (!v && ['backup', 'relocation', 'retention'].includes(productId)) {
const base = deps.store.get('SELECT * FROM product_versions WHERE kind=\'base\' AND published=1 AND effective_from<=? AND (effective_to IS NULL OR effective_to>?) ORDER BY version DESC, created_at DESC LIMIT 1', now, now);
if (base) {
const bp = parseJson(base.policy_json, {}) || {}; const opts = parseJson(base.options_json, []) || [];
if (productId === 'backup') { const o = opts.find((x) => x.id === 'backup'); const q = (o && o.policy) || {}; limits = { storage_bytes: q.storage_bytes }; derivedPrice = o ? o.price : null;
policy = { schedule: q.schedule, schedule_time_kst: q.schedule_time_kst, retention: q.retention, first_backup_within_hours: q.first_backup_within_hours, verify_method: q.verify_method ?? q.remote_copy_verification, restore_cost: q.restore_cost, restore_approval: q.restore_approval, restore_completion: q.restore_completion, protect_point_days: q.protect_point_days, revert_hours: q.revert_hours, expiry_retention_days: q.expiry_retention_days ?? q.expired_backup_retention_days, failure_notify_after: q.failure_notify_after ?? q.failure_alert_after, resume_after_pause: q.resume_after_pause, restore_downtime_minutes: q.restore_downtime_minutes }; }
else if (productId === 'relocation') { const r = bp.relocation || {}; policy = { target_downtime_min: r.target_downtime_min ?? r.target_downtime_minutes, revert_hours: r.revert_hours ?? r.revert_window_hours, old_retention_days: r.old_retention_days ?? (r.old_location_retention_hours != null ? Math.ceil(r.old_location_retention_hours / 24) : undefined), customer_cost: r.customer_cost ?? r.customer_requested_fee, platform_cost: r.platform_reason_fee ?? 0, dns_wait_handling: r.dns_wait_handling, write_freeze: r.write_freeze }; }
else { const rd = bp.retention_days || {}; const lc = bp.lifecycle || {}; policy = { site_days: rd.site, mail_days: rd.mail, source_days: rd.source, backup_days: rd.backup_after_expiry, readonly_retention_days: lc.readonly_retention_days, delete_after_days: lc.delete_after_days, pause_keeps_site_count: lc.pause_keeps_site_count, cancel_effective: lc.cancel_effective }; }
for (const k of Object.keys(policy)) if (policy[k] === undefined) delete policy[k];
v = { ...base, name: `${base.name} (${productId} 정책)`, price: derivedPrice ?? 0, derived_from: base.id };
}
}
const deferred = required.filter((k) => { const [bag, key] = k.split('.'); const src = bag === 'limits' ? limits : policy; return src[key] === undefined || src[key] === null || src[key] === ''; });
return { version: v, limits, policy, deferred, priceKrw: v ? v.price : null, currency: v ? v.currency : 'KRW', taxRate: v ? v.tax_rate : 0.1 };
}
/** HAL 어댑터. 없으면 503 '정보 확인 지연' */
export function halOf(deps) {
if (!deps.hal) throw new HttpError(503, 'hal_unavailable', '중앙 관리 기능(HAL)과 연결되지 않아 지금은 새 작업을 시작할 수 없습니다. 기존 서비스는 계속 제공되며 연결이 복구되면 다시 확인합니다', { state: 'stale' });
return deps.hal;
}
/** HAL 호출 오류를 작업 엔진의 일시 오류(재시도)로 바꾼다 */
export function asTransient(e) { if (e && e.code === 'service_unreachable') { e.retryable = true; } return e; }
/** 대상(사이트·메일)에 진행 중인 작업 목록 — 배포·복원·이전·소유권 이전 */
export function inProgress(deps, subjectType, subjectId) {
const out = [];
const jobs = deps.store.all('SELECT id, kind, status, label FROM jobs WHERE subject_type=? AND subject_id=?', subjectType, subjectId).filter((j) => !TERMINAL.has(j.status));
for (const j of jobs) out.push({ type: 'job', id: j.id, kind: j.kind, status: j.status, label: j.label });
if (subjectType === 'site') {
for (const r of deps.store.all('SELECT id, status FROM restores WHERE site_id=? AND status NOT IN (\'done\',\'failed\',\'cancelled\',\'reverted\',\'rejected\',\'expired\')', subjectId)) out.push({ type: 'restore', id: r.id, status: r.status });
for (const d of deps.store.all('SELECT id, status FROM deployments WHERE site_id=? AND status NOT IN (\'done\',\'failed\',\'cancelled\',\'reverted\')', subjectId)) out.push({ type: 'deployment', id: d.id, status: d.status });
}
for (const r of deps.store.all('SELECT id, status FROM relocations WHERE subject_type=? AND subject_id=? AND status NOT IN (\'done\',\'failed\',\'cancelled\',\'reverted\',\'rejected\')', subjectType, subjectId)) out.push({ type: 'relocation', id: r.id, status: r.status });
const tr = deps.store.all('SELECT id, status, scope_json FROM ownership_transfers WHERE status IN (\'requested\',\'accepted\',\'on_hold\',\'reviewing\',\'approved\',\'disputed\')');
for (const t of tr) { const sc = parseJson(t.scope_json, {}); const ids = subjectType === 'site' ? sc.siteIds || [] : sc.mailServiceIds || []; if (ids.includes(subjectId)) out.push({ type: 'transfer', id: t.id, status: t.status }); }
return out;
}
/** 설정 표 */
export function settingGet(deps, key, fallback = null) { const r = deps.store.get('SELECT value_json FROM settings WHERE key=?', key); return r ? parseJson(r.value_json, fallback) : fallback; }
export function settingSet(deps, key, value, by = 'system') { deps.store.run('INSERT INTO settings(key, value_json, updated_at, updated_by) VALUES(?,?,?,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json, updated_at=excluded.updated_at, updated_by=excluded.updated_by', key, toJson(value), nowIso(), by); }
/** 작업 요약(화면) */
export function jobView(deps, jobId) { return jobId ? describe(deps.jobs.get(jobId)) : null; }
/** 사람이 읽는 바이트 */
export function humanBytes(n) { n = Number(n) || 0; if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB'; if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB'; if (n >= 1024) return (n / 1024).toFixed(0) + ' KB'; return n + ' B'; }
/** 선택 의존(다른 영역 도우미)을 있을 때만 들여온다 */
export async function optional(rel) { try { const { existsSync } = await import('node:fs'); const u = new URL(rel, import.meta.url); if (!existsSync(u)) return null; return await import(u); } catch (_) { return null; } }
/** 요청 본문에서 확인 플래그(파괴적 작업 명시적 확인 §2.3) */
export function requireConfirm(body, field = 'confirm') { if (!body || body[field] !== true) throw new HttpError(400, 'confirmation_required', '대상과 영향을 확인한 뒤 명시적으로 확인해야 실행됩니다', { field }); }