mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
Refactor VaultPage component: remove exposed password checks, add bulk delete functionality for folders, and improve list rendering performance
- Removed password breach checking logic and related state management from VaultPage. - Introduced bulk delete functionality for folders with a confirmation dialog. - Enhanced list rendering with virtualization to improve performance. - Updated styles for folder actions and list items for better UI consistency. - Removed unused password breach library and related translations.
This commit is contained in:
@@ -518,3 +518,26 @@ export async function handleBulkMoveCiphers(request: Request, env: Env, userId:
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
// POST /api/ciphers/delete - Bulk soft delete
|
||||
export async function handleBulkDeleteCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
let body: { ids?: string[] };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
if (!body.ids || !Array.isArray(body.ids)) {
|
||||
return errorResponse('ids array is required', 400);
|
||||
}
|
||||
|
||||
const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId);
|
||||
if (revisionDate) {
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
@@ -136,3 +136,27 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
// POST /api/folders/delete
|
||||
export async function handleBulkDeleteFolders(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
let body: { ids?: string[] };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const ids = Array.isArray(body.ids) ? body.ids.map((id) => String(id || '').trim()).filter(Boolean) : [];
|
||||
if (!ids.length) {
|
||||
return errorResponse('Folder ids are required', 400);
|
||||
}
|
||||
|
||||
const revisionDate = await storage.bulkDeleteFolders(ids, userId);
|
||||
if (revisionDate) {
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
@@ -1025,6 +1025,39 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
// POST /api/sends/delete - Bulk delete
|
||||
export async function handleBulkDeleteSends(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
let body: { ids?: string[] };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
if (!body.ids || !Array.isArray(body.ids)) {
|
||||
return errorResponse('ids array is required', 400);
|
||||
}
|
||||
|
||||
const sends = await storage.getSendsByIds(body.ids, userId);
|
||||
for (const send of sends) {
|
||||
if (send.type !== SendType.File) continue;
|
||||
const data = parseStoredSendData(send);
|
||||
const fileId = typeof data.id === 'string' ? data.id : null;
|
||||
if (fileId) {
|
||||
await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId));
|
||||
}
|
||||
}
|
||||
|
||||
const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
|
||||
if (revisionDate) {
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
// PUT /api/sends/:id/remove-password
|
||||
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
void request;
|
||||
|
||||
+31
-17
@@ -34,6 +34,7 @@ import {
|
||||
handleRestoreCipher,
|
||||
handlePartialUpdateCipher,
|
||||
handleBulkMoveCiphers,
|
||||
handleBulkDeleteCiphers,
|
||||
} from './handlers/ciphers';
|
||||
|
||||
// Folder handlers
|
||||
@@ -42,7 +43,8 @@ import {
|
||||
handleGetFolder,
|
||||
handleCreateFolder,
|
||||
handleUpdateFolder,
|
||||
handleDeleteFolder
|
||||
handleDeleteFolder,
|
||||
handleBulkDeleteFolders,
|
||||
} from './handlers/folders';
|
||||
|
||||
// Send handlers
|
||||
@@ -55,6 +57,7 @@ import {
|
||||
handleUploadSendFile,
|
||||
handleUpdateSend,
|
||||
handleDeleteSend,
|
||||
handleBulkDeleteSends,
|
||||
handleRemoveSendPassword,
|
||||
handleRemoveSendAuth,
|
||||
handleAccessSend,
|
||||
@@ -141,6 +144,18 @@ function getNwIconSvg(): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="NW icon"><rect x="4" y="4" width="88" height="88" rx="20" fill="#111418"/><text x="48" y="60" text-anchor="middle" font-size="36" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-weight="800" letter-spacing="0.5" fill="#FFFFFF">NW</text></svg>`;
|
||||
}
|
||||
|
||||
function isImportBypassRequest(request: Request, path: string, method: string): boolean {
|
||||
if (request.headers.get('X-NodeWarden-Import') !== '1') return false;
|
||||
|
||||
if (method === 'POST') {
|
||||
if (path === '/api/ciphers/import') return true;
|
||||
if (/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/v2$/i.test(path)) return true;
|
||||
if (/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleNwFavicon(): Response {
|
||||
return new Response(getNwIconSvg(), {
|
||||
status: 200,
|
||||
@@ -151,17 +166,6 @@ function handleNwFavicon(): Response {
|
||||
});
|
||||
}
|
||||
|
||||
const BITWARDEN_DEFAULT_ICON_SHA256 = 'aaa64871332ad5b7d28fe8874efb19c2d9cc2f1e6de75d52b080b438225a0783';
|
||||
|
||||
function bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
async function sha256Hex(buffer: ArrayBuffer): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', buffer);
|
||||
return bytesToHex(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
function isValidIconHostname(hostname: string): boolean {
|
||||
if (!hostname) return false;
|
||||
if (hostname.length > 253) return false;
|
||||
@@ -183,7 +187,7 @@ function isValidIconHostname(hostname: string): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
// Icons handler - proxy to Bitwarden's official icon service
|
||||
// Icons handler - proxy to favicon.im
|
||||
async function handleGetIcon(request: Request, env: Env, hostname: string): Promise<Response> {
|
||||
try {
|
||||
void env;
|
||||
@@ -199,8 +203,7 @@ async function handleGetIcon(request: Request, env: Env, hostname: string): Prom
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Use Bitwarden's official icon service
|
||||
const iconUrl = `https://icons.bitwarden.net/${normalizedHostname}/icon.png`;
|
||||
const iconUrl = `https://favicon.im/${normalizedHostname}`;
|
||||
const resp = await fetch(iconUrl, {
|
||||
headers: { 'User-Agent': 'NodeWarden/1.0' },
|
||||
redirect: 'follow',
|
||||
@@ -212,7 +215,7 @@ async function handleGetIcon(request: Request, env: Env, hostname: string): Prom
|
||||
|
||||
if (resp.ok) {
|
||||
const body = await resp.arrayBuffer();
|
||||
if (body.byteLength === 500 && (await sha256Hex(body)) === BITWARDEN_DEFAULT_ICON_SHA256) {
|
||||
if (body.byteLength === 0) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
const iconResponse = new Response(body, {
|
||||
@@ -512,7 +515,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
return errorResponse('Account is disabled', 403);
|
||||
}
|
||||
// Unified rate limiting for all authenticated API requests.
|
||||
{
|
||||
if (!isImportBypassRequest(request, path, method)) {
|
||||
const rateLimit = new RateLimitService(env.DB);
|
||||
const rateLimitCheck = await rateLimit.consumeBudget(
|
||||
userId + ':api',
|
||||
@@ -600,6 +603,10 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
return handleCiphersImport(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/ciphers/delete' && method === 'POST') {
|
||||
return handleBulkDeleteCiphers(request, env, userId);
|
||||
}
|
||||
|
||||
// Bulk cipher operations (only move is allowed)
|
||||
if (path === '/api/ciphers/move') {
|
||||
if (method === 'POST' || method === 'PUT') {
|
||||
@@ -677,6 +684,9 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
if (method === 'GET') return handleGetFolders(request, env, userId);
|
||||
if (method === 'POST') return handleCreateFolder(request, env, userId);
|
||||
}
|
||||
if (path === '/api/folders/delete' && method === 'POST') {
|
||||
return handleBulkDeleteFolders(request, env, userId);
|
||||
}
|
||||
|
||||
// Match /api/folders/:id patterns
|
||||
const folderMatch = path.match(/^\/api\/folders\/([a-f0-9-]+)$/i);
|
||||
@@ -712,6 +722,10 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
if (method === 'POST') return handleCreateSend(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/sends/delete' && method === 'POST') {
|
||||
return handleBulkDeleteSends(request, env, userId);
|
||||
}
|
||||
|
||||
if ((path === '/api/sends/file/v2' || path === '/api/sends/file') && method === 'POST') {
|
||||
return handleCreateFileSendV2(request, env, userId);
|
||||
}
|
||||
|
||||
+99
-1
@@ -469,6 +469,34 @@ export class StorageService {
|
||||
await this.db.prepare('DELETE FROM ciphers WHERE id = ? AND user_id = ?').bind(id, userId).run();
|
||||
}
|
||||
|
||||
async bulkSoftDeleteCiphers(ids: string[], userId: string): Promise<string | null> {
|
||||
if (ids.length === 0) return null;
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const patch = JSON.stringify({
|
||||
deletedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const chunkSize = Math.min(LIMITS.performance.bulkMoveChunkSize, 90);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
await this.db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET deleted_at = ?, updated_at = ?, data = json_patch(data, ?)
|
||||
WHERE user_id = ? AND id IN (${placeholders})`
|
||||
)
|
||||
.bind(now, now, patch, userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
return this.updateRevisionDate(userId);
|
||||
}
|
||||
|
||||
async getAllCiphers(userId: string): Promise<Cipher[]> {
|
||||
const res = await this.db.prepare('SELECT data FROM ciphers WHERE user_id = ? ORDER BY updated_at DESC').bind(userId).all<{ data: string }>();
|
||||
return (res.results || []).flatMap(r => {
|
||||
@@ -512,7 +540,7 @@ export class StorageService {
|
||||
folderId,
|
||||
updatedAt: now,
|
||||
});
|
||||
const chunkSize = LIMITS.performance.bulkMoveChunkSize;
|
||||
const chunkSize = Math.min(LIMITS.performance.bulkMoveChunkSize, 90);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
@@ -562,6 +590,42 @@ export class StorageService {
|
||||
await this.db.prepare('DELETE FROM folders WHERE id = ? AND user_id = ?').bind(id, userId).run();
|
||||
}
|
||||
|
||||
async bulkDeleteFolders(ids: string[], userId: string): Promise<string | null> {
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const chunkSize = Math.min(LIMITS.performance.bulkMoveChunkSize, 90);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
const res = await this.db
|
||||
.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND folder_id IN (${placeholders})`)
|
||||
.bind(userId, ...chunk)
|
||||
.all<{ data: string }>();
|
||||
|
||||
for (const row of res.results || []) {
|
||||
let cipher: Cipher;
|
||||
try {
|
||||
cipher = JSON.parse(row.data) as Cipher;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
cipher.folderId = null;
|
||||
cipher.updatedAt = now;
|
||||
await this.saveCipher(cipher);
|
||||
}
|
||||
|
||||
await this.db
|
||||
.prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`)
|
||||
.bind(userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
return this.updateRevisionDate(userId);
|
||||
}
|
||||
|
||||
// Clear folder references from all ciphers owned by the user.
|
||||
// Without this, deleting a folder leaves stale folderId values in cipher JSON.
|
||||
async clearFolderFromCiphers(userId: string, folderId: string): Promise<void> {
|
||||
@@ -928,6 +992,40 @@ export class StorageService {
|
||||
await this.db.prepare('DELETE FROM sends WHERE id = ? AND user_id = ?').bind(id, userId).run();
|
||||
}
|
||||
|
||||
async getSendsByIds(ids: string[], userId: string): Promise<Send[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
if (!uniqueIds.length) return [];
|
||||
const placeholders = uniqueIds.map(() => '?').join(',');
|
||||
const res = await this.db
|
||||
.prepare(
|
||||
`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
|
||||
WHERE user_id = ? AND id IN (${placeholders})`
|
||||
)
|
||||
.bind(userId, ...uniqueIds)
|
||||
.all<any>();
|
||||
return (res.results || []).map((row) => this.mapSendRow(row));
|
||||
}
|
||||
|
||||
async bulkDeleteSends(ids: string[], userId: string): Promise<string | null> {
|
||||
if (ids.length === 0) return null;
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
if (!uniqueIds.length) return null;
|
||||
const chunkSize = Math.min(LIMITS.performance.bulkMoveChunkSize, 90);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
await this.db
|
||||
.prepare(`DELETE FROM sends WHERE user_id = ? AND id IN (${placeholders})`)
|
||||
.bind(userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
return this.updateRevisionDate(userId);
|
||||
}
|
||||
|
||||
async getAllSends(userId: string): Promise<Send[]> {
|
||||
const res = await this.db
|
||||
.prepare(
|
||||
|
||||
Reference in New Issue
Block a user