mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-21 05:10:41 +00:00
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:
+110
-29
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user