mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
0c00114cc8
- 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.
919 lines
33 KiB
TypeScript
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 });
|
|
}
|