mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: Implement admin backup export and import functionality
- Added new endpoints for exporting and importing instance-level backups. - Introduced user interface components for backup management in the web app. - Enhanced import/export logic to handle attachments and provide detailed summaries. - Updated localization files to include new strings related to backup features. - Improved styling for backup-related UI elements.
This commit is contained in:
@@ -7,6 +7,7 @@ import { generateUUID } from '../utils/uuid';
|
|||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
||||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||||
|
import { buildAccountKeys } from '../utils/user-decryption';
|
||||||
|
|
||||||
function looksLikeEncString(value: string): boolean {
|
function looksLikeEncString(value: string): boolean {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
@@ -61,6 +62,7 @@ function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' |
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toProfile(user: User, env: Env): ProfileResponse {
|
function toProfile(user: User, env: Env): ProfileResponse {
|
||||||
|
void env;
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
@@ -74,7 +76,7 @@ function toProfile(user: User, env: Env): ProfileResponse {
|
|||||||
twoFactorEnabled: !!user.totpSecret,
|
twoFactorEnabled: !!user.totpSecret,
|
||||||
key: user.key,
|
key: user.key,
|
||||||
privateKey: user.privateKey,
|
privateKey: user.privateKey,
|
||||||
accountKeys: null,
|
accountKeys: buildAccountKeys(user),
|
||||||
securityStamp: user.securityStamp || user.id,
|
securityStamp: user.securityStamp || user.id,
|
||||||
organizations: [],
|
organizations: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { StorageService } from '../services/storage';
|
|||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt';
|
import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt';
|
||||||
import { cipherToResponse } from './ciphers';
|
import { cipherToResponse, shouldOmitPasskeysForResponse } from './ciphers';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import {
|
import {
|
||||||
deleteBlobObject,
|
deleteBlobObject,
|
||||||
@@ -84,7 +84,9 @@ export async function handleCreateAttachment(
|
|||||||
attachmentId: attachmentId,
|
attachmentId: attachmentId,
|
||||||
url: `/api/ciphers/${cipherId}/attachment/${attachmentId}`,
|
url: `/api/ciphers/${cipherId}/attachment/${attachmentId}`,
|
||||||
fileUploadType: 0, // Direct upload
|
fileUploadType: 0, // Direct upload
|
||||||
cipherResponse: cipherToResponse(updatedCipher!, attachments),
|
cipherResponse: cipherToResponse(updatedCipher!, attachments, {
|
||||||
|
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,7 +311,9 @@ export async function handleDeleteAttachment(
|
|||||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
cipher: cipherToResponse(updatedCipher!, attachments),
|
cipher: cipherToResponse(updatedCipher!, attachments, {
|
||||||
|
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,678 @@
|
|||||||
|
import { zipSync, unzipSync } from 'fflate';
|
||||||
|
import { Env, User } from '../types';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobObject, getBlobStorageKind, getSendFileObjectKey, putBlobObject } from '../services/blob-store';
|
||||||
|
|
||||||
|
type SqlRow = Record<string, string | number | null>;
|
||||||
|
|
||||||
|
interface BackupManifest {
|
||||||
|
formatVersion: 1;
|
||||||
|
exportedAt: string;
|
||||||
|
appVersion: string;
|
||||||
|
storageKind: 'r2' | 'kv' | null;
|
||||||
|
tableCounts: Record<string, number>;
|
||||||
|
includes: {
|
||||||
|
attachments: boolean;
|
||||||
|
sendFiles: boolean;
|
||||||
|
};
|
||||||
|
blobSummary: {
|
||||||
|
attachmentFiles: number;
|
||||||
|
sendFiles: number;
|
||||||
|
totalBytes: number;
|
||||||
|
largestObjectBytes: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackupPayload {
|
||||||
|
manifest: BackupManifest;
|
||||||
|
db: {
|
||||||
|
config: SqlRow[];
|
||||||
|
users: SqlRow[];
|
||||||
|
user_revisions: SqlRow[];
|
||||||
|
folders: SqlRow[];
|
||||||
|
ciphers: SqlRow[];
|
||||||
|
attachments: SqlRow[];
|
||||||
|
sends: SqlRow[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdmin(user: User): boolean {
|
||||||
|
return user.role === 'admin' && user.status === 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeAuditLog(
|
||||||
|
storage: StorageService,
|
||||||
|
actorUserId: string | null,
|
||||||
|
action: string,
|
||||||
|
targetType: string | null,
|
||||||
|
targetId: string | null,
|
||||||
|
metadata: Record<string, unknown> | null
|
||||||
|
): Promise<void> {
|
||||||
|
await storage.createAuditLog({
|
||||||
|
id: generateUUID(),
|
||||||
|
actorUserId,
|
||||||
|
action,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
metadata: metadata ? JSON.stringify(metadata) : null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Promise<SqlRow[]> {
|
||||||
|
const result = await db.prepare(sql).bind(...values).all<SqlRow>();
|
||||||
|
return (result.results || []).map((row) => ({ ...row }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function streamToBytes(stream: ReadableStream | null): Promise<Uint8Array> {
|
||||||
|
if (!stream) return new Uint8Array();
|
||||||
|
const buffer = await new Response(stream).arrayBuffer();
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSendFileId(data: string | null): string | null {
|
||||||
|
if (!data) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data) as Record<string, unknown>;
|
||||||
|
return typeof parsed.id === 'string' && parsed.id.trim() ? parsed.id.trim() : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBackupFileName(date: Date = new Date()): string {
|
||||||
|
const parts = [
|
||||||
|
date.getUTCFullYear().toString().padStart(4, '0'),
|
||||||
|
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
|
||||||
|
date.getUTCDate().toString().padStart(2, '0'),
|
||||||
|
date.getUTCHours().toString().padStart(2, '0'),
|
||||||
|
date.getUTCMinutes().toString().padStart(2, '0'),
|
||||||
|
date.getUTCSeconds().toString().padStart(2, '0'),
|
||||||
|
];
|
||||||
|
return `nodewarden_instance_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}.zip`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureImportTargetIsFresh(db: D1Database): Promise<void> {
|
||||||
|
const counts = await Promise.all([
|
||||||
|
db.prepare('SELECT COUNT(*) AS count FROM ciphers').first<{ count: number }>(),
|
||||||
|
db.prepare('SELECT COUNT(*) AS count FROM folders').first<{ count: number }>(),
|
||||||
|
db.prepare('SELECT COUNT(*) AS count FROM attachments').first<{ count: number }>(),
|
||||||
|
db.prepare('SELECT COUNT(*) AS count FROM sends').first<{ count: number }>(),
|
||||||
|
]);
|
||||||
|
const total = counts.reduce((sum, row) => sum + Number(row?.count || 0), 0);
|
||||||
|
if (total > 0) {
|
||||||
|
throw new Error('Backup import requires a fresh instance with no vault or send data');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearExistingBlobFiles(env: Env, db: D1Database): Promise<void> {
|
||||||
|
const attachmentRows = await queryRows(
|
||||||
|
db,
|
||||||
|
`SELECT a.id, a.cipher_id
|
||||||
|
FROM attachments a
|
||||||
|
INNER JOIN ciphers c ON c.id = a.cipher_id`
|
||||||
|
);
|
||||||
|
for (const row of attachmentRows) {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
if (!cipherId || !attachmentId) continue;
|
||||||
|
await deleteBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendRows = await queryRows(db, 'SELECT id, data FROM sends');
|
||||||
|
for (const row of sendRows) {
|
||||||
|
const sendId = String(row.id || '').trim();
|
||||||
|
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||||
|
if (!sendId || !fileId) continue;
|
||||||
|
await deleteBlobObject(env, getSendFileObjectKey(sendId, fileId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetImportTarget(db: D1Database): Promise<void> {
|
||||||
|
const statements = [
|
||||||
|
'DELETE FROM attachments',
|
||||||
|
'DELETE FROM ciphers',
|
||||||
|
'DELETE FROM folders',
|
||||||
|
'DELETE FROM sends',
|
||||||
|
'DELETE FROM trusted_two_factor_device_tokens',
|
||||||
|
'DELETE FROM devices',
|
||||||
|
'DELETE FROM refresh_tokens',
|
||||||
|
'DELETE FROM invites',
|
||||||
|
'DELETE FROM audit_logs',
|
||||||
|
'DELETE FROM user_revisions',
|
||||||
|
'DELETE FROM users',
|
||||||
|
'DELETE FROM config',
|
||||||
|
'DELETE FROM login_attempts_ip',
|
||||||
|
'DELETE FROM api_rate_limits',
|
||||||
|
'DELETE FROM used_attachment_download_tokens',
|
||||||
|
].map((sql) => db.prepare(sql));
|
||||||
|
await db.batch(statements);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequiredZipEntries(db: BackupPayload['db']): string[] {
|
||||||
|
const entries: string[] = [];
|
||||||
|
for (const row of db.attachments) {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
if (!cipherId || !attachmentId) continue;
|
||||||
|
entries.push(`attachments/${cipherId}/${attachmentId}.bin`);
|
||||||
|
}
|
||||||
|
for (const row of db.sends) {
|
||||||
|
const sendId = String(row.id || '').trim();
|
||||||
|
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||||
|
if (!sendId || !fileId) continue;
|
||||||
|
entries.push(`send-files/${sendId}/${fileId}.bin`);
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBackupArchive(bytes: Uint8Array): { payload: BackupPayload; files: Record<string, Uint8Array> } {
|
||||||
|
let zipped: Record<string, Uint8Array>;
|
||||||
|
try {
|
||||||
|
zipped = unzipSync(bytes);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid backup archive');
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestBytes = zipped['manifest.json'];
|
||||||
|
const dbBytes = zipped['db.json'];
|
||||||
|
if (!manifestBytes || !dbBytes) {
|
||||||
|
throw new Error('Backup archive is missing manifest.json or db.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let manifest: BackupManifest;
|
||||||
|
let db: BackupPayload['db'];
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(decoder.decode(manifestBytes)) as BackupManifest;
|
||||||
|
db = JSON.parse(decoder.decode(dbBytes)) as BackupPayload['db'];
|
||||||
|
} catch {
|
||||||
|
throw new Error('Backup archive contains invalid JSON metadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest?.formatVersion !== 1) {
|
||||||
|
throw new Error('Unsupported backup format version');
|
||||||
|
}
|
||||||
|
if (!db || typeof db !== 'object') {
|
||||||
|
throw new Error('Backup archive database payload is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredEntries = getRequiredZipEntries(db);
|
||||||
|
for (const entry of requiredEntries) {
|
||||||
|
if (!zipped[entry]) {
|
||||||
|
throw new Error(`Backup archive is missing required file: ${entry}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
payload: { manifest, db },
|
||||||
|
files: zipped,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRowArray(value: unknown, table: string): SqlRow[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new Error(`Backup archive table ${table} is invalid`);
|
||||||
|
}
|
||||||
|
return value as SqlRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBackupPayloadContents(payload: BackupPayload, files: Record<string, Uint8Array>): void {
|
||||||
|
const configRows = ensureRowArray(payload.db.config, 'config');
|
||||||
|
const userRows = ensureRowArray(payload.db.users, 'users');
|
||||||
|
const revisionRows = ensureRowArray(payload.db.user_revisions, 'user_revisions');
|
||||||
|
const folderRows = ensureRowArray(payload.db.folders, 'folders');
|
||||||
|
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
|
||||||
|
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
|
||||||
|
const sendRows = ensureRowArray(payload.db.sends, 'sends');
|
||||||
|
|
||||||
|
const userIds = new Set<string>();
|
||||||
|
for (const row of userRows) {
|
||||||
|
const id = String(row.id || '').trim();
|
||||||
|
const email = String(row.email || '').trim();
|
||||||
|
if (!id || !email) throw new Error('Backup archive contains an invalid user row');
|
||||||
|
if (userIds.has(id)) throw new Error(`Backup archive contains duplicate user id: ${id}`);
|
||||||
|
userIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of configRows) {
|
||||||
|
const key = String(row.key || '').trim();
|
||||||
|
if (!key) throw new Error('Backup archive contains an invalid config row');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of revisionRows) {
|
||||||
|
const userId = String(row.user_id || '').trim();
|
||||||
|
if (!userId || !userIds.has(userId)) {
|
||||||
|
throw new Error(`Backup archive contains a revision for an unknown user: ${userId || '(empty)'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderIds = new Set<string>();
|
||||||
|
for (const row of folderRows) {
|
||||||
|
const id = String(row.id || '').trim();
|
||||||
|
const userId = String(row.user_id || '').trim();
|
||||||
|
if (!id || !userIds.has(userId)) throw new Error('Backup archive contains an invalid folder row');
|
||||||
|
if (folderIds.has(id)) throw new Error(`Backup archive contains duplicate folder id: ${id}`);
|
||||||
|
folderIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cipherIds = new Set<string>();
|
||||||
|
for (const row of cipherRows) {
|
||||||
|
const id = String(row.id || '').trim();
|
||||||
|
const userId = String(row.user_id || '').trim();
|
||||||
|
const folderId = String(row.folder_id || '').trim();
|
||||||
|
if (!id || !userIds.has(userId)) throw new Error('Backup archive contains an invalid cipher row');
|
||||||
|
if (folderId && !folderIds.has(folderId)) {
|
||||||
|
throw new Error(`Backup archive contains a cipher that references a missing folder: ${id}`);
|
||||||
|
}
|
||||||
|
if (cipherIds.has(id)) throw new Error(`Backup archive contains duplicate cipher id: ${id}`);
|
||||||
|
cipherIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentIds = new Set<string>();
|
||||||
|
for (const row of attachmentRows) {
|
||||||
|
const id = String(row.id || '').trim();
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
if (!id || !cipherIds.has(cipherId)) throw new Error('Backup archive contains an invalid attachment row');
|
||||||
|
if (attachmentIds.has(id)) throw new Error(`Backup archive contains duplicate attachment id: ${id}`);
|
||||||
|
attachmentIds.add(id);
|
||||||
|
|
||||||
|
const path = `attachments/${cipherId}/${id}.bin`;
|
||||||
|
const entry = files[path];
|
||||||
|
if (!(entry instanceof Uint8Array)) {
|
||||||
|
throw new Error(`Backup archive is missing required file: ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendIds = new Set<string>();
|
||||||
|
for (const row of sendRows) {
|
||||||
|
const id = String(row.id || '').trim();
|
||||||
|
const userId = String(row.user_id || '').trim();
|
||||||
|
if (!id || !userIds.has(userId)) throw new Error('Backup archive contains an invalid send row');
|
||||||
|
if (sendIds.has(id)) throw new Error(`Backup archive contains duplicate send id: ${id}`);
|
||||||
|
sendIds.add(id);
|
||||||
|
|
||||||
|
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||||
|
if (!fileId) continue;
|
||||||
|
const path = `send-files/${id}/${fileId}.bin`;
|
||||||
|
const entry = files[path];
|
||||||
|
if (!(entry instanceof Uint8Array)) {
|
||||||
|
throw new Error(`Backup archive is missing required file: ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateImportBlobLimits(env: Env, payload: BackupPayload, files: Record<string, Uint8Array>): void {
|
||||||
|
const storageKind = getBlobStorageKind(env);
|
||||||
|
const hasBlobFiles =
|
||||||
|
payload.db.attachments.length > 0 ||
|
||||||
|
payload.db.sends.some((row) => !!parseSendFileId(typeof row.data === 'string' ? row.data : null));
|
||||||
|
|
||||||
|
if (!storageKind && hasBlobFiles) {
|
||||||
|
throw new Error('Backup contains files but attachment storage is not configured on the target instance');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storageKind !== 'kv') return;
|
||||||
|
|
||||||
|
let largestObjectBytes = 0;
|
||||||
|
for (const row of payload.db.attachments) {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
if (!cipherId || !attachmentId) continue;
|
||||||
|
const entry = files[`attachments/${cipherId}/${attachmentId}.bin`];
|
||||||
|
if (!entry) continue;
|
||||||
|
largestObjectBytes = Math.max(largestObjectBytes, entry.byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of payload.db.sends) {
|
||||||
|
const sendId = String(row.id || '').trim();
|
||||||
|
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||||
|
if (!sendId || !fileId) continue;
|
||||||
|
const entry = files[`send-files/${sendId}/${fileId}.bin`];
|
||||||
|
if (!entry) continue;
|
||||||
|
largestObjectBytes = Math.max(largestObjectBytes, entry.byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (largestObjectBytes > KV_MAX_OBJECT_BYTES) {
|
||||||
|
throw new Error(`Backup contains a file larger than the Workers KV ${Math.floor(KV_MAX_OBJECT_BYTES / (1024 * 1024))} MiB per-object limit`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertRows(db: D1Database, table: string, columns: string[], rows: SqlRow[], upsert = false): Promise<void> {
|
||||||
|
if (!rows.length) return;
|
||||||
|
const placeholders = columns.map(() => '?').join(', ');
|
||||||
|
const updateSql = upsert
|
||||||
|
? ' ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||||
|
: '';
|
||||||
|
const sql = `INSERT INTO ${table}(${columns.join(', ')}) VALUES(${placeholders})${updateSql}`;
|
||||||
|
const statements: D1PreparedStatement[] = rows.map((row) =>
|
||||||
|
db.prepare(sql).bind(...columns.map((column) => row[column] ?? null))
|
||||||
|
);
|
||||||
|
const chunkSize = 32;
|
||||||
|
for (let i = 0; i < statements.length; i += chunkSize) {
|
||||||
|
await db.batch(statements.slice(i, i + chunkSize));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record<string, Uint8Array>): Promise<{ attachments: number; sendFiles: number }> {
|
||||||
|
let attachmentCount = 0;
|
||||||
|
let sendFileCount = 0;
|
||||||
|
|
||||||
|
for (const row of db.attachments) {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
if (!cipherId || !attachmentId) continue;
|
||||||
|
const zipPath = `attachments/${cipherId}/${attachmentId}.bin`;
|
||||||
|
const bytes = files[zipPath];
|
||||||
|
await putBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId), bytes, {
|
||||||
|
size: bytes.byteLength,
|
||||||
|
contentType: 'application/octet-stream',
|
||||||
|
customMetadata: { cipherId, attachmentId },
|
||||||
|
});
|
||||||
|
attachmentCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of db.sends) {
|
||||||
|
const sendId = String(row.id || '').trim();
|
||||||
|
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||||
|
if (!sendId || !fileId) continue;
|
||||||
|
const zipPath = `send-files/${sendId}/${fileId}.bin`;
|
||||||
|
const bytes = files[zipPath];
|
||||||
|
await putBlobObject(env, getSendFileObjectKey(sendId, fileId), bytes, {
|
||||||
|
size: bytes.byteLength,
|
||||||
|
contentType: 'application/octet-stream',
|
||||||
|
customMetadata: { sendId, fileId },
|
||||||
|
});
|
||||||
|
sendFileCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { attachments: attachmentCount, sendFiles: sendFileCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/admin/backup/export
|
||||||
|
export async function handleAdminExportBackup(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
actorUser: User
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
if (!isAdmin(actorUser)) {
|
||||||
|
return errorResponse('Forbidden', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows, sendRows] = await Promise.all([
|
||||||
|
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends ORDER BY created_at ASC'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let attachmentBlobCount = 0;
|
||||||
|
let sendFileBlobCount = 0;
|
||||||
|
let totalBlobBytes = 0;
|
||||||
|
let largestObjectBytes = 0;
|
||||||
|
|
||||||
|
const files: Record<string, Uint8Array> = {
|
||||||
|
'manifest.json': encoder.encode(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
formatVersion: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
appVersion: '1.0',
|
||||||
|
storageKind: getBlobStorageKind(env),
|
||||||
|
tableCounts: {
|
||||||
|
config: configRows.length,
|
||||||
|
users: userRows.length,
|
||||||
|
user_revisions: revisionRows.length,
|
||||||
|
folders: folderRows.length,
|
||||||
|
ciphers: cipherRows.length,
|
||||||
|
attachments: attachmentRows.length,
|
||||||
|
sends: sendRows.length,
|
||||||
|
},
|
||||||
|
includes: {
|
||||||
|
attachments: true,
|
||||||
|
sendFiles: true,
|
||||||
|
},
|
||||||
|
blobSummary: {
|
||||||
|
attachmentFiles: 0,
|
||||||
|
sendFiles: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
largestObjectBytes: 0,
|
||||||
|
},
|
||||||
|
} satisfies BackupManifest,
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'db.json': encoder.encode(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
config: configRows,
|
||||||
|
users: userRows,
|
||||||
|
user_revisions: revisionRows,
|
||||||
|
folders: folderRows,
|
||||||
|
ciphers: cipherRows,
|
||||||
|
attachments: attachmentRows,
|
||||||
|
sends: sendRows,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of attachmentRows) {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
if (!cipherId || !attachmentId) continue;
|
||||||
|
const object = await getBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId));
|
||||||
|
if (!object) {
|
||||||
|
return errorResponse(`Attachment blob missing for ${cipherId}/${attachmentId}`, 409);
|
||||||
|
}
|
||||||
|
const bytes = await streamToBytes(object.body);
|
||||||
|
files[`attachments/${cipherId}/${attachmentId}.bin`] = bytes;
|
||||||
|
attachmentBlobCount += 1;
|
||||||
|
totalBlobBytes += bytes.byteLength;
|
||||||
|
largestObjectBytes = Math.max(largestObjectBytes, bytes.byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of sendRows) {
|
||||||
|
const sendId = String(row.id || '').trim();
|
||||||
|
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||||
|
if (!sendId || !fileId) continue;
|
||||||
|
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
|
||||||
|
if (!object) {
|
||||||
|
return errorResponse(`Send file blob missing for ${sendId}/${fileId}`, 409);
|
||||||
|
}
|
||||||
|
const bytes = await streamToBytes(object.body);
|
||||||
|
files[`send-files/${sendId}/${fileId}.bin`] = bytes;
|
||||||
|
sendFileBlobCount += 1;
|
||||||
|
totalBlobBytes += bytes.byteLength;
|
||||||
|
largestObjectBytes = Math.max(largestObjectBytes, bytes.byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
files['manifest.json'] = encoder.encode(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
formatVersion: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
appVersion: '1.0',
|
||||||
|
storageKind: getBlobStorageKind(env),
|
||||||
|
tableCounts: {
|
||||||
|
config: configRows.length,
|
||||||
|
users: userRows.length,
|
||||||
|
user_revisions: revisionRows.length,
|
||||||
|
folders: folderRows.length,
|
||||||
|
ciphers: cipherRows.length,
|
||||||
|
attachments: attachmentRows.length,
|
||||||
|
sends: sendRows.length,
|
||||||
|
},
|
||||||
|
includes: {
|
||||||
|
attachments: true,
|
||||||
|
sendFiles: true,
|
||||||
|
},
|
||||||
|
blobSummary: {
|
||||||
|
attachmentFiles: attachmentBlobCount,
|
||||||
|
sendFiles: sendFileBlobCount,
|
||||||
|
totalBytes: totalBlobBytes,
|
||||||
|
largestObjectBytes,
|
||||||
|
},
|
||||||
|
} satisfies BackupManifest,
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const zipped = zipSync(files, { level: 0 });
|
||||||
|
await writeAuditLog(storage, actorUser.id, 'admin.backup.export', 'backup', null, {
|
||||||
|
users: userRows.length,
|
||||||
|
ciphers: cipherRows.length,
|
||||||
|
attachments: attachmentRows.length,
|
||||||
|
sends: sendRows.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(zipped, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/zip',
|
||||||
|
'Content-Disposition': `attachment; filename="${buildBackupFileName()}"`,
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/admin/backup/import
|
||||||
|
export async function handleAdminImportBackup(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
actorUser: User
|
||||||
|
): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) {
|
||||||
|
return errorResponse('Forbidden', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
|
let formData: FormData;
|
||||||
|
try {
|
||||||
|
formData = await request.formData();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Content-Type must be multipart/form-data', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = formData.get('file');
|
||||||
|
if (!file || typeof file !== 'object' || !('arrayBuffer' in file)) {
|
||||||
|
return errorResponse('Backup file is required', 400);
|
||||||
|
}
|
||||||
|
const backupFile = file as { arrayBuffer(): Promise<ArrayBuffer> };
|
||||||
|
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
|
||||||
|
|
||||||
|
let archiveBytes: Uint8Array;
|
||||||
|
try {
|
||||||
|
archiveBytes = new Uint8Array(await backupFile.arrayBuffer());
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Unable to read backup file', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: { payload: BackupPayload; files: Record<string, Uint8Array> };
|
||||||
|
try {
|
||||||
|
parsed = parseBackupArchive(archiveBytes);
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Invalid backup archive', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateBackupPayloadContents(parsed.payload, parsed.files);
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Backup archive contents are invalid', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateImportBlobLimits(env, parsed.payload, parsed.files);
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Backup import is not supported by the current storage backend', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetIsFresh = true;
|
||||||
|
try {
|
||||||
|
await ensureImportTargetIsFresh(env.DB);
|
||||||
|
} catch (error) {
|
||||||
|
targetIsFresh = false;
|
||||||
|
if (!replaceExisting) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Backup import requires a fresh instance', 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { db } = parsed.payload;
|
||||||
|
try {
|
||||||
|
if (!targetIsFresh) {
|
||||||
|
await clearExistingBlobFiles(env, env.DB);
|
||||||
|
await resetImportTarget(env.DB);
|
||||||
|
} else {
|
||||||
|
await resetImportTarget(env.DB);
|
||||||
|
}
|
||||||
|
await insertRows(env.DB, 'config', ['key', 'value'], db.config || [], true);
|
||||||
|
await insertRows(
|
||||||
|
env.DB,
|
||||||
|
'users',
|
||||||
|
['id', 'email', 'name', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
|
||||||
|
db.users || []
|
||||||
|
);
|
||||||
|
await insertRows(env.DB, 'user_revisions', ['user_id', 'revision_date'], db.user_revisions || []);
|
||||||
|
await insertRows(env.DB, 'folders', ['id', 'user_id', 'name', 'created_at', 'updated_at'], db.folders || []);
|
||||||
|
await insertRows(
|
||||||
|
env.DB,
|
||||||
|
'ciphers',
|
||||||
|
['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'deleted_at'],
|
||||||
|
db.ciphers || []
|
||||||
|
);
|
||||||
|
await insertRows(
|
||||||
|
env.DB,
|
||||||
|
'attachments',
|
||||||
|
['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'],
|
||||||
|
db.attachments || []
|
||||||
|
);
|
||||||
|
await insertRows(
|
||||||
|
env.DB,
|
||||||
|
'sends',
|
||||||
|
['id', 'user_id', 'type', 'name', 'notes', 'data', 'key', 'password_hash', 'password_salt', 'password_iterations', 'auth_type', 'emails', 'max_access_count', 'access_count', 'disabled', 'hide_email', 'created_at', 'updated_at', 'expiration_date', 'deletion_date'],
|
||||||
|
db.sends || []
|
||||||
|
);
|
||||||
|
|
||||||
|
const blobCounts = await restoreBlobFiles(env, db, parsed.files);
|
||||||
|
await storage.setRegistered();
|
||||||
|
const importedActorUserId = (db.users || []).some((row) => String(row.id || '').trim() === actorUser.id) ? actorUser.id : null;
|
||||||
|
await writeAuditLog(storage, importedActorUserId, 'admin.backup.import', 'backup', null, {
|
||||||
|
users: (db.users || []).length,
|
||||||
|
ciphers: (db.ciphers || []).length,
|
||||||
|
attachments: blobCounts.attachments,
|
||||||
|
sendFiles: blobCounts.sendFiles,
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'instance-backup-import',
|
||||||
|
imported: {
|
||||||
|
config: (db.config || []).length,
|
||||||
|
users: (db.users || []).length,
|
||||||
|
userRevisions: (db.user_revisions || []).length,
|
||||||
|
folders: (db.folders || []).length,
|
||||||
|
ciphers: (db.ciphers || []).length,
|
||||||
|
attachments: (db.attachments || []).length,
|
||||||
|
sends: (db.sends || []).length,
|
||||||
|
attachmentFiles: blobCounts.attachments,
|
||||||
|
sendFiles: blobCounts.sendFiles,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Backup import failed', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
+110
-29
@@ -15,31 +15,82 @@ function getAliasedProp(source: any, aliases: string[]): { present: boolean; val
|
|||||||
return { present: false, value: undefined };
|
return { present: false, value: undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Android 2026.2.0 expects fido2Credentials[].counter to be a string.
|
function looksLikeCipherString(value: unknown): boolean {
|
||||||
export function normalizeCipherLoginForCompatibility(login: any): any {
|
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
||||||
if (!login || typeof login !== 'object') return login ?? null;
|
}
|
||||||
|
|
||||||
const fido2 = Array.isArray(login.fido2Credentials)
|
export function shouldOmitPasskeysForResponse(request: Request | null | undefined): boolean {
|
||||||
? login.fido2Credentials.map((cred: any) => {
|
const userAgent = String(request?.headers.get('user-agent') || '').toLowerCase();
|
||||||
if (!cred || typeof cred !== 'object') return cred;
|
if (!userAgent) return false;
|
||||||
const rawCounter = cred.counter;
|
|
||||||
const counter =
|
// Temporary compatibility fallback:
|
||||||
rawCounter === null || rawCounter === undefined
|
// mobile clients expect official EncString payloads for most FIDO2 fields.
|
||||||
? '0'
|
// Keep passkeys available everywhere, but suppress only legacy malformed data
|
||||||
: String(rawCounter);
|
// for mobile clients so newly-saved credentials can flow through unchanged.
|
||||||
return {
|
return (
|
||||||
...cred,
|
userAgent.includes('android') ||
|
||||||
counter,
|
userAgent.includes('iphone') ||
|
||||||
};
|
userAgent.includes('ipad') ||
|
||||||
})
|
userAgent.includes('ios')
|
||||||
: login.fido2Credentials;
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCipherLoginForStorage(login: any): any {
|
||||||
|
if (!login || typeof login !== 'object') return login ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...login,
|
...login,
|
||||||
fido2Credentials: fido2,
|
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeCipherLoginForCompatibility(
|
||||||
|
login: any,
|
||||||
|
options?: { omitFido2Credentials?: boolean }
|
||||||
|
): any {
|
||||||
|
const normalized = normalizeCipherLoginForStorage(login);
|
||||||
|
if (!normalized || typeof normalized !== 'object') return normalized ?? null;
|
||||||
|
if (!options?.omitFido2Credentials) return normalized;
|
||||||
|
|
||||||
|
const credentials = Array.isArray(normalized.fido2Credentials) ? normalized.fido2Credentials : null;
|
||||||
|
if (!credentials?.length) return normalized;
|
||||||
|
|
||||||
|
const hasMalformedCredential = credentials.some((credential: any) => {
|
||||||
|
if (!credential || typeof credential !== 'object') return true;
|
||||||
|
const requiredEncryptedFields = [
|
||||||
|
credential.credentialId,
|
||||||
|
credential.keyType,
|
||||||
|
credential.keyAlgorithm,
|
||||||
|
credential.keyCurve,
|
||||||
|
credential.keyValue,
|
||||||
|
credential.rpId,
|
||||||
|
credential.counter,
|
||||||
|
credential.discoverable,
|
||||||
|
];
|
||||||
|
const optionalEncryptedFields = [
|
||||||
|
credential.userHandle,
|
||||||
|
credential.userName,
|
||||||
|
credential.rpName,
|
||||||
|
credential.userDisplayName,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (requiredEncryptedFields.some((value) => !looksLikeCipherString(value))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (optionalEncryptedFields.some((value) => value != null && !looksLikeCipherString(value))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return hasMalformedCredential
|
||||||
|
? {
|
||||||
|
...normalized,
|
||||||
|
fido2Credentials: null,
|
||||||
|
}
|
||||||
|
: normalized;
|
||||||
|
}
|
||||||
|
|
||||||
// Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads.
|
// Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads.
|
||||||
// Keep legacy alias "fingerprint" in parallel for older web payloads.
|
// Keep legacy alias "fingerprint" in parallel for older web payloads.
|
||||||
export function normalizeCipherSshKeyForCompatibility(sshKey: any): any {
|
export function normalizeCipherSshKeyForCompatibility(sshKey: any): any {
|
||||||
@@ -81,10 +132,14 @@ export function formatAttachments(attachments: Attachment[]): any[] | null {
|
|||||||
// Uses opaque passthrough: spreads ALL stored fields (including unknown/future ones),
|
// Uses opaque passthrough: spreads ALL stored fields (including unknown/future ones),
|
||||||
// then overlays server-computed fields. This ensures new Bitwarden client fields
|
// then overlays server-computed fields. This ensures new Bitwarden client fields
|
||||||
// survive a round-trip without code changes.
|
// survive a round-trip without code changes.
|
||||||
export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse {
|
export function cipherToResponse(
|
||||||
|
cipher: Cipher,
|
||||||
|
attachments: Attachment[] = [],
|
||||||
|
options?: { omitFido2Credentials?: boolean }
|
||||||
|
): CipherResponse {
|
||||||
// Strip internal-only fields that must not appear in the API response
|
// Strip internal-only fields that must not appear in the API response
|
||||||
const { userId, createdAt, updatedAt, deletedAt, ...passthrough } = cipher;
|
const { userId, createdAt, updatedAt, deletedAt, ...passthrough } = cipher;
|
||||||
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null);
|
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null, options);
|
||||||
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -119,6 +174,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const includeDeleted = url.searchParams.get('deleted') === 'true';
|
const includeDeleted = url.searchParams.get('deleted') === 'true';
|
||||||
const pagination = parsePagination(url);
|
const pagination = parsePagination(url);
|
||||||
|
const omitFido2Credentials = shouldOmitPasskeysForResponse(request);
|
||||||
|
|
||||||
let filteredCiphers: Cipher[];
|
let filteredCiphers: Cipher[];
|
||||||
let continuationToken: string | null = null;
|
let continuationToken: string | null = null;
|
||||||
@@ -145,7 +201,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
|||||||
const cipherResponses = [];
|
const cipherResponses = [];
|
||||||
for (const cipher of filteredCiphers) {
|
for (const cipher of filteredCiphers) {
|
||||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials }));
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
@@ -165,7 +221,11 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
return jsonResponse(cipherToResponse(cipher, attachments));
|
return jsonResponse(
|
||||||
|
cipherToResponse(cipher, attachments, {
|
||||||
|
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyFolderOwnership(storage: StorageService, folderId: string | null | undefined, userId: string): Promise<boolean> {
|
async function verifyFolderOwnership(storage: StorageService, folderId: string | null | undefined, userId: string): Promise<boolean> {
|
||||||
@@ -204,7 +264,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
};
|
};
|
||||||
cipher.login = normalizeCipherLoginForCompatibility(cipher.login);
|
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||||
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
||||||
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
||||||
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
|
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
|
||||||
@@ -218,7 +278,12 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
await storage.updateRevisionDate(userId);
|
await storage.updateRevisionDate(userId);
|
||||||
|
|
||||||
return jsonResponse(cipherToResponse(cipher), 200);
|
return jsonResponse(
|
||||||
|
cipherToResponse(cipher, [], {
|
||||||
|
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||||
|
}),
|
||||||
|
200
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /api/ciphers/:id
|
// PUT /api/ciphers/:id
|
||||||
@@ -256,7 +321,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
deletedAt: existingCipher.deletedAt,
|
deletedAt: existingCipher.deletedAt,
|
||||||
};
|
};
|
||||||
cipher.login = normalizeCipherLoginForCompatibility(cipher.login);
|
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||||
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
||||||
|
|
||||||
// Custom fields deletion compatibility:
|
// Custom fields deletion compatibility:
|
||||||
@@ -279,7 +344,11 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
await storage.updateRevisionDate(userId);
|
await storage.updateRevisionDate(userId);
|
||||||
|
|
||||||
return jsonResponse(cipherToResponse(cipher));
|
return jsonResponse(
|
||||||
|
cipherToResponse(cipher, [], {
|
||||||
|
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/ciphers/:id
|
// DELETE /api/ciphers/:id
|
||||||
@@ -297,7 +366,11 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
|
|||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
await storage.updateRevisionDate(userId);
|
await storage.updateRevisionDate(userId);
|
||||||
|
|
||||||
return jsonResponse(cipherToResponse(cipher));
|
return jsonResponse(
|
||||||
|
cipherToResponse(cipher, [], {
|
||||||
|
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/ciphers/:id (compat mode)
|
// DELETE /api/ciphers/:id (compat mode)
|
||||||
@@ -355,7 +428,11 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
|
|||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
await storage.updateRevisionDate(userId);
|
await storage.updateRevisionDate(userId);
|
||||||
|
|
||||||
return jsonResponse(cipherToResponse(cipher));
|
return jsonResponse(
|
||||||
|
cipherToResponse(cipher, [], {
|
||||||
|
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /api/ciphers/:id/partial - Update only favorite/folderId
|
// PUT /api/ciphers/:id/partial - Update only favorite/folderId
|
||||||
@@ -389,7 +466,11 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
|
|||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
await storage.updateRevisionDate(userId);
|
await storage.updateRevisionDate(userId);
|
||||||
|
|
||||||
return jsonResponse(cipherToResponse(cipher));
|
return jsonResponse(
|
||||||
|
cipherToResponse(cipher, [], {
|
||||||
|
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST/PUT /api/ciphers/move - Bulk move to folder
|
// POST/PUT /api/ciphers/move - Bulk move to folder
|
||||||
|
|||||||
+20
-32
@@ -9,6 +9,10 @@ import { createRefreshToken } from '../utils/jwt';
|
|||||||
import { readAuthRequestDeviceInfo } from '../utils/device';
|
import { readAuthRequestDeviceInfo } from '../utils/device';
|
||||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||||
import { issueSendAccessToken } from './sends';
|
import { issueSendAccessToken } from './sends';
|
||||||
|
import {
|
||||||
|
buildAccountKeys,
|
||||||
|
buildUserDecryptionOptions,
|
||||||
|
} from '../utils/user-decryption';
|
||||||
|
|
||||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||||
@@ -241,30 +245,22 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
||||||
Key: user.key,
|
Key: user.key,
|
||||||
PrivateKey: user.privateKey,
|
PrivateKey: user.privateKey,
|
||||||
|
AccountKeys: buildAccountKeys(user),
|
||||||
|
accountKeys: buildAccountKeys(user),
|
||||||
Kdf: user.kdfType,
|
Kdf: user.kdfType,
|
||||||
KdfIterations: user.kdfIterations,
|
KdfIterations: user.kdfIterations,
|
||||||
KdfMemory: user.kdfMemory,
|
KdfMemory: user.kdfMemory,
|
||||||
KdfParallelism: user.kdfParallelism,
|
KdfParallelism: user.kdfParallelism,
|
||||||
ForcePasswordReset: false,
|
ForcePasswordReset: false,
|
||||||
ResetMasterPassword: false,
|
ResetMasterPassword: false,
|
||||||
|
MasterPasswordPolicy: {
|
||||||
|
Object: 'masterPasswordPolicy',
|
||||||
|
},
|
||||||
|
ApiUseKeyConnector: false,
|
||||||
scope: 'api offline_access',
|
scope: 'api offline_access',
|
||||||
unofficialServer: true,
|
unofficialServer: true,
|
||||||
UserDecryptionOptions: {
|
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
||||||
HasMasterPassword: true,
|
userDecryptionOptions: buildUserDecryptionOptions(user),
|
||||||
Object: 'userDecryptionOptions',
|
|
||||||
MasterPasswordUnlock: {
|
|
||||||
Kdf: {
|
|
||||||
KdfType: user.kdfType,
|
|
||||||
Iterations: user.kdfIterations,
|
|
||||||
Memory: user.kdfMemory || null,
|
|
||||||
Parallelism: user.kdfParallelism || null,
|
|
||||||
},
|
|
||||||
MasterKeyEncryptedUserKey: user.key,
|
|
||||||
MasterKeyWrappedUserKey: user.key,
|
|
||||||
Salt: email, // email is already lowercased above
|
|
||||||
Object: 'masterPasswordUnlock',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return jsonResponse(response);
|
return jsonResponse(response);
|
||||||
@@ -360,30 +356,22 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
refresh_token: newRefreshToken,
|
refresh_token: newRefreshToken,
|
||||||
Key: user.key,
|
Key: user.key,
|
||||||
PrivateKey: user.privateKey,
|
PrivateKey: user.privateKey,
|
||||||
|
AccountKeys: buildAccountKeys(user),
|
||||||
|
accountKeys: buildAccountKeys(user),
|
||||||
Kdf: user.kdfType,
|
Kdf: user.kdfType,
|
||||||
KdfIterations: user.kdfIterations,
|
KdfIterations: user.kdfIterations,
|
||||||
KdfMemory: user.kdfMemory,
|
KdfMemory: user.kdfMemory,
|
||||||
KdfParallelism: user.kdfParallelism,
|
KdfParallelism: user.kdfParallelism,
|
||||||
ForcePasswordReset: false,
|
ForcePasswordReset: false,
|
||||||
ResetMasterPassword: false,
|
ResetMasterPassword: false,
|
||||||
|
MasterPasswordPolicy: {
|
||||||
|
Object: 'masterPasswordPolicy',
|
||||||
|
},
|
||||||
|
ApiUseKeyConnector: false,
|
||||||
scope: 'api offline_access',
|
scope: 'api offline_access',
|
||||||
unofficialServer: true,
|
unofficialServer: true,
|
||||||
UserDecryptionOptions: {
|
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
||||||
HasMasterPassword: true,
|
userDecryptionOptions: buildUserDecryptionOptions(user),
|
||||||
Object: 'userDecryptionOptions',
|
|
||||||
MasterPasswordUnlock: {
|
|
||||||
Kdf: {
|
|
||||||
KdfType: user.kdfType,
|
|
||||||
Iterations: user.kdfIterations,
|
|
||||||
Memory: user.kdfMemory || null,
|
|
||||||
Parallelism: user.kdfParallelism || null,
|
|
||||||
},
|
|
||||||
MasterKeyEncryptedUserKey: user.key,
|
|
||||||
MasterKeyWrappedUserKey: user.key,
|
|
||||||
Salt: user.email.toLowerCase(),
|
|
||||||
Object: 'masterPasswordUnlock',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return jsonResponse(response);
|
return jsonResponse(response);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { StorageService } from '../services/storage';
|
|||||||
import { errorResponse, jsonResponse } from '../utils/response';
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import { normalizeCipherLoginForCompatibility, normalizeCipherSshKeyForCompatibility } from './ciphers';
|
import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility } from './ciphers';
|
||||||
|
|
||||||
// Bitwarden client import request format
|
// Bitwarden client import request format
|
||||||
interface CiphersImportRequest {
|
interface CiphersImportRequest {
|
||||||
@@ -232,7 +232,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
};
|
};
|
||||||
cipher.login = normalizeCipherLoginForCompatibility(cipher.login);
|
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||||
|
|
||||||
cipherRows.push(cipher);
|
cipherRows.push(cipher);
|
||||||
cipherMapRows.push({ index: i, sourceId, id: cipher.id });
|
cipherMapRows.push({ index: i, sourceId, id: cipher.id });
|
||||||
|
|||||||
+15
-31
@@ -4,6 +4,11 @@ import { errorResponse } from '../utils/response';
|
|||||||
import { cipherToResponse } from './ciphers';
|
import { cipherToResponse } from './ciphers';
|
||||||
import { sendToResponse } from './sends';
|
import { sendToResponse } from './sends';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
|
import {
|
||||||
|
buildAccountKeys,
|
||||||
|
buildUserDecryptionCompat,
|
||||||
|
buildUserDecryptionOptions,
|
||||||
|
} from '../utils/user-decryption';
|
||||||
|
|
||||||
interface SyncCacheEntry {
|
interface SyncCacheEntry {
|
||||||
body: string;
|
body: string;
|
||||||
@@ -43,6 +48,12 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
||||||
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
||||||
|
const userAgent = String(request.headers.get('user-agent') || '').toLowerCase();
|
||||||
|
const omitFido2Credentials =
|
||||||
|
userAgent.includes('android') ||
|
||||||
|
userAgent.includes('iphone') ||
|
||||||
|
userAgent.includes('ipad') ||
|
||||||
|
userAgent.includes('ios');
|
||||||
|
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -78,7 +89,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
twoFactorEnabled: !!user.totpSecret,
|
twoFactorEnabled: !!user.totpSecret,
|
||||||
key: user.key,
|
key: user.key,
|
||||||
privateKey: user.privateKey,
|
privateKey: user.privateKey,
|
||||||
accountKeys: null,
|
accountKeys: buildAccountKeys(user),
|
||||||
securityStamp: user.securityStamp || user.id,
|
securityStamp: user.securityStamp || user.id,
|
||||||
organizations: [],
|
organizations: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
@@ -93,7 +104,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
const cipherResponses: CipherResponse[] = [];
|
const cipherResponses: CipherResponse[] = [];
|
||||||
for (const cipher of ciphers) {
|
for (const cipher of ciphers) {
|
||||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build folder responses
|
// Build folder responses
|
||||||
@@ -119,36 +130,9 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
policies: [],
|
policies: [],
|
||||||
sends: sends.map(sendToResponse),
|
sends: sends.map(sendToResponse),
|
||||||
// PascalCase for desktop/browser clients
|
// PascalCase for desktop/browser clients
|
||||||
UserDecryptionOptions: {
|
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
||||||
HasMasterPassword: true,
|
|
||||||
Object: 'userDecryptionOptions',
|
|
||||||
MasterPasswordUnlock: {
|
|
||||||
Kdf: {
|
|
||||||
KdfType: user.kdfType,
|
|
||||||
Iterations: user.kdfIterations,
|
|
||||||
Memory: user.kdfMemory || null,
|
|
||||||
Parallelism: user.kdfParallelism || null,
|
|
||||||
},
|
|
||||||
MasterKeyEncryptedUserKey: user.key,
|
|
||||||
MasterKeyWrappedUserKey: user.key,
|
|
||||||
Salt: user.email.toLowerCase(),
|
|
||||||
Object: 'masterPasswordUnlock',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
||||||
userDecryption: {
|
userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'],
|
||||||
masterPasswordUnlock: {
|
|
||||||
kdf: {
|
|
||||||
kdfType: user.kdfType,
|
|
||||||
iterations: user.kdfIterations,
|
|
||||||
memory: user.kdfMemory || null,
|
|
||||||
parallelism: user.kdfParallelism || null,
|
|
||||||
},
|
|
||||||
masterKeyWrappedUserKey: user.key,
|
|
||||||
masterKeyEncryptedUserKey: user.key,
|
|
||||||
salt: user.email.toLowerCase(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
object: 'sync',
|
object: 'sync',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+17
-4
@@ -99,6 +99,10 @@ import {
|
|||||||
handleAdminSetUserStatus,
|
handleAdminSetUserStatus,
|
||||||
handleAdminDeleteUser,
|
handleAdminDeleteUser,
|
||||||
} from './handlers/admin';
|
} from './handlers/admin';
|
||||||
|
import {
|
||||||
|
handleAdminExportBackup,
|
||||||
|
handleAdminImportBackup,
|
||||||
|
} from './handlers/backup';
|
||||||
|
|
||||||
function isSameOriginWriteRequest(request: Request): boolean {
|
function isSameOriginWriteRequest(request: Request): boolean {
|
||||||
const targetOrigin = new URL(request.url).origin;
|
const targetOrigin = new URL(request.url).origin;
|
||||||
@@ -269,11 +273,12 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
try {
|
try {
|
||||||
|
|
||||||
// Reject oversized bodies before any path-specific parsing.
|
// Reject oversized bodies before any path-specific parsing.
|
||||||
// File upload paths enforce their own limits and are exempt here.
|
// Large file/archive upload paths enforce their own limits and are exempt here.
|
||||||
const isFileUploadPath =
|
const isLargeUploadPath =
|
||||||
/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) ||
|
/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) ||
|
||||||
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path);
|
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path) ||
|
||||||
if (!isFileUploadPath) {
|
path === '/api/admin/backup/import';
|
||||||
|
if (!isLargeUploadPath) {
|
||||||
const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
|
const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
|
||||||
if (contentLength > LIMITS.request.maxBodyBytes) {
|
if (contentLength > LIMITS.request.maxBodyBytes) {
|
||||||
return errorResponse('Request body too large', 413);
|
return errorResponse('Request body too large', 413);
|
||||||
@@ -771,6 +776,14 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
return handleAdminListUsers(request, env, currentUser);
|
return handleAdminListUsers(request, env, currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/export' && method === 'POST') {
|
||||||
|
return handleAdminExportBackup(request, env, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/import' && method === 'POST') {
|
||||||
|
return handleAdminImportBackup(request, env, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
if (path === '/api/admin/invites') {
|
if (path === '/api/admin/invites') {
|
||||||
if (method === 'GET') return handleAdminListInvites(request, env, currentUser);
|
if (method === 'GET') return handleAdminListInvites(request, env, currentUser);
|
||||||
if (method === 'POST') return handleAdminCreateInvite(request, env, currentUser);
|
if (method === 'POST') return handleAdminCreateInvite(request, env, currentUser);
|
||||||
|
|||||||
@@ -284,6 +284,8 @@ export interface UserDecryptionOptions {
|
|||||||
Object: string;
|
Object: string;
|
||||||
// Bitwarden Android 2026.1.x expects this to exist; missing it breaks unlock when the vault is empty.
|
// Bitwarden Android 2026.1.x expects this to exist; missing it breaks unlock when the vault is empty.
|
||||||
MasterPasswordUnlock: MasterPasswordUnlock;
|
MasterPasswordUnlock: MasterPasswordUnlock;
|
||||||
|
TrustedDeviceOption: null;
|
||||||
|
KeyConnectorOption: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// API Response types
|
// API Response types
|
||||||
@@ -303,7 +305,14 @@ export interface TokenResponse {
|
|||||||
ResetMasterPassword: boolean;
|
ResetMasterPassword: boolean;
|
||||||
scope: string;
|
scope: string;
|
||||||
unofficialServer: boolean;
|
unofficialServer: boolean;
|
||||||
|
MasterPasswordPolicy?: {
|
||||||
|
Object: string;
|
||||||
|
} | null;
|
||||||
|
ApiUseKeyConnector?: boolean;
|
||||||
|
AccountKeys?: any | null;
|
||||||
|
accountKeys?: any | null;
|
||||||
UserDecryptionOptions: UserDecryptionOptions;
|
UserDecryptionOptions: UserDecryptionOptions;
|
||||||
|
userDecryptionOptions?: UserDecryptionOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileResponse {
|
export interface ProfileResponse {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { User, UserDecryptionOptions } from '../types';
|
||||||
|
|
||||||
|
export function buildAccountKeys(user: Pick<User, 'privateKey' | 'publicKey'>): Record<string, unknown> | null {
|
||||||
|
if (!user.privateKey || !user.publicKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKeyEncryptionKeyPair: {
|
||||||
|
wrappedPrivateKey: user.privateKey,
|
||||||
|
publicKey: user.publicKey,
|
||||||
|
Object: 'publicKeyEncryptionKeyPair',
|
||||||
|
},
|
||||||
|
Object: 'privateKeys',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMasterPasswordUnlock(
|
||||||
|
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>
|
||||||
|
): UserDecryptionOptions['MasterPasswordUnlock'] {
|
||||||
|
return {
|
||||||
|
Kdf: {
|
||||||
|
KdfType: user.kdfType,
|
||||||
|
Iterations: user.kdfIterations,
|
||||||
|
Memory: user.kdfMemory ?? null,
|
||||||
|
Parallelism: user.kdfParallelism ?? null,
|
||||||
|
},
|
||||||
|
MasterKeyEncryptedUserKey: user.key,
|
||||||
|
MasterKeyWrappedUserKey: user.key,
|
||||||
|
Salt: user.email.toLowerCase(),
|
||||||
|
Object: 'masterPasswordUnlock',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUserDecryptionOptions(
|
||||||
|
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>
|
||||||
|
): UserDecryptionOptions {
|
||||||
|
return {
|
||||||
|
HasMasterPassword: true,
|
||||||
|
Object: 'userDecryptionOptions',
|
||||||
|
MasterPasswordUnlock: buildMasterPasswordUnlock(user),
|
||||||
|
TrustedDeviceOption: null,
|
||||||
|
KeyConnectorOption: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUserDecryptionCompat(
|
||||||
|
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
masterPasswordUnlock: {
|
||||||
|
kdf: {
|
||||||
|
kdfType: user.kdfType,
|
||||||
|
iterations: user.kdfIterations,
|
||||||
|
memory: user.kdfMemory ?? null,
|
||||||
|
parallelism: user.kdfParallelism ?? null,
|
||||||
|
},
|
||||||
|
masterKeyWrappedUserKey: user.key,
|
||||||
|
masterKeyEncryptedUserKey: user.key,
|
||||||
|
salt: user.email.toLowerCase(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+80
-18
@@ -27,6 +27,8 @@ import {
|
|||||||
createAuthedFetch,
|
createAuthedFetch,
|
||||||
createInvite,
|
createInvite,
|
||||||
downloadCipherAttachmentDecrypted,
|
downloadCipherAttachmentDecrypted,
|
||||||
|
exportAdminBackup,
|
||||||
|
importAdminBackup,
|
||||||
importCiphers,
|
importCiphers,
|
||||||
createSend,
|
createSend,
|
||||||
deleteAllInvites,
|
deleteAllInvites,
|
||||||
@@ -109,7 +111,12 @@ function asText(value: unknown): string {
|
|||||||
|
|
||||||
function summarizeImportResult(
|
function summarizeImportResult(
|
||||||
ciphers: Array<Record<string, unknown>>,
|
ciphers: Array<Record<string, unknown>>,
|
||||||
folderCount: number
|
folderCount: number,
|
||||||
|
attachmentSummary?: {
|
||||||
|
total: number;
|
||||||
|
imported: number;
|
||||||
|
failed: Array<{ fileName: string; reason: string }>;
|
||||||
|
}
|
||||||
): ImportResultSummary {
|
): ImportResultSummary {
|
||||||
const typeLabel = (type: number): string => {
|
const typeLabel = (type: number): string => {
|
||||||
if (type === 1) return t('txt_login');
|
if (type === 1) return t('txt_login');
|
||||||
@@ -136,6 +143,9 @@ function summarizeImportResult(
|
|||||||
totalItems: ciphers.length,
|
totalItems: ciphers.length,
|
||||||
folderCount: Math.max(0, folderCount),
|
folderCount: Math.max(0, folderCount),
|
||||||
typeCounts,
|
typeCounts,
|
||||||
|
attachmentCount: Math.max(0, attachmentSummary?.total || 0),
|
||||||
|
importedAttachmentCount: Math.max(0, attachmentSummary?.imported || 0),
|
||||||
|
failedAttachments: attachmentSummary?.failed || [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1122,13 +1132,16 @@ export default function App() {
|
|||||||
async function uploadImportedAttachments(
|
async function uploadImportedAttachments(
|
||||||
attachments: ImportAttachmentFile[],
|
attachments: ImportAttachmentFile[],
|
||||||
idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> }
|
idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> }
|
||||||
): Promise<void> {
|
): Promise<{ total: number; imported: number; failed: Array<{ fileName: string; reason: string }> }> {
|
||||||
if (!attachments.length) return;
|
if (!attachments.length) {
|
||||||
|
return { total: 0, imported: 0, failed: [] };
|
||||||
|
}
|
||||||
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
||||||
|
|
||||||
const initialCiphers = (await ciphersQuery.refetch()).data || [];
|
const initialCiphers = (await ciphersQuery.refetch()).data || [];
|
||||||
const cipherById = new Map(initialCiphers.map((cipher) => [String(cipher.id || ''), cipher]));
|
const cipherById = new Map(initialCiphers.map((cipher) => [String(cipher.id || ''), cipher]));
|
||||||
const unresolved: ImportAttachmentFile[] = [];
|
const failed: Array<{ fileName: string; reason: string }> = [];
|
||||||
|
let imported = 0;
|
||||||
|
|
||||||
for (const attachment of attachments) {
|
for (const attachment of attachments) {
|
||||||
const sourceId = String(attachment.sourceCipherId || '').trim();
|
const sourceId = String(attachment.sourceCipherId || '').trim();
|
||||||
@@ -1137,7 +1150,10 @@ export default function App() {
|
|||||||
const byIndex = Number.isFinite(sourceIndex) ? idMaps.byIndex.get(sourceIndex) : null;
|
const byIndex = Number.isFinite(sourceIndex) ? idMaps.byIndex.get(sourceIndex) : null;
|
||||||
const targetCipherId = byId || byIndex || null;
|
const targetCipherId = byId || byIndex || null;
|
||||||
if (!targetCipherId) {
|
if (!targetCipherId) {
|
||||||
unresolved.push(attachment);
|
failed.push({
|
||||||
|
fileName: String(attachment.fileName || '').trim() || 'attachment.bin',
|
||||||
|
reason: t('txt_import_attachment_target_not_found'),
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1145,14 +1161,23 @@ export default function App() {
|
|||||||
const fileBytes = Uint8Array.from(attachment.bytes);
|
const fileBytes = Uint8Array.from(attachment.bytes);
|
||||||
const file = new File([fileBytes], name, { type: 'application/octet-stream' });
|
const file = new File([fileBytes], name, { type: 'application/octet-stream' });
|
||||||
const cipher = cipherById.get(targetCipherId) || null;
|
const cipher = cipherById.get(targetCipherId) || null;
|
||||||
|
try {
|
||||||
await uploadCipherAttachment(authedFetch, session, targetCipherId, file, cipher);
|
await uploadCipherAttachment(authedFetch, session, targetCipherId, file, cipher);
|
||||||
|
imported += 1;
|
||||||
|
} catch (error) {
|
||||||
|
failed.push({
|
||||||
|
fileName: name,
|
||||||
|
reason: error instanceof Error ? error.message : t('txt_upload_attachment_failed'),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (unresolved.length) {
|
|
||||||
throw new Error(t('txt_failed_to_map_attachments', { count: unresolved.length }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await ciphersQuery.refetch();
|
await ciphersQuery.refetch();
|
||||||
|
return {
|
||||||
|
total: attachments.length,
|
||||||
|
imported,
|
||||||
|
failed,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toImportedCipherMapsFromResponse(
|
function toImportedCipherMapsFromResponse(
|
||||||
@@ -1252,10 +1277,10 @@ export default function App() {
|
|||||||
const idMaps = buildImportedCipherMaps(payload.ciphers, createdCipherIdsByIndex);
|
const idMaps = buildImportedCipherMaps(payload.ciphers, createdCipherIdsByIndex);
|
||||||
await foldersQuery.refetch();
|
await foldersQuery.refetch();
|
||||||
await ciphersQuery.refetch();
|
await ciphersQuery.refetch();
|
||||||
if (attachments.length) {
|
const attachmentSummary = attachments.length
|
||||||
await uploadImportedAttachments(attachments, idMaps);
|
? await uploadImportedAttachments(attachments, idMaps)
|
||||||
}
|
: undefined;
|
||||||
return summarizeImportResult(payload.ciphers, mode === 'original' ? createdFolderCount : 0);
|
return summarizeImportResult(payload.ciphers, mode === 'original' ? createdFolderCount : 0, attachmentSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleImportEncryptedRawAction(
|
async function handleImportEncryptedRawAction(
|
||||||
@@ -1280,11 +1305,14 @@ export default function App() {
|
|||||||
returnCipherMap: attachments.length > 0,
|
returnCipherMap: attachments.length > 0,
|
||||||
});
|
});
|
||||||
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
||||||
if (attachments.length) {
|
const attachmentSummary = attachments.length
|
||||||
const idMaps = toImportedCipherMapsFromResponse(importedCipherMap);
|
? await uploadImportedAttachments(attachments, toImportedCipherMapsFromResponse(importedCipherMap))
|
||||||
await uploadImportedAttachments(attachments, idMaps);
|
: undefined;
|
||||||
}
|
return summarizeImportResult(
|
||||||
return summarizeImportResult(nextPayload.ciphers, mode === 'original' ? nextPayload.folders.length : 0);
|
nextPayload.ciphers,
|
||||||
|
mode === 'original' ? nextPayload.folders.length : 0,
|
||||||
|
attachmentSummary
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleExportAction(request: ExportRequest) {
|
async function handleExportAction(request: ExportRequest) {
|
||||||
@@ -1519,6 +1547,30 @@ export default function App() {
|
|||||||
throw new Error(t('txt_unsupported_export_format'));
|
throw new Error(t('txt_unsupported_export_format'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function downloadBytesAsFile(bytes: Uint8Array, fileName: string, mimeType: string) {
|
||||||
|
const blob = new Blob([bytes], { type: mimeType || 'application/octet-stream' });
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = objectUrl;
|
||||||
|
anchor.download = fileName || 'download.bin';
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBackupExportAction() {
|
||||||
|
const payload = await exportAdminBackup(authedFetch);
|
||||||
|
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBackupImportAction(file: File, replaceExisting: boolean = false) {
|
||||||
|
await importAdminBackup(authedFetch, file, replaceExisting);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
logoutNow();
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
|
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
|
||||||
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
|
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
|
||||||
const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0];
|
const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0];
|
||||||
@@ -1540,6 +1592,12 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [phase, isImportHashRoute, location, navigate]);
|
}, [phase, isImportHashRoute, location, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase === 'app' && profile?.role !== 'admin' && location === '/help') {
|
||||||
|
navigate('/vault');
|
||||||
|
}
|
||||||
|
}, [phase, profile?.role, location, navigate]);
|
||||||
|
|
||||||
if (jwtWarning) {
|
if (jwtWarning) {
|
||||||
return <JwtWarningPage reason={jwtWarning.reason} minLength={jwtWarning.minLength} />;
|
return <JwtWarningPage reason={jwtWarning.reason} minLength={jwtWarning.minLength} />;
|
||||||
}
|
}
|
||||||
@@ -1695,10 +1753,12 @@ export default function App() {
|
|||||||
<Shield size={16} />
|
<Shield size={16} />
|
||||||
<span>{t('nav_device_management')}</span>
|
<span>{t('nav_device_management')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
{profile?.role === 'admin' && (
|
||||||
<Link href="/help" className={`side-link ${location === '/help' ? 'active' : ''}`}>
|
<Link href="/help" className={`side-link ${location === '/help' ? 'active' : ''}`}>
|
||||||
<Cloud size={16} />
|
<Cloud size={16} />
|
||||||
<span>{t('nav_backup_strategy')}</span>
|
<span>{t('nav_backup_strategy')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
<Link href={IMPORT_ROUTE} className={`side-link ${isImportRoute ? 'active' : ''}`}>
|
<Link href={IMPORT_ROUTE} className={`side-link ${isImportRoute ? 'active' : ''}`}>
|
||||||
<ArrowUpDown size={14} />
|
<ArrowUpDown size={14} />
|
||||||
<span>{t('nav_import_export')}</span>
|
<span>{t('nav_import_export')}</span>
|
||||||
@@ -1913,7 +1973,9 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/help">
|
<Route path="/help">
|
||||||
<HelpPage />
|
{profile?.role === 'admin' ? (
|
||||||
|
<HelpPage onExport={handleBackupExportAction} onImport={handleBackupImportAction} onNotify={pushToast} />
|
||||||
|
) : null}
|
||||||
</Route>
|
</Route>
|
||||||
</Switch>
|
</Switch>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,18 +1,139 @@
|
|||||||
import { Cloud } from 'lucide-preact';
|
import { useRef, useState } from 'preact/hooks';
|
||||||
|
import { Download, FileUp } from 'lucide-preact';
|
||||||
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
export default function HelpPage() {
|
interface HelpPageProps {
|
||||||
|
onExport: () => Promise<void>;
|
||||||
|
onImport: (file: File, replaceExisting?: boolean) => Promise<void>;
|
||||||
|
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HelpPage(props: HelpPageProps) {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [localError, setLocalError] = useState('');
|
||||||
|
const [confirmReplaceOpen, setConfirmReplaceOpen] = useState(false);
|
||||||
|
|
||||||
|
function isReplaceRequiredError(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error ? String(error.message || '') : '';
|
||||||
|
return message.toLowerCase().includes('fresh instance');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
setLocalError('');
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
await props.onExport();
|
||||||
|
props.onNotify('success', t('txt_backup_export_success'));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
|
||||||
|
setLocalError(message);
|
||||||
|
props.onNotify('error', message);
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runImport(replaceExisting: boolean) {
|
||||||
|
if (!selectedFile) {
|
||||||
|
const message = t('txt_backup_file_required');
|
||||||
|
setLocalError(message);
|
||||||
|
props.onNotify('error', message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocalError('');
|
||||||
|
setImporting(true);
|
||||||
|
try {
|
||||||
|
await props.onImport(selectedFile, replaceExisting);
|
||||||
|
props.onNotify('success', t('txt_backup_import_success_relogin'));
|
||||||
|
setSelectedFile(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
setConfirmReplaceOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
if (!replaceExisting && isReplaceRequiredError(error)) {
|
||||||
|
setConfirmReplaceOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : t('txt_backup_import_failed');
|
||||||
|
setLocalError(message);
|
||||||
|
props.onNotify('error', message);
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
await runImport(false);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className="stack backup-page">
|
||||||
<section className="card">
|
<div className="import-export-panels">
|
||||||
<h3>{t('backup_strategy_title')}</h3>
|
<section className="card backup-panel">
|
||||||
<div className="empty" style={{ minHeight: 180 }}>
|
<div className="section-head">
|
||||||
<div style={{ textAlign: 'center' }}>
|
<h3>{t('txt_backup_export')}</h3>
|
||||||
<Cloud size={34} style={{ color: '#64748b', marginBottom: 8 }} />
|
|
||||||
<div>{t('backup_strategy_under_construction')}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="backup-inline-note">{t('txt_backup_export_description')}</p>
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" className="btn btn-primary" disabled={exporting || importing} onClick={() => void handleExport()}>
|
||||||
|
<Download size={14} className="btn-icon" />
|
||||||
|
{exporting ? t('txt_backup_exporting') : t('txt_backup_export')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="card backup-panel">
|
||||||
|
<div className="section-head">
|
||||||
|
<h3>{t('txt_backup_import')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="backup-inline-note">{t('txt_backup_import_description')}</p>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_file')}</span>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="input"
|
||||||
|
type="file"
|
||||||
|
accept=".zip,application/zip"
|
||||||
|
disabled={importing || exporting}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextFile = (event.currentTarget as HTMLInputElement).files?.[0] || null;
|
||||||
|
setSelectedFile(nextFile);
|
||||||
|
setLocalError('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="backup-file-meta">
|
||||||
|
{selectedFile ? (
|
||||||
|
<span>{t('txt_backup_selected_file_name', { name: selectedFile.name })}</span>
|
||||||
|
) : (
|
||||||
|
<span>{t('txt_backup_no_file_selected')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="backup-inline-note">{t('txt_backup_restore_note')}</p>
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" className="btn btn-primary" disabled={importing || exporting} onClick={() => void handleImport()}>
|
||||||
|
<FileUp size={14} className="btn-icon" />
|
||||||
|
{importing ? t('txt_backup_importing') : t('txt_backup_import')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{localError && <div className="local-error">{localError}</div>}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmReplaceOpen}
|
||||||
|
title={t('txt_backup_replace_confirm_title')}
|
||||||
|
message={t('txt_backup_replace_confirm_message')}
|
||||||
|
confirmText={t('txt_backup_clear_and_import')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
danger
|
||||||
|
onConfirm={() => void runImport(true)}
|
||||||
|
onCancel={() => setConfirmReplaceOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { argon2idAsync } from '@noble/hashes/argon2.js';
|
import { argon2idAsync } from '@noble/hashes/argon2.js';
|
||||||
import { strFromU8, unzipSync } from 'fflate';
|
import { strFromU8, unzipSync } from 'fflate';
|
||||||
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
|
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
|
||||||
import { Archive, ArrowLeftRight, Download, FileJson, FileUp } from 'lucide-preact';
|
import { Download, FileUp } from 'lucide-preact';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
import type { CiphersImportPayload } from '@/lib/api';
|
import type { CiphersImportPayload } from '@/lib/api';
|
||||||
import {
|
import {
|
||||||
@@ -55,6 +55,9 @@ export interface ImportResultSummary {
|
|||||||
totalItems: number;
|
totalItems: number;
|
||||||
folderCount: number;
|
folderCount: number;
|
||||||
typeCounts: Array<{ label: string; count: number }>;
|
typeCounts: Array<{ label: string; count: number }>;
|
||||||
|
attachmentCount: number;
|
||||||
|
importedAttachmentCount: number;
|
||||||
|
failedAttachments: Array<{ fileName: string; reason: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BitwardenPasswordProtectedInput extends BitwardenJsonInput {
|
interface BitwardenPasswordProtectedInput extends BitwardenJsonInput {
|
||||||
@@ -582,46 +585,10 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="import-export-page">
|
<div className="import-export-page">
|
||||||
<section className="card import-export-hero">
|
|
||||||
<h3>{t('txt_import_export_title')}</h3>
|
|
||||||
<p className="import-export-hero-sub">{t('txt_import_export_feature_intro')}</p>
|
|
||||||
<div className="import-export-feature-grid">
|
|
||||||
<article className="import-export-feature-item">
|
|
||||||
<span className="import-export-feature-icon">
|
|
||||||
<Archive size={16} />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<strong>{t('txt_import_export_feature_bw_zip_title')}</strong>
|
|
||||||
<p>{t('txt_import_export_feature_bw_zip_desc')}</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<article className="import-export-feature-item">
|
|
||||||
<span className="import-export-feature-icon">
|
|
||||||
<FileJson size={16} />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<strong>{t('txt_import_export_feature_nodewarden_json_title')}</strong>
|
|
||||||
<p>{t('txt_import_export_feature_nodewarden_json_desc')}</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<article className="import-export-feature-item">
|
|
||||||
<span className="import-export-feature-icon">
|
|
||||||
<ArrowLeftRight size={16} />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<strong>{t('txt_import_export_feature_compat_title')}</strong>
|
|
||||||
<p>{t('txt_import_export_feature_compat_desc')}</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="import-export-panels">
|
<div className="import-export-panels">
|
||||||
<section className="card import-export-panel">
|
<section className="card import-export-panel">
|
||||||
<h3>{t('txt_import')}</h3>
|
<h3>{t('txt_import')}</h3>
|
||||||
<p className="muted" style={{ textAlign: 'left', marginBottom: 12 }}>
|
<p className="backup-inline-note">{t('txt_import_vault_data_hint')}</p>
|
||||||
{t('txt_import_vault_data_hint')}
|
|
||||||
</p>
|
|
||||||
<div className="field-grid">
|
<div className="field-grid">
|
||||||
<label className="field field-span-2">
|
<label className="field field-span-2">
|
||||||
<span>{t('txt_format')}</span>
|
<span>{t('txt_format')}</span>
|
||||||
@@ -702,9 +669,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
|
|
||||||
<section className="card import-export-panel">
|
<section className="card import-export-panel">
|
||||||
<h3>{t('txt_export')}</h3>
|
<h3>{t('txt_export')}</h3>
|
||||||
<p className="muted" style={{ textAlign: 'left', marginBottom: 12 }}>
|
<p className="backup-inline-note">{t('txt_export_vault_data_hint')}</p>
|
||||||
{t('txt_export_vault_data_hint')}
|
|
||||||
</p>
|
|
||||||
<div className="field-grid">
|
<div className="field-grid">
|
||||||
<label className="field field-span-2">
|
<label className="field field-span-2">
|
||||||
<span>{t('txt_format')}</span>
|
<span>{t('txt_format')}</span>
|
||||||
@@ -862,6 +827,29 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
</button>
|
</button>
|
||||||
<h3 className="dialog-title">{t('txt_import_success')}</h3>
|
<h3 className="dialog-title">{t('txt_import_success')}</h3>
|
||||||
<div className="dialog-message">{t('txt_import_success_number_of_items', { count: importSummary.totalItems })}</div>
|
<div className="dialog-message">{t('txt_import_success_number_of_items', { count: importSummary.totalItems })}</div>
|
||||||
|
{importSummary.attachmentCount > 0 && (
|
||||||
|
<div className="dialog-message">
|
||||||
|
{t('txt_import_attachment_summary', {
|
||||||
|
imported: String(importSummary.importedAttachmentCount),
|
||||||
|
total: String(importSummary.attachmentCount),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{importSummary.failedAttachments.length > 0 && (
|
||||||
|
<div className="import-summary-failed-list">
|
||||||
|
<div className="import-summary-failed-title">
|
||||||
|
{t('txt_import_failed_attachments_title', { count: String(importSummary.failedAttachments.length) })}
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{importSummary.failedAttachments.map((row, index) => (
|
||||||
|
<li key={`${row.fileName}-${index}`}>
|
||||||
|
<strong>{row.fileName}</strong>
|
||||||
|
{`: ${row.reason}`}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="import-summary-table-wrap">
|
<div className="import-summary-table-wrap">
|
||||||
<table className="import-summary-table">
|
<table className="import-summary-table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
+116
-25
@@ -63,6 +63,25 @@ async function parseJson<T>(response: Response): Promise<T | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseContentDispositionFileName(response: Response, fallback: string): string {
|
||||||
|
const header = String(response.headers.get('Content-Disposition') || '').trim();
|
||||||
|
if (!header) return fallback;
|
||||||
|
|
||||||
|
const utf8Match = header.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
|
||||||
|
if (utf8Match?.[1]) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(utf8Match[1]);
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed filename*= values and fall back to the plain filename.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const plainMatch = header.match(/filename\s*=\s*"([^"]+)"|filename\s*=\s*([^;]+)/i);
|
||||||
|
const raw = plainMatch?.[1] || plainMatch?.[2] || '';
|
||||||
|
const normalized = String(raw).trim().replace(/^"+|"+$/g, '');
|
||||||
|
return normalized || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSetupStatus(): Promise<SetupStatusResponse> {
|
export async function getSetupStatus(): Promise<SetupStatusResponse> {
|
||||||
const resp = await fetch('/setup/status');
|
const resp = await fetch('/setup/status');
|
||||||
const body = await parseJson<SetupStatusResponse>(resp);
|
const body = await parseJson<SetupStatusResponse>(resp);
|
||||||
@@ -804,6 +823,63 @@ export async function deleteUser(authedFetch: (input: string, init?: RequestInit
|
|||||||
if (!resp.ok) throw new Error('Delete user failed');
|
if (!resp.ok) throw new Error('Delete user failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminBackupImportCounts {
|
||||||
|
config: number;
|
||||||
|
users: number;
|
||||||
|
userRevisions: number;
|
||||||
|
folders: number;
|
||||||
|
ciphers: number;
|
||||||
|
attachments: number;
|
||||||
|
sends: number;
|
||||||
|
attachmentFiles: number;
|
||||||
|
sendFiles: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminBackupImportResponse {
|
||||||
|
object: 'instance-backup-import';
|
||||||
|
imported: AdminBackupImportCounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminBackupExportPayload {
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportAdminBackup(
|
||||||
|
authedFetch: (input: string, init?: RequestInit) => Promise<Response>
|
||||||
|
): Promise<AdminBackupExportPayload> {
|
||||||
|
const resp = await authedFetch('/api/admin/backup/export', { method: 'POST' });
|
||||||
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Backup export failed'));
|
||||||
|
|
||||||
|
const mimeType = String(resp.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip';
|
||||||
|
const fileName = parseContentDispositionFileName(resp, 'nodewarden_instance_backup.zip');
|
||||||
|
const bytes = new Uint8Array(await resp.arrayBuffer());
|
||||||
|
return { fileName, mimeType, bytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importAdminBackup(
|
||||||
|
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||||
|
file: File,
|
||||||
|
replaceExisting: boolean = false
|
||||||
|
): Promise<AdminBackupImportResponse> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set('file', file, file.name || 'nodewarden_instance_backup.zip');
|
||||||
|
if (replaceExisting) {
|
||||||
|
formData.set('replaceExisting', '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await authedFetch('/api/admin/backup/import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Backup import failed'));
|
||||||
|
|
||||||
|
const body = await parseJson<AdminBackupImportResponse>(resp);
|
||||||
|
if (!body?.imported) throw new Error('Invalid backup import response');
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
function asNullable(v: string): string | null {
|
function asNullable(v: string): string | null {
|
||||||
const s = String(v || '').trim();
|
const s = String(v || '').trim();
|
||||||
return s ? s : null;
|
return s ? s : null;
|
||||||
@@ -851,16 +927,6 @@ async function encryptUris(uris: string[], enc: Uint8Array, mac: Uint8Array): Pr
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function asFidoString(value: unknown, fallback = ''): string {
|
|
||||||
const normalized = String(value ?? '').trim();
|
|
||||||
return normalized || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asNullableFidoString(value: unknown): string | null {
|
|
||||||
const normalized = String(value ?? '').trim();
|
|
||||||
return normalized || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toIsoDateOrNow(value: unknown): string {
|
function toIsoDateOrNow(value: unknown): string {
|
||||||
const raw = String(value ?? '').trim();
|
const raw = String(value ?? '').trim();
|
||||||
if (!raw) return new Date().toISOString();
|
if (!raw) return new Date().toISOString();
|
||||||
@@ -869,26 +935,51 @@ function toIsoDateOrNow(value: unknown): string {
|
|||||||
return parsed.toISOString();
|
return parsed.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeFido2Credentials(
|
async function encryptMaybeFidoValue(
|
||||||
|
value: unknown,
|
||||||
|
enc: Uint8Array,
|
||||||
|
mac: Uint8Array,
|
||||||
|
fallback = ''
|
||||||
|
): Promise<string> {
|
||||||
|
const normalized = String(value ?? '').trim() || fallback;
|
||||||
|
if (looksLikeCipherString(normalized)) return normalized;
|
||||||
|
return encryptBw(new TextEncoder().encode(normalized), enc, mac);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encryptMaybeNullableFidoValue(
|
||||||
|
value: unknown,
|
||||||
|
enc: Uint8Array,
|
||||||
|
mac: Uint8Array
|
||||||
|
): Promise<string | null> {
|
||||||
|
const normalized = String(value ?? '').trim();
|
||||||
|
if (!normalized) return null;
|
||||||
|
if (looksLikeCipherString(normalized)) return normalized;
|
||||||
|
return encryptBw(new TextEncoder().encode(normalized), enc, mac);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function normalizeFido2Credentials(
|
||||||
credentials: Array<Record<string, unknown>> | null | undefined
|
credentials: Array<Record<string, unknown>> | null | undefined
|
||||||
|
,
|
||||||
|
enc: Uint8Array,
|
||||||
|
mac: Uint8Array
|
||||||
): Array<Record<string, unknown>> | null {
|
): Array<Record<string, unknown>> | null {
|
||||||
if (!Array.isArray(credentials) || credentials.length === 0) return null;
|
if (!Array.isArray(credentials) || credentials.length === 0) return null;
|
||||||
const out: Array<Record<string, unknown>> = [];
|
const out: Array<Record<string, unknown>> = [];
|
||||||
for (const credential of credentials) {
|
for (const credential of credentials) {
|
||||||
if (!credential || typeof credential !== 'object') continue;
|
if (!credential || typeof credential !== 'object') continue;
|
||||||
out.push({
|
out.push({
|
||||||
credentialId: asFidoString(credential.credentialId),
|
credentialId: await encryptMaybeFidoValue(credential.credentialId, enc, mac),
|
||||||
keyType: asFidoString(credential.keyType, 'public-key'),
|
keyType: await encryptMaybeFidoValue(credential.keyType, enc, mac, 'public-key'),
|
||||||
keyAlgorithm: asFidoString(credential.keyAlgorithm, 'ECDSA'),
|
keyAlgorithm: await encryptMaybeFidoValue(credential.keyAlgorithm, enc, mac, 'ECDSA'),
|
||||||
keyCurve: asFidoString(credential.keyCurve, 'P-256'),
|
keyCurve: await encryptMaybeFidoValue(credential.keyCurve, enc, mac, 'P-256'),
|
||||||
keyValue: asFidoString(credential.keyValue),
|
keyValue: await encryptMaybeFidoValue(credential.keyValue, enc, mac),
|
||||||
rpId: asFidoString(credential.rpId),
|
rpId: await encryptMaybeFidoValue(credential.rpId, enc, mac),
|
||||||
rpName: asNullableFidoString(credential.rpName),
|
rpName: await encryptMaybeNullableFidoValue(credential.rpName, enc, mac),
|
||||||
userHandle: asNullableFidoString(credential.userHandle),
|
userHandle: await encryptMaybeNullableFidoValue(credential.userHandle, enc, mac),
|
||||||
userName: asNullableFidoString(credential.userName),
|
userName: await encryptMaybeNullableFidoValue(credential.userName, enc, mac),
|
||||||
userDisplayName: asNullableFidoString(credential.userDisplayName),
|
userDisplayName: await encryptMaybeNullableFidoValue(credential.userDisplayName, enc, mac),
|
||||||
counter: asFidoString(credential.counter, '0'),
|
counter: await encryptMaybeFidoValue(credential.counter, enc, mac, '0'),
|
||||||
discoverable: asFidoString(credential.discoverable, 'false'),
|
discoverable: await encryptMaybeFidoValue(credential.discoverable, enc, mac, 'false'),
|
||||||
creationDate: toIsoDateOrNow(credential.creationDate),
|
creationDate: toIsoDateOrNow(credential.creationDate),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -937,7 +1028,7 @@ export async function createCipher(
|
|||||||
username: await encryptTextValue(draft.loginUsername, enc, mac),
|
username: await encryptTextValue(draft.loginUsername, enc, mac),
|
||||||
password: await encryptTextValue(draft.loginPassword, enc, mac),
|
password: await encryptTextValue(draft.loginPassword, enc, mac),
|
||||||
totp: await encryptTextValue(draft.loginTotp, enc, mac),
|
totp: await encryptTextValue(draft.loginTotp, enc, mac),
|
||||||
fido2Credentials: normalizeFido2Credentials(draft.loginFido2Credentials),
|
fido2Credentials: await normalizeFido2Credentials(draft.loginFido2Credentials, enc, mac),
|
||||||
uris: await encryptUris(draft.loginUris || [], enc, mac),
|
uris: await encryptUris(draft.loginUris || [], enc, mac),
|
||||||
};
|
};
|
||||||
} else if (type === 3) {
|
} else if (type === 3) {
|
||||||
@@ -1032,7 +1123,7 @@ export async function updateCipher(
|
|||||||
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
|
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
|
||||||
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
|
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
|
||||||
totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac),
|
totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac),
|
||||||
fido2Credentials: normalizeFido2Credentials(existingFido2),
|
fido2Credentials: await normalizeFido2Credentials(existingFido2, keys.enc, keys.mac),
|
||||||
uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac),
|
uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac),
|
||||||
};
|
};
|
||||||
} else if (type === 3) {
|
} else if (type === 3) {
|
||||||
|
|||||||
+42
-14
@@ -15,6 +15,23 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
backup_strategy_under_construction: "Under construction.",
|
backup_strategy_under_construction: "Under construction.",
|
||||||
import_export_title: "Import & Export",
|
import_export_title: "Import & Export",
|
||||||
import_export_under_construction: "Under construction.",
|
import_export_under_construction: "Under construction.",
|
||||||
|
txt_backup_export: "Backup Export",
|
||||||
|
txt_backup_import: "Backup Import",
|
||||||
|
txt_backup_export_description: "Download a full instance backup ZIP for manual safekeeping.",
|
||||||
|
txt_backup_import_description: "Upload a previously exported backup ZIP and restore it into a fresh instance shell.",
|
||||||
|
txt_backup_exporting: "Exporting...",
|
||||||
|
txt_backup_importing: "Importing...",
|
||||||
|
txt_backup_export_success: "Backup exported",
|
||||||
|
txt_backup_import_success_relogin: "Backup imported. Please sign in again.",
|
||||||
|
txt_backup_export_failed: "Backup export failed",
|
||||||
|
txt_backup_import_failed: "Backup import failed",
|
||||||
|
txt_backup_file: "Backup File",
|
||||||
|
txt_backup_file_required: "Please select a backup file",
|
||||||
|
txt_backup_no_file_selected: "No backup file selected",
|
||||||
|
txt_backup_selected_file_name: "Selected file: {name}",
|
||||||
|
txt_backup_replace_confirm_title: "Replace Current Instance Data",
|
||||||
|
txt_backup_replace_confirm_message: "The current instance already contains data. Clear it and import the new backup?",
|
||||||
|
txt_backup_clear_and_import: "Clear and Import",
|
||||||
txt_access_count: "Access Count",
|
txt_access_count: "Access Count",
|
||||||
txt_accessed_count_times: "Accessed {count} times",
|
txt_accessed_count_times: "Accessed {count} times",
|
||||||
txt_actions: "Actions",
|
txt_actions: "Actions",
|
||||||
@@ -385,6 +402,23 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
backup_strategy_under_construction: '正在搭建中',
|
backup_strategy_under_construction: '正在搭建中',
|
||||||
import_export_title: '导入导出',
|
import_export_title: '导入导出',
|
||||||
import_export_under_construction: '正在搭建中',
|
import_export_under_construction: '正在搭建中',
|
||||||
|
txt_backup_export: '备份导出',
|
||||||
|
txt_backup_import: '备份导入',
|
||||||
|
txt_backup_export_description: '下载一个完整的实例备份 ZIP,手动保管即可。',
|
||||||
|
txt_backup_import_description: '上传之前导出的备份 ZIP,并恢复到全新实例空壳。',
|
||||||
|
txt_backup_exporting: '正在导出...',
|
||||||
|
txt_backup_importing: '正在导入...',
|
||||||
|
txt_backup_export_success: '备份已导出',
|
||||||
|
txt_backup_import_success_relogin: '备份已导入,请重新登录',
|
||||||
|
txt_backup_export_failed: '备份导出失败',
|
||||||
|
txt_backup_import_failed: '备份导入失败',
|
||||||
|
txt_backup_file: '备份文件',
|
||||||
|
txt_backup_file_required: '请选择备份文件',
|
||||||
|
txt_backup_no_file_selected: '尚未选择备份文件',
|
||||||
|
txt_backup_selected_file_name: '已选择文件:{name}',
|
||||||
|
txt_backup_replace_confirm_title: '替换当前实例数据',
|
||||||
|
txt_backup_replace_confirm_message: '当前实例里已经有数据。要先清空当前数据库和文件,再导入新的备份吗?',
|
||||||
|
txt_backup_clear_and_import: '清空后导入',
|
||||||
txt_sign_out: '退出登录',
|
txt_sign_out: '退出登录',
|
||||||
txt_log_in: '登录',
|
txt_log_in: '登录',
|
||||||
txt_log_out: '退出',
|
txt_log_out: '退出',
|
||||||
@@ -759,13 +793,6 @@ messages.en.txt_select_folder_placeholder = '-- Select folder --';
|
|||||||
messages.en.txt_import_vault_data_hint = 'Import vault data into your current account.';
|
messages.en.txt_import_vault_data_hint = 'Import vault data into your current account.';
|
||||||
messages.en.txt_export_vault_data_hint = 'Export vault data from your current account.';
|
messages.en.txt_export_vault_data_hint = 'Export vault data from your current account.';
|
||||||
messages.en.txt_import_export_title = 'Import & Export';
|
messages.en.txt_import_export_title = 'Import & Export';
|
||||||
messages.en.txt_import_export_feature_intro = 'Provides standardized vault migration across clients, including attachment-aware and encrypted workflows.';
|
|
||||||
messages.en.txt_import_export_feature_bw_zip_title = 'Bitwarden vault + attachments ZIP';
|
|
||||||
messages.en.txt_import_export_feature_bw_zip_desc = 'Supports both import and export for Bitwarden ZIP archives containing vault data and attachments.';
|
|
||||||
messages.en.txt_import_export_feature_nodewarden_json_title = 'NodeWarden vault + attachments JSON';
|
|
||||||
messages.en.txt_import_export_feature_nodewarden_json_desc = 'Supports NodeWarden JSON import/export with vault and attachment payloads in a single document. Exported vault data remains importable by Bitwarden clients.';
|
|
||||||
messages.en.txt_import_export_feature_compat_title = 'Cross-client compatibility';
|
|
||||||
messages.en.txt_import_export_feature_compat_desc = 'Supports Bitwarden JSON/CSV and mainstream migration formats with consistent field normalization and import mapping.';
|
|
||||||
messages.en.txt_encrypted_mode = 'Encrypted mode';
|
messages.en.txt_encrypted_mode = 'Encrypted mode';
|
||||||
messages.en.txt_account_verification = 'Account verification';
|
messages.en.txt_account_verification = 'Account verification';
|
||||||
messages.en.txt_password_verification = 'Password verification';
|
messages.en.txt_password_verification = 'Password verification';
|
||||||
@@ -776,6 +803,10 @@ messages.en.txt_close = 'Close';
|
|||||||
messages.en.txt_total = 'Total';
|
messages.en.txt_total = 'Total';
|
||||||
messages.en.txt_import_success = 'Import successful';
|
messages.en.txt_import_success = 'Import successful';
|
||||||
messages.en.txt_import_success_number_of_items = 'Imported {count} item(s) in total.';
|
messages.en.txt_import_success_number_of_items = 'Imported {count} item(s) in total.';
|
||||||
|
messages.en.txt_import_attachment_summary = 'Imported {imported} of {total} attachment(s).';
|
||||||
|
messages.en.txt_import_failed_attachments_title = '{count} attachment(s) were not imported:';
|
||||||
|
messages.en.txt_import_attachment_target_not_found = 'Matching imported item not found.';
|
||||||
|
messages.en.txt_upload_attachment_failed = 'Attachment upload failed.';
|
||||||
messages.en.txt_import_file_password_required = 'Please enter file password.';
|
messages.en.txt_import_file_password_required = 'Please enter file password.';
|
||||||
messages.en.txt_import_invalid_zip_password = 'Invalid ZIP password.';
|
messages.en.txt_import_invalid_zip_password = 'Invalid ZIP password.';
|
||||||
messages.en.txt_export_completed = 'Export completed';
|
messages.en.txt_export_completed = 'Export completed';
|
||||||
@@ -832,6 +863,10 @@ zhCNOverrides.txt_close = '关闭';
|
|||||||
zhCNOverrides.txt_total = '总计';
|
zhCNOverrides.txt_total = '总计';
|
||||||
zhCNOverrides.txt_import_success = '数据导入成功';
|
zhCNOverrides.txt_import_success = '数据导入成功';
|
||||||
zhCNOverrides.txt_import_success_number_of_items = '一共导入了 {count} 个项目。';
|
zhCNOverrides.txt_import_success_number_of_items = '一共导入了 {count} 个项目。';
|
||||||
|
zhCNOverrides.txt_import_attachment_summary = '附件已导入 {imported}/{total} 个。';
|
||||||
|
zhCNOverrides.txt_import_failed_attachments_title = '以下 {count} 个附件未导入:';
|
||||||
|
zhCNOverrides.txt_import_attachment_target_not_found = '没有找到对应的导入项目。';
|
||||||
|
zhCNOverrides.txt_upload_attachment_failed = '附件上传失败。';
|
||||||
zhCNOverrides.txt_import_file_password_required = '请输入文件密码。';
|
zhCNOverrides.txt_import_file_password_required = '请输入文件密码。';
|
||||||
zhCNOverrides.txt_import_invalid_zip_password = 'ZIP 密码错误。';
|
zhCNOverrides.txt_import_invalid_zip_password = 'ZIP 密码错误。';
|
||||||
zhCNOverrides.txt_export_completed = '导出完成';
|
zhCNOverrides.txt_export_completed = '导出完成';
|
||||||
@@ -850,13 +885,6 @@ zhCNOverrides.txt_import_encrypted_zip_title = '导入加密 ZIP';
|
|||||||
zhCNOverrides.txt_import_encrypted_zip_message = '该 ZIP 压缩包已加密,请输入 ZIP 密码继续。';
|
zhCNOverrides.txt_import_encrypted_zip_message = '该 ZIP 压缩包已加密,请输入 ZIP 密码继续。';
|
||||||
|
|
||||||
zhCNOverrides.txt_import_export_title = '导入导出';
|
zhCNOverrides.txt_import_export_title = '导入导出';
|
||||||
zhCNOverrides.txt_import_export_feature_intro = '提供标准化的数据迁移能力,覆盖附件与加密场景。';
|
|
||||||
zhCNOverrides.txt_import_export_feature_bw_zip_title = 'Bitwarden 密码库 + 附件 ZIP';
|
|
||||||
zhCNOverrides.txt_import_export_feature_bw_zip_desc = '支持导入与导出包含密码库和附件的 Bitwarden ZIP 压缩包。';
|
|
||||||
zhCNOverrides.txt_import_export_feature_nodewarden_json_title = 'NodeWarden 密码库 + 附件 JSON';
|
|
||||||
zhCNOverrides.txt_import_export_feature_nodewarden_json_desc = '支持 NodeWarden JSON 导入导出,单文件包含密码库与附件;导出的密码库数据可被 Bitwarden 客户端导入。';
|
|
||||||
zhCNOverrides.txt_import_export_feature_compat_title = '跨客户端兼容';
|
|
||||||
zhCNOverrides.txt_import_export_feature_compat_desc = '支持 Bitwarden JSON/CSV 与主流迁移格式,统一字段映射与导入行为。';
|
|
||||||
zhCNOverrides.txt_new_type_header = '新建{type}';
|
zhCNOverrides.txt_new_type_header = '新建{type}';
|
||||||
zhCNOverrides.txt_edit_type_header = '编辑{type}';
|
zhCNOverrides.txt_edit_type_header = '编辑{type}';
|
||||||
zhCNOverrides.txt_delete_folder = '删除文件夹';
|
zhCNOverrides.txt_delete_folder = '删除文件夹';
|
||||||
|
|||||||
@@ -1178,6 +1178,35 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backup-panel {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-list {
|
||||||
|
margin: 12px 0 14px;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: #475467;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-list li + li {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-file-meta {
|
||||||
|
margin: -2px 0 12px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-inline-note {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.import-export-panel h3 {
|
.import-export-panel h3 {
|
||||||
margin: 0 0 6px 0;
|
margin: 0 0 6px 0;
|
||||||
}
|
}
|
||||||
@@ -1532,6 +1561,30 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.import-summary-failed-list {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #991b1b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary-failed-title {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary-failed-list ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary-failed-list li + li {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-twofactor-grid {
|
.settings-twofactor-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
Reference in New Issue
Block a user