From ff8b04bddfc2c957ec149b4677cd505c4a22b2a8 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 3 Oct 2026 14:36:12 +0700 Subject: [PATCH] =?UTF-8?q?fix(settings):=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=8F=D1=82=D1=8C=20=D1=80=D0=B5=D0=B7=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=BE=D0=BF=D0=B8=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D0=B8=D1=81=D0=BA=D0=BB=D1=8E=D1=87=D0=B8=D1=82=D1=8C=20=D1=81?= =?UTF-8?q?=D0=B5=D0=BA=D1=80=D0=B5=D1=82=D1=8B=20=D0=B8=D0=B7=20=D0=B6?= =?UTF-8?q?=D1=83=D1=80=D0=BD=D0=B0=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/system-database.ts | 3 - .../src/services/system-database-backup.ts | 6 +- docs/ux-ui-plan.md | 2 + docs/ux-ui-progress.md | 10 ++- docs/ux-ui-stage-11b.md | 2 + shared/api/system-database.test.ts | 28 +++++++ shared/api/system-database.ts | 74 +++++++++---------- 7 files changed, 78 insertions(+), 47 deletions(-) create mode 100644 shared/api/system-database.test.ts diff --git a/backend/src/routes/system-database.ts b/backend/src/routes/system-database.ts index 44dae317..143735a7 100644 --- a/backend/src/routes/system-database.ts +++ b/backend/src/routes/system-database.ts @@ -2,7 +2,6 @@ import type { FastifyPluginAsync } from "fastify" import { appendEvent } from "../modules/events/service/events-service.js" import { exportSystemDatabaseBackup, - getSystemDatabasePath, restoreSystemDatabaseBackup, } from "../services/system-database-backup.js" @@ -29,7 +28,6 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => { payload: { filename, sizeBytes: buffer.length, - databasePath: getSystemDatabasePath(), }, }) reply.header("Content-Type", "application/octet-stream") @@ -58,7 +56,6 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => { entityId: "restore", payload: { sizeBytes: body.length, - databasePath: getSystemDatabasePath(), }, }) return reply.send({ ok: true }) diff --git a/backend/src/services/system-database-backup.ts b/backend/src/services/system-database-backup.ts index 78cfbd34..e16e99cd 100644 --- a/backend/src/services/system-database-backup.ts +++ b/backend/src/services/system-database-backup.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto" import { spawn } from "node:child_process" -import { mkdir, readFile, rm, writeFile } from "node:fs/promises" +import { mkdir, rm, writeFile } from "node:fs/promises" import os from "node:os" import path from "node:path" import { env } from "../config.js" @@ -95,7 +95,3 @@ export async function restoreSystemDatabaseBackup(buffer: Buffer): Promise } }) } - -export function getSystemDatabasePath(): string { - return env.DATABASE_URL -} diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 6a8c2948..621fac87 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -288,3 +288,5 @@ REST запрос → ожидание → подтверждённый output / Завершён 11Б.3.6.1: общие Settings без фиктивных сохранений/demo keys/MFA, реальная тема/source URL, переходы к инструментам; 32 E2E с одним исправленным locator, 3 source с исправленным alert locator, 8 Evolution source, typecheck/lint/build прошли. 6 screenshots, 320 просмотрены. System DB ещё не принят. Далее 11Б.3.6.1.2 health/source guards; затем 11Б.3.6.2 DB operations. Завершён 11Б.3.6.1.2: DataSource health token/revision/abort/ABA, explicit checking и validated health body. 2 unit, 10 source/health, 16 Settings/Evo provider и 8 Evo source E2E, typecheck/lint/build прошли; два test setup уточнены. System monitor остаётся отдельным reader. Далее 11Б.3.6.2.1 — system DB API/credentials in events; затем backend outcomes и UI. + +Завершён 11Б.3.6.2.1: system DB export PGDMP/leaf filename и restore strict receipt/limits/abort без повтора; DATABASE_URL удалён из новых event payload. 3 unit группы/typecheck/lint/backend и frontend builds прошли. UI/backend partial/restore transaction не приняты. Далее 11Б.3.6.2.2 — backend transaction/outcomes/DI HTTP/JWT, затем 11Б.3.6.2.3 форма. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 796b8125..e3941b40 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -28,7 +28,7 @@ | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | -| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.6.1.2; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.6.2.1; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 11Б.3.6.2.1 — system database API и журнал (2026-10-03) + +- Export reader проверяет PGDMP prefix/nonempty binary вместо принятия HTML/JSON, безопасное leaf filename (RFC5987/quoted/plain) и fallback .dump вместо .db. Restore проверяет размер ≤512 МБ, PGDMP prefix и caller abort до POST; после запроса нужен strict literal ok receipt, false/204/malformed/contradictory error не дают успеха. Нет автоматического повтора; 10 минут timeout/caller signal не отменяет серверное восстановление. +- Helper возвращает отдельные event/listener/scheduler warnings в receipt для следующего backend/UI блока; действующий backend пока отдаёт ok:true. Prefix — проверка формата, не полная проверка целостности архива или доказательство восстановления. +- Из новых backup/restore event payload удалён databasePath=DATABASE_URL, который мог содержать пароль подключения. Ненужный getSystemDatabasePath удалён; прежние строки журнала не переписывались, реальная БД недоступна. Unused readFile import и blank EOF исправлены по lint/diff gate. +- 3 API unit группы (valid/unsafe exports; invalid/oversize/abort без POST; strict receipts/errors без повтора), общий typecheck, backend build, профильный lint и production frontend/contracts build прошли; окончательный strict receipt дополнительно проверен unit после build. +- UI system DB/source/locks/unknown review и backend restart/event partial outcomes ещё не приняты. Текущий pg_restore пока без single transaction; реальное восстановление не запускалось. Следующий блок 11Б.3.6.2.2 — backend transaction/partial outcomes и DI HTTP/JWT; затем отдельная форма 11Б.3.6.2.3. + ## Блок 11Б.3.6.1.2 — достоверный health источника (2026-10-03) - DataSource health привязан к immutable token каждой смены backend URL, revision/AbortController и active lifetime; старый success/error и ABA не меняют новый статус. Новый источник не наследует прежний available status. Checking отличается от not checked; повторная проверка отменяет прежний reader. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index 5a562583..513a7f5c 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -220,3 +220,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S Завершён 11Б.3.6.1: общие Settings без фиктивных сохранений/demo keys/MFA, реальная тема/source URL, переходы к инструментам; 32 E2E с одним исправленным locator, 3 source с исправленным alert locator, 8 Evolution source, typecheck/lint/build прошли. 6 screenshots, 320 просмотрены. System DB ещё не принят. Далее 11Б.3.6.1.2 health/source guards; затем 11Б.3.6.2 DB operations. Завершён 11Б.3.6.1.2: DataSource health token/revision/abort/ABA, explicit checking и validated health body. 2 unit, 10 source/health, 16 Settings/Evo provider и 8 Evo source E2E, typecheck/lint/build прошли; два test setup уточнены. System monitor остаётся отдельным reader. Далее 11Б.3.6.2.1 — system DB API/credentials in events; затем backend outcomes и UI. + +Завершён 11Б.3.6.2.1: system DB export PGDMP/leaf filename и restore strict receipt/limits/abort без повтора; DATABASE_URL удалён из новых event payload. 3 unit группы/typecheck/lint/backend и frontend builds прошли. UI/backend partial/restore transaction не приняты. Далее 11Б.3.6.2.2 — backend transaction/outcomes/DI HTTP/JWT, затем 11Б.3.6.2.3 форма. diff --git a/shared/api/system-database.test.ts b/shared/api/system-database.test.ts new file mode 100644 index 00000000..6bc80a3c --- /dev/null +++ b/shared/api/system-database.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict" +import {test} from "node:test" +import {downloadSystemDatabaseBackup,restoreSystemDatabaseBackup} from "./system-database" +const dump=new File(["PGDMPfixture"],"fixture.dump") +test("export requires PGDMP bytes and a safe filename; fallback is dump, not db",async()=>{ + const original=globalThis.fetch;try{ + globalThis.fetch=async()=>new Response("PGDMPfixture",{headers:{"Content-Disposition":"attachment; filename*=UTF-8''backup%20fixture.dump"}}) + const result=await downloadSystemDatabaseBackup("https://backend.test");assert.equal(result.filename,"backup fixture.dump");assert.equal(await result.blob.text(),"PGDMPfixture") + globalThis.fetch=async()=>new Response("PGDMPfixture");assert.equal((await downloadSystemDatabaseBackup("https://backend.test")).filename,"mikrotik-manager.dump") + for(const bytes of ["", "{}", "proxy"]){globalThis.fetch=async()=>new Response(bytes);await assert.rejects(downloadSystemDatabaseBackup("https://backend.test"),/PGDMP/)} + for(const filename of ["../backup.dump","a/b.dump","a\\b.dump"]){globalThis.fetch=async()=>new Response("PGDMPfixture",{headers:{"Content-Disposition":`attachment; filename="${filename}"`}});await assert.rejects(downloadSystemDatabaseBackup("https://backend.test"),/Имя файла/)} + }finally{globalThis.fetch=original} +}) +test("restore validates empty/invalid/oversized file and abort before any POST",async()=>{ + const original=globalThis.fetch;let calls=0;try{globalThis.fetch=async()=>{calls++;return Response.json({ok:true})};for(const bytes of ["", "sqlite", ""]){await assert.rejects(restoreSystemDatabaseBackup("https://backend.test",new File([bytes],"wrong.dump")),/PGDMP/)} + const huge=new File(["PGDMP"],"huge.dump");Object.defineProperty(huge,"size",{value:512*1024*1024+1});await assert.rejects(restoreSystemDatabaseBackup("https://backend.test",huge),/512 МБ/) + const controller=new AbortController();controller.abort();await assert.rejects(restoreSystemDatabaseBackup("https://backend.test",dump,{signal:controller.signal}));assert.equal(calls,0) + }finally{globalThis.fetch=original} +}) +test("restore needs literal receipt; malformed/false/204/HTTP error never retry",async()=>{ + const original=globalThis.fetch;try{ + let calls=0;globalThis.fetch=async(_url,init)=>{calls++;assert.equal(init?.body,dump);assert.equal(new Headers(init?.headers).get("Content-Type"),"application/octet-stream");assert.ok(init?.signal);return Response.json({ok:true,eventError:"audit unavailable"})} + assert.deepEqual(await restoreSystemDatabaseBackup("https://backend.test",dump),{ok:true,eventError:"audit unavailable"});assert.equal(calls,1) + for(const raw of [{},{ok:false},{ok:"true"},{ok:true,eventError:5},{ok:true,error:"restore failed"}]){calls=0;globalThis.fetch=async()=>{calls++;return Response.json(raw)};await assert.rejects(restoreSystemDatabaseBackup("https://backend.test",dump),/не подтверждено/);assert.equal(calls,1)} + calls=0;globalThis.fetch=async()=>{calls++;return new Response(null,{status:204})};await assert.rejects(restoreSystemDatabaseBackup("https://backend.test",dump),/не подтверждено/);assert.equal(calls,1) + calls=0;globalThis.fetch=async()=>{calls++;return Response.json({error:"restore unknown"},{status:503})};await assert.rejects(restoreSystemDatabaseBackup("https://backend.test",dump),/restore unknown/);assert.equal(calls,1) + }finally{globalThis.fetch=original} +}) diff --git a/shared/api/system-database.ts b/shared/api/system-database.ts index 838c986c..6b5c5c03 100644 --- a/shared/api/system-database.ts +++ b/shared/api/system-database.ts @@ -1,41 +1,39 @@ -import { ApiClientError, requestBlob } from "@/shared/api/http-client" - -const MAX_RESTORE_BYTES = 512 * 1024 * 1024 - -function parseFilename(contentDisposition: string | null, fallback: string): string { - if (!contentDisposition) return fallback - const utfMatch = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition) - if (utfMatch?.[1]) { - try { - return decodeURIComponent(utfMatch[1]) - } catch { - return utfMatch[1] - } - } - const plainMatch = /filename="([^"]+)"/i.exec(contentDisposition) - if (plainMatch?.[1]) return plainMatch[1] - return fallback +import {z} from "zod" +import {ApiClientError,requestBlob,requestJson} from "@/shared/api/http-client" +const MAX_RESTORE_BYTES=512*1024*1024 +const magic=[80,71,68,77,80] +const receiptSchema=z.object({ok:z.literal(true),eventError:z.string().min(1).optional(),listenerError:z.string().min(1).optional(),schedulerError:z.string().min(1).optional()}).strict() +export type SystemDatabaseRestoreReceipt=z.output +type Options={signal?:AbortSignal} +const signal=(options?:Options)=>options?.signal?AbortSignal.any([options.signal,AbortSignal.timeout(600_000)]):AbortSignal.timeout(600_000) +function parseFilename(value:string|null):string{ + let name="mikrotik-manager.dump" + if(value){ + const utf=/filename\*=UTF-8''([^;]+)/i.exec(value) + const plain=/filename=(?:"([^"]+)"|([^;]+))/i.exec(value) + try{name=utf?.[1]?decodeURIComponent(utf[1]):(plain?.[1]??plain?.[2]??name).trim()}catch{throw new Error("Имя файла бэкапа не подтверждено")} + } + if(!name||/[\\/\r\n\x00]/.test(name)||name==="."||name==="..")throw new Error("Имя файла бэкапа не подтверждено") + return name } - -export async function downloadSystemDatabaseBackup( - baseUrl: string, -): Promise<{ blob: Blob; filename: string }> { - const res = await requestBlob(baseUrl, "/api/system/database/backup") - const blob = await res.blob() - const filename = parseFilename(res.headers.get("Content-Disposition"), "mikrotik-manager.db") - return { blob, filename } +async function confirmDump(blob:Blob):Promise{ + if(blob.sizehead[index]===value))throw new Error("Файл не похож на резервную копию PostgreSQL (PGDMP)") } - -export async function restoreSystemDatabaseBackup(baseUrl: string, file: File): Promise { - if (file.size > MAX_RESTORE_BYTES) { - throw new ApiClientError( - `Файл больше ${MAX_RESTORE_BYTES / (1024 * 1024)} МБ — уменьшите бэкап или обратитесь к администратору`, - 413, - ) - } - await requestBlob(baseUrl, "/api/system/database/restore", { - method: "POST", - headers: { "Content-Type": "application/octet-stream" }, - body: file, - }) +export async function downloadSystemDatabaseBackup(baseUrl:string,options?:Options):Promise<{blob:Blob;filename:string}>{ + const activeSignal=signal(options) + const response=await requestBlob(baseUrl,"/api/system/database/backup",{signal:activeSignal}) + const blob=await response.blob() + await confirmDump(blob);activeSignal.throwIfAborted() + return {blob,filename:parseFilename(response.headers.get("Content-Disposition"))} +} +export async function restoreSystemDatabaseBackup(baseUrl:string,file:File,options?:Options):Promise{ + if(file.size>MAX_RESTORE_BYTES)throw new ApiClientError("Файл больше 512 МБ — уменьшите бэкап или обратитесь к администратору",413) + const activeSignal=signal(options);activeSignal.throwIfAborted() + await confirmDump(file);activeSignal.throwIfAborted() + const raw=await requestJson(baseUrl,"/api/system/database/restore",{method:"POST",headers:{"Content-Type":"application/octet-stream"},body:file,signal:activeSignal}) + const receipt=receiptSchema.safeParse(raw) + if(!receipt.success)throw new Error("Восстановление базы не подтверждено сервером") + return receipt.data }