From c9e74178251f51c4ee8fba3c7de9ccd2718edda0 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Tue, 7 Apr 2026 20:24:28 +0800 Subject: [PATCH 1/5] feat: add timezone support for backup file naming and extraction --- src/handlers/backup.ts | 1 + src/services/backup-archive.ts | 37 ++++++++++++++++++++++++---------- webapp/src/lib/api/backup.ts | 25 +++++++---------------- 3 files changed, 34 insertions(+), 29 deletions(-) diff --git a/src/handlers/backup.ts b/src/handlers/backup.ts index 69dfd0f..1b5584b 100644 --- a/src/handlers/backup.ts +++ b/src/handlers/backup.ts @@ -192,6 +192,7 @@ async function executeConfiguredBackup( }); const archive = await buildBackupArchive(env, now, { includeAttachments: destination.includeAttachments, + timeZone: destination.schedule.timezone, progress: progress ? async (event) => { if (event.step === 'archive_ready') { diff --git a/src/services/backup-archive.ts b/src/services/backup-archive.ts index dca00e5..590befb 100644 --- a/src/services/backup-archive.ts +++ b/src/services/backup-archive.ts @@ -71,6 +71,7 @@ export interface BackupFileIntegrityCheckResult { export interface BuildBackupArchiveOptions { includeAttachments?: boolean; progress?: BackupArchiveBuildProgressReporter; + timeZone?: string; } export interface BackupArchiveBuildProgressEvent { @@ -93,17 +94,30 @@ async function sha256Hex(bytes: Uint8Array): Promise { return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join(''); } -function buildBackupFileName(date: Date = new Date(), checksumPrefix: string | null = null): string { - const parts = [ - date.getUTCFullYear().toString().padStart(4, '0'), - (date.getUTCMonth() + 1).toString().padStart(2, '0'), - date.getUTCDate().toString().padStart(2, '0'), - date.getUTCHours().toString().padStart(2, '0'), - date.getUTCMinutes().toString().padStart(2, '0'), - date.getUTCSeconds().toString().padStart(2, '0'), - ]; +function getDateParts(date: Date, timeZone: string): string { + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + }); + const parts = formatter.formatToParts(date); + const pick = (type: string): string => parts.find((part) => part.type === type)?.value || ''; + return `${pick('year')}${pick('month')}${pick('day')}_${pick('hour')}${pick('minute')}${pick('second')}`; +} + +function buildBackupFileNameInTimeZone( + date: Date = new Date(), + checksumPrefix: string | null = null, + timeZone: string = 'UTC' +): string { + const parts = getDateParts(date, timeZone); const suffix = checksumPrefix ? `_${checksumPrefix}` : ''; - return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}${suffix}.zip`; + return `nodewarden_backup_${parts}${suffix}.zip`; } export function extractBackupFileChecksumPrefix(fileName: string): string | null { @@ -398,7 +412,8 @@ export async function buildBackupArchive( }); const bytes = zipSync(createZipEntries(files)); const fileHashPrefix = (await sha256Hex(bytes)).slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH); - const fileName = buildBackupFileName(date, fileHashPrefix); + const backupTimeZone = options.timeZone || 'UTC'; + const fileName = buildBackupFileNameInTimeZone(date, fileHashPrefix, backupTimeZone); await options.progress?.({ step: 'archive_ready', fileName, diff --git a/webapp/src/lib/api/backup.ts b/webapp/src/lib/api/backup.ts index 5965c15..caa3226 100644 --- a/webapp/src/lib/api/backup.ts +++ b/webapp/src/lib/api/backup.ts @@ -148,32 +148,21 @@ interface BackupExportManifest { const BACKUP_FILE_HASH_PREFIX_LENGTH = 5; -function parseBackupTimestampFromFileName(fileName: string): Date | null { +function extractBackupTimestampFromFileName(fileName: string): string | null { const match = String(fileName || '').match(/nodewarden_backup_(\d{8})_(\d{6})(?:_[0-9a-f]{5})?\.zip$/i); if (!match) return null; - const datePart = match[1]; - const timePart = match[2]; - const iso = `${datePart.slice(0, 4)}-${datePart.slice(4, 6)}-${datePart.slice(6, 8)}T${timePart.slice(0, 2)}:${timePart.slice(2, 4)}:${timePart.slice(4, 6)}.000Z`; - const parsed = new Date(iso); - return Number.isFinite(parsed.getTime()) ? parsed : null; + return `${match[1]}_${match[2]}`; } -function buildBackupFileName(date: Date, checksumPrefix: string): string { - const parts = [ - date.getUTCFullYear().toString().padStart(4, '0'), - (date.getUTCMonth() + 1).toString().padStart(2, '0'), - date.getUTCDate().toString().padStart(2, '0'), - date.getUTCHours().toString().padStart(2, '0'), - date.getUTCMinutes().toString().padStart(2, '0'), - date.getUTCSeconds().toString().padStart(2, '0'), - ]; - return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}_${checksumPrefix}.zip`; +function buildBackupFileName(timestamp: string, checksumPrefix: string): string { + return `nodewarden_backup_${timestamp}_${checksumPrefix}.zip`; } async function applyBackupFileIntegrityName(fileName: string, bytes: Uint8Array): Promise { const integrity = await verifyBackupFileIntegrity(bytes, fileName); - const effectiveDate = parseBackupTimestampFromFileName(fileName) || new Date(); - return buildBackupFileName(effectiveDate, integrity.actualPrefix); + const timestamp = extractBackupTimestampFromFileName(fileName); + if (!timestamp) return fileName; + return buildBackupFileName(timestamp, integrity.actualPrefix); } export async function exportAdminBackup( From 53231a4878f6d8dcf2078817ae5c40b8dc83f720 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Tue, 7 Apr 2026 20:58:23 +0800 Subject: [PATCH 2/5] feat: enhance backup progress handling and improve user status toggling --- webapp/src/App.tsx | 23 +++++++++--------- webapp/src/components/AdminPage.tsx | 24 ++++++++++++++----- webapp/src/components/BackupCenterPage.tsx | 2 +- webapp/src/components/PublicSendPage.tsx | 7 +++--- webapp/src/components/TotpCodesPage.tsx | 4 +++- .../backup-center/BackupDestinationDetail.tsx | 5 ++-- webapp/src/components/vault/VaultEditor.tsx | 5 ++-- webapp/src/lib/admin-backup-portable.ts | 12 +++++----- webapp/src/lib/api/backup.ts | 3 ++- webapp/src/lib/api/send.ts | 4 +++- webapp/src/lib/api/shared.ts | 4 ++-- webapp/src/lib/crypto.ts | 2 +- webapp/tsconfig.json | 3 +-- 13 files changed, 59 insertions(+), 39 deletions(-) diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 4627e9b..ba2576a 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -53,6 +53,17 @@ import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify'; import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress'; import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types'; +function isBackupProgressDetail(value: unknown): value is BackupProgressDetail { + if (!value || typeof value !== 'object') return false; + const detail = value as Record; + const operation = detail.operation; + return ( + (operation === 'backup-restore' || operation === 'backup-export' || operation === 'backup-remote-run') + && typeof detail.step === 'string' + && typeof detail.fileName === 'string' + ); +} + const IMPORT_ROUTE = '/backup/import-export'; const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const; const IMPORT_ROUTE_ALIASES: ReadonlySet = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE)); @@ -927,17 +938,7 @@ export default function App() { } if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) { const payload = frame.arguments?.[0]?.Payload; - if ( - payload - && typeof payload === 'object' - && ( - payload.operation === 'backup-restore' - || payload.operation === 'backup-export' - || payload.operation === 'backup-remote-run' - ) - ) { - dispatchBackupProgress(payload as BackupProgressDetail); - } + if (isBackupProgressDetail(payload)) dispatchBackupProgress(payload); continue; } if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue; diff --git a/webapp/src/components/AdminPage.tsx b/webapp/src/components/AdminPage.tsx index d9621ce..6307fce 100644 --- a/webapp/src/components/AdminPage.tsx +++ b/webapp/src/components/AdminPage.tsx @@ -40,6 +40,12 @@ export default function AdminPage(props: AdminPageProps) { return status || '-'; }; + const normalizeToggleableStatus = (status: string): 'active' | 'banned' | null => { + const normalized = String(status || '').toLowerCase(); + if (normalized === 'active' || normalized === 'banned') return normalized; + return null; + }; + return (
@@ -55,8 +61,10 @@ export default function AdminPage(props: AdminPageProps) { - {props.users.map((user) => ( - + {props.users.map((user) => { + const toggleableStatus = normalizeToggleableStatus(user.status); + return ( + {user.email} {user.name || t('txt_dash')} {roleText(user.role)} @@ -66,8 +74,11 @@ export default function AdminPage(props: AdminPageProps) {
- - ))} + + ); + })} diff --git a/webapp/src/components/BackupCenterPage.tsx b/webapp/src/components/BackupCenterPage.tsx index 9c46df7..e57e17b 100644 --- a/webapp/src/components/BackupCenterPage.tsx +++ b/webapp/src/components/BackupCenterPage.tsx @@ -625,7 +625,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { setSettings(result.settings); setSelectedDestinationId(selectedDestination.id); await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true }); - props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.fileName })); + props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.result.fileName })); } catch (error) { const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed'); setLocalError(message); diff --git a/webapp/src/components/PublicSendPage.tsx b/webapp/src/components/PublicSendPage.tsx index 81d319a..a1b15fa 100644 --- a/webapp/src/components/PublicSendPage.tsx +++ b/webapp/src/components/PublicSendPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'preact/hooks'; import { Download, Eye, Lock } from 'lucide-preact'; import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send'; +import { toBufferSource } from '@/lib/crypto'; import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download'; import StandalonePageFrame from '@/components/StandalonePageFrame'; import { t } from '@/lib/i18n'; @@ -61,13 +62,13 @@ export default function PublicSendPage(props: PublicSendPageProps) { if (props.keyPart) { try { const decryptedBytes = await decryptPublicSendFileBytes(encryptedBytes, props.keyPart); - blob = new Blob([decryptedBytes as unknown as BlobPart], { type: 'application/octet-stream' }); + blob = new Blob([toBufferSource(decryptedBytes)], { type: 'application/octet-stream' }); } catch { // Legacy compatibility: early web-created file sends uploaded plaintext bytes. - blob = new Blob([encryptedBytes], { type: 'application/octet-stream' }); + blob = new Blob([toBufferSource(encryptedBytes)], { type: 'application/octet-stream' }); } } else { - blob = new Blob([encryptedBytes], { type: 'application/octet-stream' }); + blob = new Blob([toBufferSource(encryptedBytes)], { type: 'application/octet-stream' }); } downloadBytesAsFile( new Uint8Array(await blob.arrayBuffer()), diff --git a/webapp/src/components/TotpCodesPage.tsx b/webapp/src/components/TotpCodesPage.tsx index b18a212..a3b5a85 100644 --- a/webapp/src/components/TotpCodesPage.tsx +++ b/webapp/src/components/TotpCodesPage.tsx @@ -1,3 +1,4 @@ +import type { JSX } from 'preact'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { Clipboard, Globe, GripVertical } from 'lucide-preact'; import { @@ -96,6 +97,7 @@ function SortableTotpRow(props: SortableTotpRowProps) { const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({ id: props.cipher.id, }); + const dragButtonAttributes = attributes as JSX.HTMLAttributes; const style = { transform: CSS.Transform.toString(transform), @@ -113,7 +115,7 @@ function SortableTotpRow(props: SortableTotpRowProps) { className="btn btn-secondary small totp-drag-btn" title={t('txt_drag_to_reorder')} aria-label={t('txt_drag_to_reorder')} - {...attributes} + {...dragButtonAttributes} {...listeners} > diff --git a/webapp/src/components/backup-center/BackupDestinationDetail.tsx b/webapp/src/components/backup-center/BackupDestinationDetail.tsx index 7a27293..25f2515 100644 --- a/webapp/src/components/backup-center/BackupDestinationDetail.tsx +++ b/webapp/src/components/backup-center/BackupDestinationDetail.tsx @@ -134,6 +134,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) { ...COMMON_TIME_ZONES, ...props.availableTimeZones, ])); + const selectedIntervalHours = props.selectedDestination?.schedule.intervalHours ?? 24; if (props.selectedRecommendedProvider) { return ( @@ -216,7 +217,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) { type="text" inputMode="numeric" pattern="[0-9]*" - value={String(props.selectedDestination.schedule.intervalHours || 24)} + value={String(selectedIntervalHours)} disabled={props.loadingSettings || props.disableWhileBusy} onInput={(event) => { const raw = (event.currentTarget as HTMLInputElement).value.replace(/[^\d]/g, ''); @@ -234,7 +235,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
{INTERVAL_HOUR_PRESETS.map((preset) => { - const active = preset === props.selectedDestination.schedule.intervalHours; + const active = preset === selectedIntervalHours; return (
-
{t('txt_or')}
+ + + ); + })} + + + )} )} diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index 5a911bc..34f427a 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -293,6 +293,7 @@ const messages: Record> = { txt_are_you_sure_you_want_to_delete_count_selected_items: "Are you sure you want to delete {count} selected items?", txt_are_you_sure_you_want_to_delete_count_selected_items_permanently: "Are you sure you want to permanently delete {count} selected items?", txt_are_you_sure_you_want_to_delete_this_item: "Are you sure you want to delete this item?", + txt_are_you_sure_you_want_to_delete_this_passkey: "Are you sure you want to delete this passkey?", txt_are_you_sure_you_want_to_log_out: "Are you sure you want to log out?", txt_authenticator_key: "Authenticator Key", txt_authorized_devices: "Authorized Devices", @@ -352,6 +353,7 @@ const messages: Record> = { txt_delete_all_invite_codes_active_inactive: "Delete all invite codes (active/inactive)?", txt_delete_all_invites: "Delete all invites", txt_delete_item: "Delete Item", + txt_delete_passkey: "Delete Passkey", txt_delete_item_failed: "Delete item failed", txt_delete_permanently: "Delete Permanently", txt_archive: "Archive", @@ -572,6 +574,7 @@ const messages: Record> = { txt_password_hint_load_failed: "Failed to load password hint", txt_password_hint_too_long: "Password hint must be 120 characters or fewer", txt_passkey: "Passkey", + txt_passkeys: "Passkeys", txt_passkey_created_at_value: "Created on {value}", txt_phone: "Phone", txt_please_input_email_and_password: "Please input email and password", @@ -1163,6 +1166,7 @@ const zhCNOverrides: Record = { txt_no_name: '(无名称)', txt_are_you_sure_you_want_to_log_out: '确认要退出登录吗?', txt_delete_item: '删除项目', + txt_delete_passkey: '删除通行密钥', txt_delete_selected_items: '删除所选项目', txt_move_selected_items: '移动所选项目', txt_create_folder: '创建文件夹', @@ -1226,6 +1230,7 @@ const zhCNOverrides: Record = { txt_are_you_sure_you_want_to_delete_count_selected_items: '确认删除所选的 {count} 个项目?', txt_are_you_sure_you_want_to_delete_count_selected_items_permanently: '确认永久删除所选的 {count} 个项目?', txt_are_you_sure_you_want_to_delete_this_item: '确认删除此项目?', + txt_are_you_sure_you_want_to_delete_this_passkey: '确认删除这个通行密钥?', txt_authenticator_key: '验证器密钥', txt_brand: '品牌', txt_bulk_delete_failed: '批量删除失败', @@ -1327,6 +1332,7 @@ const zhCNOverrides: Record = { txt_password_hint_load_failed: '加载密码提示失败', txt_password_hint_too_long: '密码提示最多只能输入 120 个字符', txt_passkey: '通行密钥', + txt_passkeys: '通行密钥', txt_passkey_created_at_value: '创建于 {value}', txt_phone: '电话', txt_please_input_email_and_password: '请输入邮箱和密码',