import * as fs from "node:fs"; import { mkdir, rm, stat, writeFile } from "node:fs/promises"; import { spawn } from "node:child_process"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import archiver from "archiver"; import config from "../../../../config/index.js"; import define from "../../define.js"; import { ApiError } from "../../error.js"; import { insertModerationLog } from "../../../../services/insert-moderation-log.js"; import { createTempDir } from "../../../../misc/create-temp.js"; export const meta = { tags: [ "admin" ], requireCredential: true, requireAdmin: true, res: { type: "object", optional: false, nullable: false, properties: { path: { type: "string", optional: false, nullable: false }, fileName: { type: "string", optional: false, nullable: false }, size: { type: "number", optional: false, nullable: false }, includedMedia: { type: "boolean", optional: false, nullable: false } } }, errors: { backupFailed: { message: "Failed to create backup.", code: "BACKUP_FAILED", id: "7498ab9f-4e1d-40d0-96d8-d8d1d82bd621" } } }; export const paramDef = { type: "object", properties: {}, required: [] }; const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "../../../../../../.."); function timestamp() { const now = new Date(); const pad = (value)=>value.toString().padStart(2, "0"); return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; } function run(command, args, env) { return new Promise((resolvePromise, reject)=>{ const child = spawn(command, args, { stdio: [ "ignore", "ignore", "pipe" ], env }); let stderr = ""; child.stderr.on("data", (chunk)=>{ stderr += chunk.toString(); }); child.on("error", reject); child.on("exit", (code)=>{ if (code === 0) { resolvePromise(); } else { reject(new Error(`${command} exited with code ${code}: ${stderr.trim()}`)); } }); }); } function archiveDirectory(sourceDir, outFile) { return new Promise((resolvePromise, reject)=>{ const output = fs.createWriteStream(outFile); const archive = archiver("tar", { gzip: true, gzipOptions: { level: 6 } }); output.on("close", ()=>resolvePromise()); archive.on("error", reject); archive.pipe(output); archive.directory(sourceDir, false); archive.finalize(); }); } async function pathExists(path) { return stat(path).then(()=>true).catch(()=>false); } export default define(meta, paramDef, async (_ps, me)=>{ const [workDir, cleanup] = await createTempDir(); const fileName = `iceshrimp-full-${timestamp()}.tar.gz`; const outDir = resolve(process.env.ICESHRIMP_BACKUP_DIR ?? `${rootDir}/backups`); const outFile = resolve(outDir, fileName); const dbDumpPath = resolve(workDir, "database.dump"); const mediaDir = resolve(config.mediaDir); let includedMedia = false; try { await mkdir(outDir, { recursive: true }); const env = { ...process.env, PGHOST: config.db.host, PGPORT: String(config.db.port), PGDATABASE: config.db.db, PGUSER: config.db.user, PGPASSWORD: config.db.pass }; await run("pg_dump", [ "--format=custom", "--blobs", "--no-owner", "--file", dbDumpPath, config.db.db ], env); const configDir = resolve(workDir, "config"); await mkdir(configDir, { recursive: true }); const configFiles = [ process.env.ICESHRIMP_CONFIG ? resolve(process.env.ICESHRIMP_CONFIG) : resolve(rootDir, ".config/default.yml"), ...process.env.ICESHRIMP_SECRETS ? [ resolve(process.env.ICESHRIMP_SECRETS) ] : [] ]; for (const configFile of configFiles){ if (await pathExists(configFile)) { fs.copyFileSync(configFile, resolve(configDir, configFile.split("/").pop())); } } if (await pathExists(mediaDir) && !outDir.startsWith(mediaDir + "/") && outDir !== mediaDir) { fs.cpSync(mediaDir, resolve(workDir, "files"), { recursive: true, dereference: false, errorOnExist: false }); includedMedia = true; } await writeFile(resolve(workDir, "manifest.json"), `${JSON.stringify({ type: "iceshrimp-full-backup", version: config.version, createdAt: new Date().toISOString(), host: config.host, database: config.db.db, included: { database: true, config: true, media: includedMedia }, restore: "yarn full:restore " }, null, 2)}\n`, "utf8"); await archiveDirectory(workDir, outFile); const outStat = await stat(outFile); await insertModerationLog(me, "createBackup", { path: outFile, size: outStat.size, includedMedia }); return { path: outFile, fileName, size: outStat.size, includedMedia }; } catch (e) { await rm(outFile, { force: true }).catch(()=>{}); throw new ApiError(meta.errors.backupFailed, { message: e instanceof Error ? e.message : String(e) }); } finally{ cleanup(); } });