mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
Compare commits
3 Commits
5ed7c949c1
...
615caf5946
| Author | SHA1 | Date | |
|---|---|---|---|
| 615caf5946 | |||
| 1a10df4a18 | |||
| d4749d3f82 |
+2
-2
@@ -7,8 +7,8 @@
|
||||
"main": "src/index.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm run build && wrangler dev -c wrangler.toml",
|
||||
"dev:kv": "npm run build && wrangler dev -c wrangler.kv.toml",
|
||||
"dev": "wrangler dev -c wrangler.toml",
|
||||
"dev:kv": "wrangler dev -c wrangler.kv.toml",
|
||||
"dev:demo": "vite --config webapp/vite.config.ts --mode demo --host 127.0.0.1 --port 5174",
|
||||
"build": "vite build --config webapp/vite.config.ts",
|
||||
"build:demo": "vite build --config webapp/vite.config.ts --mode demo && node scripts/pages-spa-redirects.cjs",
|
||||
|
||||
@@ -823,6 +823,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
||||
const incomingRevisionDate = readCipherRevisionDate(cipherData);
|
||||
const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData);
|
||||
const preserveRevisionDate =
|
||||
shouldPreserveRepairableCipherUris(request)
|
||||
&& (body.preserveRevisionDate === true || cipherData.preserveRevisionDate === true);
|
||||
|
||||
if (incomingKey.present && !shouldAcceptCipherKey(incomingKey.value)) {
|
||||
return errorResponse('Cipher key encryption is not supported by this server. Resync the client and try again.', 400);
|
||||
@@ -840,9 +843,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
|
||||
// Opaque passthrough: merge existing stored data with ALL incoming client fields.
|
||||
// Unknown/future fields from the client are preserved; server-controlled fields are protected.
|
||||
const { preserveRevisionDate: _preserveRevisionDate, PreserveRevisionDate: _pascalPreserveRevisionDate, ...cipherDataWithoutFlags } = cipherData;
|
||||
const cipher: Cipher = {
|
||||
...existingCipher, // start with all existing stored data (including unknowns)
|
||||
...cipherData, // overlay all client data (including new/unknown fields)
|
||||
...cipherDataWithoutFlags, // overlay all client data (including new/unknown fields)
|
||||
// Server-controlled fields (never from client)
|
||||
id: existingCipher.id,
|
||||
userId: existingCipher.userId,
|
||||
@@ -850,7 +854,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
favorite: cipherData.favorite ?? existingCipher.favorite,
|
||||
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
|
||||
createdAt: existingCipher.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedAt: preserveRevisionDate ? existingCipher.updatedAt : new Date().toISOString(),
|
||||
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
|
||||
deletedAt: existingCipher.deletedAt,
|
||||
};
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
<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="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="NodeWarden" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
<title>NodeWarden</title>
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "NodeWarden",
|
||||
"short_name": "NodeWarden",
|
||||
"description": "A lightweight Bitwarden-compatible vault for Cloudflare Workers.",
|
||||
"id": "/",
|
||||
"start_url": "/vault",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
|
||||
"orientation": "any",
|
||||
"background_color": "#eef4ff",
|
||||
"theme_color": "#0f172a",
|
||||
"categories": ["security", "productivity", "utilities"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Vault",
|
||||
"short_name": "Vault",
|
||||
"url": "/vault",
|
||||
"icons": [{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }]
|
||||
},
|
||||
{
|
||||
"name": "TOTP Codes",
|
||||
"short_name": "TOTP",
|
||||
"url": "/vault/totp",
|
||||
"icons": [{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
+9
-5
@@ -54,6 +54,7 @@ import { useToastManager } from '@/hooks/useToastManager';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
|
||||
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
||||
import { clearOfflineUnlockRecord } from '@/lib/offline-auth';
|
||||
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
|
||||
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
|
||||
import {
|
||||
@@ -433,6 +434,7 @@ export default function App() {
|
||||
(async () => {
|
||||
const boot = await bootstrapAppSession(initialBootstrap);
|
||||
if (!mounted) return;
|
||||
if (sessionRef.current?.symEncKey || sessionRef.current?.symMacKey) return;
|
||||
setDefaultKdfIterations(boot.defaultKdfIterations);
|
||||
setRegistrationInviteRequired(boot.registrationInviteRequired);
|
||||
setJwtWarning(boot.jwtWarning);
|
||||
@@ -746,6 +748,7 @@ export default function App() {
|
||||
setConfirm(null);
|
||||
setSession(null);
|
||||
clearProfileSnapshot();
|
||||
clearOfflineUnlockRecord();
|
||||
setProfile(null);
|
||||
setUnlockPreparing(false);
|
||||
setPendingTotp(null);
|
||||
@@ -910,7 +913,7 @@ export default function App() {
|
||||
const vaultCoreQuery = useQuery({
|
||||
queryKey: ['vault-core', vaultCacheKey],
|
||||
queryFn: () => loadVaultCoreSyncSnapshot(authedFetch, vaultCacheKey),
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && !!vaultCacheKey,
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!vaultCacheKey,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const encryptedVaultCore = vaultCoreQuery.data || cachedVaultCore;
|
||||
@@ -921,7 +924,7 @@ export default function App() {
|
||||
const sendsQuery = useQuery({
|
||||
queryKey: sendsQueryKey,
|
||||
queryFn: () => getSends(authedFetch),
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && location === '/sends' && !encryptedSendsFromSync,
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && location === '/sends' && !encryptedSendsFromSync,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const encryptedSends = sendsQuery.data || encryptedSendsFromSync;
|
||||
@@ -950,13 +953,13 @@ export default function App() {
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ['admin-users', vaultCacheKey],
|
||||
queryFn: () => listAdminUsers(authedFetch),
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone,
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const invitesQuery = useQuery({
|
||||
queryKey: ['admin-invites', vaultCacheKey],
|
||||
queryFn: () => listAdminInvites(authedFetch),
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone,
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const totpStatusQuery = useQuery({
|
||||
@@ -1013,7 +1016,7 @@ export default function App() {
|
||||
useQuery({
|
||||
queryKey: ['admin-backup-settings', vaultCacheKey],
|
||||
queryFn: () => backupActions.loadSettings(),
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone,
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -1083,6 +1086,7 @@ export default function App() {
|
||||
setDecryptedFolders(result.folders);
|
||||
setDecryptedCiphers(result.ciphers);
|
||||
setVaultInitialDecryptDone(true);
|
||||
if (!session.accessToken) return;
|
||||
const repairKey = `${session.accessToken}:${encryptedCiphers.map((cipher) => `${cipher.id}:${cipher.revisionDate || ''}`).join(',')}`;
|
||||
if (uriChecksumRepairAttemptRef.current !== repairKey) {
|
||||
uriChecksumRepairAttemptRef.current = repairKey;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ComponentChildren } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { Link } from 'wouter';
|
||||
import AppMainRoutes from '@/components/AppMainRoutes';
|
||||
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
|
||||
import ThemeSwitch from '@/components/ThemeSwitch';
|
||||
import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
|
||||
import { t } from '@/lib/i18n';
|
||||
@@ -237,6 +238,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<span className="mobile-page-title">{props.currentPageTitle}</span>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<NetworkStatusBadge />
|
||||
<div className="user-chip">
|
||||
<ShieldUser size={16} />
|
||||
<span>{props.profile?.email}</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { ArrowLeft, Eye, EyeOff, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
|
||||
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
|
||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
@@ -83,7 +84,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
if (props.mode === 'locked') {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<StandalonePageFrame title={t('txt_unlock_vault')}>
|
||||
<StandalonePageFrame title={t('txt_unlock_vault')} titleAccessory={<NetworkStatusBadge />}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -132,7 +133,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
if (props.mode === 'register') {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<StandalonePageFrame title={t('txt_create_account')}>
|
||||
<StandalonePageFrame title={t('txt_create_account')} titleAccessory={<NetworkStatusBadge />}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -216,7 +217,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<StandalonePageFrame title={t('txt_log_in')}>
|
||||
<StandalonePageFrame title={t('txt_log_in')} titleAccessory={<NetworkStatusBadge />}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Wifi, WifiOff } from 'lucide-preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
browserReportsOffline,
|
||||
getCurrentNetworkStatus,
|
||||
probeNodeWardenService,
|
||||
setCurrentNetworkStatus,
|
||||
subscribeNetworkStatus,
|
||||
type NetworkStatus,
|
||||
} from '@/lib/network-status';
|
||||
|
||||
const STATUS_CHECK_INTERVAL_MS = 30_000;
|
||||
|
||||
function statusLabel(status: NetworkStatus): string {
|
||||
if (status === 'online') return t('txt_online');
|
||||
return t('txt_offline');
|
||||
}
|
||||
|
||||
export default function NetworkStatusBadge() {
|
||||
const [status, setStatus] = useState<NetworkStatus>(getCurrentNetworkStatus);
|
||||
const label = statusLabel(status);
|
||||
const Icon = status === 'online' ? Wifi : WifiOff;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer = 0;
|
||||
|
||||
const checkService = async () => {
|
||||
if (browserReportsOffline()) {
|
||||
setCurrentNetworkStatus('offline');
|
||||
return;
|
||||
}
|
||||
const reachable = await probeNodeWardenService();
|
||||
if (!cancelled) {
|
||||
setCurrentNetworkStatus(reachable ? 'online' : 'offline');
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleNextCheck = () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
void checkService().finally(scheduleNextCheck);
|
||||
}, STATUS_CHECK_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const handleOnline = () => {
|
||||
void checkService();
|
||||
};
|
||||
const handleOffline = () => {
|
||||
setCurrentNetworkStatus('offline');
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') void checkService();
|
||||
};
|
||||
|
||||
const unsubscribe = subscribeNetworkStatus(setStatus);
|
||||
void checkService().finally(scheduleNextCheck);
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
window.addEventListener('focus', handleOnline);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
window.removeEventListener('focus', handleOnline);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`network-status-badge ${status}`}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Icon size={14} aria-hidden="true" />
|
||||
<span className="network-status-label">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { APP_VERSION } from '@shared/app-version';
|
||||
interface StandalonePageFrameProps {
|
||||
title: string;
|
||||
eyebrow?: ComponentChildren;
|
||||
titleAccessory?: ComponentChildren;
|
||||
children: ComponentChildren;
|
||||
}
|
||||
|
||||
@@ -19,7 +20,10 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
|
||||
|
||||
<div className="auth-card">
|
||||
{props.eyebrow && <div className="standalone-eyebrow">{props.eyebrow}</div>}
|
||||
<h1 className="standalone-title">{props.title}</h1>
|
||||
<div className="standalone-title-row">
|
||||
<h1 className="standalone-title">{props.title}</h1>
|
||||
{props.titleAccessory}
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -716,6 +716,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
setAttachmentQueue([]);
|
||||
setRemovedAttachmentIds({});
|
||||
if (isMobileLayout) setMobilePanel('detail');
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -729,6 +731,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
setPendingDelete(null);
|
||||
cancelEdit();
|
||||
if (isMobileLayout) setMobilePanel('list');
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -741,6 +745,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
if (isMobileLayout && selectedCipherId === cipher.id) {
|
||||
setMobilePanel('list');
|
||||
}
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -760,6 +766,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
}
|
||||
setSelectedMap({});
|
||||
setBulkDeleteOpen(false);
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -776,6 +784,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
await props.onBulkMove(ids, folderId);
|
||||
setSelectedMap({});
|
||||
setMoveOpen(false);
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -785,6 +795,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await props.onRefresh();
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -819,6 +831,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
await props.onCreateFolder(newFolderName);
|
||||
setCreateFolderOpen(false);
|
||||
setNewFolderName('');
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -833,6 +847,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
setSidebarFilter({ kind: 'all' });
|
||||
}
|
||||
setPendingDeleteFolder(null);
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -850,6 +866,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
await props.onRenameFolder(pendingRenameFolder.id, nextName);
|
||||
setPendingRenameFolder(null);
|
||||
setRenameFolderName('');
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -864,6 +882,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
try {
|
||||
await props.onBulkRestore(ids);
|
||||
setSelectedMap({});
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -878,6 +898,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
if (isMobileLayout && selectedCipherId === pendingArchive.id) {
|
||||
setMobilePanel('list');
|
||||
}
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -892,6 +914,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
delete next[cipher.id];
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -907,6 +931,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
await props.onBulkArchive(ids);
|
||||
setSelectedMap({});
|
||||
setBulkArchiveOpen(false);
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -921,6 +947,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
try {
|
||||
await props.onBulkUnarchive(ids);
|
||||
setSelectedMap({});
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -935,6 +963,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
setSidebarFilter({ kind: 'all' });
|
||||
}
|
||||
setDeleteAllFoldersOpen(false);
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
subscribeWebsiteIconStatus,
|
||||
} from '@/lib/website-icon-cache';
|
||||
import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
|
||||
import { getCurrentNetworkStatus, subscribeNetworkStatus } from '@/lib/network-status';
|
||||
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
||||
|
||||
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
||||
@@ -26,8 +27,11 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
||||
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
|
||||
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
|
||||
const [imageUrl, setImageUrl] = useState(() => (host ? getWebsiteIconImageUrl(host) : ''));
|
||||
const [networkStatus, setNetworkStatus] = useState(getCurrentNetworkStatus);
|
||||
const demoIconUrl = SHOULD_LOAD_DEMO_BRAND_ICONS && host ? demoBrandIconUrl(host) : '';
|
||||
|
||||
useEffect(() => subscribeNetworkStatus(setNetworkStatus), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!host) {
|
||||
setShouldLoad(true);
|
||||
@@ -77,9 +81,10 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
||||
useEffect(() => {
|
||||
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
|
||||
if (demoIconUrl) return;
|
||||
if (networkStatus !== 'online') return;
|
||||
if (!host || !src || !shouldLoad || status !== 'idle') return;
|
||||
beginWebsiteIconLoad(host, src);
|
||||
}, [demoIconUrl, host, src, shouldLoad, status]);
|
||||
}, [demoIconUrl, host, networkStatus, src, shouldLoad, status]);
|
||||
|
||||
if (demoIconUrl) {
|
||||
return (
|
||||
|
||||
@@ -302,6 +302,11 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
await Promise.all([refetchCiphers(), refetchFolders(), refetchSends()]);
|
||||
};
|
||||
|
||||
const requireOnlineWrite = () => {
|
||||
if (session?.accessToken) return;
|
||||
throw new Error(t('txt_offline_vault_readonly'));
|
||||
};
|
||||
|
||||
const syncVaultCoreInBackground = (options?: { includeFolders?: boolean }) => {
|
||||
const tasks: Promise<unknown>[] = [Promise.resolve(refetchCiphers())];
|
||||
if (options?.includeFolders) {
|
||||
@@ -447,6 +452,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
|
||||
async createVaultItem(draft: VaultDraft, attachments: File[] = []) {
|
||||
if (!session) return;
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
const optimistic = optimisticCipherFromDraft(draft, null);
|
||||
patchDecryptedCiphers((prev) => [optimistic, ...prev.filter((cipher) => cipher.id !== optimistic.id)]);
|
||||
try {
|
||||
@@ -471,6 +482,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
|
||||
async updateVaultItem(cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) {
|
||||
if (!session) return;
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
if (hasUnresolvedCipherData(cipher)) {
|
||||
throw new Error(t('txt_decrypt_failed_2'));
|
||||
}
|
||||
@@ -543,6 +560,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async deleteVaultItem(cipher: Cipher) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
const previousCipher = { ...cipher };
|
||||
if (cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt) {
|
||||
try {
|
||||
@@ -571,6 +594,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async archiveVaultItem(cipher: Cipher) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
const previousCipher = { ...cipher };
|
||||
const archivedDate = new Date().toISOString();
|
||||
patchCipherBatch([cipher.id], (current) => ({ ...current, archivedDate, deletedDate: null, revisionDate: archivedDate }));
|
||||
@@ -587,6 +616,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async unarchiveVaultItem(cipher: Cipher) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
const previousCipher = { ...cipher };
|
||||
const revisionDate = new Date().toISOString();
|
||||
patchCipherBatch([cipher.id], (current) => ({ ...current, archivedDate: null, revisionDate }));
|
||||
@@ -603,6 +638,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async bulkDeleteVaultItems(ids: string[]) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await bulkDeleteCiphers(authedFetch, ids);
|
||||
const deletedDate = new Date().toISOString();
|
||||
@@ -616,6 +657,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async bulkArchiveVaultItems(ids: string[]) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await bulkArchiveCiphers(authedFetch, ids);
|
||||
const archivedDate = new Date().toISOString();
|
||||
@@ -629,6 +676,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async bulkUnarchiveVaultItems(ids: string[]) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await bulkUnarchiveCiphers(authedFetch, ids);
|
||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate: null }));
|
||||
@@ -641,6 +694,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async bulkMoveVaultItems(ids: string[], folderId: string | null) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await bulkMoveCiphers(authedFetch, ids, folderId);
|
||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, folderId }));
|
||||
@@ -658,6 +717,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
onNotify('error', t('txt_folder_name_is_required'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||
const created = await createFolder(authedFetch, session, folderName);
|
||||
@@ -683,6 +748,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
onNotify('error', t('txt_folder_not_found'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await deleteFolder(authedFetch, id);
|
||||
patchFolderBatch([id], () => null);
|
||||
@@ -706,6 +777,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
onNotify('error', t('txt_folder_name_is_required'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||
await updateFolder(authedFetch, session, id, nextName);
|
||||
@@ -719,6 +796,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async bulkRestoreVaultItems(ids: string[]) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await bulkRestoreCiphers(authedFetch, ids);
|
||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate: null }));
|
||||
@@ -731,6 +814,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async bulkPermanentDeleteVaultItems(ids: string[]) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await bulkPermanentDeleteCiphers(authedFetch, ids);
|
||||
patchCipherBatch(ids, () => null);
|
||||
@@ -745,6 +834,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
async bulkDeleteFolders(folderIds: string[]) {
|
||||
const ids = Array.from(new Set(folderIds.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
if (!ids.length) return;
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await bulkDeleteFolders(authedFetch, ids);
|
||||
const removedIds = new Set(ids);
|
||||
@@ -765,6 +860,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
|
||||
async createSend(draft: SendDraft, autoCopyLink: boolean) {
|
||||
if (!session) return;
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const fileName = draft.type === 'file' ? String(draft.file?.name || '').trim() : '';
|
||||
if (fileName) {
|
||||
@@ -790,6 +891,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
|
||||
async updateSend(send: Send, draft: SendDraft, autoCopyLink: boolean) {
|
||||
if (!session) return;
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const updated = await updateSend(authedFetch, session, send, draft);
|
||||
await refetchSends();
|
||||
@@ -806,6 +913,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async deleteSend(send: Send) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await deleteSend(authedFetch, send.id);
|
||||
await refetchSends();
|
||||
@@ -817,6 +930,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async bulkDeleteSends(ids: string[]) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await bulkDeleteSends(authedFetch, ids);
|
||||
await refetchSends();
|
||||
@@ -833,6 +952,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
attachments: ImportAttachmentFile[] = []
|
||||
): Promise<ImportResultSummary> {
|
||||
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
||||
requireOnlineWrite();
|
||||
|
||||
const mode = options.folderMode || 'original';
|
||||
const targetFolderId = (options.targetFolderId || '').trim() || null;
|
||||
|
||||
@@ -95,6 +95,12 @@ export function loadSession(): SessionState | null {
|
||||
authMode: 'web-cookie',
|
||||
};
|
||||
}
|
||||
if (parsed.authMode === 'token' && parsed.email && !parsed.accessToken && !parsed.refreshToken) {
|
||||
return {
|
||||
email: parsed.email,
|
||||
authMode: 'token',
|
||||
};
|
||||
}
|
||||
if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null;
|
||||
return {
|
||||
accessToken: parsed.accessToken,
|
||||
@@ -233,6 +239,7 @@ export async function loginWithPassword(
|
||||
totpCode?: string;
|
||||
rememberDevice?: boolean;
|
||||
useRememberToken?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<TokenSuccess | TokenError> {
|
||||
const body = new URLSearchParams();
|
||||
@@ -262,6 +269,7 @@ export async function loginWithPassword(
|
||||
[WEB_SESSION_HEADER]: '1',
|
||||
},
|
||||
body: body.toString(),
|
||||
signal: options?.signal,
|
||||
});
|
||||
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
|
||||
if (resp.ok) {
|
||||
@@ -450,7 +458,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
||||
};
|
||||
|
||||
const session = getSession();
|
||||
if (!session?.accessToken) throw new Error('Unauthorized');
|
||||
if (!session?.accessToken) throw new Error(t('txt_offline_vault_readonly'));
|
||||
const headers = new Headers(init.headers || {});
|
||||
headers.set('Authorization', `Bearer ${session.accessToken}`);
|
||||
headers.set('X-NodeWarden-Web', '1');
|
||||
|
||||
@@ -927,6 +927,7 @@ export async function repairCipherUriChecksums(
|
||||
? cipher.fields.map(({ decName: _decName, decValue: _decValue, ...field }) => field)
|
||||
: null,
|
||||
lastKnownRevisionDate: cipher.revisionDate ?? null,
|
||||
preserveRevisionDate: true,
|
||||
};
|
||||
if (keys.key) payload.key = keys.key;
|
||||
|
||||
@@ -1091,7 +1092,9 @@ export async function repairCipherKeyMismatches(
|
||||
if (!cipher?.id || !looksLikeCipherString(cipher.key)) continue;
|
||||
if (!(await hasItemKeyFieldMismatch(cipher, userEnc, userMac))) continue;
|
||||
if (hasUnresolvedEncryptedFields(cipher)) continue;
|
||||
await updateCipher(authedFetch, session, cipher, draftFromDecryptedCipher(cipher));
|
||||
await updateCipher(authedFetch, session, cipher, draftFromDecryptedCipher(cipher), {
|
||||
preserveRevisionDate: true,
|
||||
});
|
||||
repaired += 1;
|
||||
}
|
||||
|
||||
@@ -1225,9 +1228,13 @@ export async function updateCipher(
|
||||
authedFetch: AuthedFetch,
|
||||
session: SessionState,
|
||||
cipher: Cipher,
|
||||
draft: VaultDraft
|
||||
draft: VaultDraft,
|
||||
extraPayload?: Record<string, unknown>
|
||||
): Promise<Cipher> {
|
||||
const payload = await buildCipherPayload(session, draft, cipher);
|
||||
if (extraPayload) {
|
||||
Object.assign(payload, extraPayload);
|
||||
}
|
||||
|
||||
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
|
||||
method: 'PUT',
|
||||
|
||||
+105
-7
@@ -12,12 +12,22 @@ import {
|
||||
} from '@/lib/api/auth';
|
||||
import { readInviteCodeFromUrl } from '@/lib/app-support';
|
||||
import { t, translateServerError } from '@/lib/i18n';
|
||||
import {
|
||||
getOfflineUnlockKdfIterations,
|
||||
hasOfflineUnlockRecord,
|
||||
kdfIterationsFromLogin,
|
||||
loadOfflineProfileSnapshot,
|
||||
saveOfflineUnlockRecord,
|
||||
unlockOfflineVaultWithMasterKey,
|
||||
} from '@/lib/offline-auth';
|
||||
import { probeNodeWardenService } from '@/lib/network-status';
|
||||
import type { AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
|
||||
|
||||
export interface PendingTotp {
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
masterKey: Uint8Array;
|
||||
kdfIterations: number;
|
||||
}
|
||||
|
||||
export type JwtUnsafeReason = 'missing' | 'default' | 'too_short';
|
||||
@@ -93,6 +103,20 @@ async function maybeRefreshSession(session: SessionState): Promise<SessionState
|
||||
};
|
||||
}
|
||||
|
||||
function browserReportsOffline(): boolean {
|
||||
return typeof navigator !== 'undefined' && navigator.onLine === false;
|
||||
}
|
||||
|
||||
function createTimeoutAbortController(timeoutMs: number): { controller: AbortController; cancel: () => void } | null {
|
||||
if (typeof AbortController === 'undefined') return null;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
return {
|
||||
controller,
|
||||
cancel: () => clearTimeout(timer),
|
||||
};
|
||||
}
|
||||
|
||||
function readWindowBootstrap(): WebBootstrapResponse {
|
||||
if (typeof window === 'undefined') return {};
|
||||
const raw = (window as Window & { __NW_BOOT__?: WebBootstrapResponse }).__NW_BOOT__;
|
||||
@@ -246,8 +270,24 @@ export async function hydrateLockedSession(
|
||||
session: SessionState,
|
||||
fallbackProfile: Profile | null = null
|
||||
): Promise<{ session: SessionState | null; profile: Profile | null }> {
|
||||
if (hasOfflineUnlockRecord(session.email)) {
|
||||
const serviceReachable = await probeNodeWardenService();
|
||||
if (!serviceReachable) {
|
||||
return {
|
||||
session,
|
||||
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const refreshedSession = await maybeRefreshSession(session);
|
||||
if (!refreshedSession?.accessToken) {
|
||||
if (hasOfflineUnlockRecord(session.email)) {
|
||||
return {
|
||||
session,
|
||||
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
||||
};
|
||||
}
|
||||
return { session: null, profile: null };
|
||||
}
|
||||
try {
|
||||
@@ -272,7 +312,8 @@ export async function hydrateLockedSession(
|
||||
export async function completeLogin(
|
||||
token: TokenSuccess,
|
||||
email: string,
|
||||
masterKey: Uint8Array
|
||||
masterKey: Uint8Array,
|
||||
fallbackKdfIterations: number
|
||||
): Promise<CompletedLogin> {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const fallbackProfile = loadProfileSnapshot(normalizedEmail);
|
||||
@@ -291,6 +332,12 @@ export async function completeLogin(
|
||||
throw new Error('Missing profile key');
|
||||
}
|
||||
const keys = await unlockVaultKey(profile.key, masterKey);
|
||||
saveOfflineUnlockRecord({
|
||||
email: normalizedEmail,
|
||||
profile,
|
||||
profileKey: profile.key,
|
||||
kdfIterations: kdfIterationsFromLogin(token, fallbackKdfIterations),
|
||||
});
|
||||
return {
|
||||
session: { ...baseSession, ...keys },
|
||||
profile,
|
||||
@@ -310,7 +357,7 @@ export async function performPasswordLogin(
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return {
|
||||
kind: 'success',
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey),
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -322,6 +369,7 @@ export async function performPasswordLogin(
|
||||
email: normalizedEmail,
|
||||
passwordHash: derived.hash,
|
||||
masterKey: derived.masterKey,
|
||||
kdfIterations: derived.kdfIterations,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -342,7 +390,7 @@ export async function performTotpLogin(
|
||||
rememberDevice,
|
||||
});
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey);
|
||||
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations);
|
||||
}
|
||||
const tokenError = token as { error_description?: string; error?: string };
|
||||
throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed')));
|
||||
@@ -361,7 +409,7 @@ export async function performRecoverTwoFactorLogin(
|
||||
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return {
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey),
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
|
||||
newRecoveryCode: recovered.newRecoveryCode || null,
|
||||
};
|
||||
}
|
||||
@@ -397,13 +445,62 @@ export async function performUnlock(
|
||||
fallbackIterations: number
|
||||
): Promise<PasswordLoginResult> {
|
||||
const normalizedEmail = (profile?.email || session.email).trim().toLowerCase();
|
||||
const derived = await deriveLoginHashLocally(normalizedEmail, password, fallbackIterations);
|
||||
const token = await loginWithPassword(normalizedEmail, derived.hash, { useRememberToken: true });
|
||||
const offlineIterations = getOfflineUnlockKdfIterations(normalizedEmail);
|
||||
const hasOfflineUnlock = !!offlineIterations;
|
||||
const kdfIterations = offlineIterations || fallbackIterations;
|
||||
const derived = await deriveLoginHashLocally(normalizedEmail, password, kdfIterations);
|
||||
const unlockOffline = async (): Promise<PasswordLoginResult> => {
|
||||
try {
|
||||
const offline = await unlockOfflineVaultWithMasterKey(session, profile, derived.masterKey);
|
||||
return {
|
||||
kind: 'success',
|
||||
login: {
|
||||
session: offline.session,
|
||||
profile: offline.profile,
|
||||
profilePromise: Promise.resolve(offline.profile),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
kind: 'error',
|
||||
message: t('txt_unlock_failed_master_password_is_incorrect'),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (hasOfflineUnlock) {
|
||||
if (browserReportsOffline()) {
|
||||
return unlockOffline();
|
||||
}
|
||||
const serviceReachable = await probeNodeWardenService();
|
||||
if (!serviceReachable) {
|
||||
return unlockOffline();
|
||||
}
|
||||
}
|
||||
|
||||
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||
const abortable = hasOfflineUnlock ? createTimeoutAbortController(2500) : null;
|
||||
try {
|
||||
token = await loginWithPassword(normalizedEmail, derived.hash, {
|
||||
useRememberToken: true,
|
||||
signal: abortable?.controller.signal,
|
||||
});
|
||||
} catch {
|
||||
if (hasOfflineUnlock) {
|
||||
return unlockOffline();
|
||||
}
|
||||
return {
|
||||
kind: 'error',
|
||||
message: t('txt_unlock_failed_master_password_is_incorrect'),
|
||||
};
|
||||
} finally {
|
||||
abortable?.cancel();
|
||||
}
|
||||
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return {
|
||||
kind: 'success',
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey),
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -415,6 +512,7 @@ export async function performUnlock(
|
||||
email: normalizedEmail,
|
||||
passwordHash: derived.hash,
|
||||
masterKey: derived.masterKey,
|
||||
kdfIterations: derived.kdfIterations,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -735,6 +735,7 @@ const en: Record<string, string> = {
|
||||
"txt_status": "Status",
|
||||
"txt_online": "Online",
|
||||
"txt_offline": "Offline",
|
||||
"txt_offline_vault_readonly": "Offline mode is read-only. Connect to NodeWarden before changing your vault.",
|
||||
"txt_submit": "Submit",
|
||||
"txt_sync": "Sync",
|
||||
"txt_sync_vault": "Sync Vault",
|
||||
|
||||
@@ -735,6 +735,7 @@ const es: Record<string, string> = {
|
||||
"txt_status": "Estado",
|
||||
"txt_online": "En línea",
|
||||
"txt_offline": "Sin conexión",
|
||||
"txt_offline_vault_readonly": "El modo sin conexión es de solo lectura. Conecta con NodeWarden antes de cambiar la bóveda.",
|
||||
"txt_submit": "Enviar",
|
||||
"txt_sync": "Sincronizar",
|
||||
"txt_sync_vault": "Sincronizar bóveda",
|
||||
|
||||
@@ -735,6 +735,7 @@ const ru: Record<string, string> = {
|
||||
"txt_status": "Статус",
|
||||
"txt_online": "Онлайн",
|
||||
"txt_offline": "Офлайн",
|
||||
"txt_offline_vault_readonly": "Автономный режим доступен только для чтения. Подключитесь к NodeWarden, чтобы изменить хранилище.",
|
||||
"txt_submit": "Отправить",
|
||||
"txt_sync": "Синхронизировать",
|
||||
"txt_sync_vault": "Синхронизировать хранилище",
|
||||
|
||||
@@ -735,6 +735,7 @@ const zhCN: Record<string, string> = {
|
||||
"txt_status": "状态",
|
||||
"txt_online": "在线",
|
||||
"txt_offline": "离线",
|
||||
"txt_offline_vault_readonly": "当前为离线模式,只能查看密码库。连接到 NodeWarden 后才能修改。",
|
||||
"txt_submit": "提交",
|
||||
"txt_sync": "同步",
|
||||
"txt_sync_vault": "同步",
|
||||
|
||||
@@ -735,6 +735,7 @@ const zhTW: Record<string, string> = {
|
||||
"txt_status": "狀態",
|
||||
"txt_online": "在線",
|
||||
"txt_offline": "離線",
|
||||
"txt_offline_vault_readonly": "目前為離線模式,只能查看密碼庫。連線到 NodeWarden 後才能修改。",
|
||||
"txt_submit": "提交",
|
||||
"txt_sync": "同步",
|
||||
"txt_sync_vault": "同步",
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
export type NetworkStatus = 'online' | 'offline';
|
||||
|
||||
const STATUS_PROBE_TIMEOUT_MS = 3500;
|
||||
const STATUS_PROBE_CACHE_MS = 5000;
|
||||
const listeners = new Set<(status: NetworkStatus) => void>();
|
||||
let currentStatus: NetworkStatus = getInitialNetworkStatus();
|
||||
let pendingProbe: Promise<boolean> | null = null;
|
||||
let lastProbeAt = 0;
|
||||
let lastProbeResult = false;
|
||||
|
||||
export function browserReportsOffline(): boolean {
|
||||
return typeof navigator !== 'undefined' && navigator.onLine === false;
|
||||
}
|
||||
|
||||
export function getInitialNetworkStatus(): NetworkStatus {
|
||||
return 'offline';
|
||||
}
|
||||
|
||||
export function getCurrentNetworkStatus(): NetworkStatus {
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
export function setCurrentNetworkStatus(status: NetworkStatus): void {
|
||||
if (currentStatus === status) return;
|
||||
currentStatus = status;
|
||||
for (const listener of Array.from(listeners)) {
|
||||
listener(status);
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeNetworkStatus(listener: (status: NetworkStatus) => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export async function probeNodeWardenService(): Promise<boolean> {
|
||||
if (browserReportsOffline()) {
|
||||
setCurrentNetworkStatus('offline');
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (pendingProbe) return pendingProbe;
|
||||
if (now - lastProbeAt < STATUS_PROBE_CACHE_MS) return lastProbeResult;
|
||||
|
||||
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||||
const timer = controller
|
||||
? window.setTimeout(() => controller.abort(), STATUS_PROBE_TIMEOUT_MS)
|
||||
: 0;
|
||||
|
||||
pendingProbe = (async () => {
|
||||
const response = await fetch(`/api/web-bootstrap?statusProbe=${Date.now()}`, {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
Pragma: 'no-cache',
|
||||
},
|
||||
signal: controller?.signal,
|
||||
});
|
||||
return response.ok;
|
||||
})()
|
||||
.catch(() => false)
|
||||
.then((result) => {
|
||||
lastProbeAt = Date.now();
|
||||
lastProbeResult = result;
|
||||
setCurrentNetworkStatus(result ? 'online' : 'offline');
|
||||
return result;
|
||||
})
|
||||
.finally(() => {
|
||||
if (timer) window.clearTimeout(timer);
|
||||
pendingProbe = null;
|
||||
});
|
||||
|
||||
return pendingProbe;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { deriveLoginHashLocally, unlockVaultKey } from '@/lib/api/auth';
|
||||
import type { Profile, SessionState, TokenSuccess } from '@/lib/types';
|
||||
|
||||
const OFFLINE_UNLOCK_KEY = 'nodewarden.web.offline-unlock.v1';
|
||||
|
||||
interface OfflineUnlockRecord {
|
||||
version: 1;
|
||||
email: string;
|
||||
profile: Profile;
|
||||
profileKey: string;
|
||||
kdfIterations: number;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
function normalizeEmail(email: string | null | undefined): string {
|
||||
return String(email || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function stripOfflineProfile(profile: Profile): Profile {
|
||||
return {
|
||||
...profile,
|
||||
email: normalizeEmail(profile.email),
|
||||
key: '',
|
||||
privateKey: null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseRecord(raw: string | null): OfflineUnlockRecord | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<OfflineUnlockRecord>;
|
||||
const email = normalizeEmail(parsed.email);
|
||||
const profileKey = String(parsed.profileKey || '').trim();
|
||||
const iterations = Number(parsed.kdfIterations || 0);
|
||||
if (parsed.version !== 1 || !email || !profileKey || !Number.isFinite(iterations) || iterations <= 0) {
|
||||
return null;
|
||||
}
|
||||
const profile = parsed.profile && typeof parsed.profile === 'object'
|
||||
? stripOfflineProfile(parsed.profile as Profile)
|
||||
: {
|
||||
id: '',
|
||||
email,
|
||||
name: email,
|
||||
key: '',
|
||||
privateKey: null,
|
||||
role: 'user',
|
||||
};
|
||||
return {
|
||||
version: 1,
|
||||
email,
|
||||
profile,
|
||||
profileKey,
|
||||
kdfIterations: iterations,
|
||||
savedAt: Number(parsed.savedAt || 0) || 0,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readRecord(): OfflineUnlockRecord | null {
|
||||
if (typeof localStorage === 'undefined') return null;
|
||||
return parseRecord(localStorage.getItem(OFFLINE_UNLOCK_KEY));
|
||||
}
|
||||
|
||||
export function hasOfflineUnlockRecord(email?: string | null): boolean {
|
||||
const record = readRecord();
|
||||
if (!record) return false;
|
||||
const normalized = normalizeEmail(email);
|
||||
return !normalized || record.email === normalized;
|
||||
}
|
||||
|
||||
export function getOfflineUnlockKdfIterations(email?: string | null): number | null {
|
||||
const record = readRecord();
|
||||
if (!record) return null;
|
||||
const normalized = normalizeEmail(email);
|
||||
if (normalized && record.email !== normalized) return null;
|
||||
return record.kdfIterations;
|
||||
}
|
||||
|
||||
export function loadOfflineProfileSnapshot(email?: string | null): Profile | null {
|
||||
const record = readRecord();
|
||||
if (!record) return null;
|
||||
const normalized = normalizeEmail(email);
|
||||
if (normalized && record.email !== normalized) return null;
|
||||
return stripOfflineProfile(record.profile);
|
||||
}
|
||||
|
||||
export function saveOfflineUnlockRecord(args: {
|
||||
email: string;
|
||||
profile: Profile;
|
||||
profileKey: string;
|
||||
kdfIterations: number;
|
||||
}): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
const email = normalizeEmail(args.email || args.profile.email);
|
||||
const profileKey = String(args.profileKey || '').trim();
|
||||
const kdfIterations = Number(args.kdfIterations || 0);
|
||||
if (!email || !profileKey || !Number.isFinite(kdfIterations) || kdfIterations <= 0) return;
|
||||
const record: OfflineUnlockRecord = {
|
||||
version: 1,
|
||||
email,
|
||||
profile: stripOfflineProfile({ ...args.profile, email }),
|
||||
profileKey,
|
||||
kdfIterations,
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
localStorage.setItem(OFFLINE_UNLOCK_KEY, JSON.stringify(record));
|
||||
}
|
||||
|
||||
export function clearOfflineUnlockRecord(): void {
|
||||
try {
|
||||
localStorage.removeItem(OFFLINE_UNLOCK_KEY);
|
||||
} catch {
|
||||
// Ignore storage failures during logout cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
export async function unlockOfflineVault(
|
||||
session: SessionState,
|
||||
profile: Profile | null,
|
||||
password: string
|
||||
): Promise<{ session: SessionState; profile: Profile }> {
|
||||
const record = readRecord();
|
||||
const email = normalizeEmail(profile?.email || session.email);
|
||||
if (!record || record.email !== email) {
|
||||
throw new Error('Offline unlock is not available on this device.');
|
||||
}
|
||||
const derived = await deriveLoginHashLocally(record.email, password, record.kdfIterations);
|
||||
return unlockOfflineVaultWithMasterKey(session, profile, derived.masterKey);
|
||||
}
|
||||
|
||||
export async function unlockOfflineVaultWithMasterKey(
|
||||
session: SessionState,
|
||||
profile: Profile | null,
|
||||
masterKey: Uint8Array
|
||||
): Promise<{ session: SessionState; profile: Profile }> {
|
||||
const record = readRecord();
|
||||
const email = normalizeEmail(profile?.email || session.email);
|
||||
if (!record || record.email !== email) {
|
||||
throw new Error('Offline unlock is not available on this device.');
|
||||
}
|
||||
const keys = await unlockVaultKey(record.profileKey, masterKey);
|
||||
const { accessToken: _accessToken, refreshToken: _refreshToken, ...offlineSession } = session;
|
||||
return {
|
||||
session: {
|
||||
...offlineSession,
|
||||
email: record.email,
|
||||
...keys,
|
||||
},
|
||||
profile: {
|
||||
...stripOfflineProfile(record.profile),
|
||||
key: record.profileKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function kdfIterationsFromLogin(token: TokenSuccess, fallbackIterations: number): number {
|
||||
const value = Number(token.KdfIterations || fallbackIterations || 600000);
|
||||
return Number.isFinite(value) && value > 0 ? value : 600000;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export function registerNodeWardenServiceWorker(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
if (import.meta.env.DEV) return;
|
||||
|
||||
const register = () => {
|
||||
void navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {
|
||||
// PWA support is progressive enhancement; the vault still works without it.
|
||||
});
|
||||
};
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
register();
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('load', register, { once: true });
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Send } from './types';
|
||||
import { getCurrentNetworkStatus } from './network-status';
|
||||
import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt';
|
||||
|
||||
type WorkerSuccess<T> = { id: number; ok: true; result: T };
|
||||
@@ -12,6 +13,7 @@ const pending = new Map<number, { resolve: (value: any) => void; reject: (error:
|
||||
function getWorker(): Worker | null {
|
||||
if (typeof Worker === 'undefined') return null;
|
||||
if (worker) return worker;
|
||||
if (getCurrentNetworkStatus() === 'offline') return null;
|
||||
worker = new Worker(new URL('../workers/vault-decrypt.worker.ts', import.meta.url), { type: 'module' });
|
||||
worker.addEventListener('message', (event: MessageEvent<WorkerResponse<unknown>>) => {
|
||||
const message = event.data;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { render } from 'preact';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
import { initI18n } from './lib/i18n';
|
||||
import { registerNodeWardenServiceWorker } from './lib/pwa';
|
||||
import './tailwind.css';
|
||||
import './styles.css';
|
||||
|
||||
@@ -29,4 +30,5 @@ function renderApp(): void {
|
||||
|
||||
void initI18n().finally(() => {
|
||||
renderApp();
|
||||
registerNodeWardenServiceWorker();
|
||||
});
|
||||
|
||||
@@ -451,7 +451,7 @@
|
||||
}
|
||||
|
||||
.auth-card h1 {
|
||||
@apply m-0 mb-1 text-center;
|
||||
@apply m-0;
|
||||
}
|
||||
|
||||
.standalone-eyebrow {
|
||||
@@ -486,7 +486,17 @@
|
||||
}
|
||||
|
||||
.standalone-title {
|
||||
@apply m-0 mb-1 text-left text-3xl font-bold leading-tight tracking-normal;
|
||||
@apply m-0 text-center text-3xl font-bold leading-tight tracking-normal;
|
||||
}
|
||||
|
||||
.standalone-title-row {
|
||||
@apply relative mb-1 flex min-h-9 items-center justify-center;
|
||||
padding-inline: 84px;
|
||||
}
|
||||
|
||||
.standalone-title-row > .network-status-badge {
|
||||
@apply absolute right-0 top-1/2;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.standalone-muted {
|
||||
|
||||
@@ -54,6 +54,10 @@
|
||||
@apply text-2xl;
|
||||
}
|
||||
|
||||
.standalone-title-row {
|
||||
padding-inline: 68px;
|
||||
}
|
||||
|
||||
.standalone-footer {
|
||||
@apply text-xs leading-[1.4];
|
||||
}
|
||||
@@ -128,6 +132,14 @@
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.topbar-actions > .network-status-badge {
|
||||
@apply h-8 px-2 text-[0];
|
||||
}
|
||||
|
||||
.topbar-actions > .network-status-badge svg {
|
||||
@apply m-0;
|
||||
}
|
||||
|
||||
.mobile-sidebar-toggle,
|
||||
.mobile-lock-btn {
|
||||
@apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 p-0 text-[0];
|
||||
|
||||
@@ -44,6 +44,31 @@
|
||||
@apply flex items-center gap-2.5;
|
||||
}
|
||||
|
||||
.network-status-badge {
|
||||
@apply inline-flex h-[30px] shrink-0 select-none items-center gap-1.5 rounded-full border px-2.5 text-xs font-bold leading-none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.network-status-badge svg {
|
||||
@apply shrink-0;
|
||||
}
|
||||
|
||||
.network-status-badge.online {
|
||||
background: color-mix(in srgb, var(--success) 10%, var(--panel));
|
||||
border-color: color-mix(in srgb, var(--success) 36%, var(--line));
|
||||
color: color-mix(in srgb, var(--success) 78%, var(--text));
|
||||
}
|
||||
|
||||
.network-status-badge.offline {
|
||||
background: color-mix(in srgb, var(--warning) 13%, var(--panel));
|
||||
border-color: color-mix(in srgb, var(--warning) 42%, var(--line));
|
||||
color: color-mix(in srgb, var(--warning) 82%, var(--text));
|
||||
}
|
||||
|
||||
.topbar-actions > .network-status-badge {
|
||||
@apply h-[34px] px-3;
|
||||
}
|
||||
|
||||
.mobile-tabbar {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
+203
-1
@@ -1,10 +1,212 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import preact from '@preact/preset-vite';
|
||||
import { defineConfig, type Plugin } from 'vite';
|
||||
|
||||
const rootDir = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
function buildServiceWorkerSource(precacheUrls: string[], version: string): string {
|
||||
return `const CACHE_VERSION = ${JSON.stringify(`nodewarden-pwa-${version}`)};
|
||||
const APP_SHELL_CACHE = \`\${CACHE_VERSION}-shell\`;
|
||||
const RUNTIME_CACHE = 'nodewarden-pwa-runtime-v1';
|
||||
|
||||
const PRECACHE_URLS = ${JSON.stringify(precacheUrls, null, 2)};
|
||||
const CRITICAL_SHELL_URLS = ['/', '/index.html'];
|
||||
const STATIC_PATH_RE = /^\\/(?:assets\\/|payment-logos\\/|icon-|logo-|favicon|apple-touch-icon|nodewarden-|manifest\\.webmanifest$)/;
|
||||
const NEVER_CACHE_PATH_RE = /^\\/(?:api|identity|setup|config|notifications|icons|\\.well-known|cdn-cgi)(?:\\/|$)/;
|
||||
const OFFLINE_FALLBACK_HTML = '<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>NodeWarden</title><style>html,body{height:100%;margin:0;background:#eef4ff;color:#0f172a;font-family:ui-sans-serif,system-ui,sans-serif}.boot-screen{min-height:100%;display:grid;place-items:center;padding:24px;box-sizing:border-box}.boot-card{width:min(420px,100%);display:grid;gap:12px;justify-items:center;padding:28px;border:1px solid rgba(148,163,184,.35);border-radius:22px;background:rgba(255,255,255,.86);box-shadow:0 20px 45px rgba(15,23,42,.1)}.boot-logo{width:74px;height:58px;object-fit:contain}.boot-title{font-weight:700}.boot-sub{color:#475569;text-align:center;font-size:14px;line-height:1.5}</style></head><body><div class="boot-screen"><div class="boot-card"><img class="boot-logo" src="/nodewarden-logo.svg" alt=""><div class="boot-title">NodeWarden</div><div class="boot-sub">Offline cache is not ready on this device. Open NodeWarden once while online, then try offline again.</div></div></div></body></html>';
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(APP_SHELL_CACHE)
|
||||
.then(async (cache) => {
|
||||
await cache.addAll(CRITICAL_SHELL_URLS);
|
||||
const nonCriticalUrls = PRECACHE_URLS.filter((url) => !CRITICAL_SHELL_URLS.includes(url));
|
||||
await Promise.allSettled(nonCriticalUrls.map((url) => cache.add(url)));
|
||||
})
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((keys) => Promise.all(
|
||||
keys
|
||||
.filter((key) => key.startsWith('nodewarden-pwa-') && key.endsWith('-shell') && key !== APP_SHELL_CACHE)
|
||||
.map((key) => caches.delete(key))
|
||||
))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
function isSameOriginHttpGet(request) {
|
||||
if (request.method !== 'GET') return false;
|
||||
const url = new URL(request.url);
|
||||
return url.origin === self.location.origin;
|
||||
}
|
||||
|
||||
function isCacheableResponse(response) {
|
||||
return response && response.ok && (response.type === 'basic' || response.type === 'default');
|
||||
}
|
||||
|
||||
async function refreshNavigationCache(request) {
|
||||
const cache = await caches.open(APP_SHELL_CACHE);
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (isCacheableResponse(response)) {
|
||||
await cache.put('/', response.clone());
|
||||
await cache.put('/index.html', response.clone());
|
||||
await warmStaticDependencies(response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function warmStaticDependencies(response) {
|
||||
try {
|
||||
const html = await response.text();
|
||||
const runtimeCache = await caches.open(RUNTIME_CACHE);
|
||||
const urls = Array.from(html.matchAll(/\\b(?:src|href)=["']([^"']+)["']/g))
|
||||
.map((match) => {
|
||||
try {
|
||||
return new URL(match[1], self.location.origin);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((url) => url && url.origin === self.location.origin && STATIC_PATH_RE.test(url.pathname))
|
||||
.map((url) => url.pathname + url.search);
|
||||
await Promise.allSettled(Array.from(new Set(urls)).map((url) => runtimeCache.add(url)));
|
||||
await trimRuntimeCache(runtimeCache, 120);
|
||||
} catch {
|
||||
// Dependency warming is best-effort; never slow or break navigation for it.
|
||||
}
|
||||
}
|
||||
|
||||
async function appShellNavigation(request) {
|
||||
const cache = await caches.open(APP_SHELL_CACHE);
|
||||
const url = new URL(request.url);
|
||||
return (
|
||||
(await cache.match(request, { ignoreSearch: true }))
|
||||
|| (await cache.match(url.pathname, { ignoreSearch: true }))
|
||||
|| (await cache.match('/'))
|
||||
|| (await cache.match('/index.html'))
|
||||
|| new Response(OFFLINE_FALLBACK_HTML, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html; charset=UTF-8' },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function trimRuntimeCache(cache, maxEntries) {
|
||||
const keys = await cache.keys();
|
||||
if (keys.length <= maxEntries) return;
|
||||
await Promise.all(keys.slice(0, keys.length - maxEntries).map((key) => cache.delete(key)));
|
||||
}
|
||||
|
||||
async function cacheFirst(request) {
|
||||
const shellCache = await caches.open(APP_SHELL_CACHE);
|
||||
const cachedShell = await shellCache.match(request);
|
||||
if (cachedShell) return cachedShell;
|
||||
|
||||
const runtimeCache = await caches.open(RUNTIME_CACHE);
|
||||
const cachedRuntime = await runtimeCache.match(request);
|
||||
if (cachedRuntime) return cachedRuntime;
|
||||
|
||||
const legacyRuntime = await matchLegacyRuntimeCache(request);
|
||||
if (legacyRuntime) return legacyRuntime;
|
||||
|
||||
const response = await fetch(request);
|
||||
if (isCacheableResponse(response)) {
|
||||
void runtimeCache.put(request, response.clone()).then(() => trimRuntimeCache(runtimeCache, 120));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function matchLegacyRuntimeCache(request) {
|
||||
const keys = await caches.keys();
|
||||
for (const key of keys) {
|
||||
if (key === RUNTIME_CACHE || !key.startsWith('nodewarden-pwa-') || !key.endsWith('-runtime')) continue;
|
||||
const cache = await caches.open(key);
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const request = event.request;
|
||||
if (!isSameOriginHttpGet(request)) return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (NEVER_CACHE_PATH_RE.test(url.pathname)) return;
|
||||
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(appShellNavigation(request));
|
||||
if (navigator.onLine !== false) {
|
||||
event.waitUntil(refreshNavigationCache(request));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (STATIC_PATH_RE.test(url.pathname) || request.destination === 'script' || request.destination === 'style' || request.destination === 'font' || request.destination === 'image' || request.destination === 'worker') {
|
||||
event.respondWith(cacheFirst(request));
|
||||
}
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
function buildCacheVersion(isDemo: boolean, urls: string[]): string {
|
||||
const digest = createHash('sha256')
|
||||
.update(`${isDemo ? 'demo' : 'app'}\n${urls.join('\n')}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
return `${isDemo ? 'demo' : 'app'}-${digest}`;
|
||||
}
|
||||
|
||||
function pwaServiceWorkerPlugin(isDemo: boolean): Plugin {
|
||||
return {
|
||||
name: 'nodewarden-pwa-service-worker',
|
||||
generateBundle(_, bundle) {
|
||||
const urls = new Set<string>([
|
||||
'/',
|
||||
'/index.html',
|
||||
'/vault',
|
||||
'/manifest.webmanifest',
|
||||
'/nodewarden-logo.svg',
|
||||
'/nodewarden-logo-bg.svg',
|
||||
'/nodewarden-wordmark.svg',
|
||||
'/favicon.ico',
|
||||
'/favicon-32.png',
|
||||
'/apple-touch-icon.png',
|
||||
'/icon-192.png',
|
||||
'/icon-512.png',
|
||||
'/logo-64.png',
|
||||
]);
|
||||
const buildUrls = new Set<string>(urls);
|
||||
|
||||
for (const [fileName, output] of Object.entries(bundle)) {
|
||||
if (output.type !== 'chunk' && output.type !== 'asset') continue;
|
||||
if (fileName === 'sw.js' || fileName === 'robots.txt') continue;
|
||||
if (fileName.endsWith('.map')) continue;
|
||||
buildUrls.add(`/${fileName}`);
|
||||
}
|
||||
|
||||
const sortedUrls = Array.from(buildUrls).sort();
|
||||
const version = buildCacheVersion(isDemo, Array.from(buildUrls).sort());
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: 'sw.js',
|
||||
source: buildServiceWorkerSource(sortedUrls, version),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function searchIndexPolicyPlugin(isDemo: boolean): Plugin {
|
||||
return {
|
||||
name: 'nodewarden-search-index-policy',
|
||||
@@ -59,7 +261,7 @@ export default defineConfig(({ mode }) => {
|
||||
|
||||
return {
|
||||
root: rootDir,
|
||||
plugins: [preact(), searchIndexPolicyPlugin(isDemo), resourcePriorityPlugin(isDemo)],
|
||||
plugins: [preact(), searchIndexPolicyPlugin(isDemo), resourcePriorityPlugin(isDemo), pwaServiceWorkerPlugin(isDemo)],
|
||||
define: {
|
||||
__NODEWARDEN_DEMO__: JSON.stringify(isDemo),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user