fix(settings): проверять резервные копии и исключить секреты из журнала
This commit is contained in:
1 parent
e5842b415b
commit
ff8b04bddf
7 files changed
+78
-47
No files matched your search
@@ -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 ["", "{}", "<html>proxy</html>"]){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", "<html>"]){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}
|
||||
})
|
||||
@@ -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<typeof receiptSchema>
|
||||
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<void>{
|
||||
if(blob.size<magic.length)throw new Error("Файл не похож на резервную копию PostgreSQL (PGDMP)")
|
||||
const head=new Uint8Array(await blob.slice(0,magic.length).arrayBuffer())
|
||||
if(!magic.every((value,index)=>head[index]===value))throw new Error("Файл не похож на резервную копию PostgreSQL (PGDMP)")
|
||||
}
|
||||
|
||||
export async function restoreSystemDatabaseBackup(baseUrl: string, file: File): Promise<void> {
|
||||
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<SystemDatabaseRestoreReceipt>{
|
||||
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<unknown>(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
|
||||
}
|
||||
Reference in new issue
Block a user