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:
shuaiplus
2026-04-28 23:40:43 +08:00
parent 69b98f9e67
commit 68ded534a4
16 changed files with 505 additions and 284 deletions
+1
View File
@@ -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,
+16 -3
View File
@@ -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));
} }
+151 -10
View File
@@ -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> {
await withBackupRunnerLease(env, 'scheduled', async (keepAlive) => {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
let scanStartMs = Date.now();
while (true) {
await keepAlive();
const settings = await loadBackupSettings(storage, env, 'UTC'); const settings = await loadBackupSettings(storage, env, 'UTC');
const now = new Date(); const now = new Date();
for (const destination of settings.destinations) { const dueDestinations = settings.destinations.filter((destination) =>
if (!isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)) continue; isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)
await executeConfiguredBackup(env, storage, null, 'scheduled', destination.id); || 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 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'); 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);
+8 -4
View File
@@ -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);
} }
+2 -2
View File
@@ -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`,
}, },
}); });
} }
+44
View File
@@ -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,
+16
View File
@@ -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 = ?')
+21 -37
View File
@@ -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})`)
+1
View File
@@ -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, ' +
+7 -3
View File
@@ -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);
} }
+1 -1
View File
@@ -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 };
} }
+13 -1
View File
@@ -3,10 +3,22 @@
<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>
+3 -113
View File
@@ -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 {
+123
View File
@@ -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 {
+89
View File
@@ -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;
}