import { BackupDestinationRecord, BackupDestinationType, S3BackupDestination, WebDavBackupDestination, } from './backup-config'; export interface BackupUploadResult { provider: BackupDestinationType; remotePath: string; } export interface RemoteBackupItem { path: string; name: string; isDirectory: boolean; size: number | null; modifiedAt: string | null; } export interface RemoteBackupListResult { provider: BackupDestinationType; currentPath: string; parentPath: string | null; items: RemoteBackupItem[]; } export interface RemoteBackupFile { provider: BackupDestinationType; remotePath: string; fileName: string; contentType: string; bytes: Uint8Array; } export interface RemoteBackupFilePutOptions { contentType?: string; } function isBackupArchiveName(name: string): boolean { return /\.zip$/i.test(String(name || '').trim()); } function encodePathSegments(path: string): string { return path .split('/') .filter(Boolean) .map((segment) => encodeURIComponent(segment)) .join('/'); } function trimSlashes(value: string): string { let next = String(value || ''); while (next.startsWith('/')) next = next.slice(1); while (next.endsWith('/')) next = next.slice(0, -1); return next; } function buildJoinedPath(...segments: string[]): string { return segments.map(trimSlashes).filter(Boolean).join('/'); } function normalizeRelativePath(path: string): string { const normalized = trimSlashes(path).replace(/\\/g, '/'); if (!normalized) return ''; const parts = normalized.split('/').filter(Boolean); if (parts.some((part) => part === '.' || part === '..')) { throw new Error('Invalid remote backup path'); } return parts.join('/'); } function basename(path: string): string { const normalized = trimSlashes(path); if (!normalized) return ''; const parts = normalized.split('/').filter(Boolean); return parts[parts.length - 1] || ''; } function parentPath(path: string): string | null { const normalized = normalizeRelativePath(path); if (!normalized) return null; const parts = normalized.split('/'); parts.pop(); return parts.length ? parts.join('/') : ''; } function sortRemoteItems(items: RemoteBackupItem[]): RemoteBackupItem[] { return items.slice().sort((a, b) => { const aIsAttachmentsDir = a.isDirectory && a.name === 'attachments'; const bIsAttachmentsDir = b.isDirectory && b.name === 'attachments'; if (aIsAttachmentsDir !== bIsAttachmentsDir) return aIsAttachmentsDir ? -1 : 1; if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; return a.name.localeCompare(b.name, 'en'); }); } function decodeXmlText(value: string): string { return value.replace(/&(amp|lt|gt|quot|#39);/g, (_match, entity) => { switch (entity) { case 'amp': return '&'; case 'lt': return '<'; case 'gt': return '>'; case 'quot': return '"'; case '#39': return "'"; default: return _match; } }); } function parseHttpDate(value: string): string | null { const parsed = new Date(value); return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null; } function extractXmlBlocks(xml: string, tagName: string): string[] { const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}\\b[^>]*>([\\s\\S]*?)]+:)?${tagName}>`, 'gi'); const blocks: string[] = []; let match: RegExpExecArray | null; while ((match = pattern.exec(xml))) { blocks.push(match[1]); } return blocks; } function extractXmlFirst(xml: string, tagName: string): string | null { const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}\\b[^>]*>([\\s\\S]*?)]+:)?${tagName}>`, 'i'); const match = xml.match(pattern); return match?.[1] ? decodeXmlText(match[1].trim()) : null; } async function sha256Hex(value: Uint8Array | string): Promise { const bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value; const digest = await crypto.subtle.digest('SHA-256', bytes); return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join(''); } async function hmacSha256Raw(keyBytes: Uint8Array, message: string): Promise { const key = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message)); return new Uint8Array(signature); } function toBasicAuthHeader(username: string, password: string): string { const token = btoa(`${username}:${password}`); return `Basic ${token}`; } function buildCanonicalQueryString(url: URL): string { const params = Array.from(url.searchParams.entries()).sort(([aKey, aValue], [bKey, bValue]) => { if (aKey === bKey) return aValue.localeCompare(bValue); return aKey.localeCompare(bKey); }); return params .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join('&'); } async function buildAwsV4Authorization( method: string, url: URL, headers: Record, payloadHashHex: string, accessKeyId: string, secretAccessKey: string, region: string ): Promise { const amzDate = headers['x-amz-date']; const shortDate = amzDate.slice(0, 8); const headerEntries = Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value] as const).sort(([a], [b]) => a.localeCompare(b)); const canonicalHeaders = headerEntries .map(([name, value]) => `${name}:${String(value).trim().replace(/\s+/g, ' ')}`) .join('\n'); const signedHeaders = headerEntries.map(([name]) => name).join(';'); const canonicalRequest = [ method.toUpperCase(), url.pathname || '/', buildCanonicalQueryString(url), `${canonicalHeaders}\n`, signedHeaders, payloadHashHex, ].join('\n'); const credentialScope = `${shortDate}/${region}/s3/aws4_request`; const stringToSign = [ 'AWS4-HMAC-SHA256', amzDate, credentialScope, await sha256Hex(canonicalRequest), ].join('\n'); const kDate = await hmacSha256Raw(new TextEncoder().encode(`AWS4${secretAccessKey}`), shortDate); const kRegion = await hmacSha256Raw(kDate, region); const kService = await hmacSha256Raw(kRegion, 's3'); const kSigning = await hmacSha256Raw(kService, 'aws4_request'); const signatureBytes = await hmacSha256Raw(kSigning, stringToSign); const signature = Array.from(signatureBytes).map((byte) => byte.toString(16).padStart(2, '0')).join(''); return `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; } function ensureDestinationConfigReady(destination: BackupDestinationRecord): void { if (destination.type === 'webdav') { const config = destination.destination as WebDavBackupDestination; if (!String(config.baseUrl || '').trim()) throw new Error('WebDAV server URL is required'); if (!/^https?:\/\//i.test(String(config.baseUrl || '').trim())) throw new Error('WebDAV server URL must start with http:// or https://'); if (!String(config.username || '').trim()) throw new Error('WebDAV username is required'); if (!String(config.password || '')) throw new Error('WebDAV password is required'); return; } if (destination.type === 's3') { const config = destination.destination as S3BackupDestination; if (!String(config.endpoint || '').trim()) throw new Error('S3 endpoint is required'); if (!/^https?:\/\//i.test(String(config.endpoint || '').trim())) throw new Error('S3 endpoint must start with http:// or https://'); if (!String(config.bucket || '').trim()) throw new Error('S3 bucket is required'); if (!String(config.accessKeyId || '').trim()) throw new Error('S3 access key is required'); if (!String(config.secretAccessKey || '')) throw new Error('S3 secret key is required'); } } function buildWebDavUrl(baseUrl: string, relativePath: string): string { const trimmedBase = baseUrl.replace(/\/+$/, ''); const normalized = normalizeRelativePath(relativePath); return normalized ? `${trimmedBase}/${encodePathSegments(normalized)}` : trimmedBase; } function webDavFullPath(config: WebDavBackupDestination, relativePath: string): string { return buildJoinedPath(config.remotePath, normalizeRelativePath(relativePath)); } async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, authHeader: string): Promise { const segments = trimSlashes(directoryPath).split('/').filter(Boolean); let current = ''; for (const segment of segments) { current = buildJoinedPath(current, segment); const url = buildWebDavUrl(baseUrl, current); const response = await fetch(url, { method: 'MKCOL', headers: { Authorization: authHeader, }, }); if ([200, 201, 204, 301, 302, 405].includes(response.status)) continue; throw new Error(`WebDAV directory creation failed: ${response.status}`); } } async function ensureWebDavDirectoryCached( baseUrl: string, directoryPath: string, authHeader: string, ensuredDirectories: Set ): Promise { const segments = trimSlashes(directoryPath).split('/').filter(Boolean); let current = ''; for (const segment of segments) { current = buildJoinedPath(current, segment); if (ensuredDirectories.has(current)) continue; const url = buildWebDavUrl(baseUrl, current); const response = await fetch(url, { method: 'MKCOL', headers: { Authorization: authHeader, }, }); if ([200, 201, 204, 301, 302, 405].includes(response.status)) { ensuredDirectories.add(current); continue; } throw new Error(`WebDAV directory creation failed: ${response.status}`); } } async function putToWebDav( config: WebDavBackupDestination, relativePath: string, bytes: Uint8Array, options: RemoteBackupFilePutOptions = {}, ensuredDirectories?: Set ): Promise { const authHeader = toBasicAuthHeader(config.username, config.password); const remoteFilePath = buildJoinedPath(config.remotePath, relativePath); const remoteDir = parentPath(remoteFilePath); if (remoteDir) { if (ensuredDirectories) { await ensureWebDavDirectoryCached(config.baseUrl, remoteDir, authHeader, ensuredDirectories); } else { await ensureWebDavDirectory(config.baseUrl, remoteDir, authHeader); } } const response = await fetch(buildWebDavUrl(config.baseUrl, remoteFilePath), { method: 'PUT', headers: { Authorization: authHeader, 'Content-Type': options.contentType || 'application/octet-stream', 'Content-Length': String(bytes.byteLength), }, body: bytes, }); if (!response.ok) { throw new Error(`WebDAV upload failed: ${response.status}`); } } async function uploadToWebDav(config: WebDavBackupDestination, archive: Uint8Array, fileName: string): Promise { await putToWebDav(config, fileName, archive, { contentType: 'application/zip' }); return { provider: 'webdav', remotePath: buildJoinedPath(config.remotePath, fileName), }; } function parseWebDavResponsePath(baseUrl: string, href: string): string { const base = new URL(baseUrl); const target = new URL(href, base); const basePath = trimSlashes(decodeURIComponent(base.pathname)); const entryPath = trimSlashes(decodeURIComponent(target.pathname)); if (!basePath) return entryPath; if (entryPath === basePath) return ''; return entryPath.startsWith(`${basePath}/`) ? entryPath.slice(basePath.length + 1) : entryPath; } async function listWebDavEntries(config: WebDavBackupDestination, relativePath: string): Promise { const currentPath = normalizeRelativePath(relativePath); const targetFullPath = webDavFullPath(config, currentPath); const authHeader = toBasicAuthHeader(config.username, config.password); const response = await fetch(buildWebDavUrl(config.baseUrl, targetFullPath), { method: 'PROPFIND', headers: { Authorization: authHeader, Depth: '1', 'Content-Type': 'application/xml; charset=utf-8', }, body: ``, }); if (response.status === 404) { return { provider: 'webdav', currentPath, parentPath: parentPath(currentPath), items: [], }; } if (!response.ok) { throw new Error(`WebDAV listing failed: ${response.status}`); } const xml = await response.text(); const rootFullPath = trimSlashes(config.remotePath); const items: RemoteBackupItem[] = []; for (const block of extractXmlBlocks(xml, 'response')) { const href = extractXmlFirst(block, 'href'); if (!href) continue; const fullPath = trimSlashes(parseWebDavResponsePath(config.baseUrl, href)); if (!fullPath) continue; if (fullPath === targetFullPath) continue; if (rootFullPath && !(fullPath === rootFullPath || fullPath.startsWith(`${rootFullPath}/`))) continue; const relative = rootFullPath ? fullPath === rootFullPath ? '' : fullPath.slice(rootFullPath.length + 1) : fullPath; if (!relative) continue; const directParent = parentPath(relative); if ((directParent || '') !== currentPath) continue; const resourceTypeBlock = extractXmlFirst(block, 'resourcetype') || ''; const isDirectory = /<(?:[^:>]+:)?collection\b/i.test(resourceTypeBlock); const sizeRaw = extractXmlFirst(block, 'getcontentlength'); const modifiedAtRaw = extractXmlFirst(block, 'getlastmodified'); items.push({ path: relative, name: basename(relative) || relative, isDirectory, size: !isDirectory && sizeRaw && Number.isFinite(Number(sizeRaw)) ? Number(sizeRaw) : null, modifiedAt: modifiedAtRaw ? parseHttpDate(modifiedAtRaw) : null, }); } return { provider: 'webdav', currentPath, parentPath: parentPath(currentPath), items: sortRemoteItems(items), }; } async function downloadFromWebDav(config: WebDavBackupDestination, relativePath: string): Promise { const normalized = normalizeRelativePath(relativePath); if (!normalized || normalized.endsWith('/')) { throw new Error('Please select a backup file'); } const authHeader = toBasicAuthHeader(config.username, config.password); const remotePath = webDavFullPath(config, normalized); const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), { method: 'GET', headers: { Authorization: authHeader, }, }); if (!response.ok) { throw new Error(`WebDAV download failed: ${response.status}`); } return { provider: 'webdav', remotePath: normalized, fileName: basename(normalized) || 'backup.zip', contentType: String(response.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip', bytes: new Uint8Array(await response.arrayBuffer()), }; } async function deleteFromWebDav(config: WebDavBackupDestination, relativePath: string): Promise { const authHeader = toBasicAuthHeader(config.username, config.password); const remotePath = webDavFullPath(config, relativePath); const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), { method: 'DELETE', headers: { Authorization: authHeader, }, }); if (!response.ok && response.status !== 404) { throw new Error(`WebDAV delete failed: ${response.status}`); } } async function existsInWebDav(config: WebDavBackupDestination, relativePath: string): Promise { const authHeader = toBasicAuthHeader(config.username, config.password); const remotePath = webDavFullPath(config, relativePath); const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), { method: 'HEAD', headers: { Authorization: authHeader, }, }); if (response.status === 404) return false; if (!response.ok) { throw new Error(`WebDAV existence check failed: ${response.status}`); } return true; } function s3BucketBaseUrl(config: S3BackupDestination): URL { return new URL(`${config.endpoint.replace(/\/+$/, '')}/${encodeURIComponent(config.bucket)}`); } function normalizeS3ObjectKey(config: S3BackupDestination, relativePath: string): string { return buildJoinedPath(config.rootPath, normalizeRelativePath(relativePath)); } async function signedS3Request( config: S3BackupDestination, method: 'GET' | 'PUT' | 'DELETE' | 'HEAD', url: URL, body?: Uint8Array, contentType?: string ): Promise { const payloadHashHex = await sha256Hex(body || new Uint8Array()); const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, ''); const headers: Record = { host: url.host, 'x-amz-content-sha256': payloadHashHex, 'x-amz-date': amzDate, }; if (method === 'PUT') headers['content-type'] = contentType || 'application/octet-stream'; const authorization = await buildAwsV4Authorization( method, url, headers, payloadHashHex, config.accessKeyId, config.secretAccessKey, config.region || 'auto' ); return fetch(url.toString(), { method, headers: { Authorization: authorization, 'X-Amz-Content-Sha256': headers['x-amz-content-sha256'], 'X-Amz-Date': headers['x-amz-date'], ...(method === 'PUT' ? { 'Content-Type': headers['content-type'] } : {}), }, body, }); } async function putToS3( config: S3BackupDestination, relativePath: string, bytes: Uint8Array, options: RemoteBackupFilePutOptions = {} ): Promise { const objectKey = normalizeS3ObjectKey(config, relativePath); const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`); const response = await signedS3Request(config, 'PUT', url, bytes, options.contentType); if (!response.ok) { throw new Error(`S3 upload failed: ${response.status}`); } } async function uploadToS3(config: S3BackupDestination, archive: Uint8Array, fileName: string): Promise { await putToS3(config, fileName, archive, { contentType: 'application/zip' }); return { provider: 's3', remotePath: normalizeS3ObjectKey(config, fileName), }; } async function listS3Entries(config: S3BackupDestination, relativePath: string): Promise { const currentPath = normalizeRelativePath(relativePath); const targetPrefixBase = normalizeS3ObjectKey(config, currentPath); const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : ''; const url = s3BucketBaseUrl(config); url.searchParams.set('list-type', '2'); url.searchParams.set('delimiter', '/'); if (targetPrefix) url.searchParams.set('prefix', targetPrefix); const response = await signedS3Request(config, 'GET', url); if (!response.ok) { throw new Error(`S3 listing failed: ${response.status}`); } const xml = await response.text(); const rootPrefix = trimSlashes(config.rootPath); const items: RemoteBackupItem[] = []; for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) { const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || ''); if (!fullPrefix) continue; const relative = rootPrefix ? fullPrefix === rootPrefix ? '' : fullPrefix.startsWith(`${rootPrefix}/`) ? fullPrefix.slice(rootPrefix.length + 1) : '' : fullPrefix; const normalizedRelative = trimSlashes(relative); if (!normalizedRelative) continue; const itemPath = normalizedRelative.replace(/\/+$/, ''); if ((parentPath(itemPath) || '') !== currentPath) continue; items.push({ path: itemPath, name: basename(itemPath) || itemPath, isDirectory: true, size: null, modifiedAt: null, }); } for (const content of extractXmlBlocks(xml, 'Contents')) { const fullKey = trimSlashes(extractXmlFirst(content, 'Key') || ''); if (!fullKey || (targetPrefix && fullKey === trimSlashes(targetPrefix))) continue; const relative = rootPrefix ? fullKey.startsWith(`${rootPrefix}/`) ? fullKey.slice(rootPrefix.length + 1) : '' : fullKey; const normalizedRelative = trimSlashes(relative); if (!normalizedRelative || (parentPath(normalizedRelative) || '') !== currentPath) continue; items.push({ path: normalizedRelative, name: basename(normalizedRelative) || normalizedRelative, isDirectory: false, size: Number(extractXmlFirst(content, 'Size') || 0) || null, modifiedAt: parseHttpDate(extractXmlFirst(content, 'LastModified') || '') || null, }); } const deduped = new Map(); for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item); return { provider: 's3', currentPath, parentPath: parentPath(currentPath), items: sortRemoteItems(Array.from(deduped.values())), }; } async function downloadFromS3(config: S3BackupDestination, relativePath: string): Promise { const normalized = normalizeRelativePath(relativePath); if (!normalized || normalized.endsWith('/')) { throw new Error('Please select a backup file'); } const objectKey = normalizeS3ObjectKey(config, normalized); const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`); const response = await signedS3Request(config, 'GET', url); if (!response.ok) { throw new Error(`S3 download failed: ${response.status}`); } return { provider: 's3', remotePath: normalized, fileName: basename(normalized) || 'backup.zip', contentType: String(response.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip', bytes: new Uint8Array(await response.arrayBuffer()), }; } async function deleteFromS3(config: S3BackupDestination, relativePath: string): Promise { const objectKey = normalizeS3ObjectKey(config, relativePath); const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`); const response = await signedS3Request(config, 'DELETE', url); if (!response.ok && response.status !== 404) { throw new Error(`S3 delete failed: ${response.status}`); } } async function existsInS3(config: S3BackupDestination, relativePath: string): Promise { const objectKey = normalizeS3ObjectKey(config, relativePath); const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`); const response = await signedS3Request(config, 'HEAD', url); if (response.status === 404) return false; if (!response.ok) { throw new Error(`S3 existence check failed: ${response.status}`); } return true; } interface ConfiguredDestinationAdapter { provider: 'webdav' | 's3'; config: WebDavBackupDestination | S3BackupDestination; upload: (config: WebDavBackupDestination | S3BackupDestination, archive: Uint8Array, fileName: string) => Promise; putFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions) => Promise; list: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise; download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise; deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise; exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise; } export interface RemoteBackupTransferSession { provider: BackupDestinationType; uploadArchive(archive: Uint8Array, fileName: string): Promise; putFile(relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions): Promise; list(relativePath: string): Promise; download(relativePath: string): Promise; deleteFile(relativePath: string): Promise; exists(relativePath: string): Promise; } function resolveConfiguredDestinationAdapter( destination: BackupDestinationRecord ): ConfiguredDestinationAdapter { ensureDestinationConfigReady(destination); if (destination.type === 'webdav') { return { provider: 'webdav', config: destination.destination as WebDavBackupDestination, upload: (config, archive, fileName) => uploadToWebDav(config as WebDavBackupDestination, archive, fileName), putFile: (config, relativePath, bytes, options) => putToWebDav(config as WebDavBackupDestination, relativePath, bytes, options), list: (config, relativePath) => listWebDavEntries(config as WebDavBackupDestination, relativePath), download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath), deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath), exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath), }; } if (destination.type === 's3') { return { provider: 's3', config: destination.destination as S3BackupDestination, upload: (config, archive, fileName) => uploadToS3(config as S3BackupDestination, archive, fileName), putFile: (config, relativePath, bytes, options) => putToS3(config as S3BackupDestination, relativePath, bytes, options), list: (config, relativePath) => listS3Entries(config as S3BackupDestination, relativePath), download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath), deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath), exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath), }; } throw new Error('Unsupported backup destination type'); } export function createRemoteBackupTransferSession(destination: BackupDestinationRecord): RemoteBackupTransferSession { const adapter = resolveConfiguredDestinationAdapter(destination); const ensuredDirectories = adapter.provider === 'webdav' ? new Set() : null; const putFile = async (relativePath: string, bytes: Uint8Array, options: RemoteBackupFilePutOptions = {}): Promise => { const normalized = normalizeRelativePath(relativePath); if (adapter.provider === 'webdav' && ensuredDirectories) { await putToWebDav(adapter.config as WebDavBackupDestination, normalized, bytes, options, ensuredDirectories); return; } await adapter.putFile(adapter.config, normalized, bytes, options); }; return { provider: adapter.provider, uploadArchive: async (archive: Uint8Array, fileName: string) => { await putFile(fileName, archive, { contentType: 'application/zip' }); return { provider: adapter.provider, remotePath: adapter.provider === 'webdav' ? buildJoinedPath((adapter.config as WebDavBackupDestination).remotePath, fileName) : normalizeS3ObjectKey(adapter.config as S3BackupDestination, fileName), }; }, putFile, list: async (relativePath: string) => adapter.list(adapter.config, relativePath), download: async (relativePath: string) => adapter.download(adapter.config, relativePath), deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)), exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)), }; } export async function uploadBackupArchive( destination: BackupDestinationRecord, archive: Uint8Array, fileName: string ): Promise { return createRemoteBackupTransferSession(destination).uploadArchive(archive, fileName); } export async function listRemoteBackupEntries(destination: BackupDestinationRecord, relativePath: string): Promise { return createRemoteBackupTransferSession(destination).list(relativePath); } export async function downloadRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise { return createRemoteBackupTransferSession(destination).download(relativePath); } export async function deleteRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise { const normalized = ensureRemoteRestoreCandidate(relativePath); await createRemoteBackupTransferSession(destination).deleteFile(normalized); } export async function remoteBackupFileExists(destination: BackupDestinationRecord, relativePath: string): Promise { const normalized = normalizeRelativePath(relativePath); return createRemoteBackupTransferSession(destination).exists(normalized); } export async function uploadRemoteBackupFile( destination: BackupDestinationRecord, relativePath: string, bytes: Uint8Array, options: RemoteBackupFilePutOptions = {} ): Promise { const normalized = normalizeRelativePath(relativePath); await createRemoteBackupTransferSession(destination).putFile(normalized, bytes, options); } function compareBackupItemsByRecency(a: RemoteBackupItem, b: RemoteBackupItem, preferredFileName?: string): number { if (preferredFileName) { const aPreferred = a.name === preferredFileName ? 1 : 0; const bPreferred = b.name === preferredFileName ? 1 : 0; if (aPreferred !== bPreferred) return bPreferred - aPreferred; } const aTime = a.modifiedAt ? new Date(a.modifiedAt).getTime() : 0; const bTime = b.modifiedAt ? new Date(b.modifiedAt).getTime() : 0; if (aTime !== bTime) return bTime - aTime; return b.name.localeCompare(a.name, 'en'); } export async function pruneRemoteBackupArchives( destination: BackupDestinationRecord, retentionCount: number | null, preferredFileName?: string ): Promise { if (retentionCount === null) return 0; const adapter = resolveConfiguredDestinationAdapter(destination); const listing = await adapter.list(adapter.config, ''); const backupFiles = listing.items .filter((item) => !item.isDirectory && isBackupArchiveName(item.name)) .sort((a, b) => compareBackupItemsByRecency(a, b, preferredFileName)); if (backupFiles.length <= retentionCount) return 0; for (const item of backupFiles.slice(retentionCount)) { await adapter.deleteFile(adapter.config, item.path); } return backupFiles.length - retentionCount; } export function ensureRemoteRestoreCandidate(relativePath: string): string { const normalized = normalizeRelativePath(relativePath); if (!normalized || !/\.zip$/i.test(normalized)) { throw new Error('Please select a backup ZIP file'); } return normalized; }