Major version update. Major bugfixes. Security vulnerabilites fixed

This commit is contained in:
2026-10-14 03:02:07 +01:00
parent 4e47bd8ce0
commit 7c095f6f16
112 changed files with 2479 additions and 1219 deletions
+173
View File
@@ -0,0 +1,173 @@
import fs from "fs";
import path from "path";
import { AppData } from "../data/app-data";
import grabDirNames from "../data/grab-dir-names";
import { isSchemaFileName } from "./resolve-and-load-data-file";
export const ExportArchiveMembers = {
DbFileName: "database.sqlite",
SchemaFileName: AppData.DbSchemaFileName,
};
const ARCHIVE_EXTENSIONS = [".tar.gz", ".tgz", ".tar", ".zip"];
/** Every SQLite database file starts with this header */
const SQLITE_HEADER = "SQLite format 3\0";
export function isArchivePath(filePath) {
const lower = filePath.toLowerCase();
return ARCHIVE_EXTENSIONS.some((ext) => lower.endsWith(ext));
}
export function isSqlPath(filePath) {
return filePath.toLowerCase().endsWith(".sql");
}
export function isSQLiteFile(bytes) {
return (new TextDecoder().decode(bytes.subarray(0, SQLITE_HEADER.length)) ===
SQLITE_HEADER);
}
/**
* Create a portable export archive (tar / tar.gz / zip) with the database
* file + schema file.
*/
export async function writeExportArchive({ contents, outPath, }) {
const lower = outPath.toLowerCase();
if (lower.endsWith(".zip")) {
await writeZipArchive({ contents, outPath });
return;
}
const members = {
[ExportArchiveMembers.DbFileName]: contents.db,
[contents.schemaFileName]: contents.schema,
};
const gzip = lower.endsWith(".gz") || lower.endsWith(".tgz");
if (gzip) {
await Bun.Archive.write(outPath, members, { compress: "gzip" });
}
else {
await Bun.Archive.write(outPath, members);
}
}
/**
* Read a portable export archive (tar / tar.gz / zip).
*/
export async function readExportArchive(archivePath) {
if (archivePath.toLowerCase().endsWith(".zip")) {
return readZipArchive(archivePath);
}
const bytes = await Bun.file(archivePath).bytes();
const archive = new Bun.Archive(bytes);
const files = await archive.files();
let db = null;
let schemaEntry = null;
for (const [entry, file] of files) {
const base = path.basename(entry);
if (!db && base === ExportArchiveMembers.DbFileName) {
db = await file.bytes();
}
else if (!schemaEntry && isSchemaFileName(base)) {
schemaEntry = {
content: await file.text(),
fileName: schemaMemberName(base),
};
}
}
return validateContents({ db, schemaEntry });
}
function schemaMemberName(base) {
return base === AppData.DbSchemaFileName ? "schema.ts" : base;
}
function validateContents({ db, schemaEntry, }) {
if (!db) {
throw new Error(`Archive is missing the database file (expected \`${ExportArchiveMembers.DbFileName}\`)`);
}
if (!isSQLiteFile(db)) {
throw new Error(`\`${ExportArchiveMembers.DbFileName}\` in the archive is not a SQLite database`);
}
if (!schemaEntry) {
throw new Error(`Archive is missing schema file (expected \`${AppData.DbSchemaFileName}.ts|js|json|yaml|yml\`)`);
}
return {
db,
schema: schemaEntry.content,
schemaFileName: schemaEntry.fileName,
};
}
async function runCommand(cmd, cwd) {
const proc = Bun.spawn(cmd, {
cwd,
stdout: "pipe",
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([
new Response(proc.stderr).text(),
proc.exited,
]);
if (exitCode !== 0) {
throw new Error(`${cmd[0]} failed (exit ${exitCode}): ${stderr || "unknown error"}. Ensure \`${cmd[0]}\` is installed, or use .tar.gz.`);
}
}
function makeTempDir(prefix) {
const { BUN_SQLITE_TEMP_DIR } = grabDirNames();
const tempDir = path.join(BUN_SQLITE_TEMP_DIR, `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
fs.mkdirSync(tempDir, { recursive: true });
return tempDir;
}
async function writeZipArchive({ contents, outPath, }) {
const tempDir = makeTempDir("export");
try {
fs.writeFileSync(path.join(tempDir, ExportArchiveMembers.DbFileName), contents.db);
fs.writeFileSync(path.join(tempDir, contents.schemaFileName), contents.schema, "utf-8");
await runCommand([
"zip",
"-q",
"-j",
path.resolve(outPath),
ExportArchiveMembers.DbFileName,
contents.schemaFileName,
], tempDir);
}
finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
async function readZipArchive(archivePath) {
const tempDir = makeTempDir("import");
try {
await runCommand([
"unzip",
"-q",
"-o",
path.resolve(archivePath),
"-d",
tempDir,
]);
let db = null;
let schemaEntry = null;
const stack = [tempDir];
while (stack.length) {
const current = stack.pop();
for (const entry of fs.readdirSync(current, {
withFileTypes: true,
})) {
const full = path.join(current, entry.name);
// Never follow symlinks from an extracted (untrusted) archive
if (entry.isDirectory()) {
stack.push(full);
}
else if (!entry.isFile()) {
continue;
}
else if (!db &&
entry.name === ExportArchiveMembers.DbFileName) {
db = new Uint8Array(fs.readFileSync(full));
}
else if (!schemaEntry && isSchemaFileName(entry.name)) {
schemaEntry = {
content: fs.readFileSync(full, "utf-8"),
fileName: schemaMemberName(entry.name),
};
}
}
}
return validateContents({ db, schemaEntry });
}
finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}