You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
103 lines
3.4 KiB
103 lines
3.4 KiB
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { buildExportDataJson } from "#server/service/export/build-data";
|
|
import { buildExportMediaFiles } from "#server/service/export/build-files";
|
|
import { buildExportManifest } from "#server/service/export/build-manifest";
|
|
import {
|
|
markExportTaskFailed,
|
|
markExportTaskRunning,
|
|
markExportTaskSucceeded,
|
|
} from "#server/service/export/jobs";
|
|
import { sha256File } from "#server/utils/export-hash";
|
|
|
|
const EXPORT_RESULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
|
|
function resolveExportRootDir() {
|
|
return path.resolve(process.cwd(), ".tmp", "exports");
|
|
}
|
|
|
|
async function calcChecksums(baseDir: string, files: Array<{ file: string }>) {
|
|
return Promise.all(
|
|
files.map(async (item) => ({
|
|
file: item.file,
|
|
sha256: await sha256File(path.resolve(baseDir, item.file)),
|
|
})),
|
|
);
|
|
}
|
|
|
|
export async function runExportTask(taskId: number) {
|
|
const runningTask = await markExportTaskRunning(taskId);
|
|
return runExportTaskWithRunning(taskId, runningTask);
|
|
}
|
|
|
|
export async function runExportTaskWithRunning(
|
|
taskId: number,
|
|
runningTask: Awaited<ReturnType<typeof markExportTaskRunning>>,
|
|
) {
|
|
const cutoffAt = runningTask.exportCutoffAt ?? new Date();
|
|
const exportRootDir = resolveExportRootDir();
|
|
const outputName = `export-task-${taskId}`;
|
|
const outputDir = path.resolve(exportRootDir, outputName);
|
|
const outputDataDir = path.resolve(outputDir, "data");
|
|
const outputFilesDir = path.resolve(outputDir, "files");
|
|
|
|
try {
|
|
await fs.mkdir(outputDir, { recursive: true });
|
|
|
|
const dataResult = await buildExportDataJson({
|
|
userId: runningTask.userId,
|
|
maskPolicy: runningTask.maskPolicy as "masked" | "raw",
|
|
cutoffAt,
|
|
outputDataDir,
|
|
});
|
|
const filesResult = await buildExportMediaFiles({
|
|
userId: runningTask.userId,
|
|
cutoffAt,
|
|
outputFilesDir,
|
|
});
|
|
|
|
const dataChecksums = await calcChecksums(outputDir, dataResult.dataFiles);
|
|
const fileChecksums = await calcChecksums(outputDir, filesResult.files);
|
|
const filesBytes = filesResult.files.reduce((sum, item) => sum + item.bytes, 0);
|
|
|
|
const manifest = buildExportManifest({
|
|
schemaVersion: 1,
|
|
userId: runningTask.userId,
|
|
maskPolicy: runningTask.maskPolicy as "masked" | "raw",
|
|
exportedAt: new Date(),
|
|
exportCutoffAt: cutoffAt,
|
|
stats: {
|
|
dataRows: dataResult.totalRows,
|
|
files: filesResult.files.length,
|
|
bytes: filesBytes,
|
|
},
|
|
dataChecksums,
|
|
fileChecksums,
|
|
});
|
|
|
|
const manifestPath = path.resolve(outputDir, "manifest.json");
|
|
await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
|
|
const dataBytes = await Promise.all(
|
|
dataResult.dataFiles.map(async (item) => {
|
|
const stat = await fs.stat(path.resolve(outputDir, item.file));
|
|
return stat.size;
|
|
}),
|
|
);
|
|
const manifestBytes = (await fs.stat(manifestPath)).size;
|
|
const totalBytes = dataBytes.reduce((sum, size) => sum + size, 0) + filesBytes + manifestBytes;
|
|
|
|
await markExportTaskSucceeded(taskId, {
|
|
outputDir,
|
|
outputName,
|
|
totalBytes,
|
|
expiresAt: new Date(Date.now() + EXPORT_RESULT_TTL_MS),
|
|
});
|
|
} catch (error) {
|
|
await markExportTaskFailed(taskId, {
|
|
errorCode: "EXPORT_BUILD_FAILED",
|
|
errorMessage: error instanceof Error ? error.message : "unknown export error",
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|