Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m18s
Docker images / frontend-image (push) Successful in 4m37s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 3m6s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 16s
Сохранять снимки в S3-compatible бакет, скачивать и удалять их вместе с локальными файлами. Привести /backups к DNA /servers: Frame, KPI, фильтры и реальное восстановление. Co-authored-by: Cursor <[email protected]>
558 lines
18 KiB
TypeScript
558 lines
18 KiB
TypeScript
import { randomUUID } from "node:crypto"
|
|
import { mkdir, rm, stat, writeFile, readFile } from "node:fs/promises"
|
|
import path from "node:path"
|
|
import { desc, eq } from "drizzle-orm"
|
|
import type {
|
|
BackupScheduleSettingsDto,
|
|
BackupStorageSettingsDto,
|
|
PutBackupStorageSettings,
|
|
} from "@mmapp/contracts/backups"
|
|
import { db } from "../db/index.js"
|
|
import { parseJsonArray } from "../db/json.js"
|
|
import { backupEntries, backupScheduleSettings, backupStorageSettings } from "../db/schema.js"
|
|
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
|
|
import { listServersRead } from "../modules/servers/service/servers-service.js"
|
|
import { MikrotikClient } from "./mikrotik.js"
|
|
import {
|
|
buildS3ObjectKey,
|
|
createS3ClientFromConfig,
|
|
parseS3ObjectKey,
|
|
sanitizeServerName,
|
|
s3DeleteObject,
|
|
s3GetObject,
|
|
s3ListObjects,
|
|
s3PutObject,
|
|
s3TestConnection,
|
|
type S3BackupConfig,
|
|
} from "./s3-backup-client.js"
|
|
|
|
const SETTINGS_ID = 1
|
|
const BACKUPS_DIR = path.resolve(process.cwd(), "storage", "backups")
|
|
|
|
export type BackupMeta = {
|
|
id: string
|
|
serverId: number | null
|
|
serverName: string
|
|
filename: string
|
|
sizeBytes: number
|
|
createdAt: string
|
|
kind: "manual" | "auto"
|
|
notes?: string
|
|
storage: "local" | "s3" | "both"
|
|
s3Key?: string | null
|
|
uploadError?: string | null
|
|
}
|
|
|
|
type BackupRow = typeof backupEntries.$inferSelect
|
|
|
|
function rowToMeta(row: BackupRow): BackupMeta {
|
|
return {
|
|
id: row.id,
|
|
serverId: row.serverId,
|
|
serverName: row.serverName,
|
|
filename: row.filename,
|
|
sizeBytes: row.sizeBytes,
|
|
createdAt: row.createdAt,
|
|
kind: row.kind,
|
|
notes: row.notes ?? undefined,
|
|
storage: row.storage ?? "local",
|
|
s3Key: row.s3Key,
|
|
uploadError: row.uploadError,
|
|
}
|
|
}
|
|
|
|
export async function ensureBackupStorage(): Promise<void> {
|
|
await mkdir(BACKUPS_DIR, { recursive: true })
|
|
}
|
|
|
|
export async function listBackups(): Promise<BackupMeta[]> {
|
|
const rows = await db.select().from(backupEntries).orderBy(desc(backupEntries.createdAt))
|
|
return rows.map(rowToMeta)
|
|
}
|
|
|
|
export async function getBackupById(id: string): Promise<BackupMeta | null> {
|
|
const row = (await db.select().from(backupEntries).where(eq(backupEntries.id, id)).limit(1))[0]
|
|
return row ? rowToMeta(row) : null
|
|
}
|
|
|
|
export async function insertBackup(meta: BackupMeta): Promise<void> {
|
|
await db.insert(backupEntries).values({
|
|
id: meta.id,
|
|
serverId: meta.serverId,
|
|
serverName: meta.serverName,
|
|
filename: meta.filename,
|
|
sizeBytes: meta.sizeBytes,
|
|
kind: meta.kind,
|
|
notes: meta.notes ?? null,
|
|
storage: meta.storage,
|
|
s3Key: meta.s3Key ?? null,
|
|
s3Etag: null,
|
|
uploadError: meta.uploadError ?? null,
|
|
createdAt: meta.createdAt,
|
|
})
|
|
}
|
|
|
|
async function getBackupRow(id: string): Promise<BackupRow | undefined> {
|
|
return (await db.select().from(backupEntries).where(eq(backupEntries.id, id)).limit(1))[0]
|
|
}
|
|
|
|
export async function deleteBackupRecord(id: string): Promise<BackupMeta | null> {
|
|
const row = await getBackupRow(id)
|
|
if (!row) return null
|
|
const meta = rowToMeta(row)
|
|
if (row.s3Key) {
|
|
try {
|
|
const cfg = await getS3ConfigIfEnabled()
|
|
if (cfg) {
|
|
const client = createS3ClientFromConfig(cfg)
|
|
await s3DeleteObject(client, cfg.bucket, row.s3Key)
|
|
}
|
|
} catch {
|
|
/* объект мог уже отсутствовать */
|
|
}
|
|
}
|
|
await db.delete(backupEntries).where(eq(backupEntries.id, id))
|
|
await rm(path.join(BACKUPS_DIR, row.filename), { force: true })
|
|
return meta
|
|
}
|
|
|
|
function fmtTs(d = new Date()): string {
|
|
const p = (n: number) => String(n).padStart(2, "0")
|
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`
|
|
}
|
|
|
|
function parseServerIds(raw: unknown): string[] {
|
|
return parseJsonArray(raw).map(String).filter(Boolean)
|
|
}
|
|
|
|
async function getBackupScheduleSettingsRow() {
|
|
return (await db.select().from(backupScheduleSettings).where(eq(backupScheduleSettings.id, SETTINGS_ID)).limit(1))[0]
|
|
?? {
|
|
id: SETTINGS_ID,
|
|
enabled: true,
|
|
frequency: "daily" as const,
|
|
hour: 3,
|
|
minute: 0,
|
|
weekDay: 0,
|
|
monthDay: 1,
|
|
keepCount: 7,
|
|
format: "rsc" as const,
|
|
serverIdsJson: [],
|
|
lastRunAt: null,
|
|
lastDurationMs: null,
|
|
lastError: null,
|
|
updatedAt: new Date().toISOString(),
|
|
}
|
|
}
|
|
|
|
export async function getBackupScheduleSettings(): Promise<BackupScheduleSettingsDto> {
|
|
const row = await getBackupScheduleSettingsRow()
|
|
return {
|
|
enabled: row.enabled,
|
|
frequency: row.frequency,
|
|
hour: row.hour,
|
|
minute: row.minute,
|
|
weekDay: row.weekDay,
|
|
monthDay: row.monthDay,
|
|
keepCount: row.keepCount,
|
|
format: row.format,
|
|
serverIds: parseServerIds(row.serverIdsJson),
|
|
lastRunAt: row.lastRunAt ?? null,
|
|
lastDurationMs: row.lastDurationMs ?? null,
|
|
lastError: row.lastError ?? null,
|
|
updatedAt: row.updatedAt,
|
|
}
|
|
}
|
|
|
|
export async function updateBackupScheduleSettings(patch: Partial<{
|
|
enabled: boolean
|
|
frequency: "daily" | "weekly" | "monthly"
|
|
hour: number
|
|
minute: number
|
|
weekDay: number
|
|
monthDay: number
|
|
keepCount: number
|
|
format: "rsc" | "backup"
|
|
serverIds: string[]
|
|
}>) {
|
|
const prev = await getBackupScheduleSettingsRow()
|
|
const now = new Date().toISOString()
|
|
const next = {
|
|
enabled: patch.enabled ?? prev.enabled,
|
|
frequency: patch.frequency ?? prev.frequency,
|
|
hour: patch.hour ?? prev.hour,
|
|
minute: patch.minute ?? prev.minute,
|
|
weekDay: patch.weekDay ?? prev.weekDay,
|
|
monthDay: patch.monthDay ?? prev.monthDay,
|
|
keepCount: patch.keepCount ?? prev.keepCount,
|
|
format: patch.format ?? prev.format,
|
|
serverIdsJson: patch.serverIds ?? prev.serverIdsJson,
|
|
updatedAt: now,
|
|
}
|
|
if ((await db.select().from(backupScheduleSettings).where(eq(backupScheduleSettings.id, SETTINGS_ID)).limit(1))[0]) {
|
|
await db.update(backupScheduleSettings).set(next).where(eq(backupScheduleSettings.id, SETTINGS_ID))
|
|
} else {
|
|
await db.insert(backupScheduleSettings).values({ id: SETTINGS_ID, ...next })
|
|
}
|
|
return await getBackupScheduleSettings()
|
|
}
|
|
|
|
export async function touchBackupScheduleRunMeta(patch: {
|
|
lastRunAt?: string
|
|
lastDurationMs?: number
|
|
lastError?: string | null
|
|
}) {
|
|
const prev = await getBackupScheduleSettingsRow()
|
|
await db.update(backupScheduleSettings).set({
|
|
lastRunAt: patch.lastRunAt ?? prev.lastRunAt,
|
|
lastDurationMs: patch.lastDurationMs ?? prev.lastDurationMs,
|
|
lastError: patch.lastError === undefined ? prev.lastError : patch.lastError,
|
|
updatedAt: new Date().toISOString(),
|
|
}).where(eq(backupScheduleSettings.id, SETTINGS_ID))
|
|
}
|
|
|
|
function defaultStorageRow() {
|
|
return {
|
|
id: SETTINGS_ID,
|
|
provider: "local" as const,
|
|
s3Endpoint: "",
|
|
s3Region: "us-east-1",
|
|
s3Bucket: "",
|
|
s3Prefix: "mikrotik",
|
|
s3AccessKeyId: "",
|
|
s3SecretAccessKey: "",
|
|
s3ForcePathStyle: true,
|
|
keepLocalCopy: true,
|
|
lastTestAt: null as string | null,
|
|
lastTestError: null as string | null,
|
|
updatedAt: new Date().toISOString(),
|
|
}
|
|
}
|
|
|
|
async function getBackupStorageSettingsRow() {
|
|
return (await db.select().from(backupStorageSettings).where(eq(backupStorageSettings.id, SETTINGS_ID)).limit(1))[0]
|
|
?? defaultStorageRow()
|
|
}
|
|
|
|
function toStorageDto(row: Awaited<ReturnType<typeof getBackupStorageSettingsRow>>): BackupStorageSettingsDto {
|
|
return {
|
|
provider: row.provider,
|
|
s3Endpoint: row.s3Endpoint,
|
|
s3Region: row.s3Region,
|
|
s3Bucket: row.s3Bucket,
|
|
s3Prefix: row.s3Prefix,
|
|
s3AccessKeyId: row.s3AccessKeyId,
|
|
secretConfigured: Boolean(row.s3SecretAccessKey),
|
|
s3ForcePathStyle: row.s3ForcePathStyle,
|
|
keepLocalCopy: row.keepLocalCopy,
|
|
lastTestAt: row.lastTestAt ?? null,
|
|
lastTestError: row.lastTestError ?? null,
|
|
updatedAt: row.updatedAt,
|
|
}
|
|
}
|
|
|
|
export async function getBackupStorageSettings(): Promise<BackupStorageSettingsDto> {
|
|
return toStorageDto(await getBackupStorageSettingsRow())
|
|
}
|
|
|
|
export async function updateBackupStorageSettings(
|
|
patch: PutBackupStorageSettings,
|
|
): Promise<BackupStorageSettingsDto> {
|
|
const prev = await getBackupStorageSettingsRow()
|
|
const now = new Date().toISOString()
|
|
const secret = patch.s3SecretAccessKey
|
|
const next = {
|
|
provider: patch.provider ?? prev.provider,
|
|
s3Endpoint: patch.s3Endpoint ?? prev.s3Endpoint,
|
|
s3Region: patch.s3Region ?? prev.s3Region,
|
|
s3Bucket: patch.s3Bucket ?? prev.s3Bucket,
|
|
s3Prefix: patch.s3Prefix ?? prev.s3Prefix,
|
|
s3AccessKeyId: patch.s3AccessKeyId ?? prev.s3AccessKeyId,
|
|
s3SecretAccessKey: secret && secret.length > 0 ? secret : prev.s3SecretAccessKey,
|
|
s3ForcePathStyle: patch.s3ForcePathStyle ?? prev.s3ForcePathStyle,
|
|
keepLocalCopy: patch.keepLocalCopy ?? prev.keepLocalCopy,
|
|
updatedAt: now,
|
|
}
|
|
if ((await db.select().from(backupStorageSettings).where(eq(backupStorageSettings.id, SETTINGS_ID)).limit(1))[0]) {
|
|
await db.update(backupStorageSettings).set(next).where(eq(backupStorageSettings.id, SETTINGS_ID))
|
|
} else {
|
|
await db.insert(backupStorageSettings).values({ id: SETTINGS_ID, ...next })
|
|
}
|
|
return await getBackupStorageSettings()
|
|
}
|
|
|
|
function rowToS3Config(row: Awaited<ReturnType<typeof getBackupStorageSettingsRow>>): S3BackupConfig | null {
|
|
if (row.provider !== "s3") return null
|
|
if (!row.s3Bucket.trim() || !row.s3AccessKeyId.trim() || !row.s3SecretAccessKey) return null
|
|
return {
|
|
endpoint: row.s3Endpoint,
|
|
region: row.s3Region,
|
|
bucket: row.s3Bucket.trim(),
|
|
prefix: row.s3Prefix,
|
|
accessKeyId: row.s3AccessKeyId,
|
|
secretAccessKey: row.s3SecretAccessKey,
|
|
forcePathStyle: row.s3ForcePathStyle,
|
|
}
|
|
}
|
|
|
|
async function getS3ConfigIfEnabled(): Promise<S3BackupConfig | null> {
|
|
return rowToS3Config(await getBackupStorageSettingsRow())
|
|
}
|
|
|
|
export async function testBackupStorageConnection(): Promise<BackupStorageSettingsDto> {
|
|
const row = await getBackupStorageSettingsRow()
|
|
const cfg = rowToS3Config(row)
|
|
const now = new Date().toISOString()
|
|
if (!cfg) {
|
|
const error = row.provider === "s3"
|
|
? "Заполните bucket, ключ доступа и секрет"
|
|
: "S3 не выбран"
|
|
await persistStorageTest(now, error)
|
|
throw new Error(error)
|
|
}
|
|
try {
|
|
const client = createS3ClientFromConfig(cfg)
|
|
await s3TestConnection(client, cfg.bucket)
|
|
await persistStorageTest(now, null)
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
await persistStorageTest(now, message)
|
|
throw new Error(message)
|
|
}
|
|
return await getBackupStorageSettings()
|
|
}
|
|
|
|
async function persistStorageTest(at: string, error: string | null) {
|
|
const exists = (await db.select().from(backupStorageSettings).where(eq(backupStorageSettings.id, SETTINGS_ID)).limit(1))[0]
|
|
const patch = { lastTestAt: at, lastTestError: error, updatedAt: at }
|
|
if (exists) {
|
|
await db.update(backupStorageSettings).set(patch).where(eq(backupStorageSettings.id, SETTINGS_ID))
|
|
} else {
|
|
await db.insert(backupStorageSettings).values({ id: SETTINGS_ID, ...patch })
|
|
}
|
|
}
|
|
|
|
export async function resolveBackupServerIds(settings: BackupScheduleSettingsDto): Promise<string[]> {
|
|
const enabled = new Set((await listServersRead()).map((s) => String(s.id)))
|
|
const requested = settings.serverIds.length > 0 ? settings.serverIds : [...enabled]
|
|
return [...new Set(requested)].filter((id) => enabled.has(id))
|
|
}
|
|
|
|
function scheduledSlotForDate(now: Date, settings: BackupScheduleSettingsDto): Date | null {
|
|
if (settings.frequency === "weekly") {
|
|
const currentDow = (now.getDay() + 6) % 7
|
|
if (currentDow !== settings.weekDay) return null
|
|
} else if (settings.frequency === "monthly") {
|
|
if (now.getDate() !== settings.monthDay) return null
|
|
}
|
|
|
|
const slot = new Date(now)
|
|
slot.setSeconds(0, 0)
|
|
slot.setMilliseconds(0)
|
|
slot.setHours(settings.hour, settings.minute, 0, 0)
|
|
return slot
|
|
}
|
|
|
|
function sameLocalMinute(a: Date, b: Date): boolean {
|
|
return a.getFullYear() === b.getFullYear()
|
|
&& a.getMonth() === b.getMonth()
|
|
&& a.getDate() === b.getDate()
|
|
&& a.getHours() === b.getHours()
|
|
&& a.getMinutes() === b.getMinutes()
|
|
}
|
|
|
|
/** Срабатывает только в минуту расписания; 60 с в UI — интервал проверки, не частота бэкапа. */
|
|
export function isBackupDue(now: Date, settings: BackupScheduleSettingsDto, lastRunAt: string | null | undefined): boolean {
|
|
if (!settings.enabled) return false
|
|
|
|
const slot = scheduledSlotForDate(now, settings)
|
|
if (!slot) return false
|
|
if (now < slot) return false
|
|
if (!sameLocalMinute(now, slot)) return false
|
|
|
|
if (lastRunAt) {
|
|
const prev = new Date(lastRunAt)
|
|
if (Number.isNaN(prev.getTime())) return true
|
|
if (sameLocalMinute(prev, slot)) return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
async function uploadBackupToS3(params: {
|
|
serverName: string
|
|
filename: string
|
|
body: string
|
|
}): Promise<{ key: string; etag?: string } | { error: string }> {
|
|
const cfg = await getS3ConfigIfEnabled()
|
|
if (!cfg) return { error: "S3 не настроен" }
|
|
try {
|
|
const client = createS3ClientFromConfig(cfg)
|
|
const key = buildS3ObjectKey(cfg.prefix, sanitizeServerName(params.serverName), params.filename)
|
|
const put = await s3PutObject(client, cfg.bucket, key, params.body)
|
|
return { key, etag: put.etag }
|
|
} catch (err) {
|
|
return { error: err instanceof Error ? err.message : String(err) }
|
|
}
|
|
}
|
|
|
|
export async function runBackupForServer(
|
|
id: string,
|
|
kind: BackupMeta["kind"],
|
|
notes?: string,
|
|
): Promise<BackupMeta> {
|
|
const serverIdNum = Number.parseInt(id, 10)
|
|
if (!Number.isFinite(serverIdNum)) {
|
|
throw new Error("Невалидный id сервера")
|
|
}
|
|
const row = await getServerRowById(serverIdNum)
|
|
if (!row) {
|
|
throw new Error("Сервер не найден")
|
|
}
|
|
const client = MikrotikClient.fromServer(row)
|
|
const script = await client.exportConfigScript()
|
|
const ts = fmtTs()
|
|
const safeServer = sanitizeServerName(row.name)
|
|
const filename = `${safeServer}_${ts}.rsc`
|
|
const filePath = path.join(BACKUPS_DIR, filename)
|
|
await ensureBackupStorage()
|
|
await writeFile(filePath, script, "utf8")
|
|
const st = await stat(filePath)
|
|
const storageRow = await getBackupStorageSettingsRow()
|
|
let storage: BackupMeta["storage"] = "local"
|
|
let s3Key: string | null = null
|
|
let s3Etag: string | null = null
|
|
let uploadError: string | null = null
|
|
|
|
if (storageRow.provider === "s3") {
|
|
const uploaded = await uploadBackupToS3({ serverName: row.name, filename, body: script })
|
|
if ("key" in uploaded) {
|
|
s3Key = uploaded.key
|
|
s3Etag = uploaded.etag ?? null
|
|
storage = storageRow.keepLocalCopy ? "both" : "s3"
|
|
if (!storageRow.keepLocalCopy) {
|
|
await rm(filePath, { force: true })
|
|
}
|
|
} else {
|
|
uploadError = uploaded.error
|
|
storage = "local"
|
|
}
|
|
}
|
|
|
|
const meta: BackupMeta = {
|
|
id: randomUUID(),
|
|
serverId: row.id,
|
|
serverName: row.name,
|
|
filename,
|
|
sizeBytes: st.size,
|
|
createdAt: new Date().toISOString(),
|
|
kind,
|
|
notes,
|
|
storage,
|
|
s3Key,
|
|
uploadError,
|
|
}
|
|
await db.insert(backupEntries).values({
|
|
id: meta.id,
|
|
serverId: meta.serverId,
|
|
serverName: meta.serverName,
|
|
filename: meta.filename,
|
|
sizeBytes: meta.sizeBytes,
|
|
kind: meta.kind,
|
|
notes: meta.notes ?? null,
|
|
storage: meta.storage,
|
|
s3Key: meta.s3Key ?? null,
|
|
s3Etag,
|
|
uploadError: meta.uploadError ?? null,
|
|
createdAt: meta.createdAt,
|
|
})
|
|
return meta
|
|
}
|
|
|
|
export async function pruneBackupsForServer(serverId: string, keepCount: number): Promise<number> {
|
|
const rows = await db.select().from(backupEntries)
|
|
.where(eq(backupEntries.serverId, Number(serverId)))
|
|
.orderBy(desc(backupEntries.createdAt))
|
|
if (rows.length <= keepCount) return 0
|
|
const toDelete = rows.slice(keepCount)
|
|
for (const hit of toDelete) {
|
|
await deleteBackupRecord(hit.id)
|
|
}
|
|
return toDelete.length
|
|
}
|
|
|
|
export async function readBackupContent(meta: BackupMeta): Promise<Buffer> {
|
|
if ((meta.storage === "s3" || meta.storage === "both") && meta.s3Key) {
|
|
try {
|
|
const cfg = await getS3ConfigIfEnabled()
|
|
if (cfg) {
|
|
const client = createS3ClientFromConfig(cfg)
|
|
return await s3GetObject(client, cfg.bucket, meta.s3Key)
|
|
}
|
|
} catch {
|
|
/* fallback: локальная копия, если есть */
|
|
}
|
|
}
|
|
return await readFile(path.join(BACKUPS_DIR, meta.filename))
|
|
}
|
|
|
|
export async function restoreBackupToDevice(id: string): Promise<{ filename: string; serverName: string }> {
|
|
const meta = await getBackupById(id)
|
|
if (!meta) throw new Error("Бэкап не найден")
|
|
if (!meta.serverId) throw new Error("Сервер бэкапа удалён — восстановить нельзя")
|
|
const row = await getServerRowById(meta.serverId)
|
|
if (!row) throw new Error("Сервер не найден")
|
|
const content = await readBackupContent(meta)
|
|
const client = MikrotikClient.fromServer(row)
|
|
const uploaded = await client.uploadTextFile(meta.filename, content.toString("utf8"), 60_000)
|
|
await client.importUploadedFile(uploaded)
|
|
return { filename: meta.filename, serverName: row.name }
|
|
}
|
|
|
|
export async function syncBackupsFromS3(): Promise<{ imported: number; skipped: number }> {
|
|
const cfg = await getS3ConfigIfEnabled()
|
|
if (!cfg) throw new Error("S3 не настроен")
|
|
const client = createS3ClientFromConfig(cfg)
|
|
const objects = await s3ListObjects(client, cfg.bucket, cfg.prefix)
|
|
const existing = new Set(
|
|
(await db.select({ filename: backupEntries.filename, s3Key: backupEntries.s3Key }).from(backupEntries))
|
|
.flatMap((row) => [row.filename, row.s3Key].filter((v): v is string => Boolean(v))),
|
|
)
|
|
let imported = 0
|
|
let skipped = 0
|
|
for (const obj of objects) {
|
|
if (!obj.key.toLowerCase().endsWith(".rsc")) {
|
|
skipped += 1
|
|
continue
|
|
}
|
|
const parsed = parseS3ObjectKey(obj.key)
|
|
if (existing.has(obj.key) || existing.has(parsed.filename)) {
|
|
skipped += 1
|
|
continue
|
|
}
|
|
const createdAt = obj.lastModified ?? new Date().toISOString()
|
|
await db.insert(backupEntries).values({
|
|
id: randomUUID(),
|
|
serverId: null,
|
|
serverName: parsed.serverName,
|
|
filename: parsed.filename,
|
|
sizeBytes: obj.size,
|
|
kind: "auto",
|
|
notes: "Импорт из S3",
|
|
storage: "s3",
|
|
s3Key: obj.key,
|
|
s3Etag: null,
|
|
uploadError: null,
|
|
createdAt,
|
|
})
|
|
existing.add(obj.key)
|
|
existing.add(parsed.filename)
|
|
imported += 1
|
|
}
|
|
return { imported, skipped }
|
|
}
|
|
|
|
export function getBackupsDir(): string {
|
|
return BACKUPS_DIR
|
|
}
|