feat: enhance cipher handling with nested object merging and additional fields

This commit is contained in:
shuaiplus
2026-04-16 22:29:55 +08:00
parent 681705ee13
commit 92d1f07998
5 changed files with 60 additions and 4 deletions
+25
View File
@@ -13,6 +13,26 @@ function normalizeOptionalId(value: unknown): string | null {
return normalized ? normalized : null;
}
function mergeCipherNestedObject<T>(
existingValue: T | null | undefined,
incomingValue: unknown
): T | null {
if (incomingValue === undefined) {
return (existingValue ?? null) as T | null;
}
if (incomingValue === null || typeof incomingValue !== 'object' || Array.isArray(incomingValue)) {
return incomingValue as T | null;
}
const existingObject =
existingValue && typeof existingValue === 'object' && !Array.isArray(existingValue)
? (existingValue as Record<string, unknown>)
: {};
return {
...existingObject,
...(incomingValue as Record<string, unknown>),
} as T;
}
async function notifyVaultSyncForRequest(
request: Request,
env: Env,
@@ -302,6 +322,11 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
deletedAt: existingCipher.deletedAt,
};
cipher.login = mergeCipherNestedObject(existingCipher.login, cipherData.login);
cipher.card = mergeCipherNestedObject(existingCipher.card, cipherData.card);
cipher.identity = mergeCipherNestedObject(existingCipher.identity, cipherData.identity);
cipher.secureNote = mergeCipherNestedObject(existingCipher.secureNote, cipherData.secureNote);
cipher.sshKey = mergeCipherNestedObject(existingCipher.sshKey, cipherData.sshKey);
// Custom fields deletion compatibility:
// - Accept both camelCase "fields" and PascalCase "Fields".
@@ -165,7 +165,7 @@ export function websiteIconUrl(host: string): string {
}
export function createEmptyLoginUri(): VaultDraftLoginUri {
return { uri: '', match: null };
return { uri: '', match: null, originalUri: '', extra: {} };
}
export function websiteMatchLabel(value: number | null | undefined): string {
@@ -313,6 +313,10 @@ export function draftFromCipher(cipher: Cipher): VaultDraft {
draft.loginUris = (cipher.login.uris || []).map((x) => ({
uri: x.decUri || x.uri || '',
match: x.match ?? null,
originalUri: x.decUri || x.uri || '',
extra: Object.fromEntries(
Object.entries(x as Record<string, unknown>).filter(([key]) => !['uri', 'match', 'decUri'].includes(key))
),
}));
draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials)
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
+15 -2
View File
@@ -372,12 +372,20 @@ async function encryptUris(
uris: VaultDraft['loginUris'],
enc: Uint8Array,
mac: Uint8Array
): Promise<Array<{ uri: string | null; match: number | null }>> {
const out: Array<{ uri: string | null; match: number | null }> = [];
): Promise<Array<Record<string, unknown>>> {
const out: Array<Record<string, unknown>> = [];
for (const entry of uris || []) {
const trimmed = String(entry?.uri || '').trim();
if (!trimmed) continue;
const preservedExtra =
entry?.extra && typeof entry.extra === 'object'
? { ...entry.extra }
: {};
if (String(entry?.originalUri || '').trim() !== trimmed) {
delete preservedExtra.uriChecksum;
}
out.push({
...preservedExtra,
uri: await encryptTextValue(trimmed, enc, mac),
match: typeof entry?.match === 'number' && Number.isFinite(entry.match) ? entry.match : null,
});
@@ -495,7 +503,12 @@ async function buildCipherPayload(
cipher?.login && Array.isArray((cipher.login as any).fido2Credentials)
? (cipher.login as any).fido2Credentials
: draft.loginFido2Credentials;
const existingLogin =
cipher?.login && typeof cipher.login === 'object'
? { ...(cipher.login as Record<string, unknown>) }
: {};
payload.login = {
...existingLogin,
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac),
+5 -1
View File
@@ -170,10 +170,14 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
return {
uri,
match: typeof matchRaw === 'number' && Number.isFinite(matchRaw) ? matchRaw : null,
originalUri: uri,
extra: Object.fromEntries(
Object.entries(row).filter(([key]) => !['uri', 'match'].includes(key))
),
};
})
.filter((u) => !!u.uri);
draft.loginUris = uris.length ? uris : [{ uri: '', match: null }];
draft.loginUris = uris.length ? uris : [{ uri: '', match: null, originalUri: '', extra: {} }];
draft.loginFido2Credentials = Array.isArray(login.fido2Credentials)
? login.fido2Credentials.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
: [];
+10
View File
@@ -29,13 +29,18 @@ export interface Folder {
export interface CipherLoginUri {
uri?: string | null;
uriChecksum?: string | null;
match?: number | null;
response?: unknown | null;
decUri?: string;
[key: string]: unknown;
}
export interface VaultDraftLoginUri {
uri: string;
match: number | null;
originalUri?: string;
extra?: Record<string, unknown>;
}
export interface CipherAttachment {
@@ -60,9 +65,14 @@ export interface CipherLogin {
totp?: string | null;
uris?: CipherLoginUri[] | null;
fido2Credentials?: CipherLoginPasskey[] | null;
autofillOnPageLoad?: boolean | null;
uri?: string | null;
passwordRevisionDate?: string | null;
response?: unknown | null;
decUsername?: string;
decPassword?: string;
decTotp?: string;
[key: string]: unknown;
}
export interface CipherCard {