feat: Implement admin backup export and import functionality

- Added new endpoints for exporting and importing instance-level backups.
- Introduced user interface components for backup management in the web app.
- Enhanced import/export logic to handle attachments and provide detailed summaries.
- Updated localization files to include new strings related to backup features.
- Improved styling for backup-related UI elements.
This commit is contained in:
shuaiplus
2026-03-08 13:36:51 +08:00
parent 206b0be566
commit eeb477b84c
16 changed files with 1382 additions and 217 deletions
+110 -29
View File
@@ -15,31 +15,82 @@ function getAliasedProp(source: any, aliases: string[]): { present: boolean; val
return { present: false, value: undefined };
}
// Android 2026.2.0 expects fido2Credentials[].counter to be a string.
export function normalizeCipherLoginForCompatibility(login: any): any {
if (!login || typeof login !== 'object') return login ?? null;
function looksLikeCipherString(value: unknown): boolean {
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
}
const fido2 = Array.isArray(login.fido2Credentials)
? login.fido2Credentials.map((cred: any) => {
if (!cred || typeof cred !== 'object') return cred;
const rawCounter = cred.counter;
const counter =
rawCounter === null || rawCounter === undefined
? '0'
: String(rawCounter);
return {
...cred,
counter,
};
})
: login.fido2Credentials;
export function shouldOmitPasskeysForResponse(request: Request | null | undefined): boolean {
const userAgent = String(request?.headers.get('user-agent') || '').toLowerCase();
if (!userAgent) return false;
// Temporary compatibility fallback:
// mobile clients expect official EncString payloads for most FIDO2 fields.
// Keep passkeys available everywhere, but suppress only legacy malformed data
// for mobile clients so newly-saved credentials can flow through unchanged.
return (
userAgent.includes('android') ||
userAgent.includes('iphone') ||
userAgent.includes('ipad') ||
userAgent.includes('ios')
);
}
export function normalizeCipherLoginForStorage(login: any): any {
if (!login || typeof login !== 'object') return login ?? null;
return {
...login,
fido2Credentials: fido2,
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
};
}
export function normalizeCipherLoginForCompatibility(
login: any,
options?: { omitFido2Credentials?: boolean }
): any {
const normalized = normalizeCipherLoginForStorage(login);
if (!normalized || typeof normalized !== 'object') return normalized ?? null;
if (!options?.omitFido2Credentials) return normalized;
const credentials = Array.isArray(normalized.fido2Credentials) ? normalized.fido2Credentials : null;
if (!credentials?.length) return normalized;
const hasMalformedCredential = credentials.some((credential: any) => {
if (!credential || typeof credential !== 'object') return true;
const requiredEncryptedFields = [
credential.credentialId,
credential.keyType,
credential.keyAlgorithm,
credential.keyCurve,
credential.keyValue,
credential.rpId,
credential.counter,
credential.discoverable,
];
const optionalEncryptedFields = [
credential.userHandle,
credential.userName,
credential.rpName,
credential.userDisplayName,
];
if (requiredEncryptedFields.some((value) => !looksLikeCipherString(value))) {
return true;
}
if (optionalEncryptedFields.some((value) => value != null && !looksLikeCipherString(value))) {
return true;
}
return false;
});
return hasMalformedCredential
? {
...normalized,
fido2Credentials: null,
}
: normalized;
}
// Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads.
// Keep legacy alias "fingerprint" in parallel for older web payloads.
export function normalizeCipherSshKeyForCompatibility(sshKey: any): any {
@@ -81,10 +132,14 @@ export function formatAttachments(attachments: Attachment[]): any[] | null {
// Uses opaque passthrough: spreads ALL stored fields (including unknown/future ones),
// then overlays server-computed fields. This ensures new Bitwarden client fields
// survive a round-trip without code changes.
export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse {
export function cipherToResponse(
cipher: Cipher,
attachments: Attachment[] = [],
options?: { omitFido2Credentials?: boolean }
): CipherResponse {
// Strip internal-only fields that must not appear in the API response
const { userId, createdAt, updatedAt, deletedAt, ...passthrough } = cipher;
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null);
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null, options);
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
return {
@@ -119,6 +174,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
const url = new URL(request.url);
const includeDeleted = url.searchParams.get('deleted') === 'true';
const pagination = parsePagination(url);
const omitFido2Credentials = shouldOmitPasskeysForResponse(request);
let filteredCiphers: Cipher[];
let continuationToken: string | null = null;
@@ -145,7 +201,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
const cipherResponses = [];
for (const cipher of filteredCiphers) {
const attachments = attachmentsByCipher.get(cipher.id) || [];
cipherResponses.push(cipherToResponse(cipher, attachments));
cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials }));
}
return jsonResponse({
@@ -165,7 +221,11 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
}
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse(cipherToResponse(cipher, attachments));
return jsonResponse(
cipherToResponse(cipher, attachments, {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
);
}
async function verifyFolderOwnership(storage: StorageService, folderId: string | null | undefined, userId: string): Promise<boolean> {
@@ -204,7 +264,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
updatedAt: now,
deletedAt: null,
};
cipher.login = normalizeCipherLoginForCompatibility(cipher.login);
cipher.login = normalizeCipherLoginForStorage(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
@@ -218,7 +278,12 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
await storage.saveCipher(cipher);
await storage.updateRevisionDate(userId);
return jsonResponse(cipherToResponse(cipher), 200);
return jsonResponse(
cipherToResponse(cipher, [], {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
}),
200
);
}
// PUT /api/ciphers/:id
@@ -256,7 +321,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
updatedAt: new Date().toISOString(),
deletedAt: existingCipher.deletedAt,
};
cipher.login = normalizeCipherLoginForCompatibility(cipher.login);
cipher.login = normalizeCipherLoginForStorage(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
// Custom fields deletion compatibility:
@@ -279,7 +344,11 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
await storage.saveCipher(cipher);
await storage.updateRevisionDate(userId);
return jsonResponse(cipherToResponse(cipher));
return jsonResponse(
cipherToResponse(cipher, [], {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
);
}
// DELETE /api/ciphers/:id
@@ -297,7 +366,11 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
await storage.saveCipher(cipher);
await storage.updateRevisionDate(userId);
return jsonResponse(cipherToResponse(cipher));
return jsonResponse(
cipherToResponse(cipher, [], {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
);
}
// DELETE /api/ciphers/:id (compat mode)
@@ -355,7 +428,11 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
await storage.saveCipher(cipher);
await storage.updateRevisionDate(userId);
return jsonResponse(cipherToResponse(cipher));
return jsonResponse(
cipherToResponse(cipher, [], {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
);
}
// PUT /api/ciphers/:id/partial - Update only favorite/folderId
@@ -389,7 +466,11 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
await storage.saveCipher(cipher);
await storage.updateRevisionDate(userId);
return jsonResponse(cipherToResponse(cipher));
return jsonResponse(
cipherToResponse(cipher, [], {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
);
}
// POST/PUT /api/ciphers/move - Bulk move to folder