mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 21:35:19 +00:00
BREAKING: postgres + saas
This commit is contained in:
145
app/(app)/settings/backups/actions.ts
Normal file
145
app/(app)/settings/backups/actions.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
"use server"
|
||||
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { getUserUploadsDirectory } 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
|
||||
|
||||
export async function restoreBackupAction(prevState: any, formData: FormData) {
|
||||
const user = await getCurrentUser()
|
||||
const userUploadsDirectory = await getUserUploadsDirectory(user)
|
||||
const file = formData.get("file") as File
|
||||
|
||||
if (!file || file.size === 0) {
|
||||
return { success: false, error: "No file provided" }
|
||||
}
|
||||
|
||||
// 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" }
|
||||
}
|
||||
|
||||
if (REMOVE_EXISTING_DATA) {
|
||||
await cleanupUserTables(user.id)
|
||||
await fs.rm(userUploadsDirectory, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
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 = await getUserUploadsDirectory(user)
|
||||
|
||||
for (const file of files) {
|
||||
const filePathWithoutPrefix = 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 fullFilePath = path.join(userUploadsDirectory, filePathWithoutPrefix)
|
||||
const fileContent = await zipFile.async("nodebuffer")
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.dirname(fullFilePath), { recursive: true })
|
||||
await fs.writeFile(fullFilePath, fileContent)
|
||||
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, message: "Restore completed successfully", 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
112
app/(app)/settings/backups/data/route.ts
Normal file
112
app/(app)/settings/backups/data/route.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { fileExists, getUserUploadsDirectory } from "@/lib/files"
|
||||
import { MODEL_BACKUP, modelToJSON } from "@/models/backups"
|
||||
import fs from "fs/promises"
|
||||
import JSZip from "jszip"
|
||||
import { NextResponse } from "next/server"
|
||||
import path from "path"
|
||||
|
||||
const MAX_FILE_SIZE = 64 * 1024 * 1024 // 64MB
|
||||
const BACKUP_VERSION = "1.0"
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const user = await getCurrentUser()
|
||||
const userUploadsDirectory = await getUserUploadsDirectory(user)
|
||||
|
||||
try {
|
||||
const zip = new JSZip()
|
||||
const rootFolder = zip.folder("data")
|
||||
if (!rootFolder) {
|
||||
console.error("Failed to create zip folder")
|
||||
return new NextResponse("Internal Server Error", { status: 500 })
|
||||
}
|
||||
|
||||
// Add metadata with version information
|
||||
rootFolder.file(
|
||||
"metadata.json",
|
||||
JSON.stringify(
|
||||
{
|
||||
version: BACKUP_VERSION,
|
||||
timestamp: new Date().toISOString(),
|
||||
models: MODEL_BACKUP.map((m) => m.filename),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
// Backup models
|
||||
for (const backup of MODEL_BACKUP) {
|
||||
try {
|
||||
const jsonContent = await modelToJSON(user.id, backup)
|
||||
rootFolder.file(backup.filename, jsonContent)
|
||||
} catch (error) {
|
||||
console.error(`Error exporting table ${backup.filename}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
const uploadsFolder = rootFolder.folder("uploads")
|
||||
if (!uploadsFolder) {
|
||||
console.error("Failed to create uploads folder")
|
||||
return new NextResponse("Internal Server Error", { status: 500 })
|
||||
}
|
||||
|
||||
const uploadedFiles = await getAllFilePaths(userUploadsDirectory)
|
||||
for (const file of uploadedFiles) {
|
||||
try {
|
||||
// Check file size before reading
|
||||
const stats = await fs.stat(file)
|
||||
if (stats.size > MAX_FILE_SIZE) {
|
||||
console.warn(
|
||||
`Skipping large file ${file} (${Math.round(stats.size / 1024 / 1024)}MB > ${
|
||||
MAX_FILE_SIZE / 1024 / 1024
|
||||
}MB limit)`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const fileContent = await fs.readFile(file)
|
||||
uploadsFolder.file(file.replace(userUploadsDirectory, ""), fileContent)
|
||||
} catch (error) {
|
||||
console.error(`Error reading file ${file}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
const archive = await zip.generateAsync({ type: "blob" })
|
||||
|
||||
return new NextResponse(archive, {
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="data.zip"`,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error exporting database:", error)
|
||||
return new NextResponse("Internal Server Error", { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
async function getAllFilePaths(dirPath: string): Promise<string[]> {
|
||||
let filePaths: string[] = []
|
||||
|
||||
async function readDirectoryRecursively(currentPath: string) {
|
||||
const isDirExists = await fileExists(currentPath)
|
||||
if (!isDirExists) {
|
||||
return
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(currentPath, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentPath, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
await readDirectoryRecursively(fullPath)
|
||||
} else {
|
||||
filePaths.push(fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await readDirectoryRecursively(dirPath)
|
||||
|
||||
return filePaths
|
||||
}
|
||||
72
app/(app)/settings/backups/page.tsx
Normal file
72
app/(app)/settings/backups/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import { FormError } from "@/components/forms/error"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Download, Loader2 } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useActionState } from "react"
|
||||
import { restoreBackupAction } from "./actions"
|
||||
|
||||
export default function BackupSettingsPage() {
|
||||
const [restoreState, restoreBackup, restorePending] = useActionState(restoreBackupAction, null)
|
||||
|
||||
return (
|
||||
<div className="container flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<h1 className="text-2xl font-bold">Download backup</h1>
|
||||
<div className="flex flex-row gap-4">
|
||||
<Link href="/settings/backups/data">
|
||||
<Button>
|
||||
<Download /> Download Data Archive
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground max-w-xl">
|
||||
Inside the archive you will find all the uploaded files, as well as JSON files for transactions, categories,
|
||||
projects, fields, currencies, and settings. You can view, edit or migrate your data to another service.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="flex flex-col gap-2 mt-16 p-5 bg-red-100 max-w-xl">
|
||||
<h2 className="text-xl font-semibold">Restore from a backup</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
⚠️ This action will delete all existing data from your current database and remove all uploaded files. Be
|
||||
careful and make a backup first!
|
||||
</p>
|
||||
<form action={restoreBackup}>
|
||||
<div className="flex flex-col gap-4 pt-4">
|
||||
<input type="hidden" name="removeExistingData" value="true" />
|
||||
<label>
|
||||
<input type="file" name="file" />
|
||||
</label>
|
||||
<Button type="submit" variant="destructive" disabled={restorePending}>
|
||||
{restorePending ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" /> Restoring from backup...
|
||||
</>
|
||||
) : (
|
||||
"Delete existing data and restore from backup"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{restoreState?.error && <FormError>{restoreState.error}</FormError>}
|
||||
</Card>
|
||||
|
||||
{restoreState?.success && (
|
||||
<Card className="flex flex-col gap-2 p-5 bg-green-100 max-w-xl">
|
||||
<h2 className="text-xl font-semibold">Backup restored successfully</h2>
|
||||
<p className="text-sm text-muted-foreground">You can now continue using the app. Import stats:</p>
|
||||
<ul className="list-disc list-inside">
|
||||
{Object.entries(restoreState.counters || {}).map(([key, value]) => (
|
||||
<li key={key}>
|
||||
<span className="font-bold">{key}</span>: {value} items
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user