Files
xbin/scripts/import-privatebin-filesystem.mjs
T

181 lines
5.2 KiB
JavaScript

#!/usr/bin/env node
/**
* Imports a filesystem export from PrivateBin into the worker's admin import endpoint.
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
// CLI argument parsing and filesystem discovery turn the export tree into import bundles.
function printUsage() {
console.error(
[
'Usage:',
' node scripts/import-privatebin-filesystem.mjs --source <privatebin-data-dir> --base-url <worker-url> --token <import-token> [--report <path>]',
].join('\n'),
);
}
function parseArgs(argv) {
const args = {};
for (let index = 0; index < argv.length; index += 1) {
const current = argv[index];
if (!current.startsWith('--')) {
continue;
}
const key = current.slice(2);
const value = argv[index + 1];
if (!value || value.startsWith('--')) {
args[key] = 'true';
continue;
}
args[key] = value;
index += 1;
}
return args;
}
function isHexId(value) {
return /^[a-f0-9]{16}$/i.test(value);
}
function stripProtectionPrefix(content) {
const prefix = '<?php http_response_code(403); /*';
return content.startsWith(`${prefix}\n`) ? content.slice(prefix.length + 1) : content;
}
async function readPrivateBinJson(filePath) {
const raw = await fs.readFile(filePath, 'utf8');
return JSON.parse(stripProtectionPrefix(raw));
}
async function collectPasteFiles(rootDir) {
const results = [];
const stack = [rootDir];
while (stack.length > 0) {
const current = stack.pop();
const entries = await fs.readdir(current, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
if (entry.name.endsWith('.discussion')) {
continue;
}
stack.push(fullPath);
continue;
}
if (!entry.isFile()) {
continue;
}
const match = entry.name.match(/^([a-f0-9]{16})\.php$/i);
if (!match) {
continue;
}
results.push({ pasteId: match[1].toLowerCase(), filePath: fullPath });
}
}
return results.sort((left, right) => left.pasteId.localeCompare(right.pasteId));
}
// Comment files live in sibling ".discussion" directories and are replayed in chronological order.
async function collectComments(pasteFilePath, pasteId) {
const discussionDir = path.join(path.dirname(pasteFilePath), `${pasteId}.discussion`);
try {
const entries = await fs.readdir(discussionDir, { withFileTypes: true });
const comments = [];
for (const entry of entries) {
if (!entry.isFile()) {
continue;
}
const match = entry.name.match(new RegExp(`^${pasteId}\\.([a-f0-9]{16})\\.([a-f0-9]{16})\\.php$`, 'i'));
if (!match) {
continue;
}
const commentId = match[1].toLowerCase();
const parentId = match[2].toLowerCase();
const filePath = path.join(discussionDir, entry.name);
const envelope = await readPrivateBinJson(filePath);
comments.push({
id: commentId,
...envelope,
pasteid: pasteId,
parentid: parentId,
});
}
return comments.sort((left, right) => {
const leftCreated = Number(left.meta?.created ?? 0);
const rightCreated = Number(right.meta?.created ?? 0);
return leftCreated - rightCreated;
});
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
return [];
}
throw error;
}
}
// Each bundle is posted to the worker so the server can normalize and persist the payload.
async function importPaste(baseUrl, token, bundle) {
const response = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/v1/admin/import/privatebin`, {
method: 'POST',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
body: JSON.stringify(bundle),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const message = typeof payload.error === 'string' ? payload.error : `HTTP ${response.status}`;
throw new Error(`Failed to import ${bundle.pasteId}: ${message}`);
}
return payload;
}
// main wires together discovery, import execution, and optional reporting.
async function main() {
const args = parseArgs(process.argv.slice(2));
if (!args.source || !args['base-url'] || !args.token) {
printUsage();
process.exitCode = 1;
return;
}
const sourceDir = path.resolve(args.source);
const baseUrl = args['base-url'];
const token = args.token;
const reportPath = args.report ? path.resolve(args.report) : null;
const pasteFiles = await collectPasteFiles(sourceDir);
const report = [];
for (const { pasteId, filePath } of pasteFiles) {
if (!isHexId(pasteId)) {
continue;
}
const paste = await readPrivateBinJson(filePath);
const comments = await collectComments(filePath, pasteId);
const result = await importPaste(baseUrl, token, {
source: 'privatebin-filesystem',
pasteId,
paste,
comments,
});
report.push(result);
process.stdout.write(`${pasteId}: ${result.skipped ? 'skipped expired' : `imported (${result.importedComments} comments)`}\n`);
if (result.deleteToken) {
process.stdout.write(` deleteToken: ${result.deleteToken}\n`);
}
}
if (reportPath) {
await fs.mkdir(path.dirname(reportPath), { recursive: true });
await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});