From 4b8cad6d00b9c563854865f5466eee2874e9bb09 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 15 Mar 2026 23:12:45 +0800 Subject: [PATCH] feat: enhance backup and download functionalities - Updated `BackupCenterPage` to support download progress tracking during remote backup downloads. - Modified `ImportPage` to simplify export functionality by removing unnecessary payload handling. - Improved `JwtWarningPage` to utilize a new clipboard utility for copying text with feedback. - Enhanced `PublicSendPage` to show download progress for files being downloaded. - Updated `RecoverTwoFactorPage` to include autocomplete attributes for better user experience. - Refactored `SendsPage` to use the new clipboard utility for copying access URLs. - Enhanced `SettingsPage` to utilize the clipboard utility for copying sensitive information. - Improved `TotpCodesPage` to use the clipboard utility for copying TOTP codes. - Updated `VaultPage` and related components to support download progress for attachments. - Introduced a new `app-notify` module for consistent notification handling across the application. - Created a `clipboard` utility for improved clipboard interactions with user feedback. - Added progress tracking for file downloads in the API layer, enhancing user experience during downloads. --- src/handlers/identity.ts | 23 +++-- src/router-authenticated.ts | 5 ++ src/router-public.ts | 84 +++++++++---------- src/utils/response.ts | 2 +- webapp/index.html | 2 +- webapp/src/App.tsx | 14 ++++ webapp/src/components/AdminPage.tsx | 3 +- webapp/src/components/AppGlobalOverlays.tsx | 4 +- webapp/src/components/AppMainRoutes.tsx | 6 +- webapp/src/components/AuthViews.tsx | 11 +++ webapp/src/components/BackupCenterPage.tsx | 8 +- webapp/src/components/ImportPage.tsx | 15 +--- webapp/src/components/JwtWarningPage.tsx | 7 +- webapp/src/components/PublicSendPage.tsx | 22 ++--- .../src/components/RecoverTwoFactorPage.tsx | 3 + webapp/src/components/SendsPage.tsx | 4 +- webapp/src/components/SettingsPage.tsx | 7 +- webapp/src/components/TotpCodesPage.tsx | 19 +++-- webapp/src/components/VaultPage.tsx | 6 ++ .../backup-center/BackupDestinationDetail.tsx | 2 + .../backup-center/RemoteBackupBrowser.tsx | 10 ++- .../src/components/vault/VaultDetailView.tsx | 49 +++++++++-- webapp/src/components/vault/VaultEditor.tsx | 40 +++++++-- .../components/vault/vault-page-helpers.tsx | 10 ++- webapp/src/hooks/useBackupActions.ts | 4 +- webapp/src/hooks/useVaultSendActions.ts | 16 +++- webapp/src/lib/api/backup.ts | 6 +- webapp/src/lib/api/vault.ts | 6 +- webapp/src/lib/app-notify.ts | 13 +++ webapp/src/lib/clipboard.ts | 36 ++++++++ webapp/src/lib/download.ts | 58 +++++++++++++ webapp/src/lib/i18n.ts | 10 ++- webapp/src/lib/types.ts | 3 + 33 files changed, 387 insertions(+), 121 deletions(-) create mode 100644 webapp/src/lib/app-notify.ts create mode 100644 webapp/src/lib/clipboard.ts diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 4eb6711..d450366 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -37,20 +37,29 @@ function twoFactorRequiredResponse(message: string = 'Two factor required.', inc : [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)]; const providers2: Record = {}; for (const provider of providers) providers2[provider] = null; + const customResponse = { + TwoFactorProviders: providers, + TwoFactorProviders2: providers2, + SsoEmail2faSessionToken: null, + MasterPasswordPolicy: { + Object: 'masterPasswordPolicy', + }, + }; // Bitwarden clients rely on these fields to trigger the 2FA UI flow. return jsonResponse( { error: 'invalid_grant', error_description: message, - TwoFactorProviders: providers, - TwoFactorProviders2: providers2, + Error: 'invalid_grant', + ErrorDescription: message, + ErrorMessage: message, + TwoFactorProviders: customResponse.TwoFactorProviders, + TwoFactorProviders2: customResponse.TwoFactorProviders2, // Required by current Android parser (nullable value is acceptable). - SsoEmail2faSessionToken: null, - // Keep payload shape close to upstream implementations. - MasterPasswordPolicy: { - Object: 'masterPasswordPolicy', - }, + SsoEmail2faSessionToken: customResponse.SsoEmail2faSessionToken, + MasterPasswordPolicy: customResponse.MasterPasswordPolicy, + CustomResponse: customResponse, ErrorModel: { Message: message, Object: 'error', diff --git a/src/router-authenticated.ts b/src/router-authenticated.ts index 5614b7f..1a92d28 100644 --- a/src/router-authenticated.ts +++ b/src/router-authenticated.ts @@ -37,6 +37,7 @@ import { handleGetSends, handleGetSend, handleCreateSend, + handleCreateFileSendV2, handleGetSendFileUpload, handleUploadSendFile, handleUpdateSend, @@ -217,6 +218,10 @@ export async function handleAuthenticatedRoute( return null; } + if (path === '/api/sends/file/v2' && method === 'POST') { + return handleCreateFileSendV2(request, env, userId); + } + if (path === '/api/sends/delete' && method === 'POST') { return handleBulkDeleteSends(request, env, userId); } diff --git a/src/router-public.ts b/src/router-public.ts index 1c299f2..f593360 100644 --- a/src/router-public.ts +++ b/src/router-public.ts @@ -57,68 +57,60 @@ function handleNwFavicon(): Response { }); } -function isValidIconHostname(hostname: string): boolean { - if (!hostname) return false; - if (hostname.length > 253) return false; - - const normalized = hostname.toLowerCase().replace(/\.$/, ''); - const domainPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})$/; - const ipv4Pattern = /^(?:\d{1,3}\.){3}\d{1,3}$/; - - if (domainPattern.test(normalized)) return true; - if (!ipv4Pattern.test(normalized)) return false; - - const parts = normalized.split('.'); - return parts.every((p) => { - const n = Number(p); - return Number.isInteger(n) && n >= 0 && n <= 255; - }); +function buildIconServiceBase(origin: string): string { + return `${origin}/icons`; } -async function handleGetIcon(env: Env, hostname: string): Promise { +function buildIconServiceTemplate(origin: string): string { + return `${buildIconServiceBase(origin)}/{}/icon.png`; +} + +function buildIconServiceCsp(origin: string): string { + return `img-src 'self' data: ${origin}`; +} + +function normalizeIconHost(rawHost: string): string | null { + const decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, ''); + if (!decoded || decoded.includes('/') || decoded.includes('\\')) return null; try { - void env; - const normalizedHostname = hostname.toLowerCase(); - if (!isValidIconHostname(normalizedHostname)) { - return new Response(null, { status: 204 }); - } + const parsed = new URL(`https://${decoded}`); + return parsed.hostname === decoded ? decoded : null; + } catch { + return null; + } +} - const cache = caches.default; - const cacheKey = new Request(`https://nodewarden-icons.local/icons/${normalizedHostname}/icon.png`, { method: 'GET' }); - const cached = await cache.match(cacheKey); - if (cached) return cached; +async function handleWebsiteIcon(host: string): Promise { + const normalizedHost = normalizeIconHost(host); + if (!normalizedHost) return handleNwFavicon(); - const resp = await fetch(`https://favicon.im/${normalizedHostname}`, { - headers: { 'User-Agent': 'NodeWarden/1.0' }, + const upstream = `https://favicon.im/${encodeURIComponent(normalizedHost)}`; + try { + const resp = await fetch(upstream, { redirect: 'follow', cf: { cacheEverything: true, cacheTtl: LIMITS.cache.iconTtlSeconds, }, - }); + } as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } }); - if (!resp.ok) return new Response(null, { status: 204 }); + if (!resp.ok) return handleNwFavicon(); + const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase(); + if (!contentType.startsWith('image/')) return handleNwFavicon(); - const body = await resp.arrayBuffer(); - if (body.byteLength === 0) { - return new Response(null, { status: 204 }); - } - - const iconResponse = new Response(body, { + return new Response(resp.body, { status: 200, headers: { 'Content-Type': resp.headers.get('Content-Type') || 'image/png', 'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`, }, }); - await cache.put(cacheKey, iconResponse.clone()); - return iconResponse; } catch { - return new Response(null, { status: 204 }); + return handleNwFavicon(); } } -export function buildWebConfigResponse(env: Env) { +export function buildWebConfigResponse(env: Env, origin: string) { const secret = (env.JWT_SECRET || '').trim(); const jwtUnsafeReason = !secret @@ -133,6 +125,9 @@ export function buildWebConfigResponse(env: Env) { defaultKdfIterations: LIMITS.auth.defaultKdfIterations, jwtUnsafeReason, jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength, + _icon_service_url: buildIconServiceTemplate(origin), + _icon_service_csp: buildIconServiceCsp(origin), + iconServiceUrl: buildIconServiceTemplate(origin), }; } @@ -152,7 +147,7 @@ export async function handlePublicRoute( if (path === '/api/web/config' && method === 'GET') { const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute); if (blocked) return blocked; - return jsonResponse(buildWebConfigResponse(env)); + return jsonResponse(buildWebConfigResponse(env, new URL(request.url).origin)); } if (path === '/.well-known/appspecific/com.chrome.devtools.json' && method === 'GET') { @@ -170,8 +165,8 @@ export async function handlePublicRoute( } const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i); - if (iconMatch) { - return handleGetIcon(env, iconMatch[1]); + if (iconMatch && method === 'GET') { + return handleWebsiteIcon(iconMatch[1]); } const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i); @@ -250,8 +245,11 @@ export async function handlePublicRoute( api: origin + '/api', identity: origin + '/identity', notifications: origin + '/notifications', + icons: origin, sso: '', }, + _icon_service_url: buildIconServiceTemplate(origin), + _icon_service_csp: buildIconServiceCsp(origin), featureStates: { 'duo-redirect': true, 'email-verification': true, diff --git a/src/utils/response.ts b/src/utils/response.ts index b870a64..b104d16 100644 --- a/src/utils/response.ts +++ b/src/utils/response.ts @@ -67,7 +67,7 @@ export function applyCors( headers.set('X-Frame-Options', 'DENY'); headers.set('X-Content-Type-Options', 'nosniff'); headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); - headers.set('Content-Security-Policy', "frame-ancestors 'none'"); + headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:"); return new Response(response.body, { status: response.status, statusText: response.statusText, diff --git a/webapp/index.html b/webapp/index.html index 4b97df8..1508126 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -3,7 +3,7 @@ - + NodeWarden diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 995bb88..38880a2 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -46,6 +46,7 @@ import useBackupActions from '@/hooks/useBackupActions'; import useVaultSendActions from '@/hooks/useVaultSendActions'; import { useToastManager } from '@/hooks/useToastManager'; import { t } from '@/lib/i18n'; +import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify'; import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types'; const IMPORT_ROUTE = '/help/import-export'; @@ -96,6 +97,17 @@ export default function App() { const refreshAuthorizedDevicesRef = useRef<() => Promise>(async () => {}); const { toasts, pushToast, removeToast } = useToastManager(); + useEffect(() => { + const handleAppNotify = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail?.text) return; + pushToast(detail.type, detail.text); + }; + + window.addEventListener(APP_NOTIFY_EVENT, handleAppNotify as EventListener); + return () => window.removeEventListener(APP_NOTIFY_EVENT, handleAppNotify as EventListener); + }, [pushToast]); + useEffect(() => { const syncInviteFromUrl = () => { setInviteCodeFromUrl(readInviteCodeFromUrl()); @@ -873,6 +885,8 @@ export default function App() { onDeleteFolder: vaultSendActions.deleteFolder, onBulkDeleteFolders: vaultSendActions.bulkDeleteFolders, onDownloadVaultAttachment: vaultSendActions.downloadVaultAttachment, + downloadingAttachmentKey: vaultSendActions.downloadingAttachmentKey, + attachmentDownloadPercent: vaultSendActions.attachmentDownloadPercent, onRefreshVault: vaultSendActions.refreshVault, onCreateSend: vaultSendActions.createSend, onUpdateSend: vaultSendActions.updateSend, diff --git a/webapp/src/components/AdminPage.tsx b/webapp/src/components/AdminPage.tsx index dc78a0e..01f1563 100644 --- a/webapp/src/components/AdminPage.tsx +++ b/webapp/src/components/AdminPage.tsx @@ -1,5 +1,6 @@ import { useState } from 'preact/hooks'; import { ChevronLeft, ChevronRight, Clipboard, Plus, RefreshCw, Trash2, UserCheck, UserX } from 'lucide-preact'; +import { copyTextToClipboard } from '@/lib/clipboard'; import type { AdminInvite, AdminUser } from '@/lib/types'; import { t } from '@/lib/i18n'; @@ -134,7 +135,7 @@ export default function AdminPage(props: AdminPageProps) { diff --git a/webapp/src/components/AppGlobalOverlays.tsx b/webapp/src/components/AppGlobalOverlays.tsx index 544d8c4..6336389 100644 --- a/webapp/src/components/AppGlobalOverlays.tsx +++ b/webapp/src/components/AppGlobalOverlays.tsx @@ -64,7 +64,7 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) { > @@ -41,6 +42,7 @@ export default function RecoverTwoFactorPage(props: RecoverTwoFactorPageProps) { className="input" type={showPassword ? 'text' : 'password'} value={props.values.password} + autoComplete="current-password" onInput={(e) => props.onChange({ ...props.values, password: (e.currentTarget as HTMLInputElement).value })} /> + +
{t('txt_public_key')} @@ -201,7 +223,11 @@ export default function VaultDetailView(props: VaultDetailViewProps) { {props.selectedCipher.sshKey.decPublicKey || ''}
-
+
+ +
{t('txt_fingerprint')} @@ -210,7 +236,11 @@ export default function VaultDetailView(props: VaultDetailViewProps) { {props.selectedCipher.sshKey.decFingerprint || ''}
-
+
+ +
)} @@ -292,8 +322,13 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
-
diff --git a/webapp/src/components/vault/VaultEditor.tsx b/webapp/src/components/vault/VaultEditor.tsx index 1a7a9a0..54398e6 100644 --- a/webapp/src/components/vault/VaultEditor.tsx +++ b/webapp/src/components/vault/VaultEditor.tsx @@ -16,6 +16,8 @@ interface VaultEditorProps { attachmentQueue: File[]; attachmentInputRef: RefObject; localError: string; + downloadingAttachmentKey: string; + attachmentDownloadPercent: number | null; onUpdateDraft: (patch: Partial) => void; onSeedSshDefaults: (force?: boolean) => void; onUpdateSshPublicKey: (value: string) => void; @@ -33,6 +35,14 @@ interface VaultEditorProps { } export default function VaultEditor(props: VaultEditorProps) { + const formatDownloadLabel = (attachmentId: string) => { + const downloadKey = `${props.selectedCipher?.id || ''}:${attachmentId}`; + if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download'); + return props.attachmentDownloadPercent == null + ? t('txt_downloading') + : t('txt_downloading_percent', { percent: props.attachmentDownloadPercent }); + }; + return ( <>
@@ -162,17 +172,32 @@ export default function VaultEditor(props: VaultEditorProps) {

{t('txt_ssh_key')}

-