feat: add docker pipeline and preview fixes
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import path from 'node:path';
|
||||
|
||||
function parsePositiveInteger(value, fallback) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
port: parsePositiveInteger(process.env.PORT, 3000),
|
||||
appDbPath:
|
||||
process.env.APP_DB_PATH ??
|
||||
path.resolve(process.cwd(), 'data', 'app.sqlite'),
|
||||
uploadDir:
|
||||
process.env.UPLOAD_DIR ??
|
||||
path.resolve(process.cwd(), 'data', 'sources'),
|
||||
previewMaxBytes: parsePositiveInteger(process.env.PREVIEW_MAX_BYTES, 16 * 1024 * 1024)
|
||||
};
|
||||
@@ -0,0 +1,515 @@
|
||||
import mimeTypes from 'mime-types';
|
||||
|
||||
import { HttpError } from '../errors.js';
|
||||
import { decodeOpaqueId, encodeOpaqueId } from '../lib/fileId.js';
|
||||
import {
|
||||
buildResolvedFilesetId,
|
||||
computeCanonicalBlockSize,
|
||||
inferPreviewKind,
|
||||
normalizeCompressionMethod,
|
||||
normalizeCrc32,
|
||||
stripAesSuffix,
|
||||
toZipEntryName
|
||||
} from '../lib/duplicati.js';
|
||||
import {
|
||||
getImmediateChildName,
|
||||
getNameFromApiPath,
|
||||
getParentApiPath,
|
||||
isSameOrDescendant,
|
||||
joinApiPath,
|
||||
normalizeApiPath,
|
||||
toApiPath
|
||||
} from '../lib/paths.js';
|
||||
import { duplicatiTicksToIso, unixSecondsToIso } from '../lib/time.js';
|
||||
|
||||
function buildFileRowProjectionSql(sourceLayout) {
|
||||
if (sourceLayout === 'file_lookup') {
|
||||
return `
|
||||
SELECT
|
||||
"FileLookup"."ID" AS "fileId",
|
||||
${buildPathExpressionSql(sourceLayout)} AS "dbPath",
|
||||
"FileLookup"."BlocksetID" AS "blocksetId",
|
||||
"FileLookup"."MetadataID" AS "metadataId",
|
||||
"FilesetEntry"."Lastmodified" AS "lastModifiedTicks",
|
||||
"Blockset"."Length" AS "contentLength",
|
||||
"Blockset"."FullHash" AS "fullHash"
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
SELECT
|
||||
"File"."ID" AS "fileId",
|
||||
"File"."Path" AS "dbPath",
|
||||
"File"."BlocksetID" AS "blocksetId",
|
||||
"File"."MetadataID" AS "metadataId",
|
||||
"FilesetEntry"."Lastmodified" AS "lastModifiedTicks",
|
||||
"Blockset"."Length" AS "contentLength",
|
||||
"Blockset"."FullHash" AS "fullHash"
|
||||
`;
|
||||
}
|
||||
|
||||
function buildPathExpressionSql(sourceLayout) {
|
||||
if (sourceLayout === 'file_lookup') {
|
||||
return `(COALESCE("PathPrefix"."Prefix", '') || "FileLookup"."Path")`;
|
||||
}
|
||||
|
||||
return `"File"."Path"`;
|
||||
}
|
||||
|
||||
function buildFileRowSourceSql(sourceLayout) {
|
||||
if (sourceLayout === 'file_lookup') {
|
||||
return `
|
||||
FROM source."FilesetEntry"
|
||||
JOIN source."FileLookup" ON "FileLookup"."ID" = "FilesetEntry"."FileID"
|
||||
LEFT JOIN source."PathPrefix" ON "PathPrefix"."ID" = "FileLookup"."PrefixID"
|
||||
LEFT JOIN source."Blockset" ON "Blockset"."ID" = "FileLookup"."BlocksetID"
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
FROM source."FilesetEntry"
|
||||
JOIN source."File" ON "File"."ID" = "FilesetEntry"."FileID"
|
||||
LEFT JOIN source."Blockset" ON "Blockset"."ID" = "File"."BlocksetID"
|
||||
`;
|
||||
}
|
||||
|
||||
function buildListSnapshotRowsSql(sourceLayout) {
|
||||
return `
|
||||
${buildFileRowProjectionSql(sourceLayout)}
|
||||
${buildFileRowSourceSql(sourceLayout)}
|
||||
WHERE "FilesetEntry"."FilesetID" = ?
|
||||
ORDER BY "dbPath" ASC
|
||||
`;
|
||||
}
|
||||
|
||||
function buildGetFileRowSql(sourceLayout) {
|
||||
return `
|
||||
${buildFileRowProjectionSql(sourceLayout)}
|
||||
${buildFileRowSourceSql(sourceLayout)}
|
||||
WHERE "FilesetEntry"."FilesetID" = ?
|
||||
AND ${buildPathExpressionSql(sourceLayout)} = ?
|
||||
ORDER BY "FilesetEntry"."Lastmodified" DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
}
|
||||
|
||||
function buildSegmentRowsSql(includeVolumeCryptoCache) {
|
||||
return `
|
||||
SELECT
|
||||
"BlocksetEntry"."Index" AS "segmentIndex",
|
||||
"Block"."Hash" AS "blockHashBase64",
|
||||
"Block"."Size" AS "logicalSize",
|
||||
"Block"."VolumeID" AS "remoteVolumeId",
|
||||
"Remotevolume"."Name" AS "volumeName",
|
||||
"archive_entry_index"."entry_name" AS "entryName",
|
||||
"archive_entry_index"."local_header_offset_plain" AS "localHeaderOffsetPlain",
|
||||
"archive_entry_index"."data_offset_plain" AS "dataOffsetPlain",
|
||||
"archive_entry_index"."compressed_size" AS "compressedSize",
|
||||
"archive_entry_index"."uncompressed_size" AS "uncompressedSize",
|
||||
"archive_entry_index"."compression_method" AS "compressionMethod",
|
||||
"archive_entry_index"."crc32" AS "crc32",
|
||||
${
|
||||
includeVolumeCryptoCache
|
||||
? `source."volume_crypto_cache"."stream_format" AS "streamFormat",
|
||||
source."volume_crypto_cache"."header_probe_bytes" AS "headerProbeBytes",
|
||||
source."volume_crypto_cache"."kdf_iterations" AS "kdfIterations",
|
||||
source."volume_crypto_cache"."salt_hex" AS "saltHex",
|
||||
source."volume_crypto_cache"."iv_hex" AS "ivHex"`
|
||||
: `NULL AS "streamFormat",
|
||||
NULL AS "headerProbeBytes",
|
||||
NULL AS "kdfIterations",
|
||||
NULL AS "saltHex",
|
||||
NULL AS "ivHex"`
|
||||
}
|
||||
FROM source."BlocksetEntry"
|
||||
JOIN source."Block" ON "Block"."ID" = "BlocksetEntry"."BlockID"
|
||||
JOIN source."Remotevolume" ON "Remotevolume"."ID" = "Block"."VolumeID"
|
||||
LEFT JOIN source."archive_entry_index"
|
||||
ON "archive_entry_index"."volume_name" = "Remotevolume"."Name"
|
||||
AND "archive_entry_index"."entry_name" = REPLACE(REPLACE("Block"."Hash", '/', '_'), '+', '-')
|
||||
${includeVolumeCryptoCache ? 'LEFT JOIN source."volume_crypto_cache" ON source."volume_crypto_cache"."volume_name" = "Remotevolume"."Name"' : ''}
|
||||
WHERE "BlocksetEntry"."BlocksetID" = ?
|
||||
ORDER BY "BlocksetEntry"."Index" ASC
|
||||
`;
|
||||
}
|
||||
|
||||
function resolveMime(apiPath, type) {
|
||||
if (type !== 'file') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = mimeTypes.lookup(apiPath);
|
||||
return result || null;
|
||||
}
|
||||
|
||||
function buildSnapshotShape(selection, row) {
|
||||
const timestamp = unixSecondsToIso(row.Timestamp ?? row.timestamp);
|
||||
return {
|
||||
id: selection,
|
||||
filesetDbId: Number(row.ID ?? row.id),
|
||||
resolvedFilesetId: buildResolvedFilesetId(timestamp),
|
||||
timestamp
|
||||
};
|
||||
}
|
||||
|
||||
function encodeEntryId(snapshot, kind, source, dbPath, apiPath) {
|
||||
return encodeOpaqueId({
|
||||
kind,
|
||||
sourceId: source.id,
|
||||
snapshotId: snapshot.id,
|
||||
filesetDbId: snapshot.filesetDbId,
|
||||
resolvedFilesetId: snapshot.resolvedFilesetId,
|
||||
timestamp: snapshot.timestamp,
|
||||
dbPath,
|
||||
apiPath
|
||||
});
|
||||
}
|
||||
|
||||
function makeDirectoryEntry(snapshot, source, basePath, childName, existing = undefined) {
|
||||
const path = joinApiPath(basePath, childName);
|
||||
return {
|
||||
id:
|
||||
existing?.id ??
|
||||
encodeEntryId(snapshot, 'dir', source, existing?.dbPath ?? null, path),
|
||||
type: 'dir',
|
||||
name: childName,
|
||||
path,
|
||||
size: null,
|
||||
mtime: existing?.mtime ?? null,
|
||||
mime: null
|
||||
};
|
||||
}
|
||||
|
||||
function makeFileEntry(snapshot, source, row) {
|
||||
const path = toApiPath(row.dbPath);
|
||||
const mime = resolveMime(path, 'file');
|
||||
const preview = inferPreviewKind(mime);
|
||||
const size = row.contentLength === null || row.contentLength === undefined ? null : Number(row.contentLength);
|
||||
const fullHash = row.fullHash ?? null;
|
||||
|
||||
return {
|
||||
id: encodeEntryId(snapshot, 'file', source, row.dbPath, path),
|
||||
type: 'file',
|
||||
name: getNameFromApiPath(path),
|
||||
path,
|
||||
size,
|
||||
mtime: duplicatiTicksToIso(row.lastModifiedTicks),
|
||||
mime,
|
||||
...(fullHash
|
||||
? {
|
||||
hash: {
|
||||
algo: 'sha256',
|
||||
base64: fullHash
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
hints: {
|
||||
preview,
|
||||
download: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function sortEntries(entries) {
|
||||
return entries.sort((left, right) => {
|
||||
if (left.type !== right.type) {
|
||||
return left.type === 'dir' ? -1 : 1;
|
||||
}
|
||||
|
||||
return left.name.localeCompare(right.name, 'en');
|
||||
});
|
||||
}
|
||||
|
||||
export class DuplicatiRepository {
|
||||
constructor({ database, previewMaxBytes, source, sourceLayout }) {
|
||||
this.database = database;
|
||||
this.previewMaxBytes = previewMaxBytes;
|
||||
this.source = source;
|
||||
this.sourceLayout = sourceLayout;
|
||||
}
|
||||
|
||||
async resolveSnapshot(selection = 'latest') {
|
||||
if (selection === 'latest') {
|
||||
const row = await this.database.get(
|
||||
'SELECT "ID", "Timestamp" FROM source."Fileset" ORDER BY "Timestamp" DESC LIMIT 1'
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
throw new HttpError(404, 'SNAPSHOT_NOT_FOUND', 'No filesets were found in the uploaded database.');
|
||||
}
|
||||
|
||||
return buildSnapshotShape('latest', row);
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(selection)) {
|
||||
const row = await this.database.get(
|
||||
'SELECT "ID", "Timestamp" FROM source."Fileset" WHERE "ID" = ?',
|
||||
[selection]
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
throw new HttpError(404, 'SNAPSHOT_NOT_FOUND', `Fileset ${selection} was not found.`);
|
||||
}
|
||||
|
||||
return buildSnapshotShape(selection, row);
|
||||
}
|
||||
|
||||
throw new HttpError(
|
||||
400,
|
||||
'INVALID_SNAPSHOT',
|
||||
'Snapshot must be "latest" or a numeric Fileset ID.'
|
||||
);
|
||||
}
|
||||
|
||||
async listDirectory({ apiPath = '/', snapshotId = 'latest' }) {
|
||||
const normalizedPath = normalizeApiPath(apiPath);
|
||||
const snapshot = await this.resolveSnapshot(snapshotId);
|
||||
const rows = await this.database.all(buildListSnapshotRowsSql(this.sourceLayout), [snapshot.filesetDbId]);
|
||||
const entriesByPath = new Map();
|
||||
let directoryExists = normalizedPath === '/';
|
||||
|
||||
for (const row of rows) {
|
||||
const candidateApiPath = toApiPath(row.dbPath);
|
||||
const directMatch = candidateApiPath === normalizedPath;
|
||||
|
||||
if (directMatch && Number(row.blocksetId) < 0) {
|
||||
directoryExists = true;
|
||||
}
|
||||
|
||||
if (!isSameOrDescendant(normalizedPath, candidateApiPath) || directMatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
directoryExists = true;
|
||||
const childName = getImmediateChildName(normalizedPath, candidateApiPath);
|
||||
if (!childName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const childPath = joinApiPath(normalizedPath, childName);
|
||||
const isDirectChild = childPath === candidateApiPath;
|
||||
const isDirectoryRow = Number(row.blocksetId) < 0;
|
||||
|
||||
if (!isDirectChild || isDirectoryRow) {
|
||||
const existing = entriesByPath.get(childPath);
|
||||
entriesByPath.set(
|
||||
childPath,
|
||||
makeDirectoryEntry(snapshot, this.source, normalizedPath, childName, {
|
||||
...existing,
|
||||
dbPath: row.dbPath,
|
||||
mtime: existing?.mtime ?? duplicatiTicksToIso(row.lastModifiedTicks)
|
||||
})
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
entriesByPath.set(childPath, makeFileEntry(snapshot, this.source, row));
|
||||
}
|
||||
|
||||
if (!directoryExists) {
|
||||
throw new HttpError(404, 'PATH_NOT_FOUND', `Backup path "${normalizedPath}" was not found.`);
|
||||
}
|
||||
|
||||
return {
|
||||
apiVersion: '1',
|
||||
sourceId: this.source.id,
|
||||
snapshot: {
|
||||
id: snapshot.id,
|
||||
resolvedFilesetId: snapshot.resolvedFilesetId,
|
||||
timestamp: snapshot.timestamp
|
||||
},
|
||||
path: normalizedPath,
|
||||
parent: getParentApiPath(normalizedPath),
|
||||
entries: sortEntries([...entriesByPath.values()])
|
||||
};
|
||||
}
|
||||
|
||||
async getFileInfo(id) {
|
||||
let token;
|
||||
try {
|
||||
token = decodeOpaqueId(id);
|
||||
} catch (error) {
|
||||
throw new HttpError(400, 'INVALID_FILE_ID', 'The provided file id is malformed.');
|
||||
}
|
||||
|
||||
if (token.kind !== 'file') {
|
||||
throw new HttpError(400, 'NOT_A_FILE', 'The provided id does not point to a file.');
|
||||
}
|
||||
|
||||
if (!token.sourceId) {
|
||||
throw new HttpError(400, 'INVALID_FILE_ID', 'The provided file id is missing source metadata.');
|
||||
}
|
||||
|
||||
if (token.sourceId !== this.source.id) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
'STALE_SOURCE_ID',
|
||||
'The requested file id belongs to a different source. Refresh the file list and try again.'
|
||||
);
|
||||
}
|
||||
|
||||
const snapshot = {
|
||||
id: token.snapshotId ?? 'latest',
|
||||
filesetDbId: Number(token.filesetDbId),
|
||||
resolvedFilesetId: token.resolvedFilesetId,
|
||||
timestamp: token.timestamp
|
||||
};
|
||||
|
||||
if (!snapshot.filesetDbId || !token.dbPath) {
|
||||
throw new HttpError(400, 'INVALID_FILE_ID', 'The provided file id is missing required fields.');
|
||||
}
|
||||
|
||||
const fileRow = await this.database.get(buildGetFileRowSql(this.sourceLayout), [snapshot.filesetDbId, token.dbPath]);
|
||||
if (!fileRow) {
|
||||
throw new HttpError(404, 'FILE_NOT_FOUND', 'The requested file was not found in the selected snapshot.');
|
||||
}
|
||||
|
||||
if (Number(fileRow.blocksetId) < 0) {
|
||||
throw new HttpError(400, 'NOT_A_FILE', 'The provided id points to a directory entry.');
|
||||
}
|
||||
|
||||
const fileSize = Number(fileRow.contentLength ?? 0);
|
||||
const apiPath = token.apiPath ?? toApiPath(fileRow.dbPath);
|
||||
const mime = resolveMime(apiPath, 'file');
|
||||
const segmentRows =
|
||||
fileSize === 0
|
||||
? []
|
||||
: await this.database.all(
|
||||
buildSegmentRowsSql(this.source.capabilities.volumeCryptoCache),
|
||||
[Number(fileRow.blocksetId)]
|
||||
);
|
||||
|
||||
if (fileSize > 0 && !segmentRows.length) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
'BLOCKSET_EMPTY',
|
||||
'The file has non-zero length but no block rows were found in BlocksetEntry.'
|
||||
);
|
||||
}
|
||||
|
||||
const missingZipIndex = segmentRows
|
||||
.filter((row) => row.dataOffsetPlain === null || row.entryName === null)
|
||||
.map((row) => ({
|
||||
volumeName: row.volumeName,
|
||||
blockHashBase64: row.blockHashBase64
|
||||
}));
|
||||
|
||||
if (missingZipIndex.length > 0) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
'ZIP_INDEX_MISSING',
|
||||
'archive_entry_index is incomplete for one or more required dblock entries.',
|
||||
{ missing: missingZipIndex }
|
||||
);
|
||||
}
|
||||
|
||||
const volumes = [];
|
||||
const volumesByRemoteId = new Map();
|
||||
const segments = [];
|
||||
let logicalOffset = 0;
|
||||
|
||||
for (const row of segmentRows) {
|
||||
const remoteVolumeId = Number(row.remoteVolumeId);
|
||||
const volumeRef = `rv_${remoteVolumeId}`;
|
||||
|
||||
if (!volumesByRemoteId.has(remoteVolumeId)) {
|
||||
const volume = {
|
||||
volumeId: volumeRef,
|
||||
remoteVolumeId,
|
||||
name: row.volumeName,
|
||||
plainZipName: stripAesSuffix(row.volumeName),
|
||||
encryption: {
|
||||
module: 'duplicati-aes',
|
||||
container: 'AESCrypt',
|
||||
streamFormat: row.streamFormat ?? 'v2-or-v3',
|
||||
cipher: 'AES-256-CBC',
|
||||
integrity: 'HMAC-SHA256',
|
||||
headerSource: 'remote-file-header',
|
||||
headerProbeBytes: Number(row.headerProbeBytes ?? 256),
|
||||
salt: this.source.capabilities.volumeCryptoCache ? row.saltHex ?? null : null,
|
||||
iv: this.source.capabilities.volumeCryptoCache ? row.ivHex ?? null : null
|
||||
},
|
||||
access: {
|
||||
ciphertextRandomAccess: false,
|
||||
requiresDecryptFromVolumeStart: true
|
||||
}
|
||||
};
|
||||
|
||||
volumesByRemoteId.set(remoteVolumeId, volume);
|
||||
volumes.push(volume);
|
||||
}
|
||||
|
||||
const logicalSize = Number(row.logicalSize);
|
||||
segments.push({
|
||||
segmentIndex: Number(row.segmentIndex),
|
||||
logicalOffset,
|
||||
logicalSize,
|
||||
blockHashBase64: row.blockHashBase64,
|
||||
zipEntryName: row.entryName ?? toZipEntryName(row.blockHashBase64),
|
||||
volumeRef,
|
||||
zip: {
|
||||
entryType: 'data',
|
||||
localHeaderOffsetPlain: Number(row.localHeaderOffsetPlain),
|
||||
dataOffsetPlain: Number(row.dataOffsetPlain),
|
||||
compressedSize: Number(row.compressedSize),
|
||||
uncompressedSize: Number(row.uncompressedSize),
|
||||
compressionMethod: normalizeCompressionMethod(row.compressionMethod),
|
||||
crc32: normalizeCrc32(row.crc32)
|
||||
}
|
||||
});
|
||||
|
||||
logicalOffset += logicalSize;
|
||||
}
|
||||
|
||||
if (fileSize !== logicalOffset) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
'BLOCKSET_LENGTH_MISMATCH',
|
||||
'The blockset byte count does not match the declared file length.',
|
||||
{
|
||||
declaredSize: fileSize,
|
||||
expandedSize: logicalOffset
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
apiVersion: '1',
|
||||
sourceId: this.source.id,
|
||||
snapshot: {
|
||||
id: snapshot.id,
|
||||
resolvedFilesetId: snapshot.resolvedFilesetId,
|
||||
timestamp: snapshot.timestamp
|
||||
},
|
||||
file: {
|
||||
id,
|
||||
path: apiPath,
|
||||
name: getNameFromApiPath(apiPath),
|
||||
size: fileSize,
|
||||
mime,
|
||||
mtime: duplicatiTicksToIso(fileRow.lastModifiedTicks),
|
||||
...(fileRow.fullHash
|
||||
? {
|
||||
hash: {
|
||||
algo: 'sha256',
|
||||
base64: fileRow.fullHash
|
||||
}
|
||||
}
|
||||
: {})
|
||||
},
|
||||
restorePlan: {
|
||||
type: 'ordered-segments',
|
||||
blockSize: computeCanonicalBlockSize(segments),
|
||||
segmentCount: segments.length
|
||||
},
|
||||
volumes,
|
||||
segments,
|
||||
requiredDblocks: volumes.map((volume) => volume.name),
|
||||
hints: {
|
||||
canPreviewInMemory: fileSize <= this.previewMaxBytes,
|
||||
canStreamDownload: true,
|
||||
rangeReady: segments.length === 0 || missingZipIndex.length === 0
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
function assertAlias(alias) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(alias)) {
|
||||
throw new Error(`Invalid SQLite alias: ${alias}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class SqliteDatabase {
|
||||
constructor(database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
async get(sql, params = []) {
|
||||
const statement = this.database.prepare(sql);
|
||||
statement.setReadBigInts(true);
|
||||
return statement.get(...params) ?? null;
|
||||
}
|
||||
|
||||
async all(sql, params = []) {
|
||||
const statement = this.database.prepare(sql);
|
||||
statement.setReadBigInts(true);
|
||||
return statement.all(...params) ?? [];
|
||||
}
|
||||
|
||||
async run(sql, params = []) {
|
||||
const statement = this.database.prepare(sql);
|
||||
statement.setReadBigInts(true);
|
||||
return statement.run(...params);
|
||||
}
|
||||
|
||||
async exec(sql) {
|
||||
this.database.exec(sql);
|
||||
}
|
||||
|
||||
async attachDatabase(alias, filename, options = {}) {
|
||||
assertAlias(alias);
|
||||
const target = options.readOnly
|
||||
? `${pathToFileURL(filename).href}?mode=ro`
|
||||
: filename;
|
||||
|
||||
this.database.prepare(`ATTACH DATABASE ? AS "${alias}"`).run(target);
|
||||
}
|
||||
|
||||
async detachDatabase(alias) {
|
||||
assertAlias(alias);
|
||||
this.database.prepare(`DETACH DATABASE "${alias}"`).run();
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.database.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function openSqlite(filename, options = {}) {
|
||||
const database = new DatabaseSync(filename, {
|
||||
readOnly: Boolean(options.readOnly)
|
||||
});
|
||||
|
||||
return new SqliteDatabase(database);
|
||||
}
|
||||
|
||||
export async function openReadOnlySqlite(filename) {
|
||||
return openSqlite(filename, { readOnly: true });
|
||||
}
|
||||
|
||||
export async function openWritableSqlite(filename) {
|
||||
return openSqlite(filename, { readOnly: false });
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export class HttpError extends Error {
|
||||
constructor(status, code, message, details = undefined) {
|
||||
super(message);
|
||||
this.name = 'HttpError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export function isHttpError(error) {
|
||||
return error instanceof HttpError;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { createDecipheriv, createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
|
||||
import { HttpError } from '../errors.js';
|
||||
|
||||
const AES_BLOCK_SIZE = 16;
|
||||
const HEADER_MAGIC = Buffer.from('AES', 'utf8');
|
||||
const ENCRYPTED_IV_AND_KEY_LENGTH = 48;
|
||||
const HEADER_HMAC_LENGTH = 32;
|
||||
const FOOTER_SIZE_LENGTH = 1;
|
||||
const FOOTER_HMAC_LENGTH = 32;
|
||||
const FOOTER_TOTAL_LENGTH = FOOTER_SIZE_LENGTH + FOOTER_HMAC_LENGTH;
|
||||
const VERSION_2_ITERATIONS = 8192;
|
||||
|
||||
function createAesError(code, message, details = undefined) {
|
||||
return new HttpError(422, code, message, details);
|
||||
}
|
||||
|
||||
function stretchPasswordV2(passphrase, externalIv) {
|
||||
const passwordBytes = Buffer.from(String(passphrase), 'utf16le');
|
||||
let digest = Buffer.concat([externalIv, Buffer.alloc(16, 0)]);
|
||||
|
||||
for (let index = 0; index < VERSION_2_ITERATIONS; index += 1) {
|
||||
const hash = createHash('sha256');
|
||||
hash.update(digest);
|
||||
hash.update(passwordBytes);
|
||||
digest = hash.digest();
|
||||
}
|
||||
|
||||
return digest;
|
||||
}
|
||||
|
||||
function readUInt16BE(buffer, offset) {
|
||||
return buffer.readUInt16BE(offset);
|
||||
}
|
||||
|
||||
async function parseAesCryptHeader(handle) {
|
||||
const magic = Buffer.alloc(3);
|
||||
await handle.read(magic, 0, magic.length, 0);
|
||||
if (!magic.equals(HEADER_MAGIC)) {
|
||||
throw createAesError('AESC_HEADER_INVALID', 'The remote volume does not start with an AES Crypt header.');
|
||||
}
|
||||
|
||||
const versionBuffer = Buffer.alloc(2);
|
||||
await handle.read(versionBuffer, 0, versionBuffer.length, 3);
|
||||
const version = versionBuffer[0];
|
||||
const reserved = versionBuffer[1];
|
||||
if (reserved !== 0) {
|
||||
throw createAesError('AESC_HEADER_INVALID', 'The AES Crypt header reserved byte was not zero.');
|
||||
}
|
||||
|
||||
if (version !== 2) {
|
||||
throw createAesError(
|
||||
'AESC_VERSION_UNSUPPORTED',
|
||||
`AES Crypt version ${version} is not supported by the current enhancement job.`
|
||||
);
|
||||
}
|
||||
|
||||
let offset = 5;
|
||||
while (true) {
|
||||
const extensionLengthBytes = Buffer.alloc(2);
|
||||
await handle.read(extensionLengthBytes, 0, extensionLengthBytes.length, offset);
|
||||
const extensionLength = readUInt16BE(extensionLengthBytes, 0);
|
||||
offset += 2;
|
||||
|
||||
if (extensionLength === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (extensionLength < 2) {
|
||||
throw createAesError('AESC_HEADER_INVALID', 'An AES Crypt extension record length was invalid.');
|
||||
}
|
||||
|
||||
offset += extensionLength;
|
||||
}
|
||||
|
||||
const externalIv = Buffer.alloc(AES_BLOCK_SIZE);
|
||||
await handle.read(externalIv, 0, externalIv.length, offset);
|
||||
offset += externalIv.length;
|
||||
|
||||
const encryptedIvAndKey = Buffer.alloc(ENCRYPTED_IV_AND_KEY_LENGTH);
|
||||
await handle.read(encryptedIvAndKey, 0, encryptedIvAndKey.length, offset);
|
||||
offset += encryptedIvAndKey.length;
|
||||
|
||||
const headerHmac = Buffer.alloc(HEADER_HMAC_LENGTH);
|
||||
await handle.read(headerHmac, 0, headerHmac.length, offset);
|
||||
offset += headerHmac.length;
|
||||
|
||||
return {
|
||||
headerLength: offset,
|
||||
streamFormat: 'v2',
|
||||
externalIv,
|
||||
encryptedIvAndKey,
|
||||
headerHmac,
|
||||
kdfIterations: VERSION_2_ITERATIONS
|
||||
};
|
||||
}
|
||||
|
||||
async function readFooter(handle, size) {
|
||||
if (size < FOOTER_TOTAL_LENGTH) {
|
||||
throw createAesError('AESC_FILE_TOO_SMALL', 'The AES volume is too small to contain a valid footer.');
|
||||
}
|
||||
|
||||
const footer = Buffer.alloc(FOOTER_TOTAL_LENGTH);
|
||||
await handle.read(footer, 0, footer.length, size - footer.length);
|
||||
return {
|
||||
fileSizeModulo: footer.readUInt8(0),
|
||||
payloadHmac: footer.subarray(1)
|
||||
};
|
||||
}
|
||||
|
||||
function validateHeaderAndDeriveSession(passphrase, header) {
|
||||
const stretchedKey = stretchPasswordV2(passphrase, header.externalIv);
|
||||
const actualHeaderHmac = createHmac('sha256', stretchedKey)
|
||||
.update(header.encryptedIvAndKey)
|
||||
.digest();
|
||||
|
||||
if (!timingSafeEqual(actualHeaderHmac, header.headerHmac)) {
|
||||
throw createAesError('AESC_INVALID_PASSPHRASE', 'The AES volume header HMAC did not match. Check the passphrase.');
|
||||
}
|
||||
|
||||
const decipher = createDecipheriv('aes-256-cbc', stretchedKey, header.externalIv);
|
||||
decipher.setAutoPadding(false);
|
||||
const sessionBytes = Buffer.concat([
|
||||
decipher.update(header.encryptedIvAndKey),
|
||||
decipher.final()
|
||||
]);
|
||||
|
||||
if (sessionBytes.length !== ENCRYPTED_IV_AND_KEY_LENGTH) {
|
||||
throw createAesError('AESC_HEADER_INVALID', 'The AES volume session payload length was invalid.');
|
||||
}
|
||||
|
||||
return {
|
||||
internalIv: sessionBytes.subarray(0, 16),
|
||||
internalKey: sessionBytes.subarray(16)
|
||||
};
|
||||
}
|
||||
|
||||
export async function decryptAesCryptFileToZip({ encryptedPath, outputPath, passphrase }) {
|
||||
const handle = await fsp.open(encryptedPath, 'r');
|
||||
|
||||
try {
|
||||
const stats = await handle.stat();
|
||||
const header = await parseAesCryptHeader(handle);
|
||||
const footer = await readFooter(handle, stats.size);
|
||||
const session = validateHeaderAndDeriveSession(passphrase, header);
|
||||
const ciphertextLength = stats.size - header.headerLength - FOOTER_TOTAL_LENGTH;
|
||||
|
||||
if (ciphertextLength < 0 || ciphertextLength % AES_BLOCK_SIZE !== 0) {
|
||||
throw createAesError(
|
||||
'AESC_CIPHERTEXT_INVALID',
|
||||
'The AES volume ciphertext length was invalid for AES-CBC decryption.'
|
||||
);
|
||||
}
|
||||
|
||||
const decipher = createDecipheriv('aes-256-cbc', session.internalKey, session.internalIv);
|
||||
decipher.setAutoPadding(false);
|
||||
const payloadHmac = createHmac('sha256', session.internalKey);
|
||||
const input = fs.createReadStream(encryptedPath, {
|
||||
start: header.headerLength,
|
||||
end: stats.size - FOOTER_TOTAL_LENGTH - 1,
|
||||
highWaterMark: 64 * 1024
|
||||
});
|
||||
const output = fs.createWriteStream(outputPath, { flags: 'wx' });
|
||||
|
||||
let trailingBlock = Buffer.alloc(0);
|
||||
let sawPlaintext = false;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
input.on('error', reject);
|
||||
output.on('error', reject);
|
||||
|
||||
input.on('data', (chunk) => {
|
||||
payloadHmac.update(chunk);
|
||||
const plainChunk = decipher.update(chunk);
|
||||
const combined = trailingBlock.length > 0
|
||||
? Buffer.concat([trailingBlock, plainChunk])
|
||||
: plainChunk;
|
||||
|
||||
if (combined.length > AES_BLOCK_SIZE) {
|
||||
output.write(combined.subarray(0, combined.length - AES_BLOCK_SIZE));
|
||||
}
|
||||
trailingBlock = combined.subarray(Math.max(0, combined.length - AES_BLOCK_SIZE));
|
||||
sawPlaintext = true;
|
||||
});
|
||||
|
||||
input.on('end', () => {
|
||||
try {
|
||||
const finalChunk = decipher.final();
|
||||
const combined = finalChunk.length > 0
|
||||
? Buffer.concat([trailingBlock, finalChunk])
|
||||
: trailingBlock;
|
||||
|
||||
if (!timingSafeEqual(payloadHmac.digest(), footer.payloadHmac)) {
|
||||
throw createAesError(
|
||||
'AESC_PAYLOAD_HMAC_MISMATCH',
|
||||
'The AES volume ciphertext HMAC did not match. The remote file may be corrupted.'
|
||||
);
|
||||
}
|
||||
|
||||
if (combined.length > 0) {
|
||||
if (combined.length < AES_BLOCK_SIZE) {
|
||||
throw createAesError(
|
||||
'AESC_DECRYPTION_INVALID',
|
||||
'The AES volume did not yield a complete final plaintext block.'
|
||||
);
|
||||
}
|
||||
|
||||
const finalLength = footer.fileSizeModulo === 0 ? combined.length : footer.fileSizeModulo;
|
||||
if (finalLength < 0 || finalLength > combined.length) {
|
||||
throw createAesError(
|
||||
'AESC_FOOTER_INVALID',
|
||||
'The AES volume footer declared an invalid plaintext block remainder.'
|
||||
);
|
||||
}
|
||||
output.write(combined.subarray(0, finalLength));
|
||||
} else if (sawPlaintext) {
|
||||
throw createAesError(
|
||||
'AESC_DECRYPTION_INVALID',
|
||||
'The AES volume produced no final plaintext block after decryption.'
|
||||
);
|
||||
}
|
||||
|
||||
output.end(() => resolve());
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
streamFormat: header.streamFormat,
|
||||
headerProbeBytes: header.headerLength,
|
||||
kdfIterations: header.kdfIterations,
|
||||
saltHex: null,
|
||||
ivHex: header.externalIv.toString('hex')
|
||||
};
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
export function buildResolvedFilesetId(timestampIso) {
|
||||
return `fs_${timestampIso}`;
|
||||
}
|
||||
|
||||
export function toZipEntryName(blockHashBase64) {
|
||||
return String(blockHashBase64).replaceAll('/', '_').replaceAll('+', '-');
|
||||
}
|
||||
|
||||
export function stripAesSuffix(volumeName) {
|
||||
return volumeName.endsWith('.aes') ? volumeName.slice(0, -4) : volumeName;
|
||||
}
|
||||
|
||||
export function inferPreviewKind(mime) {
|
||||
if (!mime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mime.startsWith('image/')) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (mime.startsWith('video/')) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (mime.startsWith('text/') || mime === 'application/json') {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeCompressionMethod(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = String(value).toLowerCase();
|
||||
if (normalized === '0') {
|
||||
return 'store';
|
||||
}
|
||||
|
||||
if (normalized === '8') {
|
||||
return 'deflate';
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeCrc32(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = String(value).trim().toLowerCase().replace(/^0x/, '');
|
||||
if (/^[0-9a-f]{1,8}$/.test(raw)) {
|
||||
return raw.padStart(8, '0');
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function computeCanonicalBlockSize(segments) {
|
||||
if (!segments.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const counts = new Map();
|
||||
for (const segment of segments) {
|
||||
const size = Number(segment.logicalSize);
|
||||
counts.set(size, (counts.get(size) ?? 0) + 1);
|
||||
}
|
||||
|
||||
let winner = Number(segments[0].logicalSize);
|
||||
let winnerCount = counts.get(winner) ?? 0;
|
||||
|
||||
for (const [size, count] of counts.entries()) {
|
||||
if (count > winnerCount || (count === winnerCount && size > winner)) {
|
||||
winner = size;
|
||||
winnerCount = count;
|
||||
}
|
||||
}
|
||||
|
||||
return winner;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
const TOKEN_VERSION = 3;
|
||||
|
||||
export function encodeOpaqueId(payload) {
|
||||
const token = {
|
||||
v: TOKEN_VERSION,
|
||||
...payload
|
||||
};
|
||||
|
||||
return Buffer.from(JSON.stringify(token), 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
export function decodeOpaqueId(id) {
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.from(id, 'base64url').toString('utf8'));
|
||||
if (parsed?.v !== TOKEN_VERSION) {
|
||||
throw new Error('Unsupported token version');
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
throw new Error('Invalid opaque id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
export function normalizeApiPath(inputPath = '/') {
|
||||
const source = String(inputPath || '/').replaceAll('\\', '/');
|
||||
let normalized = source.startsWith('/') ? source : `/${source}`;
|
||||
normalized = normalized.replace(/\/{2,}/g, '/');
|
||||
|
||||
if (normalized.length > 1 && normalized.endsWith('/')) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
|
||||
return normalized || '/';
|
||||
}
|
||||
|
||||
export function toApiPath(dbPath) {
|
||||
return normalizeApiPath(dbPath);
|
||||
}
|
||||
|
||||
export function getParentApiPath(apiPath) {
|
||||
const normalized = normalizeApiPath(apiPath);
|
||||
if (normalized === '/') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const index = normalized.lastIndexOf('/');
|
||||
return index <= 0 ? '/' : normalized.slice(0, index);
|
||||
}
|
||||
|
||||
export function isSameOrDescendant(basePath, candidatePath) {
|
||||
const normalizedBase = normalizeApiPath(basePath);
|
||||
const normalizedCandidate = normalizeApiPath(candidatePath);
|
||||
|
||||
if (normalizedBase === '/') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
normalizedCandidate === normalizedBase ||
|
||||
normalizedCandidate.startsWith(`${normalizedBase}/`)
|
||||
);
|
||||
}
|
||||
|
||||
export function getImmediateChildName(basePath, candidatePath) {
|
||||
const normalizedBase = normalizeApiPath(basePath);
|
||||
const normalizedCandidate = normalizeApiPath(candidatePath);
|
||||
|
||||
if (!isSameOrDescendant(normalizedBase, normalizedCandidate) || normalizedBase === normalizedCandidate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relative =
|
||||
normalizedBase === '/'
|
||||
? normalizedCandidate.slice(1)
|
||||
: normalizedCandidate.slice(normalizedBase.length + 1);
|
||||
|
||||
return relative.split('/')[0] || null;
|
||||
}
|
||||
|
||||
export function joinApiPath(basePath, childName) {
|
||||
const normalizedBase = normalizeApiPath(basePath);
|
||||
return normalizedBase === '/' ? `/${childName}` : `${normalizedBase}/${childName}`;
|
||||
}
|
||||
|
||||
export function getNameFromApiPath(apiPath) {
|
||||
const normalized = normalizeApiPath(apiPath);
|
||||
if (normalized === '/') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
return normalized.slice(normalized.lastIndexOf('/') + 1);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
const UNIX_EPOCH_TICKS = 621355968000000000n;
|
||||
const TICKS_PER_MILLISECOND = 10000n;
|
||||
|
||||
export function unixSecondsToIso(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const seconds = Number(value);
|
||||
if (!Number.isFinite(seconds)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(seconds * 1000).toISOString();
|
||||
}
|
||||
|
||||
export function duplicatiTicksToIso(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const ticks = BigInt(value);
|
||||
const milliseconds = Number((ticks - UNIX_EPOCH_TICKS) / TICKS_PER_MILLISECOND);
|
||||
if (!Number.isFinite(milliseconds)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(milliseconds).toISOString();
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { mkdir, rm, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { Readable, Transform } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
||||
export async function stageUploadedFile(file, uploadDir) {
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
|
||||
const stagedPath = path.join(uploadDir, `staging-${randomUUID()}.sqlite.upload`);
|
||||
const hash = createHash('sha256');
|
||||
const hashingStream = new Transform({
|
||||
transform(chunk, encoding, callback) {
|
||||
hash.update(chunk);
|
||||
callback(null, chunk);
|
||||
}
|
||||
});
|
||||
|
||||
await pipeline(
|
||||
Readable.fromWeb(file.stream()),
|
||||
hashingStream,
|
||||
fs.createWriteStream(stagedPath, { flags: 'wx' })
|
||||
);
|
||||
|
||||
const details = await stat(stagedPath);
|
||||
return {
|
||||
stagedPath,
|
||||
fileSize: details.size,
|
||||
sha256: hash.digest('hex')
|
||||
};
|
||||
}
|
||||
|
||||
export async function removeFileIfPresent(filename) {
|
||||
await rm(filename, { force: true });
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
import { HttpError } from '../errors.js';
|
||||
|
||||
const EOCD_SIGNATURE = 0x06054b50;
|
||||
const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
|
||||
const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
||||
const MAX_EOCD_SEARCH = 65_557;
|
||||
|
||||
function readUInt32LE(buffer, offset) {
|
||||
return buffer.readUInt32LE(offset);
|
||||
}
|
||||
|
||||
function readUInt16LE(buffer, offset) {
|
||||
return buffer.readUInt16LE(offset);
|
||||
}
|
||||
|
||||
function createZipError(code, message, details = undefined) {
|
||||
return new HttpError(422, code, message, details);
|
||||
}
|
||||
|
||||
async function findEndOfCentralDirectory(handle, size) {
|
||||
const searchLength = Math.min(MAX_EOCD_SEARCH, size);
|
||||
const searchOffset = size - searchLength;
|
||||
const buffer = Buffer.alloc(searchLength);
|
||||
await handle.read(buffer, 0, searchLength, searchOffset);
|
||||
|
||||
for (let index = searchLength - 22; index >= 0; index -= 1) {
|
||||
if (readUInt32LE(buffer, index) === EOCD_SIGNATURE) {
|
||||
return {
|
||||
absoluteOffset: searchOffset + index,
|
||||
buffer,
|
||||
offset: index
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw createZipError('ZIP_EOCD_NOT_FOUND', 'Failed to locate the ZIP end-of-central-directory record.');
|
||||
}
|
||||
|
||||
async function readLocalHeaderOffset(handle, localHeaderOffset) {
|
||||
const localHeader = Buffer.alloc(30);
|
||||
await handle.read(localHeader, 0, localHeader.length, localHeaderOffset);
|
||||
if (readUInt32LE(localHeader, 0) !== LOCAL_FILE_HEADER_SIGNATURE) {
|
||||
throw createZipError(
|
||||
'ZIP_LOCAL_HEADER_INVALID',
|
||||
'A ZIP entry local header did not match the expected PK signature.'
|
||||
);
|
||||
}
|
||||
|
||||
const fileNameLength = readUInt16LE(localHeader, 26);
|
||||
const extraFieldLength = readUInt16LE(localHeader, 28);
|
||||
return localHeaderOffset + 30 + fileNameLength + extraFieldLength;
|
||||
}
|
||||
|
||||
export async function scanZipEntries(zipPath) {
|
||||
const handle = await fs.open(zipPath, 'r');
|
||||
|
||||
try {
|
||||
const stats = await handle.stat();
|
||||
if (stats.size < 22) {
|
||||
throw createZipError('ZIP_TOO_SMALL', 'The decrypted dblock ZIP file is too small to be valid.');
|
||||
}
|
||||
|
||||
const eocd = await findEndOfCentralDirectory(handle, stats.size);
|
||||
const buffer = eocd.buffer;
|
||||
const offset = eocd.offset;
|
||||
|
||||
const entryCount = readUInt16LE(buffer, offset + 10);
|
||||
const centralDirectorySize = readUInt32LE(buffer, offset + 12);
|
||||
const centralDirectoryOffset = readUInt32LE(buffer, offset + 16);
|
||||
|
||||
if (
|
||||
entryCount === 0xffff ||
|
||||
centralDirectorySize === 0xffffffff ||
|
||||
centralDirectoryOffset === 0xffffffff
|
||||
) {
|
||||
throw createZipError(
|
||||
'ZIP64_UNSUPPORTED',
|
||||
'ZIP64 volumes are not supported by the current enhancement scanner.'
|
||||
);
|
||||
}
|
||||
|
||||
let currentOffset = centralDirectoryOffset;
|
||||
const entries = [];
|
||||
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
const header = Buffer.alloc(46);
|
||||
await handle.read(header, 0, header.length, currentOffset);
|
||||
if (readUInt32LE(header, 0) !== CENTRAL_DIRECTORY_SIGNATURE) {
|
||||
throw createZipError(
|
||||
'ZIP_CENTRAL_DIRECTORY_INVALID',
|
||||
'A ZIP central-directory record did not match the expected PK signature.'
|
||||
);
|
||||
}
|
||||
|
||||
const compressionMethod = readUInt16LE(header, 10);
|
||||
const crc32 = readUInt32LE(header, 16).toString(16).padStart(8, '0');
|
||||
const compressedSize = readUInt32LE(header, 20);
|
||||
const uncompressedSize = readUInt32LE(header, 24);
|
||||
const fileNameLength = readUInt16LE(header, 28);
|
||||
const extraFieldLength = readUInt16LE(header, 30);
|
||||
const commentLength = readUInt16LE(header, 32);
|
||||
const localHeaderOffset = readUInt32LE(header, 42);
|
||||
|
||||
const fileNameBuffer = Buffer.alloc(fileNameLength);
|
||||
await handle.read(fileNameBuffer, 0, fileNameLength, currentOffset + 46);
|
||||
const entryName = fileNameBuffer.toString('utf8');
|
||||
const dataOffset = await readLocalHeaderOffset(handle, localHeaderOffset);
|
||||
|
||||
entries.push({
|
||||
entryName,
|
||||
localHeaderOffset,
|
||||
dataOffset,
|
||||
compressedSize,
|
||||
uncompressedSize,
|
||||
compressionMethod,
|
||||
crc32
|
||||
});
|
||||
|
||||
currentOffset += 46 + fileNameLength + extraFieldLength + commentLength;
|
||||
}
|
||||
|
||||
return {
|
||||
plainZipSize: stats.size,
|
||||
entryCount: entries.length,
|
||||
entries
|
||||
};
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
+465
@@ -0,0 +1,465 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import express from 'express';
|
||||
|
||||
import { config } from './config.js';
|
||||
import { SourceCatalog } from './db/sourceCatalog.js';
|
||||
import { HttpError, isHttpError } from './errors.js';
|
||||
import { ActiveSourceService } from './services/activeSourceService.js';
|
||||
import { SourceEnhancementService } from './services/sourceEnhancementService.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const publicDir = path.resolve(__dirname, '..', 'public');
|
||||
|
||||
function toPublicSource(source) {
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { sourceDir, rawDbPath, enhancedDbPath, rawSha256, ...rest } = source;
|
||||
return rest;
|
||||
}
|
||||
|
||||
function toPublicServerDb(serverDb) {
|
||||
return serverDb ?? {
|
||||
available: false,
|
||||
originalFilename: null,
|
||||
uploadedAt: null,
|
||||
backupCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
function toWebRequest(request) {
|
||||
const host = request.headers.host ?? '127.0.0.1';
|
||||
return new Request(`http://${host}${request.originalUrl}`, {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: request,
|
||||
duplex: 'half'
|
||||
});
|
||||
}
|
||||
|
||||
async function parseJsonBody(request) {
|
||||
if (!request.is('application/json')) {
|
||||
throw new HttpError(415, 'UNSUPPORTED_MEDIA_TYPE', 'Request body must be application/json.');
|
||||
}
|
||||
|
||||
return request.body ?? {};
|
||||
}
|
||||
|
||||
async function parseMultipartDatabaseFile(request) {
|
||||
const formData = await parseMultipartFormData(request);
|
||||
const databaseFile = formData.get('database');
|
||||
if (!databaseFile || typeof databaseFile !== 'object' || typeof databaseFile.stream !== 'function') {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'MISSING_DATABASE_FILE',
|
||||
'Form field "database" is required and must be a file.'
|
||||
);
|
||||
}
|
||||
|
||||
return databaseFile;
|
||||
}
|
||||
|
||||
async function parseMultipartFormData(request) {
|
||||
const contentType = String(request.headers['content-type'] ?? '').toLowerCase();
|
||||
if (!contentType.startsWith('multipart/form-data')) {
|
||||
throw new HttpError(
|
||||
415,
|
||||
'UNSUPPORTED_MEDIA_TYPE',
|
||||
'Upload requests must use multipart/form-data.'
|
||||
);
|
||||
}
|
||||
|
||||
let formData;
|
||||
try {
|
||||
formData = await toWebRequest(request).formData();
|
||||
} catch (error) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'INVALID_MULTIPART_UPLOAD',
|
||||
'The upload body could not be parsed as multipart/form-data.'
|
||||
);
|
||||
}
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
export async function createServerApp(overrides = {}) {
|
||||
const runtimeConfig = {
|
||||
...config,
|
||||
...overrides
|
||||
};
|
||||
|
||||
const sourceCatalog = await SourceCatalog.create({
|
||||
appDbPath: runtimeConfig.appDbPath,
|
||||
uploadDir: runtimeConfig.uploadDir
|
||||
});
|
||||
const activeSourceService = new ActiveSourceService({
|
||||
sourceCatalog,
|
||||
previewMaxBytes: runtimeConfig.previewMaxBytes
|
||||
});
|
||||
const sourceEnhancementService = new SourceEnhancementService({
|
||||
sourceCatalog
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(
|
||||
express.static(publicDir, {
|
||||
etag: false,
|
||||
lastModified: false,
|
||||
maxAge: 0,
|
||||
setHeaders(response) {
|
||||
response.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
response.setHeader('Pragma', 'no-cache');
|
||||
response.setHeader('Expires', '0');
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
app.get('/healthz', async (request, response, next) => {
|
||||
try {
|
||||
const sources = await sourceCatalog.listSources();
|
||||
response.json({
|
||||
ok: true,
|
||||
sourceCount: sources.length
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/sources', async (request, response, next) => {
|
||||
try {
|
||||
const sources = await sourceCatalog.listSources();
|
||||
response.json({
|
||||
sources: sources.map((source) => toPublicSource(source))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/server-db', async (request, response, next) => {
|
||||
try {
|
||||
const serverDb = await sourceCatalog.getServerDatabaseSummary();
|
||||
response.json({
|
||||
serverDb: toPublicServerDb(serverDb)
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/server-db/upload', async (request, response, next) => {
|
||||
try {
|
||||
const databaseFile = await parseMultipartDatabaseFile(request);
|
||||
const serverDb = await sourceCatalog.ingestServerDatabase(databaseFile);
|
||||
response.status(201).json({
|
||||
serverDb: toPublicServerDb(serverDb)
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/webdav-defaults', async (request, response, next) => {
|
||||
try {
|
||||
const defaults = await sourceCatalog.getGlobalWebdavDefaultsSummary();
|
||||
response.json({
|
||||
defaults
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/webdav-defaults', async (request, response, next) => {
|
||||
try {
|
||||
const payload = await parseJsonBody(request);
|
||||
const defaults = await sourceCatalog.saveGlobalWebdavDefaults(payload);
|
||||
response.json({
|
||||
defaults
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/sources/:sourceId', async (request, response, next) => {
|
||||
try {
|
||||
const source = await sourceCatalog.getSource(request.params.sourceId);
|
||||
if (!source) {
|
||||
throw new HttpError(404, 'SOURCE_NOT_FOUND', `Source ${request.params.sourceId} was not found.`);
|
||||
}
|
||||
|
||||
response.json({
|
||||
source: toPublicSource(source)
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/sources/:sourceId/browser-secrets', async (request, response, next) => {
|
||||
try {
|
||||
const secrets = await sourceCatalog.resolveEffectiveSourceSecrets(request.params.sourceId);
|
||||
response.json({
|
||||
secrets
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/sources/:sourceId/thumbnails', async (request, response, next) => {
|
||||
try {
|
||||
const formData = await parseMultipartFormData(request);
|
||||
const fileId = String(formData.get('fileId') ?? '').trim();
|
||||
const image = formData.get('image');
|
||||
if (!fileId) {
|
||||
throw new HttpError(400, 'MISSING_FILE_ID', 'Form field "fileId" is required.');
|
||||
}
|
||||
if (!image || typeof image !== 'object' || typeof image.stream !== 'function') {
|
||||
throw new HttpError(400, 'MISSING_THUMBNAIL_IMAGE', 'Form field "image" is required and must be a file.');
|
||||
}
|
||||
|
||||
const fileInfo = await activeSourceService.getFileInfo({
|
||||
sourceId: request.params.sourceId,
|
||||
id: fileId
|
||||
});
|
||||
if (!String(fileInfo.file.mime ?? '').toLowerCase().startsWith('video/')) {
|
||||
throw new HttpError(409, 'THUMBNAIL_VIDEO_ONLY', 'Preview thumbnails are only supported for video files.');
|
||||
}
|
||||
|
||||
const thumbnail = await sourceCatalog.savePreviewThumbnail({
|
||||
sourceId: request.params.sourceId,
|
||||
fileId,
|
||||
imageFile: image
|
||||
});
|
||||
|
||||
response.status(201).json({
|
||||
thumbnail: {
|
||||
available: true,
|
||||
thumbnailId: thumbnail.thumbnailId,
|
||||
thumbnailUrl: sourceCatalog.buildThumbnailUrl(request.params.sourceId, thumbnail.thumbnailId)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/sources/:sourceId/thumbnails/:thumbnailId', async (request, response, next) => {
|
||||
try {
|
||||
const thumbnail = await sourceCatalog.getThumbnailById(request.params.sourceId, request.params.thumbnailId);
|
||||
if (!thumbnail) {
|
||||
throw new HttpError(404, 'THUMBNAIL_NOT_FOUND', `Thumbnail ${request.params.thumbnailId} was not found.`);
|
||||
}
|
||||
|
||||
response.set('Content-Type', thumbnail.contentType);
|
||||
response.set('Content-Length', String(thumbnail.byteSize));
|
||||
response.set('Cache-Control', 'public, max-age=300');
|
||||
response.sendFile(thumbnail.storagePath);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/sources/:sourceId/thumbnails/:thumbnailId', async (request, response, next) => {
|
||||
try {
|
||||
const deleted = await sourceCatalog.deleteThumbnailById(request.params.sourceId, request.params.thumbnailId);
|
||||
response.json({
|
||||
deleted
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/sources/:sourceId/thumbnails', async (request, response, next) => {
|
||||
try {
|
||||
const cleared = await sourceCatalog.clearSourceThumbnails(request.params.sourceId);
|
||||
response.json({
|
||||
cleared
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/sources/:sourceId', async (request, response, next) => {
|
||||
try {
|
||||
const deleted = await sourceCatalog.deleteSource(request.params.sourceId);
|
||||
response.json({
|
||||
deleted
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/sources/:sourceId/secrets', async (request, response, next) => {
|
||||
try {
|
||||
const payload = await parseJsonBody(request);
|
||||
const source = await sourceEnhancementService.saveSecrets(request.params.sourceId, payload);
|
||||
response.json({
|
||||
source: toPublicSource(source)
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/sources/:sourceId/enhance', async (request, response, next) => {
|
||||
try {
|
||||
const contentType = String(request.headers['content-type'] ?? '').toLowerCase();
|
||||
const payload = contentType.startsWith('application/json') ? await parseJsonBody(request) : null;
|
||||
const source = await sourceEnhancementService.scheduleEnhancement(
|
||||
request.params.sourceId,
|
||||
payload && Object.keys(payload).length > 0 ? payload : null
|
||||
);
|
||||
response.status(202).json({
|
||||
source: toPublicSource(source)
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/sources/:sourceId/enhance/status', async (request, response, next) => {
|
||||
try {
|
||||
const enhancement = await sourceCatalog.getEnhancementJob(request.params.sourceId);
|
||||
response.json({
|
||||
sourceId: request.params.sourceId,
|
||||
enhancement
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSourceUpload(request, response, next) {
|
||||
try {
|
||||
const databaseFile = await parseMultipartDatabaseFile(request);
|
||||
const { source, reused } = await sourceCatalog.ingestUploadedDatabase(databaseFile);
|
||||
response.status(reused ? 200 : 201).json({
|
||||
reused,
|
||||
source: toPublicSource(source)
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
app.post('/api/sources/upload', handleSourceUpload);
|
||||
app.post('/api/source/upload', handleSourceUpload);
|
||||
|
||||
app.get('/api/source/current', async (request, response, next) => {
|
||||
try {
|
||||
const source = await sourceCatalog.getCompatCurrentSource();
|
||||
response.json({
|
||||
source: toPublicSource(source)
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/ls', async (request, response, next) => {
|
||||
try {
|
||||
const result = await activeSourceService.listDirectory({
|
||||
sourceId: request.query.sourceId ?? null,
|
||||
apiPath: request.query.path ?? '/',
|
||||
snapshotId: request.query.snapshot ?? 'latest'
|
||||
});
|
||||
|
||||
response.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/file-info', async (request, response, next) => {
|
||||
try {
|
||||
const id = request.query.id;
|
||||
if (!id) {
|
||||
throw new HttpError(400, 'MISSING_ID', 'Query parameter "id" is required.');
|
||||
}
|
||||
|
||||
const result = await activeSourceService.getFileInfo({
|
||||
sourceId: request.query.sourceId ?? null,
|
||||
id
|
||||
});
|
||||
response.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.use((request, response, next) => {
|
||||
next(new HttpError(404, 'NOT_FOUND', `No route matched ${request.method} ${request.path}`));
|
||||
});
|
||||
|
||||
app.use((error, request, response, next) => {
|
||||
const normalized = isHttpError(error)
|
||||
? error
|
||||
: new HttpError(500, 'INTERNAL_ERROR', error.message || 'Unexpected server error');
|
||||
|
||||
response.status(normalized.status).json({
|
||||
error: {
|
||||
code: normalized.code,
|
||||
message: normalized.message,
|
||||
...(normalized.details ? { details: normalized.details } : {})
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
app,
|
||||
async close() {
|
||||
await sourceCatalog.cleanupOrphanedUploads();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function startServer(overrides = {}) {
|
||||
const runtime = await createServerApp(overrides);
|
||||
const runtimeConfig = {
|
||||
...config,
|
||||
...overrides
|
||||
};
|
||||
|
||||
const server = runtime.app.listen(runtimeConfig.port, () => {
|
||||
console.log(`Duplicati metadata API listening on http://127.0.0.1:${runtimeConfig.port}`);
|
||||
});
|
||||
|
||||
async function shutdown(signal) {
|
||||
console.log(`Received ${signal}, shutting down...`);
|
||||
server.close(async () => {
|
||||
await runtime.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
void shutdown('SIGINT');
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
void shutdown('SIGTERM');
|
||||
});
|
||||
|
||||
return { ...runtime, server };
|
||||
}
|
||||
|
||||
const isMainModule =
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) === path.resolve(__filename);
|
||||
|
||||
if (isMainModule) {
|
||||
await startServer();
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { HttpError } from '../errors.js';
|
||||
import { DuplicatiRepository } from '../db/duplicatiRepository.js';
|
||||
|
||||
function buildThumbnailShape(sourceCatalog, sourceId, thumbnail) {
|
||||
if (!thumbnail) {
|
||||
return {
|
||||
available: false,
|
||||
thumbnailId: null,
|
||||
thumbnailUrl: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
thumbnailId: thumbnail.thumbnailId,
|
||||
thumbnailUrl: sourceCatalog.buildThumbnailUrl(sourceId, thumbnail.thumbnailId)
|
||||
};
|
||||
}
|
||||
|
||||
export class ActiveSourceService {
|
||||
constructor({ sourceCatalog, previewMaxBytes }) {
|
||||
this.sourceCatalog = sourceCatalog;
|
||||
this.previewMaxBytes = previewMaxBytes;
|
||||
}
|
||||
|
||||
async withRepository(sourceId, callback) {
|
||||
const source = await this.sourceCatalog.resolveSourceForQuery(sourceId);
|
||||
const database = await this.sourceCatalog.openAppDatabase();
|
||||
let attached = false;
|
||||
|
||||
try {
|
||||
try {
|
||||
await database.attachDatabase('source', source.queryDbPath, { readOnly: true });
|
||||
attached = true;
|
||||
} catch (error) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
'SOURCE_UNAVAILABLE',
|
||||
'The selected source database could not be attached. Re-upload the database and try again.'
|
||||
);
|
||||
}
|
||||
|
||||
const repository = new DuplicatiRepository({
|
||||
database,
|
||||
previewMaxBytes: this.previewMaxBytes,
|
||||
source,
|
||||
sourceLayout: source.sourceLayout
|
||||
});
|
||||
|
||||
return await callback(repository, source);
|
||||
} finally {
|
||||
if (attached) {
|
||||
try {
|
||||
await database.detachDatabase('source');
|
||||
} catch (error) {
|
||||
// ignore detach failures during shutdown of a per-request connection
|
||||
}
|
||||
}
|
||||
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async listDirectory({ sourceId, apiPath, snapshotId }) {
|
||||
const result = await this.withRepository(sourceId, (repository) =>
|
||||
repository.listDirectory({ apiPath, snapshotId })
|
||||
);
|
||||
|
||||
const fileIds = result.entries.filter((entry) => entry.type === 'file').map((entry) => entry.id);
|
||||
const thumbnailsByFileId = await this.sourceCatalog.getThumbnailsForFileIds(result.sourceId, fileIds);
|
||||
|
||||
return {
|
||||
...result,
|
||||
entries: result.entries.map((entry) =>
|
||||
entry.type === 'file'
|
||||
? {
|
||||
...entry,
|
||||
thumbnail: buildThumbnailShape(this.sourceCatalog, result.sourceId, thumbnailsByFileId.get(entry.id) ?? null)
|
||||
}
|
||||
: entry
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
async getFileInfo({ sourceId, id }) {
|
||||
const source = await this.sourceCatalog.resolveSourceForQuery(sourceId);
|
||||
|
||||
if (source.enhancement.status === 'failed') {
|
||||
throw new HttpError(
|
||||
409,
|
||||
'ENHANCEMENT_FAILED',
|
||||
'This source failed to build archive indexes. Fix the credentials or remote volumes and rerun enhancement.'
|
||||
);
|
||||
}
|
||||
|
||||
if (source.enhancement.status !== 'ready') {
|
||||
throw new HttpError(
|
||||
409,
|
||||
'ENHANCEMENT_NOT_READY',
|
||||
'This source has not finished building archive indexes yet.'
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.withRepository(source.id, (repository) => repository.getFileInfo(id));
|
||||
const thumbnail = await this.sourceCatalog.getThumbnailByFileId(source.id, id);
|
||||
|
||||
return {
|
||||
...result,
|
||||
file: {
|
||||
...result.file,
|
||||
thumbnail: buildThumbnailShape(this.sourceCatalog, source.id, thumbnail)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
||||
import { decryptAesCryptFileToZip } from '../lib/aesCrypt.js';
|
||||
import { scanZipEntries } from '../lib/zip.js';
|
||||
import { openReadOnlySqlite, openWritableSqlite } from '../db/sqlite.js';
|
||||
|
||||
const SUPPLEMENTAL_TABLES_SQL = `
|
||||
DROP TABLE IF EXISTS "archive_entry_index";
|
||||
DROP TABLE IF EXISTS "volume_crypto_cache";
|
||||
DROP TABLE IF EXISTS "volume_scan_inventory";
|
||||
DROP TABLE IF EXISTS "enhancement_meta";
|
||||
|
||||
CREATE TABLE "archive_entry_index" (
|
||||
"volume_name" TEXT NOT NULL,
|
||||
"entry_name" TEXT NOT NULL,
|
||||
"local_header_offset_plain" INTEGER NOT NULL,
|
||||
"data_offset_plain" INTEGER NOT NULL,
|
||||
"compressed_size" INTEGER NOT NULL,
|
||||
"uncompressed_size" INTEGER NOT NULL,
|
||||
"compression_method" TEXT NOT NULL,
|
||||
"crc32" TEXT NOT NULL,
|
||||
PRIMARY KEY ("volume_name", "entry_name")
|
||||
);
|
||||
|
||||
CREATE INDEX "archive_entry_index_volume_offset"
|
||||
ON "archive_entry_index" ("volume_name", "data_offset_plain");
|
||||
|
||||
CREATE TABLE "volume_crypto_cache" (
|
||||
"volume_name" TEXT PRIMARY KEY,
|
||||
"stream_format" TEXT NULL,
|
||||
"header_probe_bytes" INTEGER NULL,
|
||||
"kdf_iterations" INTEGER NULL,
|
||||
"salt_hex" TEXT NULL,
|
||||
"iv_hex" TEXT NULL,
|
||||
"cipher" TEXT NULL,
|
||||
"integrity" TEXT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "volume_scan_inventory" (
|
||||
"volume_name" TEXT PRIMARY KEY,
|
||||
"remote_type" TEXT NULL,
|
||||
"remote_size" INTEGER NULL,
|
||||
"remote_hash" TEXT NULL,
|
||||
"plain_zip_size" INTEGER NULL,
|
||||
"entry_count" INTEGER NULL,
|
||||
"scan_status" TEXT NOT NULL,
|
||||
"scanned_at" TEXT NOT NULL,
|
||||
"error_code" TEXT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "enhancement_meta" (
|
||||
"id" INTEGER PRIMARY KEY CHECK ("id" = 1),
|
||||
"schema_version" TEXT NOT NULL,
|
||||
"generator_version" TEXT NOT NULL,
|
||||
"created_at" TEXT NOT NULL,
|
||||
"source_layout" TEXT NOT NULL,
|
||||
"blocksize" TEXT NULL,
|
||||
"blockhash" TEXT NULL,
|
||||
"filehash" TEXT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
class EnhancementJobError extends Error {
|
||||
constructor(code, message, details = {}) {
|
||||
super(message);
|
||||
this.name = 'EnhancementJobError';
|
||||
this.code = code;
|
||||
this.currentVolume = details.currentVolume ?? null;
|
||||
this.processedVolumes = details.processedVolumes ?? 0;
|
||||
this.totalVolumes = details.totalVolumes ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
function buildBasicAuthorization(username, password) {
|
||||
return `Basic ${Buffer.from(`${username ?? ''}:${password ?? ''}`, 'utf8').toString('base64')}`;
|
||||
}
|
||||
|
||||
function buildRemoteUrl(baseUrl, volumeName) {
|
||||
return new URL(volumeName, baseUrl).toString();
|
||||
}
|
||||
|
||||
async function downloadResponseBodyToFile(response, targetPath) {
|
||||
if (!response.body) {
|
||||
throw new EnhancementJobError(
|
||||
'WEBDAV_EMPTY_BODY',
|
||||
'The remote WebDAV response did not include a response body.'
|
||||
);
|
||||
}
|
||||
|
||||
await pipeline(
|
||||
Readable.fromWeb(response.body),
|
||||
fs.createWriteStream(targetPath, { flags: 'wx' })
|
||||
);
|
||||
}
|
||||
|
||||
async function removeIfPresent(targetPath) {
|
||||
await fsp.rm(targetPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function loadSourceConfiguration(rawDbPath) {
|
||||
const database = await openReadOnlySqlite(rawDbPath);
|
||||
|
||||
try {
|
||||
const tables = await database.all(
|
||||
'SELECT "name" FROM "sqlite_master" WHERE "type" = ? AND "name" = ?',
|
||||
['table', 'Configuration']
|
||||
);
|
||||
if (tables.length === 0) {
|
||||
return {
|
||||
blocksize: null,
|
||||
blockhash: null,
|
||||
filehash: null
|
||||
};
|
||||
}
|
||||
|
||||
const rows = await database.all('SELECT "Key", "Value" FROM "Configuration"');
|
||||
const map = new Map(rows.map((row) => [String(row.Key ?? row.key).toLowerCase(), row.Value ?? row.value]));
|
||||
return {
|
||||
blocksize: map.get('blocksize') ?? null,
|
||||
blockhash: map.get('blockhash') ?? null,
|
||||
filehash: map.get('filehash') ?? null
|
||||
};
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBlockVolumes(rawDbPath) {
|
||||
const database = await openReadOnlySqlite(rawDbPath);
|
||||
|
||||
try {
|
||||
const rows = await database.all(`
|
||||
SELECT
|
||||
"ID" AS "id",
|
||||
"Name" AS "name",
|
||||
"Type" AS "type",
|
||||
"Size" AS "size",
|
||||
"Hash" AS "hash"
|
||||
FROM "Remotevolume"
|
||||
WHERE COALESCE("Type", 'Blocks') = 'Blocks'
|
||||
AND "Name" LIKE '%.dblock.zip.aes'
|
||||
ORDER BY "ID" ASC
|
||||
`);
|
||||
return rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
name: row.name,
|
||||
type: row.type ?? 'Blocks',
|
||||
size: row.size === null || row.size === undefined ? null : Number(row.size),
|
||||
hash: row.hash ?? null
|
||||
}));
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeEnhancedDatabase(enhancedTmpPath, sourceLayout, configuration) {
|
||||
const database = await openWritableSqlite(enhancedTmpPath);
|
||||
|
||||
try {
|
||||
await database.exec(SUPPLEMENTAL_TABLES_SQL);
|
||||
await database.run(
|
||||
`
|
||||
INSERT INTO "enhancement_meta" (
|
||||
"id",
|
||||
"schema_version",
|
||||
"generator_version",
|
||||
"created_at",
|
||||
"source_layout",
|
||||
"blocksize",
|
||||
"blockhash",
|
||||
"filehash"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
1,
|
||||
'1',
|
||||
'zero-bandwidth-duplicati-api/0.2.0',
|
||||
new Date().toISOString(),
|
||||
sourceLayout,
|
||||
configuration.blocksize,
|
||||
configuration.blockhash,
|
||||
configuration.filehash
|
||||
]
|
||||
);
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function insertVolumeScan(database, volume, scanResult, cryptoMeta) {
|
||||
for (const entry of scanResult.entries) {
|
||||
await database.run(
|
||||
`
|
||||
INSERT INTO "archive_entry_index" (
|
||||
"volume_name",
|
||||
"entry_name",
|
||||
"local_header_offset_plain",
|
||||
"data_offset_plain",
|
||||
"compressed_size",
|
||||
"uncompressed_size",
|
||||
"compression_method",
|
||||
"crc32"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
volume.name,
|
||||
entry.entryName,
|
||||
entry.localHeaderOffset,
|
||||
entry.dataOffset,
|
||||
entry.compressedSize,
|
||||
entry.uncompressedSize,
|
||||
String(entry.compressionMethod),
|
||||
entry.crc32
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
await database.run(
|
||||
`
|
||||
INSERT INTO "volume_crypto_cache" (
|
||||
"volume_name",
|
||||
"stream_format",
|
||||
"header_probe_bytes",
|
||||
"kdf_iterations",
|
||||
"salt_hex",
|
||||
"iv_hex",
|
||||
"cipher",
|
||||
"integrity"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
volume.name,
|
||||
cryptoMeta.streamFormat,
|
||||
cryptoMeta.headerProbeBytes,
|
||||
cryptoMeta.kdfIterations,
|
||||
cryptoMeta.saltHex,
|
||||
cryptoMeta.ivHex,
|
||||
'AES-256-CBC',
|
||||
'HMAC-SHA256'
|
||||
]
|
||||
);
|
||||
|
||||
await database.run(
|
||||
`
|
||||
INSERT INTO "volume_scan_inventory" (
|
||||
"volume_name",
|
||||
"remote_type",
|
||||
"remote_size",
|
||||
"remote_hash",
|
||||
"plain_zip_size",
|
||||
"entry_count",
|
||||
"scan_status",
|
||||
"scanned_at",
|
||||
"error_code"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
volume.name,
|
||||
volume.type,
|
||||
volume.size,
|
||||
volume.hash,
|
||||
scanResult.plainZipSize,
|
||||
scanResult.entryCount,
|
||||
'ready',
|
||||
new Date().toISOString(),
|
||||
null
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async function withWritableEnhancedDatabase(enhancedTmpPath, callback) {
|
||||
const database = await openWritableSqlite(enhancedTmpPath);
|
||||
|
||||
try {
|
||||
await callback(database);
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
|
||||
export class SourceEnhancementService {
|
||||
constructor({ sourceCatalog, fetchImpl = globalThis.fetch }) {
|
||||
this.sourceCatalog = sourceCatalog;
|
||||
this.fetchImpl = fetchImpl;
|
||||
this.queue = [];
|
||||
this.pending = new Set();
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
async saveSecrets(sourceId, secrets) {
|
||||
return this.sourceCatalog.saveSourceSecrets(sourceId, secrets);
|
||||
}
|
||||
|
||||
async scheduleEnhancement(sourceId, secrets = null) {
|
||||
if (secrets) {
|
||||
await this.sourceCatalog.saveSourceSecrets(sourceId, secrets);
|
||||
}
|
||||
|
||||
await this.sourceCatalog.getSourceForEnhancement(sourceId);
|
||||
await this.sourceCatalog.markEnhancementQueued(sourceId);
|
||||
|
||||
if (!this.pending.has(sourceId)) {
|
||||
this.pending.add(sourceId);
|
||||
this.queue.push(sourceId);
|
||||
void this.drainQueue();
|
||||
}
|
||||
|
||||
return this.sourceCatalog.getSource(sourceId);
|
||||
}
|
||||
|
||||
async drainQueue() {
|
||||
if (this.running) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
try {
|
||||
while (this.queue.length > 0) {
|
||||
const sourceId = this.queue.shift();
|
||||
if (!sourceId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.runEnhancement(sourceId);
|
||||
} finally {
|
||||
this.pending.delete(sourceId);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
async runEnhancement(sourceId) {
|
||||
let source;
|
||||
let volumes = [];
|
||||
let processedVolumes = 0;
|
||||
let workDir = null;
|
||||
let enhancedTmpPath = null;
|
||||
let enhancedDbPath = null;
|
||||
|
||||
try {
|
||||
const prepared = await this.sourceCatalog.getSourceForEnhancement(sourceId);
|
||||
source = prepared.source;
|
||||
const secrets = prepared.secrets;
|
||||
workDir = path.join(source.sourceDir, 'work');
|
||||
enhancedTmpPath = path.join(source.sourceDir, 'enhanced.sqlite.tmp');
|
||||
enhancedDbPath = path.join(source.sourceDir, 'enhanced.sqlite');
|
||||
const configuration = await loadSourceConfiguration(source.rawDbPath);
|
||||
volumes = await loadBlockVolumes(source.rawDbPath);
|
||||
|
||||
if (volumes.length === 0) {
|
||||
throw new EnhancementJobError(
|
||||
'NO_DBLOCK_VOLUMES',
|
||||
'No dblock volumes were found in Remotevolume, so enhancement cannot continue.'
|
||||
);
|
||||
}
|
||||
|
||||
await this.sourceCatalog.updateEnhancementState(sourceId, {
|
||||
sourceStatus: 'running',
|
||||
jobStatus: 'running',
|
||||
phase: 'preparing',
|
||||
processedVolumes: 0,
|
||||
totalVolumes: volumes.length,
|
||||
currentVolume: null,
|
||||
lastErrorCode: null,
|
||||
lastErrorMessage: null
|
||||
});
|
||||
|
||||
await fsp.mkdir(workDir, { recursive: true });
|
||||
await removeIfPresent(enhancedTmpPath);
|
||||
await fs.promises.copyFile(source.rawDbPath, enhancedTmpPath);
|
||||
await initializeEnhancedDatabase(enhancedTmpPath, source.sourceLayout, configuration);
|
||||
|
||||
await withWritableEnhancedDatabase(enhancedTmpPath, async (database) => {
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
for (const volume of volumes) {
|
||||
const encryptedPath = path.join(workDir, `${volume.name}.download`);
|
||||
const plainZipPath = path.join(workDir, `${volume.name}.zip.tmp`);
|
||||
|
||||
await this.sourceCatalog.updateEnhancementState(sourceId, {
|
||||
sourceStatus: 'running',
|
||||
jobStatus: 'running',
|
||||
phase: 'fetching',
|
||||
processedVolumes,
|
||||
totalVolumes: volumes.length,
|
||||
currentVolume: volume.name,
|
||||
lastErrorCode: null,
|
||||
lastErrorMessage: null
|
||||
});
|
||||
|
||||
await removeIfPresent(encryptedPath);
|
||||
await removeIfPresent(plainZipPath);
|
||||
|
||||
const response = await this.fetchImpl(buildRemoteUrl(secrets.webdavBaseUrl, volume.name), {
|
||||
headers:
|
||||
secrets.authMode === 'basic'
|
||||
? {
|
||||
Authorization: buildBasicAuthorization(secrets.username, secrets.password)
|
||||
}
|
||||
: {}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new EnhancementJobError(
|
||||
'WEBDAV_FETCH_FAILED',
|
||||
`Fetching ${volume.name} failed with HTTP ${response.status}.`,
|
||||
{
|
||||
currentVolume: volume.name,
|
||||
processedVolumes,
|
||||
totalVolumes: volumes.length
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await downloadResponseBodyToFile(response, encryptedPath);
|
||||
|
||||
await this.sourceCatalog.updateEnhancementState(sourceId, {
|
||||
sourceStatus: 'running',
|
||||
jobStatus: 'running',
|
||||
phase: 'decrypting',
|
||||
processedVolumes,
|
||||
totalVolumes: volumes.length,
|
||||
currentVolume: volume.name,
|
||||
lastErrorCode: null,
|
||||
lastErrorMessage: null
|
||||
});
|
||||
|
||||
const cryptoMeta = await decryptAesCryptFileToZip({
|
||||
encryptedPath,
|
||||
outputPath: plainZipPath,
|
||||
passphrase: secrets.passphrase
|
||||
});
|
||||
|
||||
await this.sourceCatalog.updateEnhancementState(sourceId, {
|
||||
sourceStatus: 'running',
|
||||
jobStatus: 'running',
|
||||
phase: 'scanning',
|
||||
processedVolumes,
|
||||
totalVolumes: volumes.length,
|
||||
currentVolume: volume.name,
|
||||
lastErrorCode: null,
|
||||
lastErrorMessage: null
|
||||
});
|
||||
|
||||
const scanResult = await scanZipEntries(plainZipPath);
|
||||
await insertVolumeScan(database, volume, scanResult, cryptoMeta);
|
||||
|
||||
processedVolumes += 1;
|
||||
await this.sourceCatalog.updateEnhancementState(sourceId, {
|
||||
sourceStatus: 'running',
|
||||
jobStatus: 'running',
|
||||
phase: 'scanning',
|
||||
processedVolumes,
|
||||
totalVolumes: volumes.length,
|
||||
currentVolume: volume.name,
|
||||
lastErrorCode: null,
|
||||
lastErrorMessage: null
|
||||
});
|
||||
|
||||
await removeIfPresent(encryptedPath);
|
||||
await removeIfPresent(plainZipPath);
|
||||
}
|
||||
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
try {
|
||||
await database.exec('ROLLBACK');
|
||||
} catch (rollbackError) {
|
||||
// ignore cleanup failures after rollback
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
await this.sourceCatalog.updateEnhancementState(sourceId, {
|
||||
sourceStatus: 'running',
|
||||
jobStatus: 'running',
|
||||
phase: 'finalizing',
|
||||
processedVolumes,
|
||||
totalVolumes: volumes.length,
|
||||
currentVolume: null,
|
||||
lastErrorCode: null,
|
||||
lastErrorMessage: null
|
||||
});
|
||||
|
||||
await removeIfPresent(enhancedDbPath);
|
||||
await fsp.rename(enhancedTmpPath, enhancedDbPath);
|
||||
await this.sourceCatalog.finalizeEnhancementSuccess(sourceId, {
|
||||
enhancedDbPath,
|
||||
capabilities: {
|
||||
archiveEntryIndex: true,
|
||||
volumeCryptoCache: true
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (enhancedTmpPath) {
|
||||
await removeIfPresent(enhancedTmpPath);
|
||||
}
|
||||
if (workDir) {
|
||||
await removeIfPresent(workDir);
|
||||
}
|
||||
const normalized =
|
||||
error instanceof EnhancementJobError
|
||||
? error
|
||||
: new EnhancementJobError('ENHANCEMENT_FAILED', error.message || 'Enhancement failed.', {
|
||||
currentVolume: error.currentVolume ?? null,
|
||||
processedVolumes,
|
||||
totalVolumes: volumes.length
|
||||
});
|
||||
await this.sourceCatalog.finalizeEnhancementFailure(sourceId, normalized);
|
||||
return;
|
||||
}
|
||||
|
||||
if (workDir) {
|
||||
await removeIfPresent(workDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user