195 lines
6.2 KiB
JavaScript
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();
|
|
}
|
|
});
|