mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
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.
This commit is contained in:
@@ -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_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 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_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 (
|
CREATE TABLE IF NOT EXISTS folders (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -445,12 +445,25 @@ export async function handleDeleteAttachment(
|
|||||||
export async function deleteAllAttachmentsForCipher(
|
export async function deleteAllAttachmentsForCipher(
|
||||||
env: Env,
|
env: Env,
|
||||||
cipherId: string
|
cipherId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await deleteAllAttachmentsForCiphers(env, [cipherId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAllAttachmentsForCiphers(
|
||||||
|
env: Env,
|
||||||
|
cipherIds: string[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(cipherIds);
|
||||||
await runWithConcurrency(attachments, LIMITS.performance.attachmentDeleteConcurrency, async (attachment) => {
|
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);
|
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
||||||
await deleteBlobObject(env, path);
|
await deleteBlobObject(env, path);
|
||||||
await storage.deleteAttachment(attachment.id);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await storage.bulkDeleteAttachmentsByIds(attachments.map(({ attachment }) => attachment.id));
|
||||||
}
|
}
|
||||||
|
|||||||
+156
-15
@@ -14,6 +14,7 @@ import {
|
|||||||
getBackupLocalDateKey,
|
getBackupLocalDateKey,
|
||||||
getDefaultBackupSettings,
|
getDefaultBackupSettings,
|
||||||
getBackupSettingsRepairState,
|
getBackupSettingsRepairState,
|
||||||
|
hasBackupSlotBetween,
|
||||||
isBackupDueNow,
|
isBackupDueNow,
|
||||||
loadBackupSettings,
|
loadBackupSettings,
|
||||||
normalizeBackupSettingsInput,
|
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<void>;
|
||||||
|
release: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acquireBackupRunnerLease(env: Env, reason: string): Promise<BackupRunnerLease | null> {
|
||||||
|
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<T>(
|
||||||
|
env: Env,
|
||||||
|
reason: string,
|
||||||
|
task: (keepAlive: () => Promise<void>) => Promise<T>
|
||||||
|
): Promise<T | null> {
|
||||||
|
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 {
|
function ensureBackupBlobName(value: string): string {
|
||||||
const normalized = String(value || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
const normalized = String(value || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -160,6 +253,7 @@ async function executeConfiguredBackup(
|
|||||||
actorUserId: string | null,
|
actorUserId: string | null,
|
||||||
trigger: 'manual' | 'scheduled',
|
trigger: 'manual' | 'scheduled',
|
||||||
destinationId?: string | null,
|
destinationId?: string | null,
|
||||||
|
keepAlive?: (() => Promise<void>) | null,
|
||||||
progress?: ((event: {
|
progress?: ((event: {
|
||||||
operation: 'backup-remote-run';
|
operation: 'backup-remote-run';
|
||||||
step: string;
|
step: string;
|
||||||
@@ -172,6 +266,9 @@ async function executeConfiguredBackup(
|
|||||||
}) => Promise<void>) | null
|
}) => Promise<void>) | null
|
||||||
): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> {
|
): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> {
|
||||||
const maxArchiveUploadAttempts = 3;
|
const maxArchiveUploadAttempts = 3;
|
||||||
|
const touchLease = async () => {
|
||||||
|
await keepAlive?.();
|
||||||
|
};
|
||||||
const currentSettings = await loadBackupSettings(storage, env, 'UTC');
|
const currentSettings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
const destination = requireBackupDestination(currentSettings, destinationId);
|
const destination = requireBackupDestination(currentSettings, destinationId);
|
||||||
|
|
||||||
@@ -180,9 +277,11 @@ async function executeConfiguredBackup(
|
|||||||
destination.runtime.lastAttemptLocalDate = getBackupLocalDateKey(now, destination.schedule.timezone);
|
destination.runtime.lastAttemptLocalDate = getBackupLocalDateKey(now, destination.schedule.timezone);
|
||||||
destination.runtime.lastErrorAt = null;
|
destination.runtime.lastErrorAt = null;
|
||||||
destination.runtime.lastErrorMessage = null;
|
destination.runtime.lastErrorMessage = null;
|
||||||
|
await touchLease();
|
||||||
await saveBackupSettings(storage, env, currentSettings);
|
await saveBackupSettings(storage, env, currentSettings);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await touchLease();
|
||||||
await progress?.({
|
await progress?.({
|
||||||
operation: 'backup-remote-run',
|
operation: 'backup-remote-run',
|
||||||
step: 'remote_run_prepare',
|
step: 'remote_run_prepare',
|
||||||
@@ -190,6 +289,7 @@ async function executeConfiguredBackup(
|
|||||||
stageTitle: 'txt_backup_remote_run_progress_prepare_title',
|
stageTitle: 'txt_backup_remote_run_progress_prepare_title',
|
||||||
stageDetail: 'txt_backup_remote_run_progress_prepare_detail',
|
stageDetail: 'txt_backup_remote_run_progress_prepare_detail',
|
||||||
});
|
});
|
||||||
|
await touchLease();
|
||||||
const archive = await buildBackupArchive(env, now, {
|
const archive = await buildBackupArchive(env, now, {
|
||||||
includeAttachments: destination.includeAttachments,
|
includeAttachments: destination.includeAttachments,
|
||||||
timeZone: destination.schedule.timezone,
|
timeZone: destination.schedule.timezone,
|
||||||
@@ -219,9 +319,11 @@ async function executeConfiguredBackup(
|
|||||||
});
|
});
|
||||||
const remoteSession = createRemoteBackupTransferSession(destination);
|
const remoteSession = createRemoteBackupTransferSession(destination);
|
||||||
if (destination.includeAttachments) {
|
if (destination.includeAttachments) {
|
||||||
|
await touchLease();
|
||||||
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
|
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
|
||||||
let attachmentIndexChanged = false;
|
let attachmentIndexChanged = false;
|
||||||
for (const attachment of archive.manifest.attachmentBlobs || []) {
|
for (const attachment of archive.manifest.attachmentBlobs || []) {
|
||||||
|
await touchLease();
|
||||||
if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) {
|
if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -238,11 +340,13 @@ async function executeConfiguredBackup(
|
|||||||
attachmentIndexChanged = true;
|
attachmentIndexChanged = true;
|
||||||
}
|
}
|
||||||
if (attachmentIndexChanged) {
|
if (attachmentIndexChanged) {
|
||||||
|
await touchLease();
|
||||||
await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
|
await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
|
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
|
||||||
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
|
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
|
||||||
|
await touchLease();
|
||||||
await progress?.({
|
await progress?.({
|
||||||
operation: 'backup-remote-run',
|
operation: 'backup-remote-run',
|
||||||
step: 'remote_run_upload_archive',
|
step: 'remote_run_upload_archive',
|
||||||
@@ -252,6 +356,7 @@ async function executeConfiguredBackup(
|
|||||||
});
|
});
|
||||||
upload = await remoteSession.uploadArchive(archive.bytes, archive.fileName);
|
upload = await remoteSession.uploadArchive(archive.bytes, archive.fileName);
|
||||||
try {
|
try {
|
||||||
|
await touchLease();
|
||||||
await progress?.({
|
await progress?.({
|
||||||
operation: 'backup-remote-run',
|
operation: 'backup-remote-run',
|
||||||
step: 'remote_run_verify_archive',
|
step: 'remote_run_verify_archive',
|
||||||
@@ -282,6 +387,7 @@ async function executeConfiguredBackup(
|
|||||||
let prunedFileCount = 0;
|
let prunedFileCount = 0;
|
||||||
let pruneErrorMessage: string | null = null;
|
let pruneErrorMessage: string | null = null;
|
||||||
try {
|
try {
|
||||||
|
await touchLease();
|
||||||
await progress?.({
|
await progress?.({
|
||||||
operation: 'backup-remote-run',
|
operation: 'backup-remote-run',
|
||||||
step: 'remote_run_cleanup',
|
step: 'remote_run_cleanup',
|
||||||
@@ -300,8 +406,10 @@ async function executeConfiguredBackup(
|
|||||||
destination.runtime.lastUploadedFileName = archive.fileName;
|
destination.runtime.lastUploadedFileName = archive.fileName;
|
||||||
destination.runtime.lastUploadedSizeBytes = archive.bytes.byteLength;
|
destination.runtime.lastUploadedSizeBytes = archive.bytes.byteLength;
|
||||||
destination.runtime.lastUploadedDestination = upload.remotePath;
|
destination.runtime.lastUploadedDestination = upload.remotePath;
|
||||||
|
await touchLease();
|
||||||
await saveBackupSettings(storage, env, currentSettings);
|
await saveBackupSettings(storage, env, currentSettings);
|
||||||
|
|
||||||
|
await touchLease();
|
||||||
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, {
|
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, {
|
||||||
...getBackupDestinationSummary(destination),
|
...getBackupDestinationSummary(destination),
|
||||||
provider: upload.provider,
|
provider: upload.provider,
|
||||||
@@ -332,8 +440,10 @@ async function executeConfiguredBackup(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
destination.runtime.lastErrorAt = new Date().toISOString();
|
destination.runtime.lastErrorAt = new Date().toISOString();
|
||||||
destination.runtime.lastErrorMessage = error instanceof Error ? error.message : 'Backup upload failed';
|
destination.runtime.lastErrorMessage = error instanceof Error ? error.message : 'Backup upload failed';
|
||||||
|
await touchLease();
|
||||||
await saveBackupSettings(storage, env, currentSettings);
|
await saveBackupSettings(storage, env, currentSettings);
|
||||||
|
|
||||||
|
await touchLease();
|
||||||
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
|
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
|
||||||
...getBackupDestinationSummary(destination),
|
...getBackupDestinationSummary(destination),
|
||||||
error: destination.runtime.lastErrorMessage,
|
error: destination.runtime.lastErrorMessage,
|
||||||
@@ -404,13 +514,30 @@ async function runImportAndAudit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runScheduledBackupIfDue(env: Env): Promise<void> {
|
export async function runScheduledBackupIfDue(env: Env): Promise<void> {
|
||||||
const storage = new StorageService(env.DB);
|
await withBackupRunnerLease(env, 'scheduled', async (keepAlive) => {
|
||||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
const storage = new StorageService(env.DB);
|
||||||
const now = new Date();
|
let scanStartMs = Date.now();
|
||||||
for (const destination of settings.destinations) {
|
|
||||||
if (!isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)) continue;
|
while (true) {
|
||||||
await executeConfiguredBackup(env, storage, null, 'scheduled', destination.id);
|
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<Response> {
|
export async function handleGetAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
@@ -512,7 +639,6 @@ export async function handleRepairAdminBackupSettings(request: Request, env: Env
|
|||||||
export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
|
||||||
try {
|
try {
|
||||||
let body: { destinationId?: string } | null = null;
|
let body: { destinationId?: string } | null = null;
|
||||||
try {
|
try {
|
||||||
@@ -536,17 +662,32 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
|
|||||||
}) => {
|
}) => {
|
||||||
await notifyUserBackupProgress(env, actorUser.id, event, targetDeviceIdentifier);
|
await notifyUserBackupProgress(env, actorUser.id, event, targetDeviceIdentifier);
|
||||||
};
|
};
|
||||||
const result = await executeConfiguredBackup(env, storage, actorUser.id, 'manual', body?.destinationId || null, progress);
|
const outcome = await withBackupRunnerLease(env, `manual:${actorUser.id}`, async (keepAlive) => {
|
||||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
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({
|
return jsonResponse({
|
||||||
object: 'backup-run',
|
object: 'backup-run',
|
||||||
result: {
|
result: {
|
||||||
fileName: result.fileName,
|
fileName: outcome.result.fileName,
|
||||||
fileSize: result.fileSize,
|
fileSize: outcome.result.fileSize,
|
||||||
provider: result.provider,
|
provider: outcome.result.provider,
|
||||||
remotePath: result.remotePath,
|
remotePath: outcome.result.remotePath,
|
||||||
},
|
},
|
||||||
settings,
|
settings: outcome.settings,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500);
|
return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { StorageService } from '../services/storage';
|
|||||||
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { deleteAllAttachmentsForCipher } from './attachments';
|
import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments';
|
||||||
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||||
import { readActingDeviceIdentifier } from '../utils/device';
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
|
|
||||||
@@ -744,11 +744,15 @@ export async function handleBulkPermanentDeleteCiphers(request: Request, env: En
|
|||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const id of ids) {
|
const ownedCiphers = await storage.getCiphersByIds(ids, userId);
|
||||||
await deleteAllAttachmentsForCipher(env, id);
|
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) {
|
if (revisionDate) {
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ function handleNwFavicon(): Response {
|
|||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'image/svg+xml; charset=utf-8',
|
'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,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
|
'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`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -598,6 +598,50 @@ function getBackupSlotStartsForLocalDay(
|
|||||||
return slots;
|
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<string>();
|
||||||
|
|
||||||
|
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(
|
export function isBackupDueNow(
|
||||||
destination: BackupDestinationRecord,
|
destination: BackupDestinationRecord,
|
||||||
now: Date,
|
now: Date,
|
||||||
|
|||||||
@@ -34,6 +34,22 @@ export async function deleteAttachment(db: D1Database, id: string): Promise<void
|
|||||||
await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
|
await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function bulkDeleteAttachmentsByIds(
|
||||||
|
db: D1Database,
|
||||||
|
sqlChunkSize: SqlChunkSize,
|
||||||
|
attachmentIds: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
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<Attachment[]> {
|
export async function getAttachmentsByCipher(db: D1Database, cipherId: string): Promise<Attachment[]> {
|
||||||
const res = await db
|
const res = await db
|
||||||
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id = ?')
|
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id = ?')
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Cipher, Folder } from '../types';
|
import type { Folder } from '../types';
|
||||||
|
|
||||||
function mapFolderRow(row: any): Folder {
|
function mapFolderRow(row: any): Folder {
|
||||||
return {
|
return {
|
||||||
@@ -36,26 +36,18 @@ export async function deleteFolder(db: D1Database, id: string, userId: string):
|
|||||||
export async function clearFolderFromCiphers(
|
export async function clearFolderFromCiphers(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
userId: string,
|
userId: string,
|
||||||
folderId: string,
|
folderId: string
|
||||||
saveCipher: (cipher: Cipher) => Promise<void>
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const res = await db
|
const patch = JSON.stringify({ folderId: null, updatedAt: now });
|
||||||
.prepare('SELECT data FROM ciphers WHERE user_id = ? AND folder_id = ?')
|
await db
|
||||||
.bind(userId, folderId)
|
.prepare(
|
||||||
.all<{ data: string }>();
|
`UPDATE ciphers
|
||||||
|
SET folder_id = NULL, updated_at = ?, data = json_patch(data, ?)
|
||||||
for (const row of (res.results || [])) {
|
WHERE user_id = ? AND folder_id = ?`
|
||||||
let cipher: Cipher;
|
)
|
||||||
try {
|
.bind(now, patch, userId, folderId)
|
||||||
cipher = JSON.parse(row.data) as Cipher;
|
.run();
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
cipher.folderId = null;
|
|
||||||
cipher.updatedAt = now;
|
|
||||||
await saveCipher(cipher);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkDeleteFolders(
|
export async function bulkDeleteFolders(
|
||||||
@@ -63,34 +55,26 @@ export async function bulkDeleteFolders(
|
|||||||
userId: string,
|
userId: string,
|
||||||
ids: string[],
|
ids: string[],
|
||||||
sqlChunkSize: (fixedBindCount: number) => number,
|
sqlChunkSize: (fixedBindCount: number) => number,
|
||||||
saveCipher: (cipher: Cipher) => Promise<void>,
|
|
||||||
updateRevisionDate: (userId: string) => Promise<string>
|
updateRevisionDate: (userId: string) => Promise<string>
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||||
if (!uniqueIds.length) return null;
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
const chunkSize = sqlChunkSize(1);
|
|
||||||
const now = new Date().toISOString();
|
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) {
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
const placeholders = chunk.map(() => '?').join(',');
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
const res = await db
|
await db
|
||||||
.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND folder_id IN (${placeholders})`)
|
.prepare(
|
||||||
.bind(userId, ...chunk)
|
`UPDATE ciphers
|
||||||
.all<{ data: string }>();
|
SET folder_id = NULL, updated_at = ?, data = json_patch(data, ?)
|
||||||
|
WHERE user_id = ? AND folder_id IN (${placeholders})`
|
||||||
for (const row of res.results || []) {
|
)
|
||||||
let cipher: Cipher;
|
.bind(now, patch, userId, ...chunk)
|
||||||
try {
|
.run();
|
||||||
cipher = JSON.parse(row.data) as Cipher;
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
cipher.folderId = null;
|
|
||||||
cipher.updatedAt = now;
|
|
||||||
await saveCipher(cipher);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`)
|
.prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`)
|
||||||
|
|||||||
@@ -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_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 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_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 (' +
|
'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, ' +
|
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
} from './storage-cipher-repo';
|
} from './storage-cipher-repo';
|
||||||
import {
|
import {
|
||||||
addAttachmentToCipher as attachStoredAttachmentToCipher,
|
addAttachmentToCipher as attachStoredAttachmentToCipher,
|
||||||
|
bulkDeleteAttachmentsByIds as deleteStoredAttachmentsByIds,
|
||||||
deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher,
|
deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher,
|
||||||
deleteAttachment as deleteStoredAttachment,
|
deleteAttachment as deleteStoredAttachment,
|
||||||
getAttachment as findStoredAttachment,
|
getAttachment as findStoredAttachment,
|
||||||
@@ -107,7 +108,7 @@ import {
|
|||||||
|
|
||||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||||
const STORAGE_SCHEMA_VERSION = '2026-04-22';
|
const STORAGE_SCHEMA_VERSION = '2026-04-28';
|
||||||
|
|
||||||
// D1-backed storage.
|
// D1-backed storage.
|
||||||
// Contract:
|
// Contract:
|
||||||
@@ -339,7 +340,6 @@ export class StorageService {
|
|||||||
userId,
|
userId,
|
||||||
ids,
|
ids,
|
||||||
this.sqlChunkSize.bind(this),
|
this.sqlChunkSize.bind(this),
|
||||||
this.saveCipher.bind(this),
|
|
||||||
this.updateRevisionDate.bind(this)
|
this.updateRevisionDate.bind(this)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -347,7 +347,7 @@ export class StorageService {
|
|||||||
// Clear folder references from all ciphers owned by the user.
|
// Clear folder references from all ciphers owned by the user.
|
||||||
// Without this, deleting a folder leaves stale folderId values in cipher JSON.
|
// Without this, deleting a folder leaves stale folderId values in cipher JSON.
|
||||||
async clearFolderFromCiphers(userId: string, folderId: string): Promise<void> {
|
async clearFolderFromCiphers(userId: string, folderId: string): Promise<void> {
|
||||||
await clearStoredFolderFromCiphers(this.db, userId, folderId, this.saveCipher.bind(this));
|
await clearStoredFolderFromCiphers(this.db, userId, folderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllFolders(userId: string): Promise<Folder[]> {
|
async getAllFolders(userId: string): Promise<Folder[]> {
|
||||||
@@ -372,6 +372,10 @@ export class StorageService {
|
|||||||
await deleteStoredAttachment(this.db, id);
|
await deleteStoredAttachment(this.db, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async bulkDeleteAttachmentsByIds(ids: string[]): Promise<void> {
|
||||||
|
await deleteStoredAttachmentsByIds(this.db, this.sqlChunkSize.bind(this), ids);
|
||||||
|
}
|
||||||
|
|
||||||
async getAttachmentsByCipher(cipherId: string): Promise<Attachment[]> {
|
async getAttachmentsByCipher(cipherId: string): Promise<Attachment[]> {
|
||||||
return listStoredAttachmentsByCipher(this.db, cipherId);
|
return listStoredAttachmentsByCipher(this.db, cipherId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCre
|
|||||||
return { allowOrigin: origin, allowCredentials: true };
|
return { allowOrigin: origin, allowCredentials: true };
|
||||||
}
|
}
|
||||||
if (isExtensionOrigin(origin)) {
|
if (isExtensionOrigin(origin)) {
|
||||||
return { allowOrigin: origin, allowCredentials: false };
|
return { allowOrigin: origin, allowCredentials: true };
|
||||||
}
|
}
|
||||||
return { allowOrigin: null, allowCredentials: false };
|
return { allowOrigin: null, allowCredentials: false };
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-2
@@ -3,14 +3,26 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' https://cloudflareinsights.com https://*.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cloudflareinsights.com https://*.cloudflareinsights.com; connect-src 'self' https://api.pwnedpasswords.com https://cloudflareinsights.com https://*.cloudflareinsights.com; font-src 'self'; form-action 'self'; base-uri 'self';" />
|
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="
|
||||||
|
default-src 'self';
|
||||||
|
script-src 'self' 'unsafe-inline';
|
||||||
|
style-src 'self' 'unsafe-inline';
|
||||||
|
img-src 'self' data:;
|
||||||
|
connect-src 'self';
|
||||||
|
font-src 'self';
|
||||||
|
form-action 'self';
|
||||||
|
base-uri 'self';
|
||||||
|
" />
|
||||||
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/nodewarden-logo-bg.svg" />
|
<link rel="icon" type="image/svg+xml" href="/nodewarden-logo-bg.svg" />
|
||||||
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
|
||||||
<title>NodeWarden</title>
|
<title>NodeWarden</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -22,7 +22,8 @@ import { calcTotpNow } from '@/lib/crypto';
|
|||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher } from '@/lib/types';
|
||||||
import LoadingState from '@/components/LoadingState';
|
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 {
|
interface TotpCodesPageProps {
|
||||||
ciphers: Cipher[];
|
ciphers: Cipher[];
|
||||||
@@ -35,10 +36,6 @@ const TOTP_RING_RADIUS = 14;
|
|||||||
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||||
const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order';
|
const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order';
|
||||||
const TOTP_REFRESH_BATCH_SIZE = 16;
|
const TOTP_REFRESH_BATCH_SIZE = 16;
|
||||||
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
|
||||||
const failedIconHosts = new Set<string>();
|
|
||||||
const loadedIconHosts = new Set<string>();
|
|
||||||
|
|
||||||
function getTotpTimeState(): { windowId: number; remain: number } {
|
function getTotpTimeState(): { windowId: number; remain: number } {
|
||||||
const epoch = Math.floor(Date.now() / 1000);
|
const epoch = Math.floor(Date.now() / 1000);
|
||||||
return {
|
return {
|
||||||
@@ -54,115 +51,8 @@ function formatTotp(code: string): string {
|
|||||||
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
|
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 }) {
|
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
||||||
const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]);
|
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
|
||||||
const iconStackRef = useRef<HTMLSpanElement | null>(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 (
|
|
||||||
<span className="list-icon-stack" ref={iconStackRef}>
|
|
||||||
<span className="list-icon-fallback">
|
|
||||||
<Globe size={18} />
|
|
||||||
</span>
|
|
||||||
{shouldLoad && (
|
|
||||||
<img
|
|
||||||
className="list-icon loaded"
|
|
||||||
src={websiteIconUrl(host)}
|
|
||||||
alt=""
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
ref={handleImgRef}
|
|
||||||
onLoad={hideFallback}
|
|
||||||
onError={markIconError}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className="list-icon-fallback">
|
|
||||||
<Globe size={18} />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SortableTotpRowProps {
|
interface SortableTotpRowProps {
|
||||||
|
|||||||
@@ -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<HTMLSpanElement | null>(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 <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="list-icon-stack" ref={nodeRef}>
|
||||||
|
{status !== 'loaded' && <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>}
|
||||||
|
{status === 'loaded' && (
|
||||||
|
<img
|
||||||
|
className="list-icon loaded"
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
onLoad={() => markWebsiteIconLoaded(host)}
|
||||||
|
onError={() => markWebsiteIconErrored(host)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useMemo } from 'preact/hooks';
|
||||||
import {
|
import {
|
||||||
CreditCard,
|
CreditCard,
|
||||||
FileKey2,
|
FileKey2,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types';
|
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 TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
||||||
export type VaultSortMode = 'edited' | 'created' | 'name';
|
export type VaultSortMode = 'edited' | 'created' | 'name';
|
||||||
@@ -433,110 +434,8 @@ export function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const failedIconHosts = new Set<string>();
|
|
||||||
const loadedIconHosts = new Set<string>();
|
|
||||||
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
|
||||||
|
|
||||||
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
||||||
const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]);
|
return <WebsiteIcon cipher={cipher} fallback={<TypeIcon type={Number(cipher.type || 1)} />} />;
|
||||||
const iconStackRef = useRef<HTMLSpanElement | null>(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 (
|
|
||||||
<span className="list-icon-stack" ref={iconStackRef}>
|
|
||||||
<span className="list-icon-fallback">
|
|
||||||
<Globe size={18} />
|
|
||||||
</span>
|
|
||||||
{shouldLoad && (
|
|
||||||
<img
|
|
||||||
className="list-icon loaded"
|
|
||||||
src={websiteIconUrl(host)}
|
|
||||||
alt=""
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
ref={handleImgRef}
|
|
||||||
onLoad={hideFallback}
|
|
||||||
onError={markIconError}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className="list-icon-fallback">
|
|
||||||
<TypeIcon type={Number(cipher.type || 1)} />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function copyToClipboard(value: string): void {
|
export function copyToClipboard(value: string): void {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
type WebsiteIconStatus = 'idle' | 'loading' | 'loaded' | 'error';
|
||||||
|
|
||||||
|
interface WebsiteIconRecord {
|
||||||
|
status: WebsiteIconStatus;
|
||||||
|
promise: Promise<WebsiteIconStatus> | null;
|
||||||
|
listeners: Set<(status: WebsiteIconStatus) => void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconRecords = new Map<string, WebsiteIconRecord>();
|
||||||
|
|
||||||
|
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<WebsiteIconStatus> {
|
||||||
|
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<WebsiteIconStatus>((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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user