mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
Update localization files for backup destinations and API client credentials
- Changed references from E3 to S3 in Russian, Simplified Chinese, and Traditional Chinese localization files. - Updated the corresponding keys and descriptions to reflect the change in backup destination protocols. - Improved the Vite configuration to dynamically match locale files, simplifying the code for locale handling.
This commit is contained in:
+168
-11
@@ -78,6 +78,43 @@ function syncCipherComputedAliases(cipher: Cipher): Cipher {
|
||||
return cipher;
|
||||
}
|
||||
|
||||
function isValidEncString(value: unknown): value is string {
|
||||
if (typeof value !== 'string') return false;
|
||||
const trimmed = value.trim();
|
||||
const dot = trimmed.indexOf('.');
|
||||
if (dot <= 0) return false;
|
||||
const type = Number(trimmed.slice(0, dot));
|
||||
if (!Number.isInteger(type) || type < 0) return false;
|
||||
const parts = trimmed.slice(dot + 1).split('|');
|
||||
if (parts.some((part) => part.length === 0)) return false;
|
||||
|
||||
// Bitwarden's legacy symmetric EncString variants require IV + data,
|
||||
// while the authenticated AES-CBC-HMAC variant requires IV + data + MAC.
|
||||
if (type === 0 || type === 1 || type === 4) return parts.length >= 2;
|
||||
if (type === 2) return parts.length === 3;
|
||||
|
||||
// Keep newer one-part formats, such as COSE Encrypt0, future-compatible.
|
||||
return parts.length >= 1;
|
||||
}
|
||||
|
||||
function optionalEncString(value: unknown): string | null {
|
||||
if (value == null || value === '') return null;
|
||||
return isValidEncString(value) ? value.trim() : null;
|
||||
}
|
||||
|
||||
function sanitizeEncryptedObject<T extends Record<string, any>>(
|
||||
source: T | null | undefined,
|
||||
encryptedKeys: readonly string[]
|
||||
): T | null {
|
||||
if (!source || typeof source !== 'object') return source ?? null;
|
||||
const next: Record<string, any> = { ...source };
|
||||
for (const key of encryptedKeys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(next, key)) continue;
|
||||
next[key] = optionalEncString(next[key]);
|
||||
}
|
||||
return next as T;
|
||||
}
|
||||
|
||||
function normalizeCipherForStorage(cipher: Cipher): Cipher {
|
||||
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
||||
@@ -100,7 +137,53 @@ export function normalizeCipherLoginForStorage(login: any): any {
|
||||
export function normalizeCipherLoginForCompatibility(login: any): any {
|
||||
const normalized = normalizeCipherLoginForStorage(login);
|
||||
if (!normalized || typeof normalized !== 'object') return normalized ?? null;
|
||||
return normalized;
|
||||
const next = sanitizeEncryptedObject(normalized, ['username', 'password', 'totp', 'uri']);
|
||||
if (!next) return null;
|
||||
next.uris = Array.isArray(next.uris)
|
||||
? next.uris
|
||||
.map((uri: any) => sanitizeEncryptedObject(uri, ['uri', 'uriChecksum']))
|
||||
.filter((uri: any) => !!uri && (uri.uri || uri.uriChecksum || uri.match != null))
|
||||
: null;
|
||||
next.fido2Credentials = normalizeFido2CredentialsForCompatibility(next.fido2Credentials);
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeFido2CredentialsForCompatibility(credentials: any): any[] | null {
|
||||
if (!Array.isArray(credentials) || credentials.length === 0) return null;
|
||||
const requiredEncryptedKeys = [
|
||||
'credentialId',
|
||||
'keyType',
|
||||
'keyAlgorithm',
|
||||
'keyCurve',
|
||||
'keyValue',
|
||||
'rpId',
|
||||
'counter',
|
||||
'discoverable',
|
||||
];
|
||||
const optionalEncryptedKeys = ['userHandle', 'userName', 'rpName', 'userDisplayName'];
|
||||
const out: any[] = [];
|
||||
|
||||
for (const credential of credentials) {
|
||||
if (!credential || typeof credential !== 'object') continue;
|
||||
const next: Record<string, any> = { ...credential };
|
||||
let valid = true;
|
||||
for (const key of requiredEncryptedKeys) {
|
||||
if (!isValidEncString(next[key])) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
next[key] = String(next[key]).trim();
|
||||
}
|
||||
if (!valid) continue;
|
||||
for (const key of optionalEncryptedKeys) {
|
||||
if (Object.prototype.hasOwnProperty.call(next, key)) {
|
||||
next[key] = optionalEncString(next[key]);
|
||||
}
|
||||
}
|
||||
out.push(next);
|
||||
}
|
||||
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
// Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads.
|
||||
@@ -118,8 +201,18 @@ export function normalizeCipherSshKeyForCompatibility(sshKey: any): any {
|
||||
? ''
|
||||
: String(candidate);
|
||||
|
||||
if (
|
||||
!isValidEncString(sshKey.privateKey) ||
|
||||
!isValidEncString(sshKey.publicKey) ||
|
||||
!isValidEncString(normalizedFingerprint)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...sshKey,
|
||||
privateKey: String(sshKey.privateKey).trim(),
|
||||
publicKey: String(sshKey.publicKey).trim(),
|
||||
keyFingerprint: normalizedFingerprint,
|
||||
fingerprint: normalizedFingerprint,
|
||||
};
|
||||
@@ -128,16 +221,52 @@ export function normalizeCipherSshKeyForCompatibility(sshKey: any): any {
|
||||
// Format attachments for API response
|
||||
export function formatAttachments(attachments: Attachment[]): any[] | null {
|
||||
if (attachments.length === 0) return null;
|
||||
return attachments.map(a => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
// Bitwarden clients decode attachment size as string in cipher payloads.
|
||||
size: String(Number(a.size) || 0),
|
||||
sizeName: a.sizeName,
|
||||
key: a.key,
|
||||
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`, // Android requires non-null url!
|
||||
object: 'attachment',
|
||||
}));
|
||||
const formatted = attachments
|
||||
.filter((a) => isValidEncString(a.fileName))
|
||||
.map(a => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName.trim(),
|
||||
// Bitwarden clients decode attachment size as string in cipher payloads.
|
||||
size: String(Number(a.size) || 0),
|
||||
sizeName: a.sizeName,
|
||||
key: optionalEncString(a.key),
|
||||
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`, // Android requires non-null url!
|
||||
object: 'attachment',
|
||||
}));
|
||||
return formatted.length ? formatted : null;
|
||||
}
|
||||
|
||||
function normalizeCipherFieldsForCompatibility(fields: any): any[] | null {
|
||||
if (!Array.isArray(fields) || fields.length === 0) return null;
|
||||
const out = fields
|
||||
.map((field: any) => {
|
||||
if (!field || typeof field !== 'object') return null;
|
||||
return {
|
||||
...field,
|
||||
name: optionalEncString(field.name),
|
||||
value: optionalEncString(field.value),
|
||||
type: Number(field.type) || 0,
|
||||
linkedId: field.linkedId ?? null,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
function normalizePasswordHistoryForCompatibility(passwordHistory: any): PasswordHistory[] | null {
|
||||
if (!Array.isArray(passwordHistory) || passwordHistory.length === 0) return null;
|
||||
const out = passwordHistory
|
||||
.filter((entry: any) => entry && typeof entry === 'object' && isValidEncString(entry.password))
|
||||
.map((entry: any) => ({
|
||||
...entry,
|
||||
password: String(entry.password).trim(),
|
||||
lastUsedDate: normalizeCipherTimestamp(entry.lastUsedDate) ?? new Date().toISOString(),
|
||||
}));
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
export function isCipherResponseSyncCompatible(cipher: CipherResponse): boolean {
|
||||
return isValidEncString(cipher.name);
|
||||
}
|
||||
|
||||
// Convert internal cipher to API response format.
|
||||
@@ -151,6 +280,27 @@ export function cipherToResponse(
|
||||
// Strip internal-only fields that must not appear in the API response
|
||||
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
|
||||
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null);
|
||||
const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code']);
|
||||
const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [
|
||||
'title',
|
||||
'firstName',
|
||||
'middleName',
|
||||
'lastName',
|
||||
'address1',
|
||||
'address2',
|
||||
'address3',
|
||||
'city',
|
||||
'state',
|
||||
'postalCode',
|
||||
'country',
|
||||
'company',
|
||||
'email',
|
||||
'phone',
|
||||
'ssn',
|
||||
'username',
|
||||
'passportNumber',
|
||||
'licenseNumber',
|
||||
]);
|
||||
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
||||
|
||||
return {
|
||||
@@ -174,8 +324,15 @@ export function cipherToResponse(
|
||||
object: 'cipherDetails',
|
||||
collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [],
|
||||
attachments: formatAttachments(attachments),
|
||||
name: isValidEncString(cipher.name) ? cipher.name.trim() : cipher.name,
|
||||
notes: optionalEncString(cipher.notes),
|
||||
login: normalizedLogin,
|
||||
card: normalizedCard,
|
||||
identity: normalizedIdentity,
|
||||
fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields),
|
||||
passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory),
|
||||
sshKey: normalizedSshKey,
|
||||
key: optionalEncString(cipher.key),
|
||||
encryptedFor: (passthrough as any).encryptedFor ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { errorResponse } from '../utils/response';
|
||||
import { cipherToResponse } from './ciphers';
|
||||
import { cipherToResponse, isCipherResponseSyncCompatible } from './ciphers';
|
||||
import { sendToResponse } from './sends';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import {
|
||||
@@ -86,7 +86,10 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
|
||||
const cipherResponses: CipherResponse[] = [];
|
||||
for (const cipher of ciphers) {
|
||||
cipherResponses.push(cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []));
|
||||
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []);
|
||||
if (isCipherResponseSyncCompatible(response)) {
|
||||
cipherResponses.push(response);
|
||||
}
|
||||
}
|
||||
|
||||
const folderResponses: FolderResponse[] = [];
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type BackupRuntimeState,
|
||||
type BackupScheduleConfig,
|
||||
type BackupSettings,
|
||||
type E3BackupDestination,
|
||||
type S3BackupDestination,
|
||||
type WebDavBackupDestination,
|
||||
createBackupRandomId,
|
||||
createDefaultBackupDestinationName,
|
||||
@@ -35,7 +35,7 @@ export type {
|
||||
BackupRuntimeState,
|
||||
BackupScheduleConfig,
|
||||
BackupSettings,
|
||||
E3BackupDestination,
|
||||
S3BackupDestination,
|
||||
WebDavBackupDestination,
|
||||
} from '../../shared/backup-schema';
|
||||
|
||||
@@ -105,7 +105,7 @@ function normalizeStartTime(value: unknown, fallback: string = BACKUP_DEFAULT_ST
|
||||
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function normalizeE3Destination(value: unknown, allowIncomplete = false): E3BackupDestination {
|
||||
function normalizeS3Destination(value: unknown, allowIncomplete = false): S3BackupDestination {
|
||||
const source = isPlainObject(value) ? value : {};
|
||||
const endpoint = asTrimmedString(source.endpoint);
|
||||
const bucket = asTrimmedString(source.bucket);
|
||||
@@ -115,17 +115,17 @@ function normalizeE3Destination(value: unknown, allowIncomplete = false): E3Back
|
||||
const rootPath = normalizePath(source.rootPath);
|
||||
|
||||
if (!allowIncomplete || endpoint) {
|
||||
if (!endpoint) throw new Error('E3 endpoint is required');
|
||||
if (!/^https?:\/\//i.test(endpoint)) throw new Error('E3 endpoint must start with http:// or https://');
|
||||
if (!endpoint) throw new Error('S3 endpoint is required');
|
||||
if (!/^https?:\/\//i.test(endpoint)) throw new Error('S3 endpoint must start with http:// or https://');
|
||||
}
|
||||
if (!allowIncomplete || bucket) {
|
||||
if (!bucket) throw new Error('E3 bucket is required');
|
||||
if (!bucket) throw new Error('S3 bucket is required');
|
||||
}
|
||||
if (!allowIncomplete || accessKeyId) {
|
||||
if (!accessKeyId) throw new Error('E3 access key is required');
|
||||
if (!accessKeyId) throw new Error('S3 access key is required');
|
||||
}
|
||||
if (!allowIncomplete || secretAccessKey) {
|
||||
if (!secretAccessKey) throw new Error('E3 secret key is required');
|
||||
if (!secretAccessKey) throw new Error('S3 secret key is required');
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -169,7 +169,7 @@ function normalizeDestination(
|
||||
destination: unknown,
|
||||
allowIncomplete = false
|
||||
): BackupDestinationConfig {
|
||||
if (destinationType === 'e3') return normalizeE3Destination(destination, allowIncomplete);
|
||||
if (destinationType === 's3') return normalizeS3Destination(destination, allowIncomplete);
|
||||
return normalizeWebDavDestination(destination, allowIncomplete);
|
||||
}
|
||||
|
||||
@@ -204,7 +204,8 @@ function defaultDestinationName(type: BackupDestinationType, index: number): str
|
||||
|
||||
function getDestinationType(raw: unknown): BackupDestinationType {
|
||||
const value = asTrimmedString(raw);
|
||||
if (value === 'e3' || value === 'webdav') return value;
|
||||
if (value === 'e3') return 's3';
|
||||
if (value === 's3' || value === 'webdav') return value;
|
||||
throw new Error('Backup destination type is invalid');
|
||||
}
|
||||
|
||||
@@ -266,8 +267,8 @@ function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTi
|
||||
: BACKUP_DEFAULT_INTERVAL_HOURS;
|
||||
const destinationTypeRaw = asTrimmedString(rawValue.destinationType);
|
||||
const destinationType: BackupDestinationType =
|
||||
destinationTypeRaw === 'e3' || destinationTypeRaw === 'webdav'
|
||||
? destinationTypeRaw
|
||||
destinationTypeRaw === 'e3' || destinationTypeRaw === 's3' || destinationTypeRaw === 'webdav'
|
||||
? getDestinationType(destinationTypeRaw)
|
||||
: 'webdav';
|
||||
const destination = {
|
||||
id: createBackupRandomId(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
BackupDestinationRecord,
|
||||
BackupDestinationType,
|
||||
E3BackupDestination,
|
||||
S3BackupDestination,
|
||||
WebDavBackupDestination,
|
||||
} from './backup-config';
|
||||
|
||||
@@ -213,13 +213,13 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
|
||||
if (!String(config.password || '')) throw new Error('WebDAV password is required');
|
||||
return;
|
||||
}
|
||||
if (destination.type === 'e3') {
|
||||
const config = destination.destination as E3BackupDestination;
|
||||
if (!String(config.endpoint || '').trim()) throw new Error('E3 endpoint is required');
|
||||
if (!/^https?:\/\//i.test(String(config.endpoint || '').trim())) throw new Error('E3 endpoint must start with http:// or https://');
|
||||
if (!String(config.bucket || '').trim()) throw new Error('E3 bucket is required');
|
||||
if (!String(config.accessKeyId || '').trim()) throw new Error('E3 access key is required');
|
||||
if (!String(config.secretAccessKey || '')) throw new Error('E3 secret key is required');
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,16 +448,16 @@ async function existsInWebDav(config: WebDavBackupDestination, relativePath: str
|
||||
return true;
|
||||
}
|
||||
|
||||
function e3BucketBaseUrl(config: E3BackupDestination): URL {
|
||||
function s3BucketBaseUrl(config: S3BackupDestination): URL {
|
||||
return new URL(`${config.endpoint.replace(/\/+$/, '')}/${encodeURIComponent(config.bucket)}`);
|
||||
}
|
||||
|
||||
function normalizeE3ObjectKey(config: E3BackupDestination, relativePath: string): string {
|
||||
function normalizeS3ObjectKey(config: S3BackupDestination, relativePath: string): string {
|
||||
return buildJoinedPath(config.rootPath, normalizeRelativePath(relativePath));
|
||||
}
|
||||
|
||||
async function signedE3Request(
|
||||
config: E3BackupDestination,
|
||||
async function signedS3Request(
|
||||
config: S3BackupDestination,
|
||||
method: 'GET' | 'PUT' | 'DELETE' | 'HEAD',
|
||||
url: URL,
|
||||
body?: Uint8Array,
|
||||
@@ -494,41 +494,41 @@ async function signedE3Request(
|
||||
});
|
||||
}
|
||||
|
||||
async function putToE3(
|
||||
config: E3BackupDestination,
|
||||
async function putToS3(
|
||||
config: S3BackupDestination,
|
||||
relativePath: string,
|
||||
bytes: Uint8Array,
|
||||
options: RemoteBackupFilePutOptions = {}
|
||||
): Promise<void> {
|
||||
const objectKey = normalizeE3ObjectKey(config, relativePath);
|
||||
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const response = await signedE3Request(config, 'PUT', url, bytes, options.contentType);
|
||||
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(`E3 upload failed: ${response.status}`);
|
||||
throw new Error(`S3 upload failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadToE3(config: E3BackupDestination, archive: Uint8Array, fileName: string): Promise<BackupUploadResult> {
|
||||
await putToE3(config, fileName, archive, { contentType: 'application/zip' });
|
||||
async function uploadToS3(config: S3BackupDestination, archive: Uint8Array, fileName: string): Promise<BackupUploadResult> {
|
||||
await putToS3(config, fileName, archive, { contentType: 'application/zip' });
|
||||
return {
|
||||
provider: 'e3',
|
||||
remotePath: normalizeE3ObjectKey(config, fileName),
|
||||
provider: 's3',
|
||||
remotePath: normalizeS3ObjectKey(config, fileName),
|
||||
};
|
||||
}
|
||||
|
||||
async function listE3Entries(config: E3BackupDestination, relativePath: string): Promise<RemoteBackupListResult> {
|
||||
async function listS3Entries(config: S3BackupDestination, relativePath: string): Promise<RemoteBackupListResult> {
|
||||
const currentPath = normalizeRelativePath(relativePath);
|
||||
const targetPrefixBase = normalizeE3ObjectKey(config, currentPath);
|
||||
const targetPrefixBase = normalizeS3ObjectKey(config, currentPath);
|
||||
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
|
||||
const url = e3BucketBaseUrl(config);
|
||||
const url = s3BucketBaseUrl(config);
|
||||
url.searchParams.set('list-type', '2');
|
||||
url.searchParams.set('delimiter', '/');
|
||||
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
|
||||
|
||||
const response = await signedE3Request(config, 'GET', url);
|
||||
const response = await signedS3Request(config, 'GET', url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`E3 listing failed: ${response.status}`);
|
||||
throw new Error(`S3 listing failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
@@ -581,26 +581,26 @@ async function listE3Entries(config: E3BackupDestination, relativePath: string):
|
||||
for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item);
|
||||
|
||||
return {
|
||||
provider: 'e3',
|
||||
provider: 's3',
|
||||
currentPath,
|
||||
parentPath: parentPath(currentPath),
|
||||
items: sortRemoteItems(Array.from(deduped.values())),
|
||||
};
|
||||
}
|
||||
|
||||
async function downloadFromE3(config: E3BackupDestination, relativePath: string): Promise<RemoteBackupFile> {
|
||||
async function downloadFromS3(config: S3BackupDestination, relativePath: string): Promise<RemoteBackupFile> {
|
||||
const normalized = normalizeRelativePath(relativePath);
|
||||
if (!normalized || normalized.endsWith('/')) {
|
||||
throw new Error('Please select a backup file');
|
||||
}
|
||||
const objectKey = normalizeE3ObjectKey(config, normalized);
|
||||
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const response = await signedE3Request(config, 'GET', url);
|
||||
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(`E3 download failed: ${response.status}`);
|
||||
throw new Error(`S3 download failed: ${response.status}`);
|
||||
}
|
||||
return {
|
||||
provider: 'e3',
|
||||
provider: 's3',
|
||||
remotePath: normalized,
|
||||
fileName: basename(normalized) || 'backup.zip',
|
||||
contentType: String(response.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip',
|
||||
@@ -608,35 +608,35 @@ async function downloadFromE3(config: E3BackupDestination, relativePath: string)
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteFromE3(config: E3BackupDestination, relativePath: string): Promise<void> {
|
||||
const objectKey = normalizeE3ObjectKey(config, relativePath);
|
||||
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const response = await signedE3Request(config, 'DELETE', url);
|
||||
async function deleteFromS3(config: S3BackupDestination, relativePath: string): Promise<void> {
|
||||
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(`E3 delete failed: ${response.status}`);
|
||||
throw new Error(`S3 delete failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function existsInE3(config: E3BackupDestination, relativePath: string): Promise<boolean> {
|
||||
const objectKey = normalizeE3ObjectKey(config, relativePath);
|
||||
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const response = await signedE3Request(config, 'HEAD', url);
|
||||
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
|
||||
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(`E3 existence check failed: ${response.status}`);
|
||||
throw new Error(`S3 existence check failed: ${response.status}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface ConfiguredDestinationAdapter {
|
||||
provider: 'webdav' | 'e3';
|
||||
config: WebDavBackupDestination | E3BackupDestination;
|
||||
upload: (config: WebDavBackupDestination | E3BackupDestination, archive: Uint8Array, fileName: string) => Promise<BackupUploadResult>;
|
||||
putFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions) => Promise<void>;
|
||||
list: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<RemoteBackupListResult>;
|
||||
download: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
|
||||
deleteFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<void>;
|
||||
exists: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<boolean>;
|
||||
provider: 'webdav' | 's3';
|
||||
config: WebDavBackupDestination | S3BackupDestination;
|
||||
upload: (config: WebDavBackupDestination | S3BackupDestination, archive: Uint8Array, fileName: string) => Promise<BackupUploadResult>;
|
||||
putFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions) => Promise<void>;
|
||||
list: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupListResult>;
|
||||
download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
|
||||
deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>;
|
||||
exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface RemoteBackupTransferSession {
|
||||
@@ -666,16 +666,16 @@ function resolveConfiguredDestinationAdapter(
|
||||
exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath),
|
||||
};
|
||||
}
|
||||
if (destination.type === 'e3') {
|
||||
if (destination.type === 's3') {
|
||||
return {
|
||||
provider: 'e3',
|
||||
config: destination.destination as E3BackupDestination,
|
||||
upload: (config, archive, fileName) => uploadToE3(config as E3BackupDestination, archive, fileName),
|
||||
putFile: (config, relativePath, bytes, options) => putToE3(config as E3BackupDestination, relativePath, bytes, options),
|
||||
list: (config, relativePath) => listE3Entries(config as E3BackupDestination, relativePath),
|
||||
download: (config, relativePath) => downloadFromE3(config as E3BackupDestination, relativePath),
|
||||
deleteFile: (config, relativePath) => deleteFromE3(config as E3BackupDestination, relativePath),
|
||||
exists: (config, relativePath) => existsInE3(config as E3BackupDestination, relativePath),
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -703,7 +703,7 @@ export function createRemoteBackupTransferSession(destination: BackupDestination
|
||||
provider: adapter.provider,
|
||||
remotePath: adapter.provider === 'webdav'
|
||||
? buildJoinedPath((adapter.config as WebDavBackupDestination).remotePath, fileName)
|
||||
: normalizeE3ObjectKey(adapter.config as E3BackupDestination, fileName),
|
||||
: normalizeS3ObjectKey(adapter.config as S3BackupDestination, fileName),
|
||||
};
|
||||
},
|
||||
putFile,
|
||||
|
||||
Reference in New Issue
Block a user