Files
kvarta_project/apps/api/src/app.ts
T
nunuchek 249b4bb627 feat(api): ежедневный бэкап, копия в личку, восстановление через бота
backup.ts: db.backup() → gzip в backups/ рядом с базой, имя по времени
Минска (Intl), 14 ежедневных и pre-restore на 30 дней; планировщик
в 03:00 по Минску и при старте, если сегодня копии нет; файл админу,
итог в «Систему». Восстановление: gunzip → integrity_check → версия
схемы → пауза приёма (503) → pre-restore → атомарная подмена файла →
миграции; при сбое возвращаем прежнюю базу. createApp и очередь берут
базу через геттер, чтобы увидеть подменённую.
Бот в личке: /backups с кнопками, /backup, присланный .sqlite.gz,
подтверждение со списком пропадающих заявок, живёт 10 минут.
2026-10-17 16:07:26 +03:00

93 lines
3.7 KiB
TypeScript

import { getConnInfo } from '@hono/node-server/conninfo';
import { Hono, type Context } from 'hono';
import { bodyLimit } from 'hono/body-limit';
import { validateLead, type LeadResponse } from '@kvarta/shared/lead';
import type { Db } from './db/index.js';
import { saveLead } from './leads.js';
import { notifyLater, type Notifier } from './notifier.js';
import { clientIp, LOOPBACK, type RateLimiter } from './rateLimit.js';
export type AppDeps = {
/** Геттер, а не объект: восстановление из бэкапа подменяет базу на лету. */
getDb: () => Db;
now: () => Date;
rateLimiter: RateLimiter;
notifier: Notifier;
/** true на время восстановления базы: приём заявок отвечает 503. */
isPaused?: () => boolean;
/** Адреса, от которых верим X-Real-IP, помимо loopback. */
trustedProxies?: readonly string[];
version?: string;
/** 500: сообщить в «Систему». Ответ клиенту от этого не зависит. */
onServerError?: (err: unknown) => void;
};
export const MAX_BODY_BYTES = 16 * 1024;
const USER_AGENT_MAX = 500;
type Status = 200 | 201 | 400 | 413 | 415 | 429 | 500 | 503;
function socketAddress(c: Context): string | undefined {
try {
return getConnInfo(c).remote.address;
} catch {
// app.request() в тестах без node-сокета
return undefined;
}
}
export function createApp(deps: AppDeps) {
const app = new Hono();
const trusted = new Set<string>([...LOOPBACK, ...(deps.trustedProxies ?? [])]);
const reply = (body: LeadResponse, status: Status, headers?: Record<string, string>) =>
Response.json(body, { status, headers });
app.onError((err) => {
console.error(err);
deps.onServerError?.(err);
return reply({ ok: false, error: 'server' }, 500);
});
app.get('/api/health', (c) => c.json({ ok: true, version: deps.version ?? 'dev' }));
app.post(
'/api/lead',
async (c, next) => {
if (deps.isPaused?.()) return reply({ ok: false, error: 'unavailable' }, 503);
const ip = clientIp(socketAddress(c), c.req.header('x-real-ip'), trusted);
const decision = deps.rateLimiter.hit(ip, deps.now());
if (!decision.ok) {
return reply({ ok: false, error: 'rate_limited' }, 429, { 'Retry-After': String(decision.retryAfterSec) });
}
const type = c.req.header('content-type')?.split(';')[0]?.trim().toLowerCase();
if (type !== 'application/json') return reply({ ok: false, error: 'bad_request' }, 415);
await next();
},
bodyLimit({ maxSize: MAX_BODY_BYTES, onError: () => reply({ ok: false, error: 'bad_request' }, 413) }),
async (c) => {
let body: unknown;
try {
body = await c.req.json();
} catch {
return reply({ ok: false, error: 'bad_request' }, 400);
}
const result = validateLead(body);
if (!result.ok) return reply({ ok: false, error: 'validation', fieldErrors: result.fieldErrors }, 400);
// Бот не должен понять, что его раскусили: тот же ответ, что и на настоящую заявку.
if (result.data.honeypot) return reply({ ok: true, duplicate: false }, 201);
const userAgent = c.req.header('user-agent')?.slice(0, USER_AGENT_MAX) ?? null;
const saved = saveLead(deps.getDb(), result.data, { now: deps.now(), userAgent });
if (saved.kind === 'unchanged') return reply({ ok: true, duplicate: true, clarified: false }, 200);
notifyLater(deps.notifier, saved.event);
if (saved.kind === 'clarified') return reply({ ok: true, duplicate: true, clarified: true }, 200);
return reply({ ok: true, duplicate: false }, 201);
},
);
return app;
}