feat: add docker pipeline and preview fixes

This commit is contained in:
2026-05-06 12:59:50 +08:00
commit 9ba85b01f3
44 changed files with 13894 additions and 0 deletions
+21
View File
@@ -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)
};
+515
View File
@@ -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
+70
View File
@@ -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 });
}
+13
View File
@@ -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;
}
+242
View File
@@ -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();
}
}
+85
View File
@@ -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;
}
+23
View File
@@ -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');
}
}
+69
View File
@@ -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);
}
+33
View File
@@ -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;
}
}
+36
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
+115
View File
@@ -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)
}
};
}
}
+526
View File
@@ -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);
}
}
}