From 68ded534a4232d59770354672d8a2848e68fd54c Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Tue, 28 Apr 2026 23:40:43 +0800 Subject: [PATCH] feat: enhance backup process with lease management and attachment deletion - Implemented a backup runner lease mechanism to prevent concurrent backup executions. - Added `deleteAllAttachmentsForCiphers` function to delete attachments for multiple ciphers efficiently. - Introduced `bulkDeleteAttachmentsByIds` method in storage to handle batch deletion of attachments. - Updated backup execution logic to utilize the new lease management and ensure timely updates during the backup process. - Refactored cipher deletion to handle attachments more effectively. - Improved website icon loading with a dedicated caching mechanism for better performance. - Added new index on `ciphers` table for `folder_id` to optimize queries related to folder management. - Enhanced response handling for CORS policy to allow credentials for specific origins. --- migrations/0001_init.sql | 1 + src/handlers/attachments.ts | 19 +- src/handlers/backup.ts | 171 ++++++++++++++++-- src/handlers/ciphers.ts | 12 +- src/router-public.ts | 4 +- src/services/backup-config.ts | 44 +++++ src/services/storage-attachment-repo.ts | 16 ++ src/services/storage-folder-repo.ts | 58 +++--- src/services/storage-schema.ts | 1 + src/services/storage.ts | 10 +- src/utils/response.ts | 2 +- webapp/index.html | 16 +- webapp/src/components/TotpCodesPage.tsx | 116 +----------- webapp/src/components/vault/WebsiteIcon.tsx | 123 +++++++++++++ .../components/vault/vault-page-helpers.tsx | 107 +---------- webapp/src/lib/website-icon-cache.ts | 89 +++++++++ 16 files changed, 505 insertions(+), 284 deletions(-) create mode 100644 webapp/src/components/vault/WebsiteIcon.tsx create mode 100644 webapp/src/lib/website-icon-cache.ts diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index e6bf188..86bad23 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -61,6 +61,7 @@ CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_ CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at); CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at); CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted_updated ON ciphers(user_id, deleted_at, updated_at); +CREATE INDEX IF NOT EXISTS idx_ciphers_user_folder ON ciphers(user_id, folder_id); CREATE TABLE IF NOT EXISTS folders ( id TEXT PRIMARY KEY, diff --git a/src/handlers/attachments.ts b/src/handlers/attachments.ts index 37bdee7..d022e78 100644 --- a/src/handlers/attachments.ts +++ b/src/handlers/attachments.ts @@ -445,12 +445,25 @@ export async function handleDeleteAttachment( export async function deleteAllAttachmentsForCipher( env: Env, cipherId: string +): Promise { + await deleteAllAttachmentsForCiphers(env, [cipherId]); +} + +export async function deleteAllAttachmentsForCiphers( + env: Env, + cipherIds: string[] ): Promise { const storage = new StorageService(env.DB); - const attachments = await storage.getAttachmentsByCipher(cipherId); - await runWithConcurrency(attachments, LIMITS.performance.attachmentDeleteConcurrency, async (attachment) => { + const attachmentsByCipher = await storage.getAttachmentsByCipherIds(cipherIds); + const attachments = Array.from(attachmentsByCipher.entries()).flatMap(([ownedCipherId, items]) => + items.map((attachment) => ({ attachment, cipherId: ownedCipherId })) + ); + if (!attachments.length) return; + + await runWithConcurrency(attachments, LIMITS.performance.attachmentDeleteConcurrency, async ({ attachment, cipherId }) => { const path = getAttachmentObjectKey(cipherId, attachment.id); await deleteBlobObject(env, path); - await storage.deleteAttachment(attachment.id); }); + + await storage.bulkDeleteAttachmentsByIds(attachments.map(({ attachment }) => attachment.id)); } diff --git a/src/handlers/backup.ts b/src/handlers/backup.ts index b12690c..bb75a61 100644 --- a/src/handlers/backup.ts +++ b/src/handlers/backup.ts @@ -14,6 +14,7 @@ import { getBackupLocalDateKey, getDefaultBackupSettings, getBackupSettingsRepairState, + hasBackupSlotBetween, isBackupDueNow, loadBackupSettings, normalizeBackupSettingsInput, @@ -80,6 +81,98 @@ function getBackupDestinationSummary(destination: BackupDestinationRecord | null }; } +const BACKUP_RUNNER_LOCK_KEY = 'backup.runner.lock.v1'; +const BACKUP_RUNNER_LEASE_MS = 10 * 60 * 1000; +const BACKUP_RUNNER_HEARTBEAT_MS = 30 * 1000; + +interface BackupRunnerLease { + token: string; + touch: () => Promise; + release: () => Promise; +} + +async function acquireBackupRunnerLease(env: Env, reason: string): Promise { + const token = generateUUID(); + const nowMs = Date.now(); + const expiresAtMs = nowMs + BACKUP_RUNNER_LEASE_MS; + const value = JSON.stringify({ + token, + reason, + acquiredAt: new Date(nowMs).toISOString(), + touchedAt: new Date(nowMs).toISOString(), + expiresAtMs, + }); + const result = await env.DB + .prepare( + `INSERT INTO config(key, value) VALUES(?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + WHERE COALESCE(CAST(json_extract(config.value, '$.expiresAtMs') AS INTEGER), 0) <= ?` + ) + .bind(BACKUP_RUNNER_LOCK_KEY, value, nowMs) + .run(); + + if ((result.meta?.changes || 0) < 1) { + return null; + } + + return { + token, + touch: async () => { + const nextNowMs = Date.now(); + const nextValue = JSON.stringify({ + token, + reason, + acquiredAt: new Date(nowMs).toISOString(), + touchedAt: new Date(nextNowMs).toISOString(), + expiresAtMs: nextNowMs + BACKUP_RUNNER_LEASE_MS, + }); + await env.DB + .prepare( + `UPDATE config + SET value = ? + WHERE key = ? + AND json_extract(value, '$.token') = ?` + ) + .bind(nextValue, BACKUP_RUNNER_LOCK_KEY, token) + .run(); + }, + release: async () => { + await env.DB + .prepare( + `DELETE FROM config + WHERE key = ? + AND json_extract(value, '$.token') = ?` + ) + .bind(BACKUP_RUNNER_LOCK_KEY, token) + .run(); + }, + }; +} + +async function withBackupRunnerLease( + env: Env, + reason: string, + task: (keepAlive: () => Promise) => Promise +): Promise { + const lease = await acquireBackupRunnerLease(env, reason); + if (!lease) return null; + + let lastHeartbeatAt = 0; + const keepAlive = async () => { + const nowMs = Date.now(); + if (nowMs - lastHeartbeatAt < BACKUP_RUNNER_HEARTBEAT_MS) return; + lastHeartbeatAt = nowMs; + await lease.touch(); + }; + + try { + await keepAlive(); + return await task(keepAlive); + } finally { + await lease.release(); + } +} + function ensureBackupBlobName(value: string): string { const normalized = String(value || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); if (!normalized) { @@ -160,6 +253,7 @@ async function executeConfiguredBackup( actorUserId: string | null, trigger: 'manual' | 'scheduled', destinationId?: string | null, + keepAlive?: (() => Promise) | null, progress?: ((event: { operation: 'backup-remote-run'; step: string; @@ -172,6 +266,9 @@ async function executeConfiguredBackup( }) => Promise) | null ): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> { const maxArchiveUploadAttempts = 3; + const touchLease = async () => { + await keepAlive?.(); + }; const currentSettings = await loadBackupSettings(storage, env, 'UTC'); const destination = requireBackupDestination(currentSettings, destinationId); @@ -180,9 +277,11 @@ async function executeConfiguredBackup( destination.runtime.lastAttemptLocalDate = getBackupLocalDateKey(now, destination.schedule.timezone); destination.runtime.lastErrorAt = null; destination.runtime.lastErrorMessage = null; + await touchLease(); await saveBackupSettings(storage, env, currentSettings); try { + await touchLease(); await progress?.({ operation: 'backup-remote-run', step: 'remote_run_prepare', @@ -190,6 +289,7 @@ async function executeConfiguredBackup( stageTitle: 'txt_backup_remote_run_progress_prepare_title', stageDetail: 'txt_backup_remote_run_progress_prepare_detail', }); + await touchLease(); const archive = await buildBackupArchive(env, now, { includeAttachments: destination.includeAttachments, timeZone: destination.schedule.timezone, @@ -219,9 +319,11 @@ async function executeConfiguredBackup( }); const remoteSession = createRemoteBackupTransferSession(destination); if (destination.includeAttachments) { + await touchLease(); const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession); let attachmentIndexChanged = false; for (const attachment of archive.manifest.attachmentBlobs || []) { + await touchLease(); if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) { continue; } @@ -238,11 +340,13 @@ async function executeConfiguredBackup( attachmentIndexChanged = true; } if (attachmentIndexChanged) { + await touchLease(); await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex); } } let upload: Awaited> | null = null; for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) { + await touchLease(); await progress?.({ operation: 'backup-remote-run', step: 'remote_run_upload_archive', @@ -252,6 +356,7 @@ async function executeConfiguredBackup( }); upload = await remoteSession.uploadArchive(archive.bytes, archive.fileName); try { + await touchLease(); await progress?.({ operation: 'backup-remote-run', step: 'remote_run_verify_archive', @@ -282,6 +387,7 @@ async function executeConfiguredBackup( let prunedFileCount = 0; let pruneErrorMessage: string | null = null; try { + await touchLease(); await progress?.({ operation: 'backup-remote-run', step: 'remote_run_cleanup', @@ -300,8 +406,10 @@ async function executeConfiguredBackup( destination.runtime.lastUploadedFileName = archive.fileName; destination.runtime.lastUploadedSizeBytes = archive.bytes.byteLength; destination.runtime.lastUploadedDestination = upload.remotePath; + await touchLease(); await saveBackupSettings(storage, env, currentSettings); + await touchLease(); await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, { ...getBackupDestinationSummary(destination), provider: upload.provider, @@ -332,8 +440,10 @@ async function executeConfiguredBackup( } catch (error) { destination.runtime.lastErrorAt = new Date().toISOString(); destination.runtime.lastErrorMessage = error instanceof Error ? error.message : 'Backup upload failed'; + await touchLease(); await saveBackupSettings(storage, env, currentSettings); + await touchLease(); await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, { ...getBackupDestinationSummary(destination), error: destination.runtime.lastErrorMessage, @@ -404,13 +514,30 @@ async function runImportAndAudit( } export async function runScheduledBackupIfDue(env: Env): Promise { - const storage = new StorageService(env.DB); - const settings = await loadBackupSettings(storage, env, 'UTC'); - const now = new Date(); - for (const destination of settings.destinations) { - if (!isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)) continue; - await executeConfiguredBackup(env, storage, null, 'scheduled', destination.id); - } + await withBackupRunnerLease(env, 'scheduled', async (keepAlive) => { + const storage = new StorageService(env.DB); + let scanStartMs = Date.now(); + + while (true) { + await keepAlive(); + const settings = await loadBackupSettings(storage, env, 'UTC'); + const now = new Date(); + const dueDestinations = settings.destinations.filter((destination) => + isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES) + || hasBackupSlotBetween(destination, new Date(scanStartMs), now) + ); + + if (!dueDestinations.length) { + return; + } + + scanStartMs = now.getTime(); + for (const destination of dueDestinations) { + await keepAlive(); + await executeConfiguredBackup(env, storage, null, 'scheduled', destination.id, keepAlive); + } + } + }); } export async function handleGetAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise { @@ -512,7 +639,6 @@ export async function handleRepairAdminBackupSettings(request: Request, env: Env export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise { if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); - const storage = new StorageService(env.DB); try { let body: { destinationId?: string } | null = null; try { @@ -536,17 +662,32 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env, }) => { await notifyUserBackupProgress(env, actorUser.id, event, targetDeviceIdentifier); }; - const result = await executeConfiguredBackup(env, storage, actorUser.id, 'manual', body?.destinationId || null, progress); - const settings = await loadBackupSettings(storage, env, 'UTC'); + const outcome = await withBackupRunnerLease(env, `manual:${actorUser.id}`, async (keepAlive) => { + const storage = new StorageService(env.DB); + const result = await executeConfiguredBackup( + env, + storage, + actorUser.id, + 'manual', + body?.destinationId || null, + keepAlive, + progress + ); + const settings = await loadBackupSettings(storage, env, 'UTC'); + return { result, settings }; + }); + if (!outcome) { + return errorResponse('Another backup run is already in progress', 409); + } return jsonResponse({ object: 'backup-run', result: { - fileName: result.fileName, - fileSize: result.fileSize, - provider: result.provider, - remotePath: result.remotePath, + fileName: outcome.result.fileName, + fileSize: outcome.result.fileSize, + provider: outcome.result.provider, + remotePath: outcome.result.remotePath, }, - settings, + settings: outcome.settings, }); } catch (error) { return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500); diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index 8c09026..a9e67a6 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -14,7 +14,7 @@ import { StorageService } from '../services/storage'; import { notifyUserVaultSync } from '../durable/notifications-hub'; import { jsonResponse, errorResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; -import { deleteAllAttachmentsForCipher } from './attachments'; +import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments'; import { parsePagination, encodeContinuationToken } from '../utils/pagination'; import { readActingDeviceIdentifier } from '../utils/device'; @@ -744,11 +744,15 @@ export async function handleBulkPermanentDeleteCiphers(request: Request, env: En return new Response(null, { status: 204 }); } - for (const id of ids) { - await deleteAllAttachmentsForCipher(env, id); + const ownedCiphers = await storage.getCiphersByIds(ids, userId); + const ownedIds = ownedCiphers.map((cipher) => cipher.id); + if (!ownedIds.length) { + return new Response(null, { status: 204 }); } - const revisionDate = await storage.bulkDeleteCiphers(ids, userId); + await deleteAllAttachmentsForCiphers(env, ownedIds); + + const revisionDate = await storage.bulkDeleteCiphers(ownedIds, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); } diff --git a/src/router-public.ts b/src/router-public.ts index 65aa4ca..13bc079 100644 --- a/src/router-public.ts +++ b/src/router-public.ts @@ -61,7 +61,7 @@ function handleNwFavicon(): Response { status: 200, headers: { 'Content-Type': 'image/svg+xml; charset=utf-8', - 'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`, + 'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`, }, }); } @@ -181,7 +181,7 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo status: 200, headers: { 'Content-Type': resp.headers.get('Content-Type') || 'image/png', - 'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`, + 'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`, }, }); } diff --git a/src/services/backup-config.ts b/src/services/backup-config.ts index a5121e0..7103377 100644 --- a/src/services/backup-config.ts +++ b/src/services/backup-config.ts @@ -598,6 +598,50 @@ function getBackupSlotStartsForLocalDay( return slots; } +export function hasBackupSlotBetween( + destination: BackupDestinationRecord, + startInclusive: Date, + endExclusive: Date +): boolean { + if (!destination.schedule.enabled) return false; + const startMs = startInclusive.getTime(); + const endMs = endExclusive.getTime(); + if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return false; + + const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null; + const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime()) + ? lastAttemptAt.getTime() + : Number.NEGATIVE_INFINITY; + + const dayCursor = new Date(startMs); + dayCursor.setUTCHours(0, 0, 0, 0); + const endDay = new Date(endMs); + endDay.setUTCHours(0, 0, 0, 0); + const checkedLocalDateKeys = new Set(); + + while (dayCursor.getTime() <= endDay.getTime() + 24 * 60 * 60 * 1000) { + const localDateKey = getBackupLocalDateKey(dayCursor, destination.schedule.timezone); + if (!checkedLocalDateKeys.has(localDateKey)) { + checkedLocalDateKeys.add(localDateKey); + const slotStarts = getBackupSlotStartsForLocalDay( + localDateKey, + destination.schedule.timezone, + destination.schedule.startTime, + destination.schedule.intervalHours + ); + for (const slotStart of slotStarts) { + const slotStartMs = slotStart.getTime(); + if (slotStartMs < startMs || slotStartMs >= endMs) continue; + if (lastAttemptMs >= slotStartMs) continue; + return true; + } + } + dayCursor.setUTCDate(dayCursor.getUTCDate() + 1); + } + + return false; +} + export function isBackupDueNow( destination: BackupDestinationRecord, now: Date, diff --git a/src/services/storage-attachment-repo.ts b/src/services/storage-attachment-repo.ts index e42ac2e..1650acf 100644 --- a/src/services/storage-attachment-repo.ts +++ b/src/services/storage-attachment-repo.ts @@ -34,6 +34,22 @@ export async function deleteAttachment(db: D1Database, id: string): Promise { + const uniqueIds = [...new Set(attachmentIds.map((id) => String(id || '').trim()).filter(Boolean))]; + if (!uniqueIds.length) return; + const chunkSize = sqlChunkSize(0); + + for (let i = 0; i < uniqueIds.length; i += chunkSize) { + const chunk = uniqueIds.slice(i, i + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + await db.prepare(`DELETE FROM attachments WHERE id IN (${placeholders})`).bind(...chunk).run(); + } +} + export async function getAttachmentsByCipher(db: D1Database, cipherId: string): Promise { const res = await db .prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id = ?') diff --git a/src/services/storage-folder-repo.ts b/src/services/storage-folder-repo.ts index 0366397..78deb64 100644 --- a/src/services/storage-folder-repo.ts +++ b/src/services/storage-folder-repo.ts @@ -1,4 +1,4 @@ -import type { Cipher, Folder } from '../types'; +import type { Folder } from '../types'; function mapFolderRow(row: any): Folder { return { @@ -36,26 +36,18 @@ export async function deleteFolder(db: D1Database, id: string, userId: string): export async function clearFolderFromCiphers( db: D1Database, userId: string, - folderId: string, - saveCipher: (cipher: Cipher) => Promise + folderId: string ): Promise { const now = new Date().toISOString(); - const res = await db - .prepare('SELECT data FROM ciphers WHERE user_id = ? AND folder_id = ?') - .bind(userId, folderId) - .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 saveCipher(cipher); - } + const patch = JSON.stringify({ folderId: null, updatedAt: now }); + await db + .prepare( + `UPDATE ciphers + SET folder_id = NULL, updated_at = ?, data = json_patch(data, ?) + WHERE user_id = ? AND folder_id = ?` + ) + .bind(now, patch, userId, folderId) + .run(); } export async function bulkDeleteFolders( @@ -63,34 +55,26 @@ export async function bulkDeleteFolders( userId: string, ids: string[], sqlChunkSize: (fixedBindCount: number) => number, - saveCipher: (cipher: Cipher) => Promise, updateRevisionDate: (userId: string) => Promise ): Promise { const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); if (!uniqueIds.length) return null; - const chunkSize = sqlChunkSize(1); const now = new Date().toISOString(); + const patch = JSON.stringify({ folderId: null, updatedAt: now }); + const chunkSize = sqlChunkSize(3); for (let i = 0; i < uniqueIds.length; i += chunkSize) { const chunk = uniqueIds.slice(i, i + chunkSize); const placeholders = chunk.map(() => '?').join(','); - const res = await 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 saveCipher(cipher); - } + await db + .prepare( + `UPDATE ciphers + SET folder_id = NULL, updated_at = ?, data = json_patch(data, ?) + WHERE user_id = ? AND folder_id IN (${placeholders})` + ) + .bind(now, patch, userId, ...chunk) + .run(); await db .prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`) diff --git a/src/services/storage-schema.ts b/src/services/storage-schema.ts index c2eecd8..01d474f 100644 --- a/src/services/storage-schema.ts +++ b/src/services/storage-schema.ts @@ -29,6 +29,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at)', 'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)', 'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted_updated ON ciphers(user_id, deleted_at, updated_at)', + 'CREATE INDEX IF NOT EXISTS idx_ciphers_user_folder ON ciphers(user_id, folder_id)', 'CREATE TABLE IF NOT EXISTS folders (' + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + diff --git a/src/services/storage.ts b/src/services/storage.ts index 981e3c4..38ab182 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -51,6 +51,7 @@ import { } from './storage-cipher-repo'; import { addAttachmentToCipher as attachStoredAttachmentToCipher, + bulkDeleteAttachmentsByIds as deleteStoredAttachmentsByIds, deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher, deleteAttachment as deleteStoredAttachment, getAttachment as findStoredAttachment, @@ -107,7 +108,7 @@ import { const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const STORAGE_SCHEMA_VERSION_KEY = 'schema.version'; -const STORAGE_SCHEMA_VERSION = '2026-04-22'; +const STORAGE_SCHEMA_VERSION = '2026-04-28'; // D1-backed storage. // Contract: @@ -339,7 +340,6 @@ export class StorageService { userId, ids, this.sqlChunkSize.bind(this), - this.saveCipher.bind(this), this.updateRevisionDate.bind(this) ); } @@ -347,7 +347,7 @@ export class StorageService { // 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 { - await clearStoredFolderFromCiphers(this.db, userId, folderId, this.saveCipher.bind(this)); + await clearStoredFolderFromCiphers(this.db, userId, folderId); } async getAllFolders(userId: string): Promise { @@ -372,6 +372,10 @@ export class StorageService { await deleteStoredAttachment(this.db, id); } + async bulkDeleteAttachmentsByIds(ids: string[]): Promise { + await deleteStoredAttachmentsByIds(this.db, this.sqlChunkSize.bind(this), ids); + } + async getAttachmentsByCipher(cipherId: string): Promise { return listStoredAttachmentsByCipher(this.db, cipherId); } diff --git a/src/utils/response.ts b/src/utils/response.ts index a8f5625..70ec46e 100644 --- a/src/utils/response.ts +++ b/src/utils/response.ts @@ -48,7 +48,7 @@ function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCre return { allowOrigin: origin, allowCredentials: true }; } if (isExtensionOrigin(origin)) { - return { allowOrigin: origin, allowCredentials: false }; + return { allowOrigin: origin, allowCredentials: true }; } return { allowOrigin: null, allowCredentials: false }; } diff --git a/webapp/index.html b/webapp/index.html index 95418b2..1113ba7 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -3,14 +3,26 @@ - + + + + NodeWarden
- + \ No newline at end of file diff --git a/webapp/src/components/TotpCodesPage.tsx b/webapp/src/components/TotpCodesPage.tsx index adca39b..3935c8a 100644 --- a/webapp/src/components/TotpCodesPage.tsx +++ b/webapp/src/components/TotpCodesPage.tsx @@ -22,7 +22,8 @@ import { calcTotpNow } from '@/lib/crypto'; import { t } from '@/lib/i18n'; import type { Cipher } from '@/lib/types'; import LoadingState from '@/components/LoadingState'; -import { hostFromUri, isCipherVisibleInNormalVault, websiteIconUrl } from '@/components/vault/vault-page-helpers'; +import WebsiteIcon from '@/components/vault/WebsiteIcon'; +import { isCipherVisibleInNormalVault } from '@/components/vault/vault-page-helpers'; interface TotpCodesPageProps { ciphers: Cipher[]; @@ -35,10 +36,6 @@ const TOTP_RING_RADIUS = 14; const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS; const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order'; const TOTP_REFRESH_BATCH_SIZE = 16; -const ICON_LOAD_ROOT_MARGIN = '180px 0px'; -const failedIconHosts = new Set(); -const loadedIconHosts = new Set(); - function getTotpTimeState(): { windowId: number; remain: number } { const epoch = Math.floor(Date.now() / 1000); return { @@ -54,115 +51,8 @@ function formatTotp(code: string): string { return `${code.slice(0, 3)} ${code.slice(3, 6)}`; } -function firstCipherUri(cipher: Cipher): string { - const uris = cipher.login?.uris || []; - for (const uri of uris) { - const raw = uri.decUri || uri.uri || ''; - if (raw.trim()) return raw.trim(); - } - return ''; -} - function TotpListIcon({ cipher }: { cipher: Cipher }) { - const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]); - const iconStackRef = useRef(null); - const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false)); - const [shouldLoad, setShouldLoad] = useState(() => { - if (!host) return true; - if (loadedIconHosts.has(host)) return true; - return false; - }); - const markIconError = () => { - if (host) { - failedIconHosts.add(host); - loadedIconHosts.delete(host); - } - setErrored(true); - }; - const hideFallback = () => { - if (host) loadedIconHosts.add(host); - const stack = iconStackRef.current; - if (stack) { - const fallback = stack.querySelector('.list-icon-fallback') as HTMLElement | null; - if (fallback) fallback.style.display = 'none'; - } - }; - const handleImgRef = (img: HTMLImageElement | null) => { - if (!img || !img.complete) return; - if (img.naturalWidth > 0) hideFallback(); - }; - - useEffect(() => { - if (!host) { - setErrored(false); - setShouldLoad(true); - } else if (failedIconHosts.has(host)) { - setErrored(true); - setShouldLoad(false); - } else { - setErrored(false); - setShouldLoad(loadedIconHosts.has(host)); - } - const fallback = iconStackRef.current?.querySelector('.list-icon-fallback') as HTMLElement | null; - if (fallback) fallback.style.display = ''; - }, [host]); - - useEffect(() => { - if (!host || errored || shouldLoad) return; - const node = iconStackRef.current; - if (!node) return; - if (typeof IntersectionObserver !== 'function') { - setShouldLoad(true); - return; - } - - let cancelled = false; - const observer = new IntersectionObserver( - (entries) => { - for (const entry of entries) { - if (!entry.isIntersecting && entry.intersectionRatio <= 0) continue; - if (!cancelled) setShouldLoad(true); - observer.disconnect(); - break; - } - }, - { rootMargin: ICON_LOAD_ROOT_MARGIN } - ); - - observer.observe(node); - return () => { - cancelled = true; - observer.disconnect(); - }; - }, [host, errored, shouldLoad]); - - if (host && !errored) { - return ( - - - - - {shouldLoad && ( - - )} - - ); - } - return ( - - - - ); + return } />; } interface SortableTotpRowProps { diff --git a/webapp/src/components/vault/WebsiteIcon.tsx b/webapp/src/components/vault/WebsiteIcon.tsx new file mode 100644 index 0000000..7ae4477 --- /dev/null +++ b/webapp/src/components/vault/WebsiteIcon.tsx @@ -0,0 +1,123 @@ +import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; +import type { ComponentChildren } from 'preact'; +import { Globe } from 'lucide-preact'; +import type { Cipher } from '@/lib/types'; +import { + getWebsiteIconStatus, + markWebsiteIconErrored, + markWebsiteIconLoaded, + preloadWebsiteIcon, + subscribeWebsiteIconStatus, +} from '@/lib/website-icon-cache'; + +const ICON_LOAD_ROOT_MARGIN = '180px 0px'; + +interface WebsiteIconProps { + cipher: Cipher; + fallback?: ComponentChildren; +} + +function firstCipherUri(cipher: Cipher): string { + const uris = cipher.login?.uris || []; + for (const uri of uris) { + const raw = uri.decUri || uri.uri || ''; + if (raw.trim()) return raw.trim(); + } + return ''; +} + +function hostFromUri(uri: string): string { + if (!uri.trim()) return ''; + try { + const normalized = /^https?:\/\//i.test(uri) ? uri : `https://${uri}`; + return new URL(normalized).hostname || ''; + } catch { + return ''; + } +} + +function websiteIconUrl(host: string): string { + return `/icons/${encodeURIComponent(host)}/icon.png?fallback=404`; +} + +export default function WebsiteIcon(props: WebsiteIconProps) { + const host = useMemo(() => hostFromUri(firstCipherUri(props.cipher)), [props.cipher]); + const src = host ? websiteIconUrl(host) : ''; + const nodeRef = useRef(null); + const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true)); + const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle')); + + useEffect(() => { + if (!host) { + setShouldLoad(true); + setStatus('idle'); + return; + } + const nextStatus = getWebsiteIconStatus(host); + setShouldLoad(nextStatus === 'loaded'); + setStatus(nextStatus); + return subscribeWebsiteIconStatus(host, setStatus); + }, [host]); + + useEffect(() => { + if (!host || shouldLoad || status === 'loaded' || status === 'error') return; + const node = nodeRef.current; + if (!node) return; + if (typeof IntersectionObserver !== 'function') { + setShouldLoad(true); + return; + } + + let cancelled = false; + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting && entry.intersectionRatio <= 0) continue; + if (!cancelled) setShouldLoad(true); + observer.disconnect(); + break; + } + }, + { rootMargin: ICON_LOAD_ROOT_MARGIN } + ); + + observer.observe(node); + return () => { + cancelled = true; + observer.disconnect(); + }; + }, [host, shouldLoad, status]); + + useEffect(() => { + if (!host || !src || !shouldLoad || status === 'loaded' || status === 'error') return; + let disposed = false; + void preloadWebsiteIcon(host, src).then((nextStatus) => { + if (!disposed) setStatus(nextStatus); + }); + return () => { + disposed = true; + }; + }, [host, src, shouldLoad, status]); + + if (!host || status === 'error') { + return {props.fallback ?? }; + } + + return ( + + {status !== 'loaded' && {props.fallback ?? }} + {status === 'loaded' && ( + markWebsiteIconLoaded(host)} + onError={() => markWebsiteIconErrored(host)} + /> + )} + + ); +} diff --git a/webapp/src/components/vault/vault-page-helpers.tsx b/webapp/src/components/vault/vault-page-helpers.tsx index 7e13901..cb40360 100644 --- a/webapp/src/components/vault/vault-page-helpers.tsx +++ b/webapp/src/components/vault/vault-page-helpers.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; +import { useMemo } from 'preact/hooks'; import { CreditCard, FileKey2, @@ -10,6 +10,7 @@ import { import { copyTextToClipboard } from '@/lib/clipboard'; import { t } from '@/lib/i18n'; import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types'; +import WebsiteIcon from './WebsiteIcon'; export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh'; export type VaultSortMode = 'edited' | 'created' | 'name'; @@ -433,110 +434,8 @@ export function firstPasskeyCreationTime(cipher: Cipher | null): string | null { return null; } -const failedIconHosts = new Set(); -const loadedIconHosts = new Set(); -const ICON_LOAD_ROOT_MARGIN = '180px 0px'; - export function VaultListIcon({ cipher }: { cipher: Cipher }) { - const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]); - const iconStackRef = useRef(null); - const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false)); - const [shouldLoad, setShouldLoad] = useState(() => { - if (!host) return true; - if (loadedIconHosts.has(host)) return true; - return false; - }); - const markIconError = () => { - if (host) { - failedIconHosts.add(host); - loadedIconHosts.delete(host); - } - setErrored(true); - }; - const hideFallback = () => { - if (host) loadedIconHosts.add(host); - const stack = iconStackRef.current; - if (stack) { - const fallback = stack.querySelector('.list-icon-fallback') as HTMLElement | null; - if (fallback) fallback.style.display = 'none'; - } - }; - const handleImgRef = (img: HTMLImageElement | null) => { - if (!img || !img.complete) return; - if (img.naturalWidth > 0) hideFallback(); - }; - - useEffect(() => { - if (!host) { - setErrored(false); - setShouldLoad(true); - } else if (failedIconHosts.has(host)) { - setErrored(true); - setShouldLoad(false); - } else { - setErrored(false); - setShouldLoad(loadedIconHosts.has(host)); - } - const fallback = iconStackRef.current?.querySelector('.list-icon-fallback') as HTMLElement | null; - if (fallback) fallback.style.display = ''; - }, [host]); - - useEffect(() => { - if (!host || errored || shouldLoad) return; - const node = iconStackRef.current; - if (!node) return; - if (typeof IntersectionObserver !== 'function') { - setShouldLoad(true); - return; - } - - let cancelled = false; - const observer = new IntersectionObserver( - (entries) => { - for (const entry of entries) { - if (!entry.isIntersecting && entry.intersectionRatio <= 0) continue; - if (!cancelled) setShouldLoad(true); - observer.disconnect(); - break; - } - }, - { rootMargin: ICON_LOAD_ROOT_MARGIN } - ); - - observer.observe(node); - return () => { - cancelled = true; - observer.disconnect(); - }; - }, [host, errored, shouldLoad]); - - if (host && !errored) { - return ( - - - - - {shouldLoad && ( - - )} - - ); - } - return ( - - - - ); + return } />; } export function copyToClipboard(value: string): void { diff --git a/webapp/src/lib/website-icon-cache.ts b/webapp/src/lib/website-icon-cache.ts new file mode 100644 index 0000000..73570b1 --- /dev/null +++ b/webapp/src/lib/website-icon-cache.ts @@ -0,0 +1,89 @@ +type WebsiteIconStatus = 'idle' | 'loading' | 'loaded' | 'error'; + +interface WebsiteIconRecord { + status: WebsiteIconStatus; + promise: Promise | null; + listeners: Set<(status: WebsiteIconStatus) => void>; +} + +const iconRecords = new Map(); + +function ensureRecord(host: string): WebsiteIconRecord { + let record = iconRecords.get(host); + if (!record) { + record = { + status: 'idle', + promise: null, + listeners: new Set(), + }; + iconRecords.set(host, record); + } + return record; +} + +function notifyRecord(host: string, status: WebsiteIconStatus): void { + const record = ensureRecord(host); + record.status = status; + for (const listener of Array.from(record.listeners)) { + listener(status); + } +} + +export function getWebsiteIconStatus(host: string): WebsiteIconStatus { + if (!host) return 'idle'; + return ensureRecord(host).status; +} + +export function subscribeWebsiteIconStatus(host: string, listener: (status: WebsiteIconStatus) => void): () => void { + if (!host) return () => undefined; + const record = ensureRecord(host); + record.listeners.add(listener); + return () => { + record.listeners.delete(listener); + }; +} + +export function markWebsiteIconLoaded(host: string): void { + if (!host) return; + const record = ensureRecord(host); + record.promise = null; + notifyRecord(host, 'loaded'); +} + +export function markWebsiteIconErrored(host: string): void { + if (!host) return; + const record = ensureRecord(host); + record.promise = null; + notifyRecord(host, 'error'); +} + +export function preloadWebsiteIcon(host: string, src: string): Promise { + if (!host) return Promise.resolve('error'); + + const record = ensureRecord(host); + if (record.status === 'loaded' || record.status === 'error') { + return Promise.resolve(record.status); + } + if (record.promise) { + return record.promise; + } + + record.status = 'loading'; + record.promise = new Promise((resolve) => { + const img = new Image(); + img.decoding = 'async'; + img.loading = 'eager'; + img.referrerPolicy = 'no-referrer'; + img.onload = () => { + markWebsiteIconLoaded(host); + resolve('loaded'); + }; + img.onerror = () => { + markWebsiteIconErrored(host); + resolve('error'); + }; + img.src = src; + }); + + return record.promise; +}