Files
nodewarden/src/handlers/import.ts
T
shuaiplus 819734ce5c feat: add export and import functionality for Bitwarden and NodeWarden formats
- Implemented export formats for Bitwarden (JSON, encrypted JSON, ZIP) and NodeWarden (JSON).
- Added support for attachments in ciphers and introduced new types for handling attachments.
- Enhanced import formats to include Bitwarden ZIP and NodeWarden JSON.
- Updated internationalization strings for attachment-related features.
- Improved UI styles for attachment management and import summary display.
2026-03-04 01:03:49 +08:00

282 lines
9.1 KiB
TypeScript

import { Env, Cipher, Folder, CipherType } from '../types';
import { StorageService } from '../services/storage';
import { errorResponse, jsonResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits';
import { normalizeCipherLoginForCompatibility } 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;
fido2Credentials?: any[] | 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) || 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: 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: (c as any).sshKey ?? null,
key: (c as any).key ?? null,
createdAt: now,
updatedAt: now,
deletedAt: null,
};
cipher.login = normalizeCipherLoginForCompatibility(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, 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, 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.deletedAt)
);
});
await runBatchInChunks(env.DB, cipherStatements, batchChunkSize);
}
// Update revision date
await storage.updateRevisionDate(userId);
if (returnCipherMap) {
return jsonResponse({
object: 'import-result',
cipherMap: cipherMapRows,
});
}
return new Response(null, { status: 200 });
}