mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
286 lines
9.5 KiB
TypeScript
286 lines
9.5 KiB
TypeScript
import { Env, Cipher, Folder, CipherType } from '../types';
|
|
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
|
import { StorageService } from '../services/storage';
|
|
import { errorResponse, jsonResponse } from '../utils/response';
|
|
import { readActingDeviceIdentifier } from '../utils/device';
|
|
import { generateUUID } from '../utils/uuid';
|
|
import { LIMITS } from '../config/limits';
|
|
import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility } from './ciphers';
|
|
|
|
// Bitwarden client import request format
|
|
interface CiphersImportRequest {
|
|
ciphers: Array<{
|
|
id?: string | null;
|
|
type: number;
|
|
name?: string | null;
|
|
notes?: string | null;
|
|
favorite?: boolean;
|
|
reprompt?: number;
|
|
sshKey?: any | null;
|
|
key?: string | null;
|
|
login?: {
|
|
uris?: Array<{ uri: string | null; match?: number | null }> | null;
|
|
username?: string | null;
|
|
password?: string | null;
|
|
totp?: string | null;
|
|
autofillOnPageLoad?: boolean | null;
|
|
uri?: string | null;
|
|
passwordRevisionDate?: string | null;
|
|
[key: string]: any;
|
|
} | null;
|
|
card?: {
|
|
cardholderName?: string | null;
|
|
brand?: string | null;
|
|
number?: string | null;
|
|
expMonth?: string | null;
|
|
expYear?: string | null;
|
|
code?: string | null;
|
|
} | null;
|
|
identity?: {
|
|
title?: string | null;
|
|
firstName?: string | null;
|
|
middleName?: string | null;
|
|
lastName?: string | null;
|
|
address1?: string | null;
|
|
address2?: string | null;
|
|
address3?: string | null;
|
|
city?: string | null;
|
|
state?: string | null;
|
|
postalCode?: string | null;
|
|
country?: string | null;
|
|
company?: string | null;
|
|
email?: string | null;
|
|
phone?: string | null;
|
|
ssn?: string | null;
|
|
username?: string | null;
|
|
passportNumber?: string | null;
|
|
licenseNumber?: string | null;
|
|
} | null;
|
|
secureNote?: { type: number } | null;
|
|
fields?: Array<{
|
|
name?: string | null;
|
|
value?: string | null;
|
|
type: number;
|
|
linkedId?: number | null;
|
|
}> | null;
|
|
passwordHistory?: Array<{
|
|
password: string;
|
|
lastUsedDate: string;
|
|
}> | null;
|
|
[key: string]: any;
|
|
}>;
|
|
folders: Array<{
|
|
name: string;
|
|
}>;
|
|
folderRelationships: Array<{
|
|
key: number; // cipher index
|
|
value: number; // folder index
|
|
}>;
|
|
}
|
|
|
|
function bindNull(v: any): any {
|
|
return v === undefined ? null : v;
|
|
}
|
|
|
|
async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> {
|
|
for (let i = 0; i < statements.length; i += chunkSize) {
|
|
const chunk = statements.slice(i, i + chunkSize);
|
|
await db.batch(chunk);
|
|
}
|
|
}
|
|
|
|
// POST /api/ciphers/import - Bitwarden client import endpoint
|
|
export async function handleCiphersImport(request: Request, env: Env, userId: string): Promise<Response> {
|
|
const storage = new StorageService(env.DB);
|
|
const url = new URL(request.url);
|
|
const returnCipherMap = url.searchParams.get('returnCipherMap') === '1';
|
|
|
|
let importData: CiphersImportRequest;
|
|
try {
|
|
importData = await request.json();
|
|
} catch {
|
|
return errorResponse('Invalid JSON', 400);
|
|
}
|
|
|
|
const folders = importData.folders || [];
|
|
const ciphers = importData.ciphers || [];
|
|
const folderRelationships = importData.folderRelationships || [];
|
|
|
|
if (folders.length + ciphers.length > LIMITS.performance.importItemLimit) {
|
|
return errorResponse(`Import exceeds maximum of ${LIMITS.performance.importItemLimit} items`, 400);
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
const batchChunkSize = LIMITS.performance.bulkMoveChunkSize;
|
|
|
|
// Create folders and build index -> id mapping
|
|
const folderIdMap = new Map<number, string>();
|
|
const folderRows: Folder[] = [];
|
|
|
|
for (let i = 0; i < folders.length; i++) {
|
|
const folderId = generateUUID();
|
|
folderIdMap.set(i, folderId);
|
|
|
|
const folder: Folder = {
|
|
id: folderId,
|
|
userId: userId,
|
|
name: folders[i].name,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
folderRows.push(folder);
|
|
}
|
|
|
|
if (folderRows.length > 0) {
|
|
const folderStatements = folderRows.map(folder =>
|
|
env.DB
|
|
.prepare(
|
|
'INSERT INTO folders(id, user_id, name, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ' +
|
|
'ON CONFLICT(id) DO UPDATE SET user_id=excluded.user_id, name=excluded.name, updated_at=excluded.updated_at'
|
|
)
|
|
.bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt)
|
|
);
|
|
await runBatchInChunks(env.DB, folderStatements, batchChunkSize);
|
|
}
|
|
|
|
// Build cipher index -> folder id mapping from relationships
|
|
const cipherFolderMap = new Map<number, string>();
|
|
for (const rel of folderRelationships) {
|
|
const folderId = folderIdMap.get(rel.value);
|
|
if (folderId) {
|
|
cipherFolderMap.set(rel.key, folderId);
|
|
}
|
|
}
|
|
|
|
// Create ciphers
|
|
const cipherRows: Cipher[] = [];
|
|
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
|
|
for (let i = 0; i < ciphers.length; i++) {
|
|
const c = ciphers[i];
|
|
const folderId = cipherFolderMap.get(i) || c.folderId || null;
|
|
const sourceIdRaw = String(c?.id ?? '').trim();
|
|
const sourceId = sourceIdRaw || null;
|
|
|
|
const cipher: Cipher = {
|
|
...c,
|
|
id: generateUUID(),
|
|
userId: userId,
|
|
type: c.type as CipherType,
|
|
folderId: folderId,
|
|
name: c.name ?? 'Untitled',
|
|
notes: c.notes ?? null,
|
|
favorite: c.favorite ?? false,
|
|
login: c.login ? {
|
|
...c.login,
|
|
username: c.login.username ?? null,
|
|
password: c.login.password ?? null,
|
|
uris: c.login.uris?.map(u => ({
|
|
...u,
|
|
uri: u.uri ?? null,
|
|
uriChecksum: null,
|
|
match: u.match ?? null,
|
|
})) || null,
|
|
totp: c.login.totp ?? null,
|
|
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null,
|
|
fido2Credentials: Array.isArray(c.login.fido2Credentials) ? c.login.fido2Credentials : null,
|
|
uri: c.login.uri ?? null,
|
|
passwordRevisionDate: c.login.passwordRevisionDate ?? null,
|
|
} : null,
|
|
card: c.card ? {
|
|
...c.card,
|
|
cardholderName: c.card.cardholderName ?? null,
|
|
brand: c.card.brand ?? null,
|
|
number: c.card.number ?? null,
|
|
expMonth: c.card.expMonth ?? null,
|
|
expYear: c.card.expYear ?? null,
|
|
code: c.card.code ?? null,
|
|
} : null,
|
|
identity: c.identity ? {
|
|
...c.identity,
|
|
title: c.identity.title ?? null,
|
|
firstName: c.identity.firstName ?? null,
|
|
middleName: c.identity.middleName ?? null,
|
|
lastName: c.identity.lastName ?? null,
|
|
address1: c.identity.address1 ?? null,
|
|
address2: c.identity.address2 ?? null,
|
|
address3: c.identity.address3 ?? null,
|
|
city: c.identity.city ?? null,
|
|
state: c.identity.state ?? null,
|
|
postalCode: c.identity.postalCode ?? null,
|
|
country: c.identity.country ?? null,
|
|
company: c.identity.company ?? null,
|
|
email: c.identity.email ?? null,
|
|
phone: c.identity.phone ?? null,
|
|
ssn: c.identity.ssn ?? null,
|
|
username: c.identity.username ?? null,
|
|
passportNumber: c.identity.passportNumber ?? null,
|
|
licenseNumber: c.identity.licenseNumber ?? null,
|
|
} : null,
|
|
secureNote: c.secureNote ?? null,
|
|
fields: c.fields?.map(f => ({
|
|
...f,
|
|
name: f.name ?? null,
|
|
value: f.value ?? null,
|
|
type: f.type,
|
|
linkedId: f.linkedId ?? null,
|
|
})) || null,
|
|
passwordHistory: c.passwordHistory ?? null,
|
|
reprompt: c.reprompt ?? 0,
|
|
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
|
|
key: (c as any).key ?? null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
archivedAt: null,
|
|
deletedAt: null,
|
|
};
|
|
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
|
|
|
cipherRows.push(cipher);
|
|
cipherMapRows.push({ index: i, sourceId, id: cipher.id });
|
|
}
|
|
|
|
if (cipherRows.length > 0) {
|
|
const cipherStatements = cipherRows.map(cipher => {
|
|
const data = JSON.stringify(cipher);
|
|
return env.DB
|
|
.prepare(
|
|
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
|
|
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
|
'ON CONFLICT(id) DO UPDATE SET ' +
|
|
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at'
|
|
)
|
|
.bind(
|
|
cipher.id,
|
|
cipher.userId,
|
|
Number(cipher.type) || 1,
|
|
bindNull(cipher.folderId),
|
|
bindNull(cipher.name),
|
|
bindNull(cipher.notes),
|
|
cipher.favorite ? 1 : 0,
|
|
data,
|
|
bindNull(cipher.reprompt ?? 0),
|
|
bindNull(cipher.key),
|
|
cipher.createdAt,
|
|
cipher.updatedAt,
|
|
bindNull(cipher.archivedAt),
|
|
bindNull(cipher.deletedAt)
|
|
);
|
|
});
|
|
await runBatchInChunks(env.DB, cipherStatements, batchChunkSize);
|
|
}
|
|
|
|
// Update revision date
|
|
const revisionDate = await storage.updateRevisionDate(userId);
|
|
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
|
|
|
if (returnCipherMap) {
|
|
return jsonResponse({
|
|
object: 'import-result',
|
|
cipherMap: cipherMapRows,
|
|
});
|
|
}
|
|
|
|
return new Response(null, { status: 200 });
|
|
}
|