diff --git a/Dockerfile b/Dockerfile index a1a7729..4ab9474 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,11 @@ FROM node:22-bookworm-slim AS base WORKDIR /app RUN apt-get update \ - && apt-get install -y --no-install-recommends curl openssl ca-certificates util-linux \ + && apt-get install -y --no-install-recommends curl openssl ca-certificates util-linux gnupg \ + && curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgresql.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends postgresql-client-16 \ && rm -rf /var/lib/apt/lists/* FROM base AS deps diff --git a/src/server/routes/settings.ts b/src/server/routes/settings.ts index 165afaa..48b1934 100644 --- a/src/server/routes/settings.ts +++ b/src/server/routes/settings.ts @@ -661,39 +661,50 @@ export async function settingsRoutes(app: FastifyInstance) { 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`, + if (process.platform === "win32") { + 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 }, ); - try { - await fs.access(candidate); - return candidate; - } catch { - // Try another active PostgreSQL process, if more than one exists. + 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 may be unavailable or PostgreSQL may not be running locally. } - } catch { - // PowerShell/CIM may be unavailable or PostgreSQL may not be running locally. } + const executable = process.platform === "win32" ? `${command}.exe` : command; + try { + await execFileAsync(executable, ["--version"], { + windowsHide: true, + timeout: 5000, + maxBuffer: 1024 * 1024, + }); + return executable; + } catch { + // Continue to the Docker runner when the local client is unavailable. + } return null; }; @@ -706,105 +717,179 @@ export async function settingsRoutes(app: FastifyInstance) { 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"], + 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); }); - 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.", - ); + process.env.DATABASE_BACKUP_CONTAINER?.trim() || + `${process.env.COMPOSE_PROJECT_NAME?.trim() || "doktainer"}-postgres`; + const dockerArgs = [ + "exec", + "-i", + databaseContainer, + command, + "--dbname", + databaseCliUrl, + ...args, + ]; + const localBinary = await findLocalPostgresBinary(command); + const runLocal = async () => { + if (!localBinary) return false; + await run( + localBinary, + ["--dbname", databaseCliUrl, ...args], + Boolean(options.outputPath), + ); + return true; + }; + const runDocker = async () => { + try { + await run("docker", dockerArgs, Boolean(options.outputPath)); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error; + return false; } - throw error; - } + }; + + // Prefer the PostgreSQL client directly in every runtime. Docker is only + // an optional fallback for environments where the client is unavailable. + if (await runLocal()) return; + if (await runDocker()) return; + + throw new Error( + process.platform === "win32" + ? "No active local PostgreSQL instance or usable Docker runner was found." + : "Neither Docker PostgreSQL runner nor local PostgreSQL client tools are available.", + ); }; - 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"); + 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 handle.read(header, 0, header.length, 0); + 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 { - await handle.close(); + // The successful response owns cleanup through the stream close handler. + if (!reply.sent) await fs.rm(filePath, { force: true }); } + }, + ); - 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.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) }); @@ -866,10 +951,12 @@ export async function settingsRoutes(app: FastifyInstance) { }); } catch (error) { const message = - error instanceof Error ? error.message : "Panel domain provisioning failed"; + 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({ @@ -882,7 +969,9 @@ export async function settingsRoutes(app: FastifyInstance) { meta: body.data, }).catch(() => undefined); - return reply.status(statusCode).send({ success: false, error: message }); + return reply + .status(statusCode) + .send({ success: false, error: message }); } }, );