Files
2026-07-26 18:25:37 +09:00

195 lines
6.2 KiB
JavaScript

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 <this archive>"
}, 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();
}
});