feat: add support for new cipher properties and enhance import functionality

This commit is contained in:
shuaiplus
2026-04-18 03:44:17 +08:00
parent 38b33df719
commit 08414d7cf2
3 changed files with 143 additions and 76 deletions
+82 -33
View File
@@ -1,4 +1,15 @@
import { Env, Cipher, CipherResponse, Attachment } from '../types'; import {
Env,
Cipher,
CipherCard,
CipherIdentity,
CipherLogin,
CipherResponse,
CipherSecureNote,
CipherSshKey,
Attachment,
PasswordHistory,
} from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { notifyUserVaultSync } from '../durable/notifications-hub'; import { notifyUserVaultSync } from '../durable/notifications-hub';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
@@ -13,26 +24,6 @@ function normalizeOptionalId(value: unknown): string | null {
return normalized ? normalized : 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( async function notifyVaultSyncForRequest(
request: Request, request: Request,
env: Env, env: Env,
@@ -52,6 +43,10 @@ function getAliasedProp(source: any, aliases: string[]): { present: boolean; val
return { present: false, value: undefined }; return { present: false, value: undefined };
} }
function readCipherProp<T = unknown>(source: any, aliases: string[]): { present: boolean; value: T | undefined } {
return getAliasedProp(source, aliases) as { present: boolean; value: T | undefined };
}
function normalizeCipherTimestamp(value: unknown): string | null { function normalizeCipherTimestamp(value: unknown): string | null {
if (value == null || value === '') return null; if (value == null || value === '') return null;
const parsed = new Date(String(value)); const parsed = new Date(String(value));
@@ -64,6 +59,19 @@ function readCipherArchivedAt(source: any, fallback: string | null = null): stri
return archived.present ? normalizeCipherTimestamp(archived.value) : fallback; return archived.present ? normalizeCipherTimestamp(archived.value) : fallback;
} }
function readCipherRevisionDate(source: any): string | null {
const revision = getAliasedProp(source, ['lastKnownRevisionDate', 'LastKnownRevisionDate']);
return revision.present ? normalizeCipherTimestamp(revision.value) : null;
}
function isStaleCipherUpdate(existingUpdatedAt: string, clientRevisionDate: string | null): boolean {
if (!clientRevisionDate) return false;
const existingTs = Date.parse(existingUpdatedAt);
const clientTs = Date.parse(clientRevisionDate);
if (Number.isNaN(existingTs) || Number.isNaN(clientTs)) return false;
return existingTs - clientTs > 1000;
}
function syncCipherComputedAliases(cipher: Cipher): Cipher { function syncCipherComputedAliases(cipher: Cipher): Cipher {
cipher.archivedDate = cipher.archivedAt ?? null; cipher.archivedDate = cipher.archivedAt ?? null;
cipher.deletedDate = cipher.deletedAt ?? null; cipher.deletedDate = cipher.deletedAt ?? null;
@@ -151,8 +159,8 @@ export function cipherToResponse(
// Server-computed / enforced fields (always override) // Server-computed / enforced fields (always override)
folderId: normalizeOptionalId(cipher.folderId), folderId: normalizeOptionalId(cipher.folderId),
type: Number(cipher.type) || 1, type: Number(cipher.type) || 1,
organizationId: null, organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
organizationUseTotp: false, organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
creationDate: createdAt, creationDate: createdAt,
revisionDate: updatedAt, revisionDate: updatedAt,
deletedDate: deletedAt, deletedDate: deletedAt,
@@ -163,12 +171,12 @@ export function cipherToResponse(
delete: true, delete: true,
restore: true, restore: true,
}, },
object: 'cipher', object: 'cipherDetails',
collectionIds: [], collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [],
attachments: formatAttachments(attachments), attachments: formatAttachments(attachments),
login: normalizedLogin, login: normalizedLogin,
sshKey: normalizedSshKey, sshKey: normalizedSshKey,
encryptedFor: null, encryptedFor: (passthrough as any).encryptedFor ?? null,
}; };
} }
@@ -251,6 +259,14 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
// Handle nested cipher object (from some clients) // Handle nested cipher object (from some clients)
// Android client sends PascalCase "Cipher" for organization ciphers // Android client sends PascalCase "Cipher" for organization ciphers
const cipherData = body.Cipher || body.cipher || body; const cipherData = body.Cipher || body.cipher || body;
const createFolderId = readCipherProp<string | null>(cipherData, ['folderId', 'FolderId']);
const createKey = readCipherProp<string | null>(cipherData, ['key', 'Key']);
const createLogin = readCipherProp<CipherLogin | null>(cipherData, ['login', 'Login']);
const createCard = readCipherProp<CipherCard | null>(cipherData, ['card', 'Card']);
const createIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
const createSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
const createPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
const now = new Date().toISOString(); const now = new Date().toISOString();
// Opaque passthrough: spread ALL client fields to preserve unknown/future ones, // Opaque passthrough: spread ALL client fields to preserve unknown/future ones,
@@ -268,6 +284,14 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
archivedAt: readCipherArchivedAt(cipherData, null), archivedAt: readCipherArchivedAt(cipherData, null),
deletedAt: null, deletedAt: null,
}; };
cipher.folderId = createFolderId.present ? normalizeOptionalId(createFolderId.value) : normalizeOptionalId(cipher.folderId);
cipher.key = createKey.present ? (createKey.value ?? null) : (cipher.key ?? null);
cipher.login = createLogin.present ? (createLogin.value ?? null) : (cipher.login ?? null);
cipher.card = createCard.present ? (createCard.value ?? null) : (cipher.card ?? null);
cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null);
cipher.secureNote = createSecureNote.present ? (createSecureNote.value ?? null) : (cipher.secureNote ?? null);
cipher.sshKey = createSshKey.present ? (createSshKey.value ?? null) : (cipher.sshKey ?? null);
cipher.passwordHistory = createPasswordHistory.present ? (createPasswordHistory.value ?? null) : (cipher.passwordHistory ?? null);
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']); const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null); cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
normalizeCipherForStorage(cipher); normalizeCipherForStorage(cipher);
@@ -307,6 +331,21 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
// Handle nested cipher object // Handle nested cipher object
// Android client sends PascalCase "Cipher" for organization ciphers // Android client sends PascalCase "Cipher" for organization ciphers
const cipherData = body.Cipher || body.cipher || body; const cipherData = body.Cipher || body.cipher || body;
const incomingFolderId = readCipherProp<string | null>(cipherData, ['folderId', 'FolderId']);
const incomingKey = readCipherProp<string | null>(cipherData, ['key', 'Key']);
const incomingLogin = readCipherProp<CipherLogin | null>(cipherData, ['login', 'Login']);
const incomingCard = readCipherProp<CipherCard | null>(cipherData, ['card', 'Card']);
const incomingIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
const incomingSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
const incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
const incomingRevisionDate = readCipherRevisionDate(cipherData);
if (isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) {
return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
}
const nextType = Number(cipherData.type) || existingCipher.type;
// Opaque passthrough: merge existing stored data with ALL incoming client fields. // Opaque passthrough: merge existing stored data with ALL incoming client fields.
// Unknown/future fields from the client are preserved; server-controlled fields are protected. // Unknown/future fields from the client are preserved; server-controlled fields are protected.
@@ -316,7 +355,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
// Server-controlled fields (never from client) // Server-controlled fields (never from client)
id: existingCipher.id, id: existingCipher.id,
userId: existingCipher.userId, userId: existingCipher.userId,
type: Number(cipherData.type) || existingCipher.type, type: nextType,
favorite: cipherData.favorite ?? existingCipher.favorite, favorite: cipherData.favorite ?? existingCipher.favorite,
reprompt: cipherData.reprompt ?? existingCipher.reprompt, reprompt: cipherData.reprompt ?? existingCipher.reprompt,
createdAt: existingCipher.createdAt, createdAt: existingCipher.createdAt,
@@ -324,11 +363,20 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null), archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
deletedAt: existingCipher.deletedAt, deletedAt: existingCipher.deletedAt,
}; };
cipher.login = mergeCipherNestedObject(existingCipher.login, cipherData.login); if (incomingFolderId.present) {
cipher.card = mergeCipherNestedObject(existingCipher.card, cipherData.card); cipher.folderId = normalizeOptionalId(incomingFolderId.value);
cipher.identity = mergeCipherNestedObject(existingCipher.identity, cipherData.identity); }
cipher.secureNote = mergeCipherNestedObject(existingCipher.secureNote, cipherData.secureNote); if (incomingKey.present) {
cipher.sshKey = mergeCipherNestedObject(existingCipher.sshKey, cipherData.sshKey); cipher.key = incomingKey.value ?? null;
}
cipher.login = nextType === 1 ? (incomingLogin.present ? (incomingLogin.value ?? null) : (existingCipher.login ?? null)) : null;
cipher.secureNote = nextType === 2 ? (incomingSecureNote.present ? (incomingSecureNote.value ?? null) : (existingCipher.secureNote ?? null)) : null;
cipher.card = nextType === 3 ? (incomingCard.present ? (incomingCard.value ?? null) : (existingCipher.card ?? null)) : null;
cipher.identity = nextType === 4 ? (incomingIdentity.present ? (incomingIdentity.value ?? null) : (existingCipher.identity ?? null)) : null;
cipher.sshKey = nextType === 5 ? (incomingSshKey.present ? (incomingSshKey.value ?? null) : (existingCipher.sshKey ?? null)) : null;
if (incomingPasswordHistory.present) {
cipher.passwordHistory = incomingPasswordHistory.value ?? null;
}
// Custom fields deletion compatibility: // Custom fields deletion compatibility:
// - Accept both camelCase "fields" and PascalCase "Fields". // - Accept both camelCase "fields" and PascalCase "Fields".
@@ -351,9 +399,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate); await notifyVaultSyncForRequest(request, env, userId, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, []) cipherToResponse(cipher, attachments)
); );
} }
+60 -43
View File
@@ -82,6 +82,16 @@ function bindNull(v: any): any {
return v === undefined ? null : v; return v === undefined ? null : v;
} }
function readAliasedImportProp<T = unknown>(source: any, aliases: string[]): T | undefined {
if (!source || typeof source !== 'object') return undefined;
for (const key of aliases) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
return source[key] as T;
}
}
return undefined;
}
async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> { async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> {
for (let i = 0; i < statements.length; i += chunkSize) { for (let i = 0; i < statements.length; i += chunkSize) {
const chunk = statements.slice(i, i + chunkSize); const chunk = statements.slice(i, i + chunkSize);
@@ -158,9 +168,16 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = []; const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
for (let i = 0; i < ciphers.length; i++) { for (let i = 0; i < ciphers.length; i++) {
const c = ciphers[i]; const c = ciphers[i];
const folderId = cipherFolderMap.get(i) || c.folderId || null; const folderId = cipherFolderMap.get(i) || readAliasedImportProp<string | null>(c, ['folderId', 'FolderId']) || null;
const sourceIdRaw = String(c?.id ?? '').trim(); const sourceIdRaw = String(c?.id ?? '').trim();
const sourceId = sourceIdRaw || null; const sourceId = sourceIdRaw || null;
const login = readAliasedImportProp<any | null>(c, ['login', 'Login']);
const card = readAliasedImportProp<any | null>(c, ['card', 'Card']);
const identity = readAliasedImportProp<any | null>(c, ['identity', 'Identity']);
const secureNote = readAliasedImportProp<any | null>(c, ['secureNote', 'SecureNote']);
const fields = readAliasedImportProp<any[] | null>(c, ['fields', 'Fields']);
const passwordHistory = readAliasedImportProp<any[] | null>(c, ['passwordHistory', 'PasswordHistory']);
const key = readAliasedImportProp<string | null>(c, ['key', 'Key']);
const cipher: Cipher = { const cipher: Cipher = {
...c, ...c,
@@ -171,64 +188,64 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
name: c.name ?? 'Untitled', name: c.name ?? 'Untitled',
notes: c.notes ?? null, notes: c.notes ?? null,
favorite: c.favorite ?? false, favorite: c.favorite ?? false,
login: c.login ? { login: login ? {
...c.login, ...login,
username: c.login.username ?? null, username: login.username ?? null,
password: c.login.password ?? null, password: login.password ?? null,
uris: c.login.uris?.map(u => ({ uris: login.uris?.map((u: any) => ({
...u, ...u,
uri: u.uri ?? null, uri: u.uri ?? null,
uriChecksum: null, uriChecksum: null,
match: u.match ?? null, match: u.match ?? null,
})) || null, })) || null,
totp: c.login.totp ?? null, totp: login.totp ?? null,
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null, autofillOnPageLoad: login.autofillOnPageLoad ?? null,
fido2Credentials: Array.isArray(c.login.fido2Credentials) ? c.login.fido2Credentials : null, fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
uri: c.login.uri ?? null, uri: login.uri ?? null,
passwordRevisionDate: c.login.passwordRevisionDate ?? null, passwordRevisionDate: login.passwordRevisionDate ?? null,
} : null, } : null,
card: c.card ? { card: card ? {
...c.card, ...card,
cardholderName: c.card.cardholderName ?? null, cardholderName: card.cardholderName ?? null,
brand: c.card.brand ?? null, brand: card.brand ?? null,
number: c.card.number ?? null, number: card.number ?? null,
expMonth: c.card.expMonth ?? null, expMonth: card.expMonth ?? null,
expYear: c.card.expYear ?? null, expYear: card.expYear ?? null,
code: c.card.code ?? null, code: card.code ?? null,
} : null, } : null,
identity: c.identity ? { identity: identity ? {
...c.identity, ...identity,
title: c.identity.title ?? null, title: identity.title ?? null,
firstName: c.identity.firstName ?? null, firstName: identity.firstName ?? null,
middleName: c.identity.middleName ?? null, middleName: identity.middleName ?? null,
lastName: c.identity.lastName ?? null, lastName: identity.lastName ?? null,
address1: c.identity.address1 ?? null, address1: identity.address1 ?? null,
address2: c.identity.address2 ?? null, address2: identity.address2 ?? null,
address3: c.identity.address3 ?? null, address3: identity.address3 ?? null,
city: c.identity.city ?? null, city: identity.city ?? null,
state: c.identity.state ?? null, state: identity.state ?? null,
postalCode: c.identity.postalCode ?? null, postalCode: identity.postalCode ?? null,
country: c.identity.country ?? null, country: identity.country ?? null,
company: c.identity.company ?? null, company: identity.company ?? null,
email: c.identity.email ?? null, email: identity.email ?? null,
phone: c.identity.phone ?? null, phone: identity.phone ?? null,
ssn: c.identity.ssn ?? null, ssn: identity.ssn ?? null,
username: c.identity.username ?? null, username: identity.username ?? null,
passportNumber: c.identity.passportNumber ?? null, passportNumber: identity.passportNumber ?? null,
licenseNumber: c.identity.licenseNumber ?? null, licenseNumber: identity.licenseNumber ?? null,
} : null, } : null,
secureNote: c.secureNote ?? null, secureNote: secureNote ?? null,
fields: c.fields?.map(f => ({ fields: fields?.map((f: any) => ({
...f, ...f,
name: f.name ?? null, name: f.name ?? null,
value: f.value ?? null, value: f.value ?? null,
type: f.type, type: f.type,
linkedId: f.linkedId ?? null, linkedId: f.linkedId ?? null,
})) || null, })) || null,
passwordHistory: c.passwordHistory ?? null, passwordHistory: passwordHistory ?? null,
reprompt: c.reprompt ?? 0, reprompt: c.reprompt ?? 0,
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null), sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
key: (c as any).key ?? null, key: key ?? null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
archivedAt: null, archivedAt: null,
+1
View File
@@ -104,6 +104,7 @@ function buildConfigResponse(origin: string) {
_icon_service_url: buildIconServiceTemplate(origin), _icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin), _icon_service_csp: buildIconServiceCsp(origin),
featureStates: { featureStates: {
'cipher-key-encryption': true,
'duo-redirect': true, 'duo-redirect': true,
'email-verification': true, 'email-verification': true,
'pm-19051-send-email-verification': false, 'pm-19051-send-email-verification': false,