diff --git a/src/app/settings/components/DatabaseSettingsPanel.tsx b/src/app/settings/components/DatabaseSettingsPanel.tsx new file mode 100644 index 0000000..850f6df --- /dev/null +++ b/src/app/settings/components/DatabaseSettingsPanel.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { Database, Download, Loader2, Upload } from "lucide-react"; +import { useRef, useState } from "react"; +import { settingsApi } from "@/lib/api"; +import ConfirmActionDialog from "@/components/ConfirmActionDialog"; + +export default function DatabaseSettingsPanel({ + onError, + onSuccess, +}: { + onError: (message: string) => void; + onSuccess: (message: string) => void; +}) { + const fileInput = useRef(null); + const [loading, setLoading] = useState<"backup" | "restore" | null>(null); + const [restoreFile, setRestoreFile] = useState(null); + + const download = async () => { + setLoading("backup"); + try { + const result = await settingsApi.downloadDatabaseBackup(); + const url = URL.createObjectURL(result.blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = result.fileName; + anchor.click(); + URL.revokeObjectURL(url); + onSuccess("Database backup downloaded"); + } catch (error) { + onError(error instanceof Error ? error.message : "Database backup failed"); + } finally { + setLoading(null); + } + }; + + const restore = async (file: File) => { + setLoading("restore"); + try { + await settingsApi.restoreDatabaseBackup(file); + onSuccess("Database restore completed. Refresh the page to load restored data."); + } catch (error) { + onError(error instanceof Error ? error.message : "Database restore failed"); + } finally { + setLoading(null); + if (fileInput.current) fileInput.current.value = ""; + } + }; + + const clearRestoreFile = () => { + setRestoreFile(null); + if (fileInput.current) fileInput.current.value = ""; + }; + + return ( + <> + } + onClose={clearRestoreFile} + onConfirm={() => { + const file = restoreFile; + setRestoreFile(null); + if (file) void restore(file); + }} + /> +
+
+ +
+

Database backup & restore

+

+ Export the internal Doktainer database as a PostgreSQL dump or restore it from a previous dump file. +

+
+
+
+ + { const file = event.target.files?.[0]; if (file) setRestoreFile(file); }} /> + +
+

Restoring will overwrite the current database data. Ensure you have downloaded the latest backup.

+
+ + ); +} diff --git a/src/app/settings/components/settings-config.ts b/src/app/settings/components/settings-config.ts index f169294..9e2af5d 100644 --- a/src/app/settings/components/settings-config.ts +++ b/src/app/settings/components/settings-config.ts @@ -1,4 +1,4 @@ -import { Globe2, Lock, Settings, type LucideIcon } from "lucide-react"; +import { Database, Globe2, Lock, Settings, type LucideIcon } from "lucide-react"; import type { SettingsTab } from "@/app/settings/components/settings-types"; export const TIMEZONE_OPTIONS = [ @@ -27,10 +27,12 @@ export const SETTINGS_TABS: Array<{ { id: "general", label: "General", icon: Settings }, { id: "panel-access", label: "Panel Access", icon: Globe2 }, { id: "security", label: "Security", icon: Lock }, + { id: "database", label: "Database", icon: Database }, ]; export const ACTIVE_TAB_LABELS: Record = { general: "General", "panel-access": "Panel Access", security: "Security", + database: "Database", }; diff --git a/src/app/settings/components/settings-types.ts b/src/app/settings/components/settings-types.ts index b5e6ada..411b22b 100644 --- a/src/app/settings/components/settings-types.ts +++ b/src/app/settings/components/settings-types.ts @@ -1,7 +1,7 @@ import type { Dispatch, SetStateAction } from "react"; import type { SettingsRecord } from "@/lib/api"; -export type SettingsTab = "general" | "panel-access" | "security"; +export type SettingsTab = "general" | "panel-access" | "security" | "database"; export type IntegrationProvider = "cloudflare" | "awsS3" | "github" | "gitlab"; diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 7b9e607..75b4c04 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -7,6 +7,7 @@ import { getStoredTheme, storeUiPreferences } from "@/lib/preferences"; import GeneralSettingsPanel from "@/app/settings/components/GeneralSettingsPanel"; import PanelAccessSettingsPanel from "@/app/settings/components/PanelAccessSettingsPanel"; import SecuritySettingsPanel from "@/app/settings/components/SecuritySettingsPanel"; +import DatabaseSettingsPanel from "@/app/settings/components/DatabaseSettingsPanel"; import { Banner, SettingsLoadingPanel, @@ -366,6 +367,13 @@ export default function SettingsPage() { onSave={() => void saveSettings()} /> ) : null} + + {activeTab === "database" ? ( + + ) : null} )} diff --git a/src/lib/api.ts b/src/lib/api.ts index 08615eb..37dabe9 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -669,7 +669,9 @@ async function requestRaw( ? setTimeout(() => controller.abort(), timeoutMs) : null; - if (options.body !== undefined && !("Content-Type" in headers)) { + const isMultipartBody = + typeof FormData !== "undefined" && options.body instanceof FormData; + if (options.body !== undefined && !isMultipartBody && !("Content-Type" in headers)) { headers["Content-Type"] = "application/json"; } @@ -1114,6 +1116,32 @@ export const apiKeys = { export const settingsApi = { get: () => get<{ success: boolean; data: SettingsRecord }>("/settings"), + downloadDatabaseBackup: async () => { + const res = await requestRaw("/settings/database-backup", { method: "GET" }); + if (!res.ok) throw new Error(await readApiErrorMessage(res, "Database backup failed")); + const disposition = res.headers.get("content-disposition") || ""; + const serverFileName = disposition.match(/filename="?([^";]+)"?/i)?.[1]; + const timestamp = new Date() + .toISOString() + .replace(/[-:]/g, "") + .replace(/\.\d{3}Z$/, "Z") + .replace("T", "-"); + const fileName = + serverFileName && /^doktainer-database-\d{8}-\d{6}Z\.dump$/i.test(serverFileName) + ? serverFileName + : `doktainer-database-${timestamp}.dump`; + return { blob: await res.blob(), fileName }; + }, + + restoreDatabaseBackup: async (file: File) => { + const body = new FormData(); + body.append("file", file); + const res = await requestRaw("/settings/database-restore", { method: "POST", body }, { timeoutMs: 120000 }); + const json = await readJsonResponse<{ success?: boolean; message?: string; error?: string }>(res, "Database restore failed"); + if (!res.ok) throw new Error(json.error || json.message || "Database restore failed"); + return json; + }, + getPanelAccessCapabilities: () => get<{ success: boolean; data: PanelAccessCapabilities }>( "/settings/panel-access/capabilities", diff --git a/src/server/index.ts b/src/server/index.ts index 65090b4..387830e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -3,6 +3,7 @@ import Fastify, { FastifyError } from "fastify"; import cors from "@fastify/cors"; import jwt from "@fastify/jwt"; import rateLimit from "@fastify/rate-limit"; +import multipart from "@fastify/multipart"; import websocket from "@fastify/websocket"; import prisma from "./lib/prisma"; import { validateEncryptionConfiguration } from "./lib/crypto"; @@ -144,6 +145,7 @@ async function start() { }); await app.register(websocket); + await app.register(multipart, { limits: { fileSize: 512 * 1024 * 1024, files: 1 } }); // ── Health check ────────────────────────────────────── app.get("/health", { config: { rateLimit: false } }, async () => ({ diff --git a/src/server/routes/settings.ts b/src/server/routes/settings.ts index 5d18772..165afaa 100644 --- a/src/server/routes/settings.ts +++ b/src/server/routes/settings.ts @@ -1,9 +1,15 @@ import { FastifyInstance } from "fastify"; +import { createReadStream, createWriteStream, promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFile, spawn } from "node:child_process"; +import { promisify } from "node:util"; +import { pipeline } from "node:stream/promises"; import { z } from "zod"; import { resolvePublicAppOrigin } from "../lib/public-url"; import prisma from "../lib/prisma"; import { decrypt, encrypt } from "../lib/crypto"; -import { authenticate } from "../middleware/auth"; +import { authenticate, requireRole } from "../middleware/auth"; import { auditLog } from "../services/audit.service"; import { getPanelAccessCapabilities, @@ -638,6 +644,167 @@ async function ensureModularSettings( } export async function settingsRoutes(app: FastifyInstance) { + const databaseAccess = [authenticate, requireRole("DEVELOPER")]; + const execFileAsync = promisify(execFile); + + const getPostgresCliUrl = (databaseUrl: string) => { + try { + const parsed = new URL(databaseUrl); + // `schema` is a Prisma connection parameter and is rejected by pg_dump/pg_restore. + parsed.searchParams.delete("schema"); + return parsed.toString(); + } catch { + return databaseUrl; + } + }; + + const findLocalPostgresBinary = async ( + command: "pg_dump" | "pg_restore" | "psql", + ) => { + if (process.platform !== "win32") return null; + + try { + const { stdout } = await execFileAsync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + "Get-Process -Name postgres -ErrorAction SilentlyContinue | ForEach-Object { try { $_.Path } catch { $null } } | Where-Object { $_ } | Sort-Object -Unique | ConvertTo-Json -Compress", + ], + { windowsHide: true, timeout: 5000, maxBuffer: 1024 * 1024 }, + ); + const parsed = JSON.parse(stdout.trim() || "[]") as string | string[]; + const postgresExecutables = Array.isArray(parsed) ? parsed : [parsed]; + + for (const postgresExecutable of postgresExecutables) { + if (!postgresExecutable) continue; + const candidate = join( + postgresExecutable.replace(/[\\/]postgres(?:\.exe)?$/i, ""), + `${command}.exe`, + ); + try { + await fs.access(candidate); + return candidate; + } catch { + // Try another active PostgreSQL process, if more than one exists. + } + } + } catch { + // PowerShell/CIM may be unavailable or PostgreSQL may not be running locally. + } + + return null; + }; + + const runDatabaseCommand = async ( + command: "pg_dump" | "pg_restore" | "psql", + args: string[], + options: { inputPath?: string; outputPath?: string } = {}, + ) => { + const databaseUrl = process.env.DATABASE_URL?.trim(); + if (!databaseUrl) throw new Error("DATABASE_URL is not configured"); + const databaseCliUrl = getPostgresCliUrl(databaseUrl); + + const run = (executable: string, commandArgs: string[], usePipe = false) => new Promise((resolve, reject) => { + const child = spawn(executable, commandArgs, { + env: { ...process.env, PGPASSFILE: undefined }, + stdio: [options.inputPath ? "pipe" : "ignore", usePipe ? "pipe" : "ignore", "pipe"], + }); + let stderr = ""; + const output = options.outputPath ? createWriteStream(options.outputPath) : null; + child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); }); + const childExit = new Promise((resolveExit, rejectExit) => { + child.once("error", rejectExit); + child.once("close", (code) => resolveExit(code ?? 1)); + }); + const streamTasks: Promise[] = []; + if (options.inputPath) streamTasks.push(pipeline(createReadStream(options.inputPath), child.stdin!)); + if (output && child.stdout) streamTasks.push(pipeline(child.stdout, output)); + void Promise.all([childExit, ...streamTasks]) + .then(([code]) => { + if (code === 0) resolve(); + else reject(new Error(stderr.trim() || `${command} failed`)); + }) + .catch(reject); + }); + + const localBinary = await findLocalPostgresBinary(command); + if (localBinary) { + await run(localBinary, ["--dbname", databaseCliUrl, ...args], Boolean(options.outputPath)); + return; + } + + const databaseContainer = + process.env.DATABASE_BACKUP_CONTAINER?.trim() || "doktainer-postgres"; + const dockerArgs = ["exec", "-i", databaseContainer, command, "--dbname", databaseCliUrl, ...args]; + try { + await run("docker", dockerArgs, Boolean(options.outputPath)); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + throw new Error( + process.platform === "win32" + ? "PostgreSQL lokal yang aktif tidak ditemukan dan Docker CLI juga tidak tersedia." + : "Docker CLI tidak tersedia untuk menjalankan backup PostgreSQL.", + ); + } + throw error; + } + }; + + app.get("/database-backup", { preHandler: databaseAccess }, async (_req, reply) => { + const timestamp = new Date() + .toISOString() + .replace(/[-:]/g, "") + .replace(/\.\d{3}Z$/, "Z") + .replace("T", "-"); + const downloadName = `doktainer-database-${timestamp}.dump`; + const filePath = join(tmpdir(), `.${downloadName}`); + try { + await runDatabaseCommand("pg_dump", ["--format=custom"], { outputPath: filePath }); + reply.header("Content-Type", "application/octet-stream"); + reply.header("Content-Disposition", `attachment; filename="${downloadName}"`); + const stream = createReadStream(filePath); + stream.once("close", () => void fs.rm(filePath, { force: true })); + return reply.send(stream); + } catch (error) { + return reply.status(500).send({ success: false, error: error instanceof Error ? error.message : "Database backup failed" }); + } finally { + // The successful response owns cleanup through the stream close handler. + if (!reply.sent) await fs.rm(filePath, { force: true }); + } + }); + + app.post("/database-restore", { preHandler: databaseAccess }, async (req, reply) => { + const upload = await req.file(); + if (!upload) return reply.status(400).send({ success: false, error: "Select a PostgreSQL dump file" }); + const filePath = join(tmpdir(), `doktainer-restore-${Date.now()}.dump`); + try { + await pipeline(upload.file, createWriteStream(filePath)); + const header = Buffer.alloc(5); + const handle = await fs.open(filePath, "r"); + try { + await handle.read(header, 0, header.length, 0); + } finally { + await handle.close(); + } + + const isCustomArchive = header.toString("ascii") === "PGDMP"; + await runDatabaseCommand( + isCustomArchive ? "pg_restore" : "psql", + isCustomArchive + ? ["--clean", "--if-exists", "--no-owner", "--exit-on-error"] + : ["--set", "ON_ERROR_STOP=1"], + { inputPath: filePath }, + ); + await auditLog({ userId: req.userId, organizationId: req.organizationId, action: "DATABASE_RESTORE", category: "SYSTEM", level: "WARNING", message: "Doktainer database restored from uploaded dump" }); + return reply.send({ success: true, message: "Database restore completed" }); + } catch (error) { + return reply.status(500).send({ success: false, error: error instanceof Error ? error.message : "Database restore failed" }); + } finally { + await fs.rm(filePath, { force: true }); + } + }); app.get("/", { preHandler: [authenticate] }, async (req, reply) => { const bundle = await ensureModularSettings(req.userId!, req.organizationId); return reply.send({ success: true, data: serializeSettings(bundle) }); @@ -646,7 +813,7 @@ export async function settingsRoutes(app: FastifyInstance) { app.get( "/panel-access/capabilities", { preHandler: [authenticate] }, - async (_req, reply) => { + async (req, reply) => { const capabilities = await getPanelAccessCapabilities(); return reply.send({ success: true, data: capabilities }); }, @@ -702,7 +869,7 @@ export async function settingsRoutes(app: FastifyInstance) { error instanceof Error ? error.message : "Panel domain provisioning failed"; const statusCode = typeof (error as { statusCode?: unknown })?.statusCode === "number" - ? (error as { statusCode: number }).statusCode + ? ((error as { statusCode: number }).statusCode) : 400; await auditLog({