Files
nodewarden/src/handlers/ciphers.ts
T
shuaiplus 0c00114cc8 Update localization files for backup destinations and API client credentials
- Changed references from E3 to S3 in Russian, Simplified Chinese, and Traditional Chinese localization files.
- Updated the corresponding keys and descriptions to reflect the change in backup destination protocols.
- Improved the Vite configuration to dynamically match locale files, simplifying the code for locale handling.
2026-04-30 15:03:05 +08:00

919 lines
33 KiB
TypeScript

import {
Env,
Cipher,
CipherCard,
CipherIdentity,
CipherLogin,
CipherResponse,
CipherSecureNote,
CipherSshKey,
Attachment,
PasswordHistory,
} from '../types';
import { StorageService } from '../services/storage';
import { notifyUserVaultSync } from '../durable/notifications-hub';
import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments';
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
import { readActingDeviceIdentifier } from '../utils/device';
function normalizeOptionalId(value: unknown): string | null {
if (value == null) return null;
const normalized = String(value).trim();
return normalized ? normalized : null;
}
function notifyVaultSyncForRequest(
request: Request,
env: Env,
userId: string,
revisionDate: string
): void {
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
function getAliasedProp(source: any, aliases: string[]): { present: boolean; value: any } {
if (!source || typeof source !== 'object') return { present: false, value: undefined };
for (const key of aliases) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
return { present: true, value: source[key] };
}
}
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 {
if (value == null || value === '') return null;
const parsed = new Date(String(value));
if (Number.isNaN(parsed.getTime())) return null;
return parsed.toISOString();
}
function readCipherArchivedAt(source: any, fallback: string | null = null): string | null {
const archived = getAliasedProp(source, ['archivedAt', 'ArchivedAt', 'archivedDate', 'ArchivedDate']);
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 {
cipher.archivedDate = cipher.archivedAt ?? null;
cipher.deletedDate = cipher.deletedAt ?? null;
return cipher;
}
function isValidEncString(value: unknown): value is string {
if (typeof value !== 'string') return false;
const trimmed = value.trim();
const dot = trimmed.indexOf('.');
if (dot <= 0) return false;
const type = Number(trimmed.slice(0, dot));
if (!Number.isInteger(type) || type < 0) return false;
const parts = trimmed.slice(dot + 1).split('|');
if (parts.some((part) => part.length === 0)) return false;
// Bitwarden's legacy symmetric EncString variants require IV + data,
// while the authenticated AES-CBC-HMAC variant requires IV + data + MAC.
if (type === 0 || type === 1 || type === 4) return parts.length >= 2;
if (type === 2) return parts.length === 3;
// Keep newer one-part formats, such as COSE Encrypt0, future-compatible.
return parts.length >= 1;
}
function optionalEncString(value: unknown): string | null {
if (value == null || value === '') return null;
return isValidEncString(value) ? value.trim() : null;
}
function sanitizeEncryptedObject<T extends Record<string, any>>(
source: T | null | undefined,
encryptedKeys: readonly string[]
): T | null {
if (!source || typeof source !== 'object') return source ?? null;
const next: Record<string, any> = { ...source };
for (const key of encryptedKeys) {
if (!Object.prototype.hasOwnProperty.call(next, key)) continue;
next[key] = optionalEncString(next[key]);
}
return next as T;
}
function normalizeCipherForStorage(cipher: Cipher): Cipher {
cipher.login = normalizeCipherLoginForStorage(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
cipher.folderId = normalizeOptionalId(cipher.folderId);
const hasArchivedAt = Object.prototype.hasOwnProperty.call(cipher as object, 'archivedAt');
cipher.archivedAt = hasArchivedAt
? normalizeCipherTimestamp(cipher.archivedAt) ?? null
: normalizeCipherTimestamp(cipher.archivedDate) ?? null;
return syncCipherComputedAliases(cipher);
}
export function normalizeCipherLoginForStorage(login: any): any {
if (!login || typeof login !== 'object') return login ?? null;
return {
...login,
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
};
}
export function normalizeCipherLoginForCompatibility(login: any): any {
const normalized = normalizeCipherLoginForStorage(login);
if (!normalized || typeof normalized !== 'object') return normalized ?? null;
const next = sanitizeEncryptedObject(normalized, ['username', 'password', 'totp', 'uri']);
if (!next) return null;
next.uris = Array.isArray(next.uris)
? next.uris
.map((uri: any) => sanitizeEncryptedObject(uri, ['uri', 'uriChecksum']))
.filter((uri: any) => !!uri && (uri.uri || uri.uriChecksum || uri.match != null))
: null;
next.fido2Credentials = normalizeFido2CredentialsForCompatibility(next.fido2Credentials);
return next;
}
function normalizeFido2CredentialsForCompatibility(credentials: any): any[] | null {
if (!Array.isArray(credentials) || credentials.length === 0) return null;
const requiredEncryptedKeys = [
'credentialId',
'keyType',
'keyAlgorithm',
'keyCurve',
'keyValue',
'rpId',
'counter',
'discoverable',
];
const optionalEncryptedKeys = ['userHandle', 'userName', 'rpName', 'userDisplayName'];
const out: any[] = [];
for (const credential of credentials) {
if (!credential || typeof credential !== 'object') continue;
const next: Record<string, any> = { ...credential };
let valid = true;
for (const key of requiredEncryptedKeys) {
if (!isValidEncString(next[key])) {
valid = false;
break;
}
next[key] = String(next[key]).trim();
}
if (!valid) continue;
for (const key of optionalEncryptedKeys) {
if (Object.prototype.hasOwnProperty.call(next, key)) {
next[key] = optionalEncString(next[key]);
}
}
out.push(next);
}
return out.length ? out : null;
}
// 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 {
if (!sshKey || typeof sshKey !== 'object') return sshKey ?? null;
const candidate =
sshKey.keyFingerprint !== undefined && sshKey.keyFingerprint !== null
? sshKey.keyFingerprint
: sshKey.fingerprint;
const normalizedFingerprint =
candidate === undefined || candidate === null
? ''
: String(candidate);
if (
!isValidEncString(sshKey.privateKey) ||
!isValidEncString(sshKey.publicKey) ||
!isValidEncString(normalizedFingerprint)
) {
return null;
}
return {
...sshKey,
privateKey: String(sshKey.privateKey).trim(),
publicKey: String(sshKey.publicKey).trim(),
keyFingerprint: normalizedFingerprint,
fingerprint: normalizedFingerprint,
};
}
// Format attachments for API response
export function formatAttachments(attachments: Attachment[]): any[] | null {
if (attachments.length === 0) return null;
const formatted = attachments
.filter((a) => isValidEncString(a.fileName))
.map(a => ({
id: a.id,
fileName: a.fileName.trim(),
// Bitwarden clients decode attachment size as string in cipher payloads.
size: String(Number(a.size) || 0),
sizeName: a.sizeName,
key: optionalEncString(a.key),
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`, // Android requires non-null url!
object: 'attachment',
}));
return formatted.length ? formatted : null;
}
function normalizeCipherFieldsForCompatibility(fields: any): any[] | null {
if (!Array.isArray(fields) || fields.length === 0) return null;
const out = fields
.map((field: any) => {
if (!field || typeof field !== 'object') return null;
return {
...field,
name: optionalEncString(field.name),
value: optionalEncString(field.value),
type: Number(field.type) || 0,
linkedId: field.linkedId ?? null,
};
})
.filter(Boolean);
return out.length ? out : null;
}
function normalizePasswordHistoryForCompatibility(passwordHistory: any): PasswordHistory[] | null {
if (!Array.isArray(passwordHistory) || passwordHistory.length === 0) return null;
const out = passwordHistory
.filter((entry: any) => entry && typeof entry === 'object' && isValidEncString(entry.password))
.map((entry: any) => ({
...entry,
password: String(entry.password).trim(),
lastUsedDate: normalizeCipherTimestamp(entry.lastUsedDate) ?? new Date().toISOString(),
}));
return out.length ? out : null;
}
export function isCipherResponseSyncCompatible(cipher: CipherResponse): boolean {
return isValidEncString(cipher.name);
}
// Convert internal cipher to API response format.
// 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 {
// Strip internal-only fields that must not appear in the API response
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null);
const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code']);
const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [
'title',
'firstName',
'middleName',
'lastName',
'address1',
'address2',
'address3',
'city',
'state',
'postalCode',
'country',
'company',
'email',
'phone',
'ssn',
'username',
'passportNumber',
'licenseNumber',
]);
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
return {
// Pass through ALL stored cipher fields (known + unknown)
...passthrough,
// Server-computed / enforced fields (always override)
folderId: normalizeOptionalId(cipher.folderId),
type: Number(cipher.type) || 1,
organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
creationDate: createdAt,
revisionDate: updatedAt,
deletedDate: deletedAt,
archivedDate: archivedAt ?? null,
edit: true,
viewPassword: true,
permissions: {
delete: true,
restore: true,
},
object: 'cipherDetails',
collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [],
attachments: formatAttachments(attachments),
name: isValidEncString(cipher.name) ? cipher.name.trim() : cipher.name,
notes: optionalEncString(cipher.notes),
login: normalizedLogin,
card: normalizedCard,
identity: normalizedIdentity,
fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields),
passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory),
sshKey: normalizedSshKey,
key: optionalEncString(cipher.key),
encryptedFor: (passthrough as any).encryptedFor ?? null,
};
}
// GET /api/ciphers
export async function handleGetCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const url = new URL(request.url);
const includeDeleted = url.searchParams.get('deleted') === 'true';
const pagination = parsePagination(url);
let filteredCiphers: Cipher[];
let continuationToken: string | null = null;
if (pagination) {
const pageRows = await storage.getCiphersPage(
userId,
includeDeleted,
pagination.limit + 1,
pagination.offset
);
const hasNext = pageRows.length > pagination.limit;
filteredCiphers = hasNext ? pageRows.slice(0, pagination.limit) : pageRows;
continuationToken = hasNext ? encodeContinuationToken(pagination.offset + filteredCiphers.length) : null;
} else {
const ciphers = await storage.getAllCiphers(userId);
filteredCiphers = includeDeleted
? ciphers
: ciphers.filter(c => !c.deletedAt);
}
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(
filteredCiphers.map((cipher) => cipher.id)
);
// Build responses only for the current page to keep pagination cheap.
const cipherResponses: CipherResponse[] = [];
for (const cipher of filteredCiphers) {
const attachments = attachmentsByCipher.get(cipher.id) || [];
cipherResponses.push(cipherToResponse(cipher, attachments));
}
return jsonResponse({
data: cipherResponses,
object: 'list',
continuationToken: continuationToken,
});
}
// GET /api/ciphers/:id
export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
}
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse(
cipherToResponse(cipher, attachments)
);
}
async function verifyFolderOwnership(storage: StorageService, folderId: string | null | undefined, userId: string): Promise<boolean> {
if (!folderId) return true;
const folder = await storage.getFolder(folderId);
return !!(folder && folder.userId === userId);
}
// POST /api/ciphers
export async function handleCreateCipher(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: any;
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
// Handle nested cipher object (from some clients)
// Android client sends PascalCase "Cipher" for organization ciphers
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();
// Opaque passthrough: spread ALL client fields to preserve unknown/future ones,
// then override only server-controlled fields.
const cipher: Cipher = {
...cipherData,
// Server-controlled fields (always override client values)
id: generateUUID(),
userId: userId,
type: Number(cipherData.type) || 1,
favorite: !!cipherData.favorite,
reprompt: cipherData.reprompt || 0,
createdAt: now,
updatedAt: now,
archivedAt: readCipherArchivedAt(cipherData, 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']);
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
normalizeCipherForStorage(cipher);
// Prevent referencing a folder owned by another user.
if (cipher.folderId) {
const folderOk = await verifyFolderOwnership(storage, cipher.folderId, userId);
if (!folderOk) return errorResponse('Folder not found', 404);
}
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(
cipherToResponse(cipher, []),
200
);
}
// PUT /api/ciphers/:id
export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const existingCipher = await storage.getCipher(id);
if (!existingCipher || existingCipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
}
let body: any;
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
// Handle nested cipher object
// Android client sends PascalCase "Cipher" for organization ciphers
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.
// Unknown/future fields from the client are preserved; server-controlled fields are protected.
const cipher: Cipher = {
...existingCipher, // start with all existing stored data (including unknowns)
...cipherData, // overlay all client data (including new/unknown fields)
// Server-controlled fields (never from client)
id: existingCipher.id,
userId: existingCipher.userId,
type: nextType,
favorite: cipherData.favorite ?? existingCipher.favorite,
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
createdAt: existingCipher.createdAt,
updatedAt: new Date().toISOString(),
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
deletedAt: existingCipher.deletedAt,
};
if (incomingFolderId.present) {
cipher.folderId = normalizeOptionalId(incomingFolderId.value);
}
if (incomingKey.present) {
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:
// - Accept both camelCase "fields" and PascalCase "Fields".
// - For full update (PUT/POST on this endpoint), missing fields means cleared fields.
// This prevents stale custom fields from being resurrected by merge fallback.
const incomingFields = getAliasedProp(cipherData, ['fields', 'Fields']);
if (incomingFields.present) {
cipher.fields = incomingFields.value ?? null;
} else if (request.method === 'PUT' || request.method === 'POST') {
cipher.fields = null;
}
normalizeCipherForStorage(cipher);
// Prevent referencing a folder owned by another user.
if (cipher.folderId) {
const folderOk = await verifyFolderOwnership(storage, cipher.folderId, userId);
if (!folderOk) return errorResponse('Folder not found', 404);
}
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse(
cipherToResponse(cipher, attachments)
);
}
// DELETE /api/ciphers/:id
export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
}
// Soft delete
cipher.deletedAt = new Date().toISOString();
cipher.updatedAt = cipher.deletedAt;
syncCipherComputedAliases(cipher);
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(
cipherToResponse(cipher, [])
);
}
// DELETE /api/ciphers/:id (compat mode)
// Bitwarden clients may call DELETE on a trashed item to purge it permanently.
// For compatibility:
// - If item is active -> soft delete.
// - If item is already soft-deleted -> hard delete.
export async function handleDeleteCipherCompat(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
}
if (cipher.deletedAt) {
await deleteAllAttachmentsForCipher(env, id);
await storage.deleteCipher(id, userId);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return new Response(null, { status: 204 });
}
return handleDeleteCipher(request, env, userId, id);
}
// DELETE /api/ciphers/:id (permanent)
export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
}
// Delete all attachments first
await deleteAllAttachmentsForCipher(env, id);
await storage.deleteCipher(id, userId);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return new Response(null, { status: 204 });
}
// PUT /api/ciphers/:id/restore
export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
}
cipher.deletedAt = null;
cipher.updatedAt = new Date().toISOString();
syncCipherComputedAliases(cipher);
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(
cipherToResponse(cipher, [])
);
}
// PUT /api/ciphers/:id/partial - Update only favorite/folderId
export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
}
let body: { folderId?: string | null; favorite?: boolean };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (body.folderId !== undefined) {
const folderId = normalizeOptionalId(body.folderId);
if (folderId) {
const folderOk = await verifyFolderOwnership(storage, folderId, userId);
if (!folderOk) return errorResponse('Folder not found', 404);
}
cipher.folderId = folderId;
}
if (body.favorite !== undefined) {
cipher.favorite = body.favorite;
}
cipher.updatedAt = new Date().toISOString();
syncCipherComputedAliases(cipher);
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(
cipherToResponse(cipher, [])
);
}
// POST/PUT /api/ciphers/move - Bulk move to folder
export async function handleBulkMoveCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: { ids?: string[]; folderId?: string | null };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (!body.ids || !Array.isArray(body.ids)) {
return errorResponse('ids array is required', 400);
}
const folderId = normalizeOptionalId(body.folderId);
if (folderId) {
const folderOk = await verifyFolderOwnership(storage, folderId, userId);
if (!folderOk) return errorResponse('Folder not found', 404);
}
const revisionDate = await storage.bulkMoveCiphers(body.ids, folderId, userId);
if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return new Response(null, { status: 204 });
}
async function buildCipherListResponse(
request: Request,
storage: StorageService,
userId: string,
ids: string[]
): Promise<Response> {
const ciphers = await storage.getCiphersByIds(ids, userId);
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map((cipher) => cipher.id));
return jsonResponse({
data: ciphers.map((cipher) =>
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [])
),
object: 'list',
continuationToken: null,
});
}
function parseCipherIdList(body: { ids?: unknown }): string[] | null {
if (!Array.isArray(body.ids)) return null;
return Array.from(new Set(body.ids.map((id) => String(id || '').trim()).filter(Boolean)));
}
// PUT/POST /api/ciphers/:id/archive
export async function handleArchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
}
if (cipher.deletedAt) {
return errorResponse('Cannot archive a deleted cipher', 400);
}
cipher.archivedAt = new Date().toISOString();
cipher.updatedAt = cipher.archivedAt;
normalizeCipherForStorage(cipher);
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse(
cipherToResponse(cipher, attachments)
);
}
// PUT/POST /api/ciphers/:id/unarchive
export async function handleUnarchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
}
cipher.archivedAt = null;
cipher.updatedAt = new Date().toISOString();
normalizeCipherForStorage(cipher);
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse(
cipherToResponse(cipher, attachments)
);
}
// PUT/POST /api/ciphers/archive
export async function handleBulkArchiveCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: { ids?: unknown };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const ids = parseCipherIdList(body);
if (!ids) {
return errorResponse('ids array is required', 400);
}
const revisionDate = await storage.bulkArchiveCiphers(ids, userId);
if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return buildCipherListResponse(request, storage, userId, ids);
}
// PUT/POST /api/ciphers/unarchive
export async function handleBulkUnarchiveCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: { ids?: unknown };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const ids = parseCipherIdList(body);
if (!ids) {
return errorResponse('ids array is required', 400);
}
const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId);
if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return buildCipherListResponse(request, storage, userId, ids);
}
// POST /api/ciphers/delete - Bulk soft delete
export async function handleBulkDeleteCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: { ids?: string[] };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (!body.ids || !Array.isArray(body.ids)) {
return errorResponse('ids array is required', 400);
}
const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId);
if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return new Response(null, { status: 204 });
}
// POST /api/ciphers/restore - Bulk restore
export async function handleBulkRestoreCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: { ids?: string[] };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (!body.ids || !Array.isArray(body.ids)) {
return errorResponse('ids array is required', 400);
}
const revisionDate = await storage.bulkRestoreCiphers(body.ids, userId);
if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return new Response(null, { status: 204 });
}
// POST /api/ciphers/delete-permanent - Bulk permanent delete
export async function handleBulkPermanentDeleteCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: { ids?: string[] };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (!body.ids || !Array.isArray(body.ids)) {
return errorResponse('ids array is required', 400);
}
const ids = Array.from(new Set(body.ids.map((id) => String(id || '').trim()).filter(Boolean)));
if (!ids.length) {
return new Response(null, { status: 204 });
}
const ownedCiphers = await storage.getCiphersByIds(ids, userId);
const ownedIds = ownedCiphers.map((cipher) => cipher.id);
if (!ownedIds.length) {
return new Response(null, { status: 204 });
}
await deleteAllAttachmentsForCiphers(env, ownedIds);
const revisionDate = await storage.bulkDeleteCiphers(ownedIds, userId);
if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return new Response(null, { status: 204 });
}