Files
TaxHacker_s23/app/(app)/settings/backups/actions.ts
2025-05-07 14:53:13 +02:00

164 lines
5.3 KiB
TypeScript

"use server"
import { ActionState } from "@/lib/actions"
import { getCurrentUser } from "@/lib/auth"
import { prisma } from "@/lib/db"
import { getUserUploadsDirectory, safePathJoin } from "@/lib/files"
import { MODEL_BACKUP, modelFromJSON } from "@/models/backups"
import fs from "fs/promises"
import JSZip from "jszip"
import path from "path"
const SUPPORTED_BACKUP_VERSIONS = ["1.0"]
const REMOVE_EXISTING_DATA = true
const MAX_BACKUP_SIZE = 256 * 1024 * 1024 // 256MB
type BackupRestoreResult = {
counters: Record<string, number>
}
export async function restoreBackupAction(
_prevState: ActionState<BackupRestoreResult> | null,
formData: FormData
): Promise<ActionState<BackupRestoreResult>> {
const user = await getCurrentUser()
const userUploadsDirectory = getUserUploadsDirectory(user)
const file = formData.get("file") as File
if (!file || file.size === 0) {
return { success: false, error: "No file provided" }
}
if (file.size > MAX_BACKUP_SIZE) {
return { success: false, error: `Backup file too large. Maximum size is ${MAX_BACKUP_SIZE / 1024 / 1024}MB` }
}
// Read zip archive
let zip: JSZip
try {
const fileBuffer = await file.arrayBuffer()
const fileData = Buffer.from(fileBuffer)
zip = await JSZip.loadAsync(fileData)
} catch (error) {
return { success: false, error: "Bad zip archive: " + (error as Error).message }
}
// Check metadata and start restoring
try {
const metadataFile = zip.file("data/metadata.json")
if (metadataFile) {
const metadataContent = await metadataFile.async("string")
try {
const metadata = JSON.parse(metadataContent)
if (!metadata.version || !SUPPORTED_BACKUP_VERSIONS.includes(metadata.version)) {
return {
success: false,
error: `Incompatible backup version: ${
metadata.version || "unknown"
}. Supported versions: ${SUPPORTED_BACKUP_VERSIONS.join(", ")}`,
}
}
console.log(`Restoring backup version ${metadata.version} created at ${metadata.timestamp}`)
} catch (error) {
console.warn("Could not parse backup metadata:", error)
}
} else {
console.warn("No metadata found in backup, assuming legacy format")
}
// Remove existing data
if (REMOVE_EXISTING_DATA) {
await cleanupUserTables(user.id)
await fs.rm(userUploadsDirectory, { recursive: true, force: true })
}
const counters: Record<string, number> = {}
// Restore tables
for (const backup of MODEL_BACKUP) {
try {
const jsonFile = zip.file(`data/${backup.filename}`)
if (jsonFile) {
const jsonContent = await jsonFile.async("string")
const restoredCount = await modelFromJSON(user.id, backup, jsonContent)
console.log(`Restored ${restoredCount} records from ${backup.filename}`)
counters[backup.filename] = restoredCount
}
} catch (error) {
console.error(`Error restoring model from ${backup.filename}:`, error)
}
}
// Restore files
try {
let restoredFilesCount = 0
const files = await prisma.file.findMany({
where: {
userId: user.id,
},
})
const userUploadsDirectory = getUserUploadsDirectory(user)
for (const file of files) {
const filePathWithoutPrefix = path.normalize(file.path.replace(/^.*\/uploads\//, ""))
const zipFilePath = path.join("data/uploads", filePathWithoutPrefix)
const zipFile = zip.file(zipFilePath)
if (!zipFile) {
console.log(`File ${file.path} not found in backup`)
continue
}
const fileContents = await zipFile.async("nodebuffer")
const fullFilePath = safePathJoin(userUploadsDirectory, filePathWithoutPrefix)
if (!fullFilePath.startsWith(path.normalize(userUploadsDirectory))) {
console.error(`Attempted path traversal detected for file ${file.path}`)
continue
}
try {
await fs.mkdir(path.dirname(fullFilePath), { recursive: true })
await fs.writeFile(fullFilePath, fileContents)
restoredFilesCount++
} catch (error) {
console.error(`Error writing file ${fullFilePath}:`, error)
continue
}
await prisma.file.update({
where: { id: file.id },
data: {
path: filePathWithoutPrefix,
},
})
}
counters["Uploaded attachments"] = restoredFilesCount
} catch (error) {
console.error("Error restoring uploaded files:", error)
return {
success: false,
error: `Error restoring uploaded files: ${error instanceof Error ? error.message : String(error)}`,
}
}
return { success: true, data: { counters } }
} catch (error) {
console.error("Error restoring from backup:", error)
return {
success: false,
error: `Error restoring from backup: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
async function cleanupUserTables(userId: string) {
// Delete in reverse order to handle foreign key constraints
for (const { model } of [...MODEL_BACKUP].reverse()) {
try {
await model.deleteMany({ where: { userId } })
} catch (error) {
console.error(`Error clearing table:`, error)
}
}
}