feat: add validFolderIds support for cipher responses and update folder handling in storage

This commit is contained in:
shuaiplus
2026-06-28 19:43:27 +08:00
parent a5ad16ac27
commit 82f968e51f
3 changed files with 26 additions and 7 deletions
+10 -2
View File
@@ -32,6 +32,7 @@ import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'
// attachments, import/export, and current official clients. // attachments, import/export, and current official clients.
export interface CipherResponseOptions { export interface CipherResponseOptions {
preserveRepairableUris?: boolean; preserveRepairableUris?: boolean;
validFolderIds?: ReadonlySet<string>;
} }
export function shouldPreserveRepairableCipherUris(request: Request): boolean { export function shouldPreserveRepairableCipherUris(request: Request): boolean {
@@ -48,6 +49,12 @@ function normalizeOptionalId(value: unknown): string | null {
return normalized ? normalized : null; return normalized ? normalized : null;
} }
function normalizeResponseFolderId(folderId: unknown, validFolderIds?: ReadonlySet<string>): string | null {
const normalized = normalizeOptionalId(folderId);
if (!normalized) return null;
return validFolderIds && !validFolderIds.has(normalized) ? null : normalized;
}
function readBooleanOrFallback(value: unknown, fallback: boolean): boolean { function readBooleanOrFallback(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback; return typeof value === 'boolean' ? value : fallback;
} }
@@ -727,7 +734,7 @@ export function cipherToResponse(
// Pass through ALL stored cipher fields (known + unknown) // Pass through ALL stored cipher fields (known + unknown)
...passthrough, ...passthrough,
// Server-computed / enforced fields (always override) // Server-computed / enforced fields (always override)
folderId: normalizeOptionalId(cipher.folderId), folderId: normalizeResponseFolderId(cipher.folderId, options.validFolderIds),
type: Number(cipher.type) || 1, type: Number(cipher.type) || 1,
organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null), organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false), organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
@@ -785,9 +792,10 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
const attachmentsByCipher = await storage.getAttachmentsByCipherIds( const attachmentsByCipher = await storage.getAttachmentsByCipherIds(
filteredCiphers.map((cipher) => cipher.id) filteredCiphers.map((cipher) => cipher.id)
); );
const validFolderIds = new Set((await storage.getAllFolders(userId)).map((folder) => folder.id));
// Build responses only for the current page to keep pagination cheap. // Build responses only for the current page to keep pagination cheap.
const responseOptions = cipherResponseOptionsForRequest(request); const responseOptions = { ...cipherResponseOptionsForRequest(request), validFolderIds };
const cipherResponses: CipherResponse[] = []; const cipherResponses: CipherResponse[] = [];
for (const cipher of filteredCiphers) { for (const cipher of filteredCiphers) {
const attachments = attachmentsByCipher.get(cipher.id) || []; const attachments = attachmentsByCipher.get(cipher.id) || [];
+2 -1
View File
@@ -88,12 +88,13 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
.map(buildWebAuthnPrfOption) .map(buildWebAuthnPrfOption)
.filter((option): option is NonNullable<typeof option> => !!option); .filter((option): option is NonNullable<typeof option> => !!option);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null); const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null);
const validFolderIds = new Set(folders.map((folder) => folder.id));
const profile: ProfileResponse = buildProfileResponse(user, env); const profile: ProfileResponse = buildProfileResponse(user, env);
const cipherResponses: CipherResponse[] = []; const cipherResponses: CipherResponse[] = [];
for (const cipher of ciphers) { for (const cipher of ciphers) {
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], { preserveRepairableUris }); const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], { preserveRepairableUris, validFolderIds });
if (isCipherResponseSyncCompatible(response)) { if (isCipherResponseSyncCompatible(response)) {
cipherResponses.push(response); cipherResponses.push(response);
} }
+14 -4
View File
@@ -44,9 +44,14 @@ export async function clearFolderFromCiphers(
`UPDATE ciphers `UPDATE ciphers
SET folder_id = NULL, updated_at = ?, SET folder_id = NULL, updated_at = ?,
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate') data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND folder_id = ?` WHERE user_id = ?
AND (
folder_id = ?
OR json_extract(data, '$.folderId') = ?
OR json_extract(data, '$.folder_id') = ?
)`
) )
.bind(now, userId, folderId) .bind(now, userId, folderId, folderId, folderId)
.run(); .run();
} }
@@ -71,9 +76,14 @@ export async function bulkDeleteFolders(
`UPDATE ciphers `UPDATE ciphers
SET folder_id = NULL, updated_at = ?, SET folder_id = NULL, updated_at = ?,
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate') data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND folder_id IN (${placeholders})` WHERE user_id = ?
AND (
folder_id IN (${placeholders})
OR json_extract(data, '$.folderId') IN (${placeholders})
OR json_extract(data, '$.folder_id') IN (${placeholders})
)`
) )
.bind(now, userId, ...chunk) .bind(now, userId, ...chunk, ...chunk, ...chunk)
.run(); .run();
await db await db