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
+3 -113
View File
@@ -22,7 +22,8 @@ import { calcTotpNow } from '@/lib/crypto';
import { t } from '@/lib/i18n';
import type { Cipher } from '@/lib/types';
import LoadingState from '@/components/LoadingState';
import { hostFromUri, isCipherVisibleInNormalVault, websiteIconUrl } from '@/components/vault/vault-page-helpers';
import WebsiteIcon from '@/components/vault/WebsiteIcon';
import { isCipherVisibleInNormalVault } from '@/components/vault/vault-page-helpers';
interface TotpCodesPageProps {
ciphers: Cipher[];
@@ -35,10 +36,6 @@ const TOTP_RING_RADIUS = 14;
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order';
const TOTP_REFRESH_BATCH_SIZE = 16;
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
const failedIconHosts = new Set<string>();
const loadedIconHosts = new Set<string>();
function getTotpTimeState(): { windowId: number; remain: number } {
const epoch = Math.floor(Date.now() / 1000);
return {
@@ -54,115 +51,8 @@ function formatTotp(code: string): string {
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
}
function firstCipherUri(cipher: Cipher): string {
const uris = cipher.login?.uris || [];
for (const uri of uris) {
const raw = uri.decUri || uri.uri || '';
if (raw.trim()) return raw.trim();
}
return '';
}
function TotpListIcon({ cipher }: { cipher: Cipher }) {
const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]);
const iconStackRef = useRef<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>
);
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
}
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 {
CreditCard,
FileKey2,
@@ -10,6 +10,7 @@ import {
import { copyTextToClipboard } from '@/lib/clipboard';
import { t } from '@/lib/i18n';
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types';
import WebsiteIcon from './WebsiteIcon';
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
export type VaultSortMode = 'edited' | 'created' | 'name';
@@ -433,110 +434,8 @@ export function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
return null;
}
const failedIconHosts = new Set<string>();
const loadedIconHosts = new Set<string>();
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]);
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>
);
return <WebsiteIcon cipher={cipher} fallback={<TypeIcon type={Number(cipher.type || 1)} />} />;
}
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;
}